| Filename | Latest commit message | Latest commit date |
|---|---|---|
Replace actions/cache with immutable snapshots stored on a persistent volume bind-mounted into the job container, keyed on the lockfile hash. - node-modules: symlink node_modules/ to a snapshot on cache hit, or install with npm ci / pnpm (frozen lockfile, shared download cache) and publish the tree atomically with mv -T; optional npm_version input pins the npm major before install - composer-vendor: cp -a --reflink vendor/ from a dev or nodev snapshot, or composer install --no-scripts and store it; then rebuild the optimized autoloader for the current commit - both prune old snapshots (keep input) and expose a hit output - README: document both actions, the runner volume setup and the on-disk layout in a new "Dependency snapshots" section |
||
| composer-vendor | ||
| deploy-ssh | ||
| deploy-static | ||
| node-modules | ||
| notify-telegram | ||
| resolve-environment | ||
| setup-ssh | ||
| README.md | ||
Cafffeine/actions
Shared composite actions for every Forgejo organization on this instance.
Hosted once under the Cafffeine org and referenced by full URL from any repo
in any org. One directory per action.
| Action | What it does |
|---|---|
setup-ssh |
Installs the deploy key and pinned host keys in ~/.ssh for later ssh / rsync / Deployer steps |
resolve-environment |
Maps the branch to production / staging / development and picks the matching DEPLOY_<ENV> target |
deploy-static |
Publishes a build to one or more hosts: releases/<release> plus an atomic current symlink |
notify-telegram |
Posts the job result to a Telegram chat |
deploy-ssh |
Deploys a single host with rsync, either direct or atomic with rollback |
node-modules |
Restores node_modules/ from an immutable snapshot on the runner's cache volume (keyed on the lockfile), or installs and stores one |
composer-vendor |
Same for Composer's vendor/ (keyed on composer.lock), then rebuilds the optimized autoloader |
setup-ssh, resolve-environment, deploy-static and notify-telegram are
written in plain POSIX sh (no bash, no Node, no Docker image). They run in any
job container: Debian node:* (even node:14), Alpine composer:2, etc. No
third-party code ever sees your SSH key or bot token.
deploy-ssh, node-modules and composer-vendor need bash and GNU
coreutils (mv -T, cp --reflink): any Debian-based image. The two cache
actions also need a persistent volume bind-mounted into the job container
(see Dependency snapshots).
Using across all organizations
-
This repo must be public at the instance level so runners in other orgs can clone it. It holds no secrets, only deploy logic, so public is safe. Runners clone anonymously: this also breaks if the instance sets
REQUIRE_SIGNIN_VIEW = true. -
Reference it by full instance URL. This works regardless of the runner's
DEFAULT_ACTIONS_URLand of the caller's org:- uses: https://git.webrocks.net/Cafffeine/actions/notify-telegram@v1 -
Secrets are NOT centralized. Forgejo scopes Actions secrets and variables to a repo or an org; there is no instance-wide store. The action code is shared, the credentials live in each org (see below).
If a runner rejects the subpath-in-URL form, either:
- set
[actions] DEFAULT_ACTIONS_URL = https://git.webrocks.netin the Forgejo config and use the short formCafffeine/actions/deploy-ssh@v1, or- split the action into its own public repo
Cafffeine/deploy-sshand referencehttps://git.webrocks.net/Cafffeine/deploy-ssh@v1.
Configure a project
Once per organization (Settings → Actions):
| Kind | Name | Value |
|---|---|---|
| Secret | SSH_PRIVATE_KEY |
Private deploy key (unencrypted) |
| Variable | SSH_KNOWN_HOSTS |
ssh-keyscan -p <port> <host> output for every server |
| Variable | TELEGRAM_BOT_TOKEN |
Token from @BotFather |
| Variable | TELEGRAM_CHAT_ID |
Target chat id, e.g. -1001234567890 |
Per repository, one variable per environment:
| Variable | Example |
|---|---|
DEPLOY_PRODUCTION |
deploy@web1.example.com:22:/var/www/app |
DEPLOY_STAGING |
deploy@staging.example.com:1337:/var/www/app |
DEPLOY_DEVELOPMENT |
deploy@dev.example.com:1337:/var/www/app |
Format: user@host:port:/path. The : before the path is optional. Put one
server per line to deploy the same build to several hosts.
vars.*values are visible to anyone who can edit the repository settings. Keep the bot token in a secret instead if that matters to you: the action doesn't care where the value comes from.
Examples
Static site (Vue, Angular, Vite…)
name: Deploy
on:
push:
branches: [develop, staging, master]
concurrency:
group: deploy-${{ github.ref_name }}
cancel-in-progress: true
jobs:
deploy:
runs-on: docker
container:
image: node:22
steps:
- uses: actions/checkout@v4
- id: env
uses: https://git.webrocks.net/Cafffeine/actions/resolve-environment@v1
with:
production: ${{ vars.DEPLOY_PRODUCTION }}
staging: ${{ vars.DEPLOY_STAGING }}
development: ${{ vars.DEPLOY_DEVELOPMENT }}
- uses: https://git.webrocks.net/Cafffeine/actions/setup-ssh@v1
with:
private_key: ${{ secrets.SSH_PRIVATE_KEY }}
known_hosts: ${{ vars.SSH_KNOWN_HOSTS }}
- run: npm ci && npm run build
- uses: https://git.webrocks.net/Cafffeine/actions/deploy-static@v1
with:
target: ${{ steps.env.outputs.target }}
source: dist
release: ${{ steps.env.outputs.release }}
- if: always()
uses: https://git.webrocks.net/Cafffeine/actions/notify-telegram@v1
with:
bot_token: ${{ vars.TELEGRAM_BOT_TOKEN }}
chat_id: ${{ vars.TELEGRAM_CHAT_ID }}
status: ${{ job.status }}
environment: ${{ steps.env.outputs.environment }}
started_at: ${{ steps.env.outputs.started_at }}
The web server's document root must point to <path>/current.
Several apps from one repository (e.g. white-label builds): build each app,
then call deploy-static once per app. Use the same release and a different
subdir for each, which gives <path>/<subdir>/current.
Deployer (Laravel / PHP)
Deployer connects on its own, so resolve-environment only picks the
environment. Here the host is defined in deploy.php from the same
DEPLOY_<ENV> variables:
container:
image: composer:2
steps:
- run: apk add --no-cache nodejs openssh-client # Node for actions/checkout, ssh for Deployer
- uses: actions/checkout@v4
- id: env
uses: https://git.webrocks.net/Cafffeine/actions/resolve-environment@v1
with:
require_target: "false"
- uses: https://git.webrocks.net/Cafffeine/actions/setup-ssh@v1
with:
private_key: ${{ secrets.SSH_PRIVATE_KEY }}
known_hosts: ${{ vars.SSH_KNOWN_HOSTS }}
- env:
DEPLOY_PRODUCTION: ${{ vars.DEPLOY_PRODUCTION }}
DEPLOY_STAGING: ${{ vars.DEPLOY_STAGING }}
DEPLOY_DEVELOPMENT: ${{ vars.DEPLOY_DEVELOPMENT }}
run: dep deploy "${{ steps.env.outputs.environment }}" --revision="$GITHUB_SHA"
- if: always()
uses: https://git.webrocks.net/Cafffeine/actions/notify-telegram@v1
with:
bot_token: ${{ vars.TELEGRAM_BOT_TOKEN }}
chat_id: ${{ vars.TELEGRAM_CHAT_ID }}
status: ${{ job.status }}
environment: ${{ steps.env.outputs.environment }}
started_at: ${{ steps.env.outputs.started_at }}
setup-ssh
| Input | Default | |
|---|---|---|
private_key |
required | Written to ~/.ssh/id_deploy and checked with ssh-keygen |
known_hosts |
"" |
Set: strict host key checking. Empty: accept-new and a warning |
resolve-environment
| Input | Default | |
|---|---|---|
production / staging / development |
"" |
Target(s) of each environment |
production_branches |
master main |
Space-separated |
staging_branches |
staging |
Any other branch → development |
require_target |
true |
Fail when the resolved environment has no target |
Outputs: environment, target, release (<UTC timestamp>-<short sha>),
short_sha, started_at.
deploy-static
| Input | Default | |
|---|---|---|
target |
required | One user@host:port:/path per line |
source |
required | Local directory; its content is published |
subdir |
"" |
Appended to the target path |
release |
timestamp-sha | Release directory name |
keep |
5 |
Releases kept per host |
method |
tar |
tar (no remote dependency) or rsync (sends only changed files; needs rsync in the job container and on the server) |
Outputs: deployed, failed (space-separated hosts). Every host is attempted.
The step fails at the end if any host failed. If current is a real directory
(legacy layout), it is removed and replaced by the symlink. Requires
setup-ssh (or an equivalent ~/.ssh) beforehand.
notify-telegram
| Input | Default | |
|---|---|---|
bot_token, chat_id |
required | Empty value: notification skipped with a notice |
status |
required | Pass ${{ job.status }} |
environment |
"" |
Shown in the message |
started_at |
"" |
Unix timestamp; adds the job duration |
details |
"" |
Extra line, e.g. app: success · admin: failure |
thread_id |
"" |
Forum topic id, for chats with topics |
Never fails the job: a Telegram error only produces a warning.
deploy-ssh
Deploys files to a single server over SSH with rsync. It sets up the SSH key
itself, so setup-ssh is not needed. Two strategies:
direct(default): plain rsync ofsourceintodeploy_path. Nothing else is touched. Use for build artifacts, static assets, config, or repo source: anything you just want copied over.atomic: rsync intoreleases/<id>/(unchanged files hard-linked from the previous release), then swap acurrentsymlink and prune old releases. Supports rollback. The web server document root must point at<deploy_path>/current.
By default the sync mirrors the source (rsync --delete): the remote ends
up matching the source exactly. Set delete: "false" for additive syncs that
never remove remote files.
Credentials for this action are per-org secrets SSH_KEY, SSH_HOST,
SSH_USER, SSH_PORT, plus a per-repo DEPLOY_PATH.
Inputs
| Input | Required | Default | Description |
|---|---|---|---|
ssh_key |
yes | — | Private SSH deploy key (PEM) |
ssh_host |
yes | — | Target server hostname/IP |
ssh_user |
yes | — | SSH user |
ssh_port |
no | 22 |
SSH port |
known_hosts |
no | "" |
Pinned host key line(s) (ssh-keyscan host). Empty = ssh-keyscan at runtime (TOFU, not MITM-safe) |
deploy_path |
yes | — | Absolute target dir. direct: files land here. atomic: base dir holding releases/ + current |
source |
no | ./ |
Local dir to deploy (trailing slash = contents) |
strategy |
no | direct |
direct (plain rsync) or atomic (releases + symlink swap) |
delete |
no | true |
true mirrors (--delete); false is additive |
mode |
no | deploy |
deploy or rollback (atomic only) |
release_id |
no | commit SHA | atomic deploy: release name. rollback: target (blank = previous) |
keep_releases |
no | 5 |
atomic: past releases to retain |
extra_excludes |
no | "" |
Newline-separated extra --exclude patterns |
Default excludes (both strategies): .git/, .forgejo/, .gitea/,
.github/, README.md.
Direct deploy — build artifacts
Rsync a compiled/build output directory straight to the server:
- name: Checkout repository
uses: actions/checkout@v4
- name: Build
run: npm ci && npm run build # produces ./dist
- name: Deploy build output
uses: https://git.webrocks.net/Cafffeine/actions/deploy-ssh@v1
with:
ssh_key: ${{ secrets.SSH_KEY }}
ssh_host: ${{ secrets.SSH_HOST }}
ssh_user: ${{ secrets.SSH_USER }}
ssh_port: ${{ secrets.SSH_PORT }}
known_hosts: ${{ secrets.SSH_KNOWN_HOSTS }}
deploy_path: /var/www/example.com
source: ./dist/ # trailing slash = contents of dist
Direct deploy — repo source
Push the repository's own files (default excludes drop .git/, CI dirs, README):
- uses: actions/checkout@v4
- uses: https://git.webrocks.net/Cafffeine/actions/deploy-ssh@v1
with:
ssh_key: ${{ secrets.SSH_KEY }}
ssh_host: ${{ secrets.SSH_HOST }}
ssh_user: ${{ secrets.SSH_USER }}
deploy_path: /srv/site
# source: ./ (default)
# delete: "false" # uncomment for additive sync (never delete remote files)
Atomic deploy — static site with rollback
- uses: actions/checkout@v4
- name: Deploy static site (atomic)
uses: https://git.webrocks.net/Cafffeine/actions/deploy-ssh@v1
with:
ssh_key: ${{ secrets.SSH_KEY }}
ssh_host: ${{ secrets.SSH_HOST }}
ssh_user: ${{ secrets.SSH_USER }}
ssh_port: ${{ secrets.SSH_PORT }}
deploy_path: ${{ secrets.DEPLOY_PATH }}
strategy: atomic
Rollback (atomic only)
Trigger the project workflow manually with strategy: atomic and
mode: rollback (and optionally a release_id). With no release_id, it rolls
back to the previous release.
Host key pinning
Generate the pinned host key once and store it as a secret:
ssh-keyscan -p 22 your.server.example.com
Put the output in an org/repo secret (e.g. SSH_KNOWN_HOSTS) and pass it as
known_hosts. Without it, the action falls back to ssh-keyscan at runtime.
That is convenient, but it is trust-on-first-use and not protected against MITM.
Server requirements
- GNU coreutils (
mv -Tfor the atomic swap), standard on Linux. Only needed forstrategy: atomic. - Atomic only, one-time: point the web root at
<deploy_path>/currentand reload the server. - The job container needs
apt-getif rsync or ssh are missing (Debian-based images only).
Dependency snapshots
node-modules and composer-vendor replace actions/cache: instead of a
tarball uploaded to the forge and downloaded back, the installed tree is kept
as an immutable directory on a volume of the runner host and restored with a
symlink (node_modules) or a cp -a --reflink (vendor). Hit = seconds. Miss
= install from the shared download cache, then publish the tree atomically
(mv -T), so concurrent jobs never see a half-written snapshot.
Runner side, once: list the host directory in the runner's
container.valid_volumes, then mount it in the job:
runs-on: docker
container:
image: node:22
volumes:
- /srv/infra/forgejo/ci-cache:/ci-cache
env:
CI_CACHE: /ci-cache/${{ github.repository }} # one root per <org>/<repo>
Layout on the volume:
/ci-cache/
npm/ shared npm download cache
pnpm-store/ shared pnpm store
composer/ shared Composer download cache
<org>/<repo>/
node_modules/<pm>-<hash>/node_modules/ one snapshot per lockfile hash
vendor-<dev|nodev>-<hash>/ one snapshot per composer.lock hash
The snapshot directory is itself named node_modules on purpose: Node
resolves the symlink to its real path and walks up looking for a directory
literally called node_modules, so a flat <hash>/ tree would make hoisted
dependencies unresolvable.
node-modules
- uses: https://git.webrocks.net/Cafffeine/actions/node-modules@v1
with:
cache_dir: ${{ env.CI_CACHE }}
npm_version: "11" # when the lockfile is written by npm 11
- run: npm run build
| Input | Default | |
|---|---|---|
cache_dir |
required | /ci-cache/<org>/<repo> on the bind-mounted volume |
package_manager |
npm |
npm (package-lock.json) or pnpm (pnpm-lock.yaml, via corepack) |
npm_version |
"" |
npm major installed globally before npm ci; empty keeps the image's npm |
npm_cache |
/ci-cache/npm |
Shared npm download cache |
pnpm_store |
/ci-cache/pnpm-store |
Shared pnpm store |
keep |
3 |
Snapshots kept per package manager |
Output: hit (true when restored from a snapshot).
composer-vendor
- uses: https://git.webrocks.net/Cafffeine/actions/composer-vendor@v1
with:
cache_dir: ${{ env.CI_CACHE }}
dev: "true" # CI: require-dev packages; deploy: leave false
| Input | Default | |
|---|---|---|
cache_dir |
required | /ci-cache/<org>/<repo> on the bind-mounted volume |
dev |
false |
true installs require-dev (two independent snapshot flavours) |
composer_cache |
/ci-cache/composer |
Shared Composer download cache |
keep |
5 |
Snapshots kept per flavour |
dump_autoload |
true |
Run composer dump-autoload --optimize for the current commit afterwards. Composer scripts run only here (--no-scripts at install) |
Output: hit.
Versioning
Tag releases and keep a moving major alias so callers can pin @v1:
git tag v1.1.0
git tag -f v1 # move the v1 alias onto the newest v1.x
git push origin v1.1.0
git push -f origin v1
Adding an action or fixing a bug: new minor/patch, move v1. Renaming an input
or changing a behavior: new major (v2), then migrate projects one at a time.