No description
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Thomas Menga 079d3b6bda ✨ feat: add dependency snapshot cache actions
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
2026-09-28 12:34:08 +02:00
composer-vendor ✨ feat: add dependency snapshot cache actions 2026-09-28 12:34:08 +02:00
deploy-ssh ✨ feat: generalize deploy-ssh action 2026-07-01 17:14:23 +02:00
deploy-static ✨ feat: add reusable composite actions 2026-09-25 01:24:12 +02:00
node-modules ✨ feat: add dependency snapshot cache actions 2026-09-28 12:34:08 +02:00
notify-telegram ✨ feat: add reusable composite actions 2026-09-25 01:24:12 +02:00
resolve-environment ✨ feat: add reusable composite actions 2026-09-25 01:24:12 +02:00
setup-ssh ✨ feat: add reusable composite actions 2026-09-25 01:24:12 +02:00
README.md ✨ feat: add dependency snapshot cache actions 2026-09-28 12:34:08 +02:00

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

  1. 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.

  2. Reference it by full instance URL. This works regardless of the runner's DEFAULT_ACTIONS_URL and of the caller's org:

    - uses: https://git.webrocks.net/Cafffeine/actions/notify-telegram@v1
    
  3. 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:

  1. set [actions] DEFAULT_ACTIONS_URL = https://git.webrocks.net in the Forgejo config and use the short form Cafffeine/actions/deploy-ssh@v1, or
  2. split the action into its own public repo Cafffeine/deploy-ssh and reference https://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 of source into deploy_path. Nothing else is touched. Use for build artifacts, static assets, config, or repo source: anything you just want copied over.
  • atomic: rsync into releases/<id>/ (unchanged files hard-linked from the previous release), then swap a current symlink 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 -T for the atomic swap), standard on Linux. Only needed for strategy: atomic.
  • Atomic only, one-time: point the web root at <deploy_path>/current and reload the server.
  • The job container needs apt-get if 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.