Open Software backend CI (Rust → GHCR)
This skill captures the pipeline that ships alongside os-accounts and
os-platform, with deployment intentionally removed. It pairs with
os-rust-backend (the code shape) — that skill defines what gets built;
this one defines how it ships as an artifact.
The pipeline in one sentence: CI checks every PR, every push to main
builds an immutable :<short-sha> image plus a mutable :staging pointer in
GHCR, and a manual workflow promotes an existing image to :production by
re-tagging — never rebuilding. What runs that image is the deployer's
concern; this pipeline stops at the GHCR push.
The three workflows + two composite actions
| File |
Trigger |
What it does |
.github/workflows/api.yml |
PR + push to main |
Lint / format / test / OpenAPI drift. Never publishes images. |
.github/workflows/build-api.yml |
Push to main touching api/ paths, or manual dispatch |
Builds + pushes :<short-sha> and :staging to GHCR. Stops there. |
.github/workflows/promote.yml |
Manual dispatch |
Verifies an existing image, resolves it to a SHA, re-tags :<short-sha> → :production in GHCR. |
.github/actions/setup-rust/action.yml |
composite |
Reads rust-toolchain.toml, installs rustup + components + mold, caches registry and target/. |
.github/actions/changed-paths/action.yml |
composite |
Diffs against the right base to decide whether api/ changed; gates all jobs. |
Route the request first
| If the user asks for... |
Open first |
Non-negotiable |
| "set up CI for the API" / "add format + clippy + tests on PR" |
ci-workflow.md |
api.yml is path-aware via changed-paths. PRs never push images. |
| "build the image on every main push" / "tag staging in GHCR" |
build-workflow.md |
Tag both :<short-sha> (immutable) and :staging (mutable pointer). Stop at the GHCR push. |
| "promote to production" / "manual prod tag" / "rollback" |
promote-workflow.md |
Re-tag an existing image. Never rebuild on promote. Rollback = promote a previous SHA. |
"what does setup-rust do" / "rust install in CI" |
composite-actions.md |
Composite reads rust-toolchain.toml; everything else inherits from it. |
| "what secrets / vars do I need" |
secrets-and-vars.md |
Just GHCR via the built-in GITHUB_TOKEN; no third-party secrets in scope here. |
| "how do I deploy this image?" |
out of scope — see your deployer's docs |
This pipeline stops at GHCR. Add a deploy step in your own workflow that consumes the published tag. |
Mental model: tags as pointers, SHA as identity
Every image goes to ghcr.io/<org>/<service-name> with these tags:
ghcr.io/open-software-network/os-accounts-api
├── :a1b2c3d <- immutable artifact, the short SHA
├── :staging -> a1b2c3d <- mutable pointer to the current staging artifact
└── :production -> 9z8y7x6 <- mutable pointer to the current production artifact
Five rules fall out of this:
- Build once, promote many. Promotion re-tags an existing image; it never
rebuilds. Whatever bytes exercised in staging are the same bytes that get
the
:production marker.
- SHA is identity. Anything that pins or consumes an image should pin to
:<short-sha>, not to :staging / :production. Pin tools to SHAs; let
tags float for humans.
- Tags are for humans and tooling.
:staging / :production are
convenient names for "the current release" — they're not what
production-grade consumers should pin to, they're how an operator selects
which artifact to promote.
- PRs never publish. Pull requests run checks only. No GHCR pushes. The
image only exists once
main has the change.
- Rollback uses the same workflow.
gh workflow run promote.yml -f from-tag=<previous-sha> is the rollback. No separate workflow because
promotion already takes any source tag.
Pipeline shape, end-to-end
┌────────────────────────────────────────────────────────────┐
│ Pull request (any branch) │
│ │
│ api.yml │
│ ├── changes (composite) path-aware │
│ ├── fmt-check │
│ ├── lint (clippy -D warnings) │
│ ├── test (Postgres in docker, real DB) │
│ ├── contract (regenerate openapi.json, diff) │
│ └── summary (gate: all green or vacuous pass) │
│ │
│ no images. no GHCR push. no main mutation. │
└────────────────────────────────────────────────────────────┘
push to main
│
▼
┌────────────────────────────────────────────────────────────┐
│ build-api.yml │
│ build (cargo-chef multi-stage, GHA cache scope=api) │
│ -> push :a1b2c3d (immutable) │
│ -> push :staging (moves pointer) │
│ STOP. The deployer's pipeline picks up from GHCR. │
└────────────────────────────────────────────────────────────┘
manual dispatch (Actions → "Promote artifact to production")
│
▼
┌────────────────────────────────────────────────────────────┐
│ promote.yml (inputs: artifact, from-tag=staging|<sha>) │
│ select │
│ verify-images (imagetools inspect, fail if missing) │
│ retag buildx imagetools create :production │
│ pointed at the verified image's digest. │
│ STOP. │
└────────────────────────────────────────────────────────────┘
Setup checklist (new repo)
The user is bringing this pipeline to a fresh service. Walk through this list.
- Confirm the backend follows the layout in
os-rust-backend. The CI
assumes api/ is the workspace root, api/Dockerfile exists,
api/openapi.json is checked in, and there is a make target for each
step (make api-fmt-check, make api-lint, make api-test,
make contract-check).
- Copy the two composite actions into
.github/actions/ — see
composite-actions.md for the templates.
- Copy
api.yml, build-api.yml, promote.yml into .github/workflows/
— see the per-workflow references for the templates. Replace
<service>-api with your service name and ghcr.io/<owner>/ if you're
publishing under a different org.
- Configure GitHub — see secrets-and-vars.md.
For this scope you need almost nothing: each workflow declares its own
packages: write permission so GHCR pushes work with the built-in
GITHUB_TOKEN. No third-party secrets.
- Bootstrap the image once. Before anything else can consume the image,
it has to exist:
gh workflow run build-api.yml
This pushes :<short-sha> and :staging to GHCR. From here the deployer
side of the world takes over.
- First production tag:
gh workflow run promote.yml -f artifact=api
This re-tags :staging → :<sha> → :production in GHCR. Again, no
deploy step happens here — that's where the deployer's tooling picks up.
Golden rules (and why they matter)
- PRs run checks only. No GHCR pushes. If a PR could push an image, an
attacker who lands a malicious PR could escalate to the registry. The
split keeps publishing behind the merge gate.
- Tags are mutable; SHAs are not.
:staging and :production move;
:<short-sha> never does. Anything that wants reproducible pinning should
pin a SHA, not a tag.
- The build job is separate from the promote job. They run on different
triggers, have different concurrency rules, and fail independently. Don't
collapse them.
changed-paths is the gate, not the matrix. Path filters on on.push
would skip the workflow; the composite skips the job. That difference
matters because the "summary" job needs to exist (status check) even on
docs-only PRs — it just passes vacuously.
- Concurrency groups cancel in-progress on the same ref, but not on
main. PR concurrency is api-${{ github.ref }} with
cancel-in-progress: true so a force-push cancels the previous run. Build
and promote workflows use cancel-in-progress: false to avoid killing a
half-pushed image.
- Production promotion is idempotent. Running it twice with the same
inputs is harmless. Re-running after a partial failure is the recovery
path. Don't add "do this only once" guards.
- The build cache is scoped per service.
cache-to: type=gha,scope=api
keeps services from evicting each other. Use the same scope on
cache-from.
- Pre-push validation matters. Before pushing a change to
.github/
or api/Dockerfile, run actionlint, bash -n any inline shell, and a
local docker build to verify the Dockerfile. A broken workflow is a
broken main branch.
- Deployment lives in the deployer's repo. Questions about how the
image reaches a running environment are not CI questions; do not invent a
deploy step. The
:production tag exists exactly so the deployer has a
stable pointer to consume without coupling them to our build pipeline.
Files this skill ships
assets/
├── workflows/
│ ├── api.yml # PR + push checks
│ ├── build-api.yml # main → GHCR (push only, no deploy)
│ └── promote.yml # manual dispatch → re-tag :production
└── actions/
├── setup-rust/action.yml
└── changed-paths/action.yml
Copy them as-is for a starting point; the per-workflow references explain
which knobs you'll usually change per service.
Common pipeline mistakes
- Adding image-publish steps to
api.yml. PR CI must not push. Keep the
build workflow separate.
- Using
:latest or :main-latest instead of :<sha>. "Latest" is the
classic anti-pattern — environments drift, rollback is impossible, and
Docker layer cache eviction becomes silent corruption.
- Adding a deploy step to
build-api.yml or promote.yml. Out of scope
for this skill. If you need one, that's a separate workflow (or a separate
repo) that consumes the GHCR tag — keep this pipeline a pure artifact
pipeline.
- Rebuilding on promote. Every rebuild is a chance to ship different
bytes than what staging exercised. The whole point of GHCR-as-staging is
that promote is just
imagetools create --tag :production <digest>.
- One concurrency group for all branches. Use
${{ github.ref }} so
feature-branch pushes don't cancel main runs and vice versa.
- Caching
target/ for the docker build job. The Dockerfile's
cargo-chef layer cache is the right place; host-side target/ cache
fights with it.
- Hardcoding the Rust version in
setup-rust/action.yml. It reads
rust-toolchain.toml — when you bump Rust, you bump that one file and CI
follows. Don't add a redundant input.
- Skipping
actionlint locally. GitHub Actions YAML is full of subtle
traps (heredocs in run, secret context in if, matrix strategy types).
actionlint catches them before push.
1---2name: os-rust-backend-ci3description: Use this skill when the user wants to set up, configure, debug, or extend a GitHub Actions pipeline for a Rust backend that runs CI checks and produces a container image artifact in GHCR. Scope is **testing + artifact only** — deployment is intentionally out of scope. Trigger on: PR checks (fmt, clippy, tests, OpenAPI / contract drift) for a Rust API; build-and-push on push-to-main; tagging images `:<short-sha>` plus mutable `:staging` / `:production` pointers in GHCR; a manual promote workflow that re-tags an existing image instead of rebuilding (build-once); rollback by re-promoting an older SHA; configuring `GHCR_TOKEN` / `packages: write` permissions; debugging `api.yml`, `build-api.yml`, `promote.yml`, or the `setup-rust` / `changed-paths` composite actions. Also trigger for vaguer phrasings like "set up CI for our notes-api", "immutable image refs with manual promote", or any image-tag-promotion / staging-then-production artifact pipeline on a Rust + GHCR stack. Do not trigger for non-Rust backends or g4---56# Open Software backend CI (Rust → GHCR)78This skill captures the **pipeline** that ships alongside `os-accounts` and9`os-platform`, with **deployment intentionally removed**. It pairs with10**`os-rust-backend`** (the code shape) — that skill defines what gets built;11this one defines how it ships as an artifact.1213The pipeline in one sentence: **CI checks every PR, every push to `main`14builds an immutable `:<short-sha>` image plus a mutable `:staging` pointer in15GHCR, and a manual workflow promotes an existing image to `:production` by16re-tagging — never rebuilding.** What runs that image is the deployer's17concern; this pipeline stops at the GHCR push.1819## The three workflows + two composite actions2021| File | Trigger | What it does |22|---|---|---|23| `.github/workflows/api.yml` | PR + push to `main` | Lint / format / test / OpenAPI drift. Never publishes images. |24| `.github/workflows/build-api.yml` | Push to `main` touching `api/` paths, or manual dispatch | Builds + pushes `:<short-sha>` and `:staging` to GHCR. Stops there. |25| `.github/workflows/promote.yml` | Manual dispatch | Verifies an existing image, resolves it to a SHA, re-tags `:<short-sha>` → `:production` in GHCR. |26| `.github/actions/setup-rust/action.yml` | composite | Reads `rust-toolchain.toml`, installs rustup + components + mold, caches registry and `target/`. |27| `.github/actions/changed-paths/action.yml` | composite | Diffs against the right base to decide whether `api/` changed; gates all jobs. |2829## Route the request first3031| If the user asks for... | Open first | Non-negotiable |32|---|---|---|33| "set up CI for the API" / "add format + clippy + tests on PR" | [ci-workflow.md](references/ci-workflow.md) | `api.yml` is path-aware via `changed-paths`. PRs **never** push images. |34| "build the image on every main push" / "tag staging in GHCR" | [build-workflow.md](references/build-workflow.md) | Tag both `:<short-sha>` (immutable) and `:staging` (mutable pointer). Stop at the GHCR push. |35| "promote to production" / "manual prod tag" / "rollback" | [promote-workflow.md](references/promote-workflow.md) | Re-tag an existing image. **Never** rebuild on promote. Rollback = promote a previous SHA. |36| "what does `setup-rust` do" / "rust install in CI" | [composite-actions.md](references/composite-actions.md) | Composite reads `rust-toolchain.toml`; everything else inherits from it. |37| "what secrets / vars do I need" | [secrets-and-vars.md](references/secrets-and-vars.md) | Just GHCR via the built-in `GITHUB_TOKEN`; no third-party secrets in scope here. |38| "how do I deploy this image?" | **out of scope** — see your deployer's docs | This pipeline stops at GHCR. Add a deploy step in your own workflow that consumes the published tag. |3940## Mental model: tags as pointers, SHA as identity4142Every image goes to `ghcr.io/<org>/<service-name>` with these tags:4344```45ghcr.io/open-software-network/os-accounts-api46 ├── :a1b2c3d <- immutable artifact, the short SHA47 ├── :staging -> a1b2c3d <- mutable pointer to the current staging artifact48 └── :production -> 9z8y7x6 <- mutable pointer to the current production artifact49```5051Five rules fall out of this:52531. **Build once, promote many.** Promotion re-tags an existing image; it never54 rebuilds. Whatever bytes exercised in staging are the same bytes that get55 the `:production` marker.562. **SHA is identity.** Anything that pins or consumes an image should pin to57 `:<short-sha>`, not to `:staging` / `:production`. Pin tools to SHAs; let58 tags float for humans.593. **Tags are for humans and tooling.** `:staging` / `:production` are60 convenient names for "the current release" — they're not what61 production-grade consumers should pin to, they're how an operator selects62 which artifact to promote.634. **PRs never publish.** Pull requests run checks only. No GHCR pushes. The64 image only exists once `main` has the change.655. **Rollback uses the same workflow.** `gh workflow run promote.yml -f66 from-tag=<previous-sha>` is the rollback. No separate workflow because67 promotion already takes any source tag.6869## Pipeline shape, end-to-end7071```72 ┌────────────────────────────────────────────────────────────┐73 │ Pull request (any branch) │74 │ │75 │ api.yml │76 │ ├── changes (composite) path-aware │77 │ ├── fmt-check │78 │ ├── lint (clippy -D warnings) │79 │ ├── test (Postgres in docker, real DB) │80 │ ├── contract (regenerate openapi.json, diff) │81 │ └── summary (gate: all green or vacuous pass) │82 │ │83 │ no images. no GHCR push. no main mutation. │84 └────────────────────────────────────────────────────────────┘8586 push to main87 │88 ▼89 ┌────────────────────────────────────────────────────────────┐90 │ build-api.yml │91 │ build (cargo-chef multi-stage, GHA cache scope=api) │92 │ -> push :a1b2c3d (immutable) │93 │ -> push :staging (moves pointer) │94 │ STOP. The deployer's pipeline picks up from GHCR. │95 └────────────────────────────────────────────────────────────┘9697 manual dispatch (Actions → "Promote artifact to production")98 │99 ▼100 ┌────────────────────────────────────────────────────────────┐101 │ promote.yml (inputs: artifact, from-tag=staging|<sha>) │102 │ select │103 │ verify-images (imagetools inspect, fail if missing) │104 │ retag buildx imagetools create :production │105 │ pointed at the verified image's digest. │106 │ STOP. │107 └────────────────────────────────────────────────────────────┘108```109110## Setup checklist (new repo)111112The user is bringing this pipeline to a fresh service. Walk through this list.1131141. **Confirm the backend follows the layout** in `os-rust-backend`. The CI115 assumes `api/` is the workspace root, `api/Dockerfile` exists,116 `api/openapi.json` is checked in, and there is a `make` target for each117 step (`make api-fmt-check`, `make api-lint`, `make api-test`,118 `make contract-check`).1192. **Copy the two composite actions** into `.github/actions/` — see120 [composite-actions.md](references/composite-actions.md) for the templates.1213. **Copy `api.yml`, `build-api.yml`, `promote.yml`** into `.github/workflows/`122 — see the per-workflow references for the templates. Replace123 `<service>-api` with your service name and `ghcr.io/<owner>/` if you're124 publishing under a different org.1254. **Configure GitHub** — see [secrets-and-vars.md](references/secrets-and-vars.md).126 For this scope you need almost nothing: each workflow declares its own127 `packages: write` permission so GHCR pushes work with the built-in128 `GITHUB_TOKEN`. No third-party secrets.1295. **Bootstrap the image once.** Before anything else can consume the image,130 it has to exist:131 ```bash132 gh workflow run build-api.yml133 ```134 This pushes `:<short-sha>` and `:staging` to GHCR. From here the deployer135 side of the world takes over.1366. **First production tag:**137 ```bash138 gh workflow run promote.yml -f artifact=api139 ```140 This re-tags `:staging` → `:<sha>` → `:production` in GHCR. Again, no141 deploy step happens here — that's where the deployer's tooling picks up.142143## Golden rules (and why they matter)1441451. **PRs run checks only.** No GHCR pushes. If a PR could push an image, an146 attacker who lands a malicious PR could escalate to the registry. The147 split keeps publishing behind the merge gate.1482. **Tags are mutable; SHAs are not.** `:staging` and `:production` move;149 `:<short-sha>` never does. Anything that wants reproducible pinning should150 pin a SHA, not a tag.1513. **The build job is separate from the promote job.** They run on different152 triggers, have different concurrency rules, and fail independently. Don't153 collapse them.1544. **`changed-paths` is the gate, not the matrix.** Path filters on `on.push`155 would skip the *workflow*; the composite skips the *job*. That difference156 matters because the "summary" job needs to exist (status check) even on157 docs-only PRs — it just passes vacuously.1585. **Concurrency groups cancel in-progress on the same ref, but not on159 `main`.** PR concurrency is `api-${{ github.ref }}` with160 `cancel-in-progress: true` so a force-push cancels the previous run. Build161 and promote workflows use `cancel-in-progress: false` to avoid killing a162 half-pushed image.1636. **Production promotion is *idempotent.*** Running it twice with the same164 inputs is harmless. Re-running after a partial failure is the recovery165 path. Don't add "do this only once" guards.1667. **The build cache is scoped per service.** `cache-to: type=gha,scope=api`167 keeps services from evicting each other. Use the same scope on168 `cache-from`.1698. **Pre-push validation matters.** Before pushing a change to `.github/`170 or `api/Dockerfile`, run `actionlint`, `bash -n` any inline shell, and a171 local `docker build` to verify the Dockerfile. A broken workflow is a172 broken main branch.1739. **Deployment lives in the deployer's repo.** Questions about how the174 image reaches a running environment are not CI questions; do not invent a175 deploy step. The `:production` tag exists exactly so the deployer has a176 stable pointer to consume without coupling them to our build pipeline.177178## Files this skill ships179180```181assets/182├── workflows/183│ ├── api.yml # PR + push checks184│ ├── build-api.yml # main → GHCR (push only, no deploy)185│ └── promote.yml # manual dispatch → re-tag :production186└── actions/187 ├── setup-rust/action.yml188 └── changed-paths/action.yml189```190191Copy them as-is for a starting point; the per-workflow references explain192which knobs you'll usually change per service.193194## Common pipeline mistakes195196- **Adding image-publish steps to `api.yml`.** PR CI must not push. Keep the197 build workflow separate.198- **Using `:latest` or `:main-latest` instead of `:<sha>`.** "Latest" is the199 classic anti-pattern — environments drift, rollback is impossible, and200 Docker layer cache eviction becomes silent corruption.201- **Adding a deploy step to `build-api.yml` or `promote.yml`.** Out of scope202 for this skill. If you need one, that's a separate workflow (or a separate203 repo) that consumes the GHCR tag — keep this pipeline a pure artifact204 pipeline.205- **Rebuilding on promote.** Every rebuild is a chance to ship different206 bytes than what staging exercised. The whole point of GHCR-as-staging is207 that promote is just `imagetools create --tag :production <digest>`.208- **One concurrency group for all branches.** Use `${{ github.ref }}` so209 feature-branch pushes don't cancel `main` runs and vice versa.210- **Caching `target/` for the docker build job.** The Dockerfile's211 cargo-chef layer cache is the right place; host-side `target/` cache212 fights with it.213- **Hardcoding the Rust version in `setup-rust/action.yml`.** It reads214 `rust-toolchain.toml` — when you bump Rust, you bump that one file and CI215 follows. Don't add a redundant input.216- **Skipping `actionlint` locally.** GitHub Actions YAML is full of subtle217 traps (heredocs in `run`, secret context in `if`, matrix strategy types).218 `actionlint` catches them before push.