Reusable Forgejo Actions and the observability contract every app implements
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Ahmad El Masri e9f1bddf9f
All checks were successful
release / release (push) Successful in 1m21s
fix: Build the comment body without requiring jq (#53)
Closes #52
2026-09-06 01:34:20 +00:00
.forgejo chore: Check the templates, not only the actions (#49) 2026-09-05 10:32:46 +00:00
deploy-vps fix: Refuse a profile the compose file does not declare (#47) 2026-09-05 09:02:45 +00:00
grafana-dashboard fix: Render the deployed version in the Version panel (#18) 2026-08-17 12:57:39 +00:00
notify-telegram feat: Add notify-telegram composite action (#3) 2026-08-12 13:49:22 +00:00
openbao-fetch feat: Host openbao-fetch and document full-URL consumption (#5) 2026-08-12 21:33:08 +00:00
release fix: Retry the release POST instead of stranding the tag (#44) 2026-09-04 19:02:43 +00:00
report-job-failure fix: Build the comment body without requiring jq (#53) 2026-09-06 01:34:20 +00:00
security-scan feat: Expose the security scan output so consumers can report it (#32) 2026-09-04 06:04:14 +00:00
templates fix: Treat a rejected Docker Hub token like a missing one (#51) 2026-09-05 18:32:35 +00:00
.gitignore fix: Match an expression that contains braces (#36) 2026-09-04 09:32:38 +00:00
.markdownlint-cli2.jsonc chore: Validate the actions on every pull request (#27) 2026-09-03 06:32:38 +00:00
README.md feat: Extract the failure-tail reporter into an action (#45) 2026-09-05 07:03:16 +00:00

platform-actions

Reusable Forgejo Actions, and the observability contract every application implements.

Validation

.forgejo/workflows/pr.yml runs on every pull request and is required on main. It parses each action.yml, checks the fields a composite action needs, and runs bash -n over every run: body with the ${{ }} expressions replaced by a placeholder — .forgejo/scripts/check-actions.py does the work and can be run locally with python3 .forgejo/scripts/check-actions.py.

It cannot execute the actions: that needs a host, a registry and credentials this pipeline has no business holding, and the consuming repositories' own runs are where that happens. What it does catch is a change that does not parse or is not valid shell — which, in a repository whose v3 tag moves on every merge, would otherwise reach every pipeline in the fleet unexecuted.

Consuming an action

Two rules, both learned the hard way on this instance:

  • Full URL, always. A bare owner/repo/action@tag resolves against the runner's default actions mirror (data.forgejo.org), not this server. Write https://git.ahmadelmasri.com/amtronics/platform-actions/<action>@<tag>.
  • Pin a tag, never main. An action referenced by every repository is production infrastructure: a bad commit on main breaks every pipeline at once, including the one needed to ship the fix.

The runner clones actions anonymously, which is why this repository is public and holds no secrets — credentials always come from the caller.

Actions

openbao-fetch

Authenticates to OpenBao with an AppRole and renders an app's kv-v2 secret into a .env file — Phase 1 of ADR-0001 in amtronics/infrastructure-vps, where this action was born and whose docs describe the operator side (adding an app, writing values, minting credentials).

Input Required Description
addr yes OpenBao address, e.g. https://192.168.2.67:8200.
role-id yes AppRole role_id.
secret-id yes AppRole secret_id.
path yes kv-v2 path, e.g. secret/<app>.
output no File to render, default .env.
tls-skip-verify no Default "true" — the LAN cert is self-signed.
- uses: https://git.ahmadelmasri.com/amtronics/platform-actions/openbao-fetch@v3
  with:
    addr: https://192.168.2.67:8200
    role-id: ${{ secrets.OPENBAO_ROLE_ID }}
    secret-id: ${{ secrets.OPENBAO_SECRET_ID }}
    path: secret/<app>

The runner must reach OpenBao on the LAN. Values render as line-based KEY=VALUE; anything multi-line is stored base64 in a *_B64 field and decoded by the consumer.

notify-telegram

Sends a pipeline result to Telegram. Best-effort by design: an empty token or chat id is a clean skip, and a delivery failure is a logged warning — the calling workflow never goes red because of a notification.

Input Required Description
token no Bot token. Empty skips the notification.
chat-id no Target chat id. Empty skips the notification.
status yes success, failure, cancelled or skipped.
title yes Short headline, e.g. CI, Release, Deploy.
url no Link to the run, release, or pull request.
details no Extra line appended to the message.

Consume it from a separate job so a Telegram outage cannot turn a required check red:

notify:
  runs-on: ubuntu-latest
  needs: [validate]
  if: always()
  steps:
    - uses: https://git.ahmadelmasri.com/amtronics/platform-actions/notify-telegram@v3
      with:
        token: ${{ env.TELEGRAM_BOT_TOKEN }}
        chat-id: ${{ env.TELEGRAM_CHAT_ID }}
        status: ${{ needs.validate.result }}
        title: CI
        url: ${{ github.event.pull_request.html_url }}

The credential values come from the caller (typically rendered from OpenBao by openbao-fetch); this action never reads a secret store itself.

deploy-vps

Deploys a tagged release to the VPS over SSH: ships the compose file, an optional collection-agent config, and a runtime env file; pulls the images, recreates the containers, and waits for their healthchecks. App-agnostic — the application supplies its own runtime env (app-env) and container names (healthcheck-containers); the observability inputs are the shared contract.

Input Required Description
version yes Image tag to deploy, e.g. v1.2.3.
deploy-host yes VPS hostname or address.
deploy-user yes SSH user.
deploy-key yes Private key for that user.
host-key yes The host's public key, pinned to verify the connection.
deploy-path yes Directory on the host holding docker-compose.vps.yml.
deploy-domain yes Public hostname traefik routes.
registry-user yes Registry username.
registry-token yes Registry password or token.
healthcheck-containers yes Space-separated container names to wait on (healthy).
health-timeout no Seconds to wait for each container, default 600. A container reporting unhealthy fails immediately regardless.
app-env no Newline-separated KEY=VALUE runtime env, written verbatim for the containers.
grafana-prom-url no Remote-write endpoint. All three grafana-prom-* set enables the agent; all empty skips it.
grafana-prom-user no Grafana Cloud instance id.
grafana-prom-token no Access-policy token with metrics:write.

The wait is a budget in seconds, not a fixed number of attempts, and it is generous on purpose. By the time it runs the rollout has already happened, so a budget that runs out undoes nothing — it only decides whether the pipeline reports the truth about the deployment. A sixty-second wait was reporting failures for deployments that had succeeded, because a container on a loaded host takes minutes to report healthy (ahmad/portfolio v0.15.1, 2026-09-02).

When a wait ends badly the message carries the container, the elapsed seconds and the last health status observed — elapsed as well as the budget, because they differ when the poll overshoots and the elapsed figure is what tells you whether a longer budget would have helped. A container that produced no log output at all says so, rather than printing an empty tail and leaving the reader to wonder whether the logs were lost.

A container that reports no health status at all — a wrong name in healthcheck-containers, or an image without a HEALTHCHECK — fails after a thirty-second grace rather than consuming the budget, since waiting cannot conjure either of those.

unhealthy is treated as a verdict rather than a reason to keep waiting: Docker sets it only once the image's own healthcheck retries are exhausted, so a crash-looping image still fails in seconds instead of costing the whole budget.

- uses: https://git.ahmadelmasri.com/amtronics/platform-actions/deploy-vps@v3
  with:
    version: ${{ steps.target.outputs.version }}
    deploy-host: ${{ env.VPS_DEPLOY_HOST }}
    deploy-user: ${{ env.VPS_DEPLOY_USER }}
    deploy-key: ${{ env.VPS_DEPLOY_KEY }}
    host-key: ${{ env.VPS_HOST_KEY }}
    deploy-path: ${{ env.VPS_DEPLOY_PATH }}
    deploy-domain: ${{ vars.VPS_DEPLOY_DOMAIN }}
    registry-user: ${{ github.actor }}
    registry-token: ${{ env.REGISTRY_TOKEN }}
    healthcheck-containers: myapp-api-1 myapp-web-1
    app-env: |
      SOME_KEY="${{ env.SOME_KEY }}"
    grafana-prom-url: ${{ vars.GRAFANA_PROM_URL }}
    grafana-prom-user: ${{ vars.GRAFANA_PROM_USER }}
    grafana-prom-token: ${{ env.GRAFANA_PROM_TOKEN }}

app-env lines are written into the host env file exactly as given — quote values that need it. The runtime secrets come from openbao-fetch; deploy-vps holds nothing itself.

grafana-dashboard

Pushes an application's standard monitoring dashboard into its own Grafana folder. The layout — service health (RED), API by route, Node.js runtime, and Loki logs — is defined once inside this action and shared by every app; only the job label and container names are substituted. Each app calls it from its own pipeline, so the app owns its dashboard while the template stays single-sourced. A panel whose metric an app does not emit yet shows "No data". Ensures the folder exists (idempotent) and overwrites the dashboard, so the pipeline is the source of truth and UI edits do not stick.

Input Required Description
grafana-url yes Base URL, e.g. https://grafana.ahmadelmasri.com.
grafana-token no Service-account token (Editor). Empty skips the push.
name yes App slug — dashboard uid stem, tag, folder uid.
folder yes Grafana folder title, e.g. Trip.
job yes Prometheus job label, e.g. trip-app.
api-container yes API container name for the "API errors" panel, e.g. trip-app-1.
log-prefix yes Container-name prefix for "All <name> containers" ({prefix}-.*).
- uses: https://git.ahmadelmasri.com/amtronics/platform-actions/grafana-dashboard@v3
  with:
    grafana-url: ${{ vars.GRAFANA_URL }}
    grafana-token: ${{ env.GRAFANA_SA_TOKEN }}
    name: trip
    folder: Trip
    job: trip-app
    api-container: trip-app-1
    log-prefix: trip

The token comes from openbao-fetch (a Grafana service-account token with the Editor role); this action never reads a secret store itself.

Propagating a template change

The dashboard layout is single-sourced here, but each app renders it from its own Monitoring workflow. So a change to this action — and moving the v3 tag — does not update dashboards that were already pushed: each app re-renders only when its Monitoring workflow next runs (a monitoring.yml change, a merge, or a manual dispatch). Until every app re-runs, dashboards drift by whichever v3 each last pushed.

To converge them all after a template change:

  1. Move the v3 tag to the new commit (git tag -f v3 origin/main && git push -f origin v3).
  2. Run the sync-dashboards workflow in amtronics/infrastructure-vps (Actions → Run) — it dispatches every app's Monitoring workflow, so all re-render the current @v3 in one click. It lives there, not here: this repo is public and holds no secrets, while that fan-out needs a cross-repo token and belongs with the estate's other reconcile jobs.

Pushes use overwrite: true, so a dashboard edited by hand in Grafana is restored to the template on the next run — the pipeline is the source of truth.

release

Derives the next version from the commits since the last tag, tags it vX.Y.Z, and publishes a Forgejo release with human-readable notes — the single source of the version/tag/notes logic every app's release.yml used to copy. Run it in a version job, after actions/checkout with fetch-depth: 0.

Input Required Description
forgejo-token yes Per-run token (secrets.GITHUB_TOKEN); pushes the tag and creates the release.
openrouter-key no Humanises the notes. Empty, no node runtime, or an API error falls back to the commit list.
openrouter-model no Default anthropic/claude-haiku-4.5.
audience no Who the notes are for, used in the prompt.
force-bump no auto (default) or patch — mint a release with no feat/fix commit (the rebake path).
exclude-types no Commit types kept out of the fallback list. Default chore style test.
Output Description
released true when a version was tagged and released.
version The version without the leading v, e.g. 1.2.3.
previous The previous tag, e.g. v1.2.2.
notes-fallback true when the notes are the raw commit list, not an AI summary.
version:
  runs-on: ubuntu-latest
  container: { image: node:22-bookworm }
  outputs:
    released: ${{ steps.release.outputs.released }}
    version: ${{ steps.release.outputs.version }}
  steps:
    - uses: actions/checkout@v4
      with: { fetch-depth: 0 }
    # Fetch OPENROUTER_API_KEY from the app's secret, load it into the env, then:
    - id: release
      uses: https://git.ahmadelmasri.com/amtronics/platform-actions/release@v3
      with:
        forgejo-token: ${{ secrets.GITHUB_TOKEN }}
        openrouter-key: ${{ env.OPENROUTER_API_KEY }}
        audience: users of <app>

The tag push and the release API call both use the per-run token; the caller adds nothing to its secret store for this action. Downstream jobs gate on needs.version.outputs.released == 'true'.

security-scan

Scans for leaked secrets (gitleaks over the working tree), vulnerable dependencies (osv-scanner over lockfiles, recursively) and, when an image ref is passed, container vulnerabilities (trivy). Report-only by default: a new repo runs it for a while to observe noise, then flips enforce and registers the job as a required check. Scanner binaries are pinned by version and sha256 and fetched from their official releases — never a vendor action wrapper (trivy's release pipeline was compromised twice in 2026; the checksum is the defence). A scanner that cannot run fails the job in every mode — an error is not a pass.

Input Required Description
scan-secrets no Default true. gitleaks over the checkout.
scan-deps no Default true. osv-scanner, recursive. Sends dependency names/versions to OSV.dev — never code.
scan-image no Image ref for trivy. Empty skips. Login beforehand if the registry is private.
enforce no Default false (report-only). true fails on findings.
severity-threshold no Default HIGH. Minimum severity that counts in enforce mode.
Output Description
log-path /tmp/security-scan.log — the combined output of every scanner that ran.

Reading the log. Forgejo serves no job logs over its API, so a red scan is a bare word unless the job posts its own output. Every scanner tees into log-path, which is truncated when the action starts, so it holds this run and not a previous job's. It exists from the first step onward and may legitimately be empty — a consumer must treat a missing file and an empty one as the same thing and say so, rather than posting an empty code fence.

Typical PR job:

security:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - uses: https://git.ahmadelmasri.com/amtronics/platform-actions/security-scan@v3

In release.yml, scan the image between build and push with scan-image: <registry>/<owner>/<repo>:<tag>.

To post the output on a failed scan:

    - name: Report the failure on the pull request
      if: failure()
      env:
        FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        PR_NUMBER: ${{ github.event.pull_request.number }}
      run: |
        set -uo pipefail
        rm -f /tmp/scan.tail
        if [ -s /tmp/security-scan.log ]; then
          tail -c 40000 /tmp/security-scan.log > /tmp/scan.tail
        fi
        # Guarded on the log having content, not on `> file || true`: the
        # redirection creates the file before tail can fail, so an absent log
        # would otherwise read back as an empty string and post an empty box.
        ...

The scanners redact as they report — gitleaks runs with --redact — but the log is read back from a file rather than from the runner's stdout, so its masking does not apply to it. Do not add a step that echoes a secret before the tee.

report-job-failure

Posts the tail of a failed job's output as a pull request comment, so the failure is in front of whoever is already looking at the change.

Input Required Description
log-path yes The file the job teed its output into.
pr-number yes Pull request to comment on. Empty is a no-op, which is what makes the action safe on push builds.
forgejo-token yes Per-run token (secrets.GITHUB_TOKEN).
job-name no Name used in the comment, e.g. validate.
max-bytes no Default 40000. How much of the tail to post.
      - name: Report the failure on the pull request
        if: failure()
        uses: https://git.ahmadelmasri.com/amtronics/platform-actions/report-job-failure@v3
        with:
          log-path: /tmp/validate.log
          pr-number: ${{ github.event.pull_request.number }}
          forgejo-token: ${{ secrets.GITHUB_TOKEN }}
          job-name: validate

The job must capture its own output — the action cannot do it. A composite action cannot wrap steps it does not own, so each step tees, and the shape matters:

      - name: Test
        run: |
          set -o pipefail
          pnpm test 2>&1 | tee -a /tmp/validate.log

set -o pipefail is not optional. A run: body is bash -e without it, so cmd 2>&1 | tee log exits with tee's status and a genuinely failing step reports success — that turned a red job green in ahmad/portfolio #70 before it was noticed.

Do not use exec > >(tee -a log) 2>&1. Both forms keep the command's exit status, but the process substitution's tee outlives the shell, so the log can be unflushed or entirely absent when a later step reads it — measured on ahmad/expiro, where the file did not exist immediately after the step returned. The pipeline finishes writing before it returns.

Two things the action does on purpose. It never fails the step: it runs when the job has already failed, so a reporter that cannot post must not replace a clear red with a confusing one — it says so on stderr and exits 0. And it treats an absent or empty log as "no output was captured" rather than posting an empty code fence, which is the defect every hand-written copy of this has had: > tail creates the file before tail can fail, so a missing log reads back as "" instead of raising and the obvious fallback never fires.

The tail bypasses the runner's secret masking. It is read from the file tee wrote and posted verbatim, so ::add-mask:: — which only redacts what the runner renders into its own log — does not touch it. Do not tee a step that holds a secret. Capture that step separately, or redact before the value reaches the log. This is the hazard adoption invites rather than a problem with the action: a consumer that tees a deploy or publish step will put its token in a pull request comment.

It posts with curl and needs jq. Both are present in the images used here, except a bare node image — ahmad/imamah's job container is node:22-bookworm and posts through node's global fetch for that reason. A consumer on a node-only image should install curl and jq or keep its own reporter; the action says so rather than failing silently.

The run log is fetchable, contrary to what several comments in these repositories still claim: GET /api/v1/repos/{owner}/{repo}/actions/jobs/{job_id}/logs, with the job id from /actions/runs/{run_id}/jobs and the run id from /actions/runs. The number in a status target_url is a per-repository index, not the id those routes take, which is why passing it 404s and reads as a missing endpoint. This action exists for discoverability, not because the log is unreachable.

Why this exists

Every application deploys itself from its own repository. That is deliberate — amtronics/infrastructure-vps owns hosts and shared services, not apps. But it means anything an app must do to be observable is currently copied per repo: the alloy sidecar, the analytics tag, the log shipping, the credential wiring.

Copied means drifted. An endpoint change becomes N pull requests, and the app nobody touched that quarter is the one still writing to an address that no longer answers.

Why not in infrastructure-vps

Referencing an action from that repository would require every app's CI to have read access to it — the repository that also holds the inventory and the encrypted vault. A shared action is consumed by everything; the infrastructure repository should be consumed by nothing.

The contract

An application is "observable" when it does four things. Each becomes an action or a documented fragment here.

What the app does What infrastructure provides
Metrics runs an alloy sidecar scraping its own private exposition, remote-writes /api/v1/write endpoint, one htpasswd credential per app
Logs emits structured JSON to stdout alloy on the host ships to Loki
Analytics script tag, build-time variables umami instance and a website id
Availability declares its public hostnames blackbox probes and a Kuma monitor

Two properties matter more than convenience:

One credential per application, never a shared one. Revoking an app's access must not rotate a secret every other app also holds.

An app that opts out still deploys. Every piece degrades to a skip when its variables are unset, the way the portfolio's monitoring sync already does. Observability that can break a deployment will eventually be removed by someone under pressure.

Versioning

Consumers pin a tag, not main. An action referenced by every repository is production infrastructure: a bad commit on main breaks every pipeline at once, including the one needed to ship the fix.

Releases are automatic. On merge to main, release.yml derives the next version from the commits (Udacity type: prefixes — feat: → minor, fix: → patch, a body BREAKING CHANGE → major; anything else is not a release), tags vX.Y.Z, and moves the major tag vX to point at it.

So pin the major (@v3): you get fixes and backward-compatible additions automatically, with no pull request per repo. A breaking change lands as a new major (@v4) that you adopt deliberately, when you are ready.

The release flow

Every application ships the same four-job release.yml, triggered on merge to main. Only the middle two jobs carry app-specific detail:

  1. versionactions/checkout with fetch-depth: 0, then the release action above. Its released / version outputs gate everything downstream; nothing releasable in the commits means the whole pipeline no-ops.
  2. publish — build and push the image(s), tagged …-v{version} and …-{sha}. Never latest: a deployment references an immutable tag.
  3. deploy — gated on needs.version.outputs.released == 'true' && vars.ENABLE_VPS_DEPLOY == 'true', via deploy-vps (or the app's own deploy action when it runs behind a reverse proxy on published ports).
  4. notifynotify-telegram in a separate if: always() job, so a provider outage never reddens the pipeline.

Base images are pulled authenticated. Docker Hub counts anonymous pulls per IP and every repository on this fleet shares one, so a burst of releases can fail an unrelated build with toomanyrequests. The publish job therefore logs in to docker.io with a read-only token before building. The step is deliberately a plain docker login rather than docker/login-action, which fails a job on an empty password: a caller without the secrets still builds, anonymously, with a warning in the log. Two secrets, neither ever written here as a value:

Name Type Scope Where it lives Required Description
DOCKER_HUB_USER secret CI (org, and the ahmad user's repositories) Forgejo secret store no Docker Hub account the read token belongs to.
DOCKER_HUB_READ_PUBLIC secret CI (org, and the ahmad user's repositories) Forgejo secret store no Read-only public-repo access token; raises the anonymous pull limit.

Both are optional by design — absent, the build is anonymous rather than broken. The runner's own image pulls are a separate matter and are handled host-side (amtronics/infrastructure-vps#395), because a job's container: image is fetched before any step of this workflow runs.

Deploy is a job here, not a tag-triggered workflow. Forgejo suppresses workflow triggers for events authored by the per-run token, so a tag this pipeline pushes never fires on: push: tags — the deploy would silently never run. deploy.yml therefore exists only for workflow_dispatch: the retry and rollback path, sharing the same deploy action, dispatched with the version to restore.

Starting a new app? Copy templates/release.yml to .forgejo/workflows/release.yml, replace the <PLACEHOLDER>s (image repo, secret/<app> path, notes audience, container names, build-args, app-env), and add a deploy.yml on workflow_dispatch for rollback (copy it from ahmad/portfolio). imamah, portfolio and launchpad are three worked references.

  • amtronics/infrastructure-vps — hosts, edge, prometheus, grafana, loki
  • ahmad/portfolio — the first consumer, and the source of the pattern