- Python 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
release / release (push) Successful in 1m21s
Closes #52 |
||
| .forgejo | ||
| deploy-vps | ||
| grafana-dashboard | ||
| notify-telegram | ||
| openbao-fetch | ||
| release | ||
| report-job-failure | ||
| security-scan | ||
| templates | ||
| .gitignore | ||
| .markdownlint-cli2.jsonc | ||
| README.md | ||
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@tagresolves against the runner's default actions mirror (data.forgejo.org), not this server. Writehttps://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 onmainbreaks 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:
- Move the
v3tag to the new commit (git tag -f v3 origin/main && git push -f origin v3). - Run the sync-dashboards workflow in
amtronics/infrastructure-vps(Actions → Run) — it dispatches every app's Monitoring workflow, so all re-render the current@v3in 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:
- version —
actions/checkoutwithfetch-depth: 0, then thereleaseaction above. Itsreleased/versionoutputs gate everything downstream; nothing releasable in the commits means the whole pipeline no-ops. - publish — build and push the image(s), tagged
…-v{version}and…-{sha}. Neverlatest: a deployment references an immutable tag. - deploy — gated on
needs.version.outputs.released == 'true' && vars.ENABLE_VPS_DEPLOY == 'true', viadeploy-vps(or the app's own deploy action when it runs behind a reverse proxy on published ports). - notify —
notify-telegramin a separateif: 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.
Related
amtronics/infrastructure-vps— hosts, edge, prometheus, grafana, lokiahmad/portfolio— the first consumer, and the source of the pattern