- Python 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .forgejo | ||
| deploy-vps | ||
| docs | ||
| grafana-dashboard | ||
| install-docker-cli | ||
| 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.
Which names a caller has to hold, and which store each belongs in, is docs/CONFIGURATION.md. The sections below describe each action's inputs; that document is the register of store names behind them, plus the three rules that decide whether a value actually arrives.
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). It requires node on PATH (every
job container in this fleet is a node image) and fails fast naming that
requirement; it installs nothing at runtime unless curl is missing — an
in-job apt-get is one expired mirror away from failing the whole job.
It stops the job by default, and that default is the safe one. Most callers
here have nothing after this step that would notice an empty .env, so a deploy
would start containers with no DATABASE_URL and report success. Set
required: false only where the job's work is worth doing without the secrets —
the notify job is the case it was added for, since a vault blip should not
turn a build that was fine into a red run.
| Input | Required | Description |
|---|---|---|
addr |
no | Default https://bao.internal:8200, the name the runners resolve to the vault. Omit it unless overriding; a value passed here always wins. |
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. |
required |
no | Default "true": a fetch that does not succeed fails the job. "false" writes an empty output file, warns with the reason and continues — for a job whose work is worth doing without the secrets, such as sending a notification. |
- uses: https://git.ahmadelmasri.com/amtronics/platform-actions/openbao-fetch@v3
with:
role-id: ${{ secrets.OPENBAO_ROLE_ID }}
secret-id: ${{ secrets.OPENBAO_SECRET_ID }}
path: secret/<app>
The runner must resolve bao.internal and reach the vault over the WireGuard tunnel. 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 ➖ for a pipeline that correctly did nothing — a merge that warranted no release is none of the other three. Any other value is sent with a warning icon rather than rejected. |
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. Written to the host .env as DEPLOY_DOMAIN; the compose decides what it means. |
registry-user |
yes | Registry username. |
registry-token |
yes | Registry password or token. |
healthcheck-containers |
yes | Space-separated container names to wait on (healthy). |
rolling-services |
no | Space-separated compose services to swap without downtime (see below). Empty, the default, recreates every container as before. |
rolling-settle-seconds |
no | Seconds the old replicas keep serving after the new ones are healthy, default 10, so the proxy routes to the new ones before the old ones stop. |
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 for the containers. Pass values raw; the action quotes them. |
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 }}
Zero-downtime rollout (opt-in)
With rolling-services set, each listed service is swapped instead of
recreated: compose starts a new replica beside the running one, the action
waits for the new replica's healthcheck by container id, and only then stops
the old one and removes it. If the new replica reports unhealthy, has no
HEALTHCHECK, or runs out of health-timeout, it is removed, the old one
keeps serving, and the deploy fails. The other services are recreated as
before, first, with --no-deps; any of them whose restart policy is no is
treated as a one-shot (a migration) and must exit 0 before the swap starts,
or the deploy fails with the old replicas untouched. A long-running service
must therefore set a restart policy.
What a consuming repository changes:
- Drop
container_namefrom each rolled service; compose names the replicas. The action refuses a rolled service that still sets one. - Keep a
HEALTHCHECKon it. Traefik's docker provider only routes to a healthy container, which is what makes the overlap safe. - Add a Traefik retry middleware (5 attempts) to its router, and point its
service at the
rolling-deploy@fileservers transport (a 1 s dial timeout, in the VPS's traefik dynamic configuration). A request already in flight when the old replica stops keeps its retries on the old routing table; without the short timeout its dial to the stopped replica hangs for traefik's default 30 s, and with too few attempts it can land on the stopped replica every time. - Make the old replica stop gracefully. The action stops it with plain
docker stop, so the image's stop signal and the service'sstop_grace_periodapply: the process gets that long to finish in-flight work before it is killed. Setstop_grace_periodabove the longest job the process can be in the middle of, and handle the signal (nginx images already useSIGQUIT, its graceful stop). - Remove the rolled containers from
healthcheck-containers.
services:
web:
image: registry/app:${IMAGE_TAG}
stop_grace_period: 30s
healthcheck:
test: ['CMD', 'wget', '-qO-', 'http://127.0.0.1:8080/healthz']
labels:
- traefik.http.middlewares.app-retry.retry.attempts=5
- traefik.http.routers.app.middlewares=app-retry
- traefik.http.services.app.loadbalancer.serverstransport=rolling-deploy@file
healthcheck-containers: app-mail-1
rolling-services: web
A deploy that changes a rolled service's traefik labels is not zero-downtime: while old and new replicas disagree, traefik refuses the router (404s for about 2 s, measured on suhba-web). Ship label changes in a quiet window.
Only the rolled services are zero-downtime. A service that is recreated still has its usual gap, and the swap assumes one host and a schema both versions can run against.
app-env values are quoted by the action, so the containers see exactly the
bytes passed — $, backticks, backslashes and " included. Pass values raw;
every value is quoted uniformly (the old honour-consumer-quoting branch is
gone — nothing used it). The one forbidden byte is a single quote: the shell
and compose's dotenv parser cannot agree on an escaping for it, so a value
containing one fails the deploy naming the key, as does a line whose key is
not a valid variable name. 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 | Regular expression for the API container name in the "API errors" panel, e.g. trip-app-1, or rihla-api-.* for a rolled service. |
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, an API error, or an answer that is not release notes 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.
install-docker-cli
Installs docker-ce-cli from Docker's own apt repository, for jobs that build
or push an image from the runner's default container.
No inputs.
- uses: https://git.ahmadelmasri.com/amtronics/platform-actions/install-docker-cli@v3
- uses: docker/setup-buildx-action@v3
The runner's default image is node:22-bookworm, which ships curl and git
but not docker — checked by running the image, and it is why deleting
these steps was not an option. Every publishing job carried its own copy of the
install before this: sixteen of them, and they had already diverged once when
two repositories needed a workaround for bullseye's unsigned security suite and
the others did not.
It is a no-op when docker is already present. That is not defensive
padding: whether the runner image would ship the CLI is the question that
parked #59, and the next image may answer it differently. When it does, this
becomes a skip in every consumer at once rather than a reinstall in each.
Architecture and suite are read from the machine (dpkg --print-architecture,
/etc/os-release) rather than written down, so the step follows the runner
image to its next base instead of needing a patch in every copy — which is
exactly what the bullseye workaround was.
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.
Naming the error-reporting DSN
Two names, never one, and the distinction is not stylistic:
| Name | When it is read | Who can see it | Where the value lives |
|---|---|---|---|
VITE_GLITCHTIP_DSN |
build time, as a build arg | anyone who loads the page — it is compiled into the bundle | the app's variables.yml, as a Forgejo variable |
GLITCHTIP_DSN |
runtime, by the server process | only the container | OpenBao secret/<app>, delivered through app-env |
The VITE_ prefix is not decoration — it is what makes the bundler expose the
value to browser code — so the public one cannot be named anything else.
Collapsing the two into a single name is the mistake to avoid. It makes one rotation touch both scopes, and it puts a value that is public by construction into the store meant for private ones. An app with only a browser bundle needs only the first; an app with only an api needs only the second; an app with both holds two independent values.
A DSN can submit events and not read them, so a leaked browser DSN is not a disclosure — it is an invitation to send noise to your project. That is the reason it is merely public rather than an incident, and not a reason to store it carelessly.
Publishing model-generated text
Release notes are rewritten by a model into something a person can read. That makes the model's answer a product output, published under the project's name to people who never see the pipeline — so it is checked before it ships, the way any other generated artefact is.
Two rules, both learned from a release whose public notes read "I don't see any commit subjects in your message. Could you please provide the list…":
Never ask for a summary of nothing. An empty commit range is a real case — the rebake path, dispatched when configuration changed but no commit did. Given no input, a model answers about the absence of input. Say plainly that nothing changed instead; there is no summary to make.
A non-empty answer is not a valid answer. Validate the shape the prompt
asked for — the expected headings, and bullets under them — and treat anything
else as a failed rewrite: a question, an apology, a refusal, a paragraph
explaining what the model could not do. A failed rewrite falls back to the
commit-derived notes, which are terse but always true, and sets
notes-fallback so the caller can surface it. The rejecting rule is named in
the job log, so a bad prompt is diagnosable without re-running the release.
The general form, for any future step that publishes what a model wrote: a deterministic fallback that is always correct, a shape check on the generated version, and the fallback whenever the check fails. Never publish an unchecked answer, and never let a failed generation fail the release.
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 five-job release.yml, triggered on merge to
main. Only the middle jobs carry app-specific detail:
-
checks — the repository's PR validate job, run again on
main. Everything else needs it, so a red tree tags nothing, publishes nothing and deploys nothing.This exists because a pull request can be correct in isolation and wrong on arrival: two branches that each pass, merge cleanly, and leave
mainred. Before this job nothing looked again after the merge, and releases went out over a broken tree (#84).It gates the whole flow rather than sitting between version and publish, because version tags and publishes the Release — a check failing after it would leave a tag and notes for a version that never shipped and burn the version number. The cost is the check suite on every merge to
main, including merges that warrant no release; the second thing it buys is that a redmainis reported when it happens rather than discovered by whoever opens the next pull request.Copy the PR workflow's validate job, not its step list.
services:, job- and step-levelenv:, and any assertion steps come with it — they are often what makes the checks mean anything.ahmad/larda's store suites skip themselves whenDATABASE_URLis unset, so its steps without itsservices:block give a green job that ran none of the SQL, which is the stateahmad/larda#49closed. Pin the same pnpm, too: an emptyPNPM_VERSIONdoes not fail, it activates the newest release. A check that gates a pull request and not a release is a check with a hole in it. -
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. It needs checks as well as the other three, and names that case in its details line: a release blocked by a red tree is the one outcome where nothing downstream even attempted, and "the checks on main did not pass" wants different people looking than "the deploy fell over".
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