Centralized CI/CD pipelines for Centrifuge app repos (apps-invest,
apps-management), published as reusable workflows (workflow_call).
Each app keeps only thin caller workflows that pass app-specific inputs and
its own secrets — pipeline changes happen once, here, and roll out to every
consumer.
.github/workflows/
app-ci-checks.yml PR quality gate: format-n-lint, pnpm-audit (own check), TruffleHog, pinact
app-build-deploy-dev.yml PR → preview deploy; push to main → nightly deploy; optional Lighthouse
app-build-deploy-release.yml prereleased → staging (+ optional demo); released → promotion notice
app-promote-production.yml allowlist-gated production promotion (workflow_dispatch caller)
app-rollback.yml allowlist-gated rollback (prod: version traffic shift; staging/demo: bundle redeploy)
lib-ci.yml this repo's own CI (actionlint, pinact, yamllint)
actions/
setup-app/ Node + pnpm bootstrap (pnpm version from package.json "packageManager")
build-app/ build + artifact upload (+ release bundle upload on prerelease)
deploy-app/ wrangler deploy per environment (preview/nightly/demo/staging/prod)
lighthouserc.json shared LHCI config used by app-build-deploy-dev's performance job
The reusable workflows here reference this library's composite actions cross-repo by full path:
- uses: actions/checkout@<sha> # caller repo
- uses: centrifuge/github-actions-lib/actions/build-app@main
with:
app-name: my-app
node-version: '24'Do not actions/checkout this library into a path and reference
uses: ./that-path/actions/x: a ./ reference in a reusable workflow
resolves at compile time against the workflow's own repo tree, not the
runtime workspace, and every caller gets a startup_failure with zero jobs.
Workflows and composites both pin @main, so a library update moves them
together. The only legitimate runtime checkout of this repo is the Lighthouse
job reading lighthouserc.json as a plain file.
Thin caller examples (see apps-invest / apps-management for the real ones):
# .github/workflows/ci-checks.yml
name: '🔍 CI Checks'
on:
pull_request:
jobs:
ci:
permissions:
contents: read
actions: read
packages: read
uses: centrifuge/github-actions-lib/.github/workflows/app-ci-checks.yml@main
with:
node-version: '24'
codespell-skip: 'src/assets/**,.svg,.png'# .github/workflows/deploy-prod.yml
name: 🚀 Staging/Production Deploy
on:
release:
types: [released, prereleased]
permissions: {}
concurrency: build-${{ github.ref_name }}
jobs:
build-deploy:
permissions:
contents: write # ceiling: gh release upload in the build job
deployments: write
packages: read
uses: centrifuge/github-actions-lib/.github/workflows/app-build-deploy-release.yml@main
with:
app-name: my-app
node-version: '24'
cloudflare-account-id: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
secrets:
cloudflare-api-token: ${{ secrets.CLOUDFLARE_API_TOKEN }}The caller job's permissions: is the ceiling for every job in the called
workflow (called jobs can only downgrade):
| Workflow | Caller job permissions |
|---|---|
app-ci-checks.yml |
contents: read, actions: read, packages: read |
app-build-deploy-dev.yml |
contents: read, deployments: write, id-token: write, packages: read |
app-build-deploy-release.yml |
contents: write, deployments: write, packages: read |
app-promote-production.yml |
contents: read, deployments: write |
app-rollback.yml |
contents: read, deployments: write |
Every input/secret/output is documented inline in each workflow's
on.workflow_call block — that block is the contract. Highlights:
app-ci-checks.yml— required:node-versiononly. Jobs:format-n-lint(defaults:pnpm run format:check,pnpm run lint, codespell oversrcwith shared skip globs;codespell-blocking: falsemakes codespell advisory),pnpm-audit(own status check; default command routes through pnpm@11 while the apps are on pnpm <11),secrets-scan(TruffleHog),pinned-actions-check(pinact). Every command and glob is an input, so apps override only where they diverge. No secrets.app-build-deploy-dev.yml— required:app-name,node-version,cloudflare-account-id; secretcloudflare-api-token. Defaults:build-args: --mode testnet, Lighthouse on PR previews enabled (run-lighthouse: true) with this library'slighthouserc.json;build-envtakes multilineKEY=VALUEpairs for the build step. Output:deployment-url.app-build-deploy-release.yml— same core contract plusbundle-name-prefix(release zip name, defaults toapp-name),pool-cache-base-url,production-url,production-worker-name, plusdemo-build-args(default--mode testnet) anddemo-build-env. The demo leg has no opt-in input: prereleases run a second, testnet-mode build andwrangler deploy --env demoit whenever the caller'swrangler.tomldeclares an[env.demo]section, taking the deployment URL from that section's custom-domain route. It uploads its own artifact (<app-name>-demo-build-<sha>) and release bundle (<bundle-prefix>-demo-bundle<tag>.zip). Secrets:cloudflare-api-token,slack-webhook-url. Outputs:staging-url,demo-url. A tag must beprereleasedbefore it can bereleased.prereleasedbuilds once and deploys to staging;releaseddoes not deploy — it posts a Slack notification with the gated promotion instructions (gh workflow run promote-production.yml ...). For a hotfix that promote would refuse, it gives therollback.ymlcommand instead (see Hotfix releases). The notification links to the caller repo'spromote-production.ymlandrollback.yml, so name those callers exactly that. The demo leg is prerelease-only and never touches production. Release bundles are immutable: rebuilding a tag whose bundle already exists fails; cut a new prerelease, or delete the asset from the release page to rebuild the same tag.app-promote-production.yml— required:app-name,production-worker-name,cloudflare-account-id,authorized-deployers; secretscloudflare-api-token,slack-webhook-url. Called from aworkflow_dispatchcaller with no inputs of its own. Notaginput — it resolves GitHub's own "latest release" (excludes drafts and prereleases) and shifts production traffic to the Worker version staged for that tag (versions deploy <id>@100%). An arbitrary tag can't be promoted through this workflow; that's what the gated rollback path is for. Gated: the dispatching actor must be inauthorized-deployers(passvars.AUTHORIZED_DEPLOYERS; empty fails closed), the dispatch must run frommainor the latest release tag, and the resolved tag's commit must be onmainor be a hotfix whose changes are already merged back tomain(squash merges and cherry-picks count; checked by content). A backport, whose fixmainhas only in another form, never passes and ships throughapp-rollback.ymlinstead (see Hotfix releases). Failed attempts and successful promotions are announced to Slack. Mirrors centrifuge/backend's activate-production model; a process control, not a hard control (see the workflow header).app-rollback.yml— required:tag,app-name,cloudflare-account-id; secretcloudflare-api-token(optionalslack-webhook-urlfor failed-attempt alerts).environment: prod(default) shifts traffic to the version tagged withtag;environment: stagingre-uploads the release bundle behind the staging preview alias;environment: demoredeploys the demo release bundle to the standalone demo Worker. Gated by the sameauthorized-deployersallowlist as promotion — for every environment, demo included — declared optional for parse-compat, but empty fails closed at runtime; callers must passvars.AUTHORIZED_DEPLOYERS.
A hotfix is a release cut from the tag running in production instead of
from main, for a fix that can't wait for the next release from main.
- Branch from the production tag, for example
git switch -c release/v1.4.1 v1.4.0 && git push -u origin release/v1.4.1. - Open the fix as a PR into that branch and merge it there. For a fix that
already exists on
main, cherry-pick it (git cherry-pick -x <sha>) so the commit names its source. - Tag the branch head and publish the tag as a prerelease. As for any release, that builds the bundle and stages the version on staging (and demo). Check staging.
- Release the tag (un-prerelease it). The Slack notification compares
the tag with
main, using the same check as promote, and gives one of two commands:- Promote (
promote-production.yml) whenmainalready has the hotfix line for line, for example a small change also squash-merged tomainand untouched since. - Rollback (
rollback.yml -f tag=<tag> -f environment=prod) otherwise. That is the path for a backport:mainhas the fix in another form (it shipped there first, ormainhas rewritten those lines since), which no line-for-line check can verify. Ifmaindoes not have the fix at all, merge it intomainfirst and promote. If production can't wait, ship it with rollback now and land it onmainbefore the next release, or that release reverts it.
- Promote (
- Rollback announces failed attempts to Slack but not a success, so post in the release thread once production is verified.
rollback.yml's prod path is the same traffic shift as promote, to the
version staged for the tag, behind the same AUTHORIZED_DEPLOYERS
allowlist, dispatched from main. Promote has no override for backports on
purpose: it only succeeds when main provably has the release.
Derive the deployment target from config the app repo already owns rather
than adding a caller input for it: an input duplicates what wrangler.toml
already states, and forces every consumer into a workflow PR to adopt the
feature. detect-demo is the worked example — declaring [env.demo] is the
opt-in, and adopting it needs no caller-workflow edit.
Parse that config with tomllib in a python3 step, not grep.
actions/deploy-app still greps for the prod Worker name; comments, key
order or quoting can change its answer. Don't add more of it.
Note the contract this implies: a Worker section added purely for local
wrangler dev becomes a real deploy target. Keep the pattern for deployment
targets, not for behavioral flags.
- GitHub Packages is opt-in from the app's
.npmrc.setup-appwrites the auth line, and the install step getsgithub.token, only when the app's.npmrcroutes a scope tohttps://npm.pkg.github.com(for example@centrifuge:registry=https://npm.pkg.github.com). Every installing job requestspackages: read, so callers must grant it even if they never opt in. For a private package, also grant the app repo read access once in the package settings under "Manage Actions access", or the install gets a 403. - Concurrency groups live in the caller (they need caller context like the PR number). Called workflows define none.
- Artifacts are scoped to the workflow run.
build-appuploads<app-name>-build-<sha>and v4 artifact names are immutable per run — never trigger two builds for the sameapp-namein one run. - Don't nest reusable workflows here. A reusable workflow that itself
calls another reusable workflow (e.g. the OSV scanner) fails the whole graph
at compile time (
startup_failure). That's why the OSV scan lives as a top-level job in each app'sci-checks.ymlcaller, not insideapp-ci-checks.yml. Compose via composite actions (as the build/deploy workflows do), not nestedworkflow_calls. - Pinning: consumers reference
@main. If your repo runs pinact, add an ignore for this library (see the apps'.pinact.yaml); everything inside this library is SHA-pinned andlib-ci.ymlenforces that. AUTHORIZED_DEPLOYERSrepository variable (required for promotion and rollback): comma-separated GitHub usernames, matched case-insensitively against the dispatching actor. Empty or unset fails closed — nobody can promote or roll back until it's populated. This gates non-admin write users; repo admins can edit variables, so it's a process control layered on top of (not a replacement for) branch protection, CODEOWNERS on.github/workflows/**, and restricted repo access. Name your callerspromote-production.ymlandrollback.yml: the release notification and the promote refusal give commands for both.
Some version knobs are internal to how this library executes jobs, not to the
apps' own toolchain — they're deliberately hardcoded literals, not
workflow_call inputs, so apps can't drift them. (GitHub Actions can't source
an input default: from an external file at runtime, so this table is the
single place to look before changing any of them — not a machine-read config.)
| Where | Value | Why |
|---|---|---|
app-ci-checks.yml → audit-command default, embedded pnpm@11.13.0 |
exact stable release | pnpm <11 hits the retired classic audit endpoint (HTTP 410); pnpm 11 uses the working bulk-advisory endpoint. Pinned to an exact version (not latest) for reproducibility — bump deliberately, not automatically. |
app-ci-checks.yml → pnpm-audit job, setup-app node-version |
'24' |
The pnpm@11 audit tool needs Node ≥22.13 (node:sqlite), independent of the app's own node-version. |
actions/deploy-app/action.yml → wrangler-version default |
'4.111.0' |
The wrangler CLI used for versions upload/versions deploy/deploy. No caller passes this input; every reusable workflow that deploys relies on this default. Distinct from each app's own wrangler devDependency (used for local wrangler dev) — apps may run a different wrangler locally without affecting CI. |
actions/deploy-app/action.yml → node-version default |
'24' |
Runs wrangler itself, not the app's build (that already happened in build-app). Must satisfy wrangler-version's own Node minimum — wrangler 4.x requires Node ≥22; this default was previously '20', which broke every deploy once wrangler-version was bumped to 4.111.0. Bump these two together. |
app-rollback.yml / app-promote-production.yml → env.WRANGLER_VERSION |
'4.111.0' |
Same CLI, same reasoning, but these workflows call wrangler directly rather than through deploy-app — kept in sync with the value above manually, not mechanically linked. |
app-rollback.yml / app-promote-production.yml → wrangler-install step, setup-node node-version |
'24' |
Same Node-minimum constraint as deploy-app above — must satisfy env.WRANGLER_VERSION's minimum, not the app's own toolchain. |
This is distinct from actions/setup-app's pnpm version, which has no
hardcoded default at all — it's resolved from each app's own package.json
"packageManager" field, because that pnpm is the one developers use
locally (pnpm install, pnpm build, …) and should stay under app control.
On the pnpm audit endpoint pin specifically: an earlier version of this
pin used pnpm@11.0.0-rc.1 after testing suggested only that release worked.
Further testing disproved that: the classic-vs-bulk-endpoint code path is
byte-identical between that RC and later stable releases, and repeated,
interleaved calls against 11.0.0-rc.1, 11.0.0, and 11.13.0 all succeeded
consistently (confirmed against a lockfile with real known vulnerabilities, to
rule out a silent no-op). The original RC-only failures were transient
registry-side behavior, not a client version regression. Pin the exact stable
release; there's no reason to run a release candidate here.
Consumers track @main, so a merge here rolls out to every app's next
workflow run immediately:
- Branch, edit, and open a PR here;
lib-ci.yml(actionlint + pinact + yamllint) must pass. - For risky changes, point one app's caller at your branch
(
...@your-branch) in a draft PR to exercise the real pipeline, then revert the ref to@mainbefore merging. - Behavior changes to deploy commands, artifact naming, or contracts must be
coordinated with both app repos — grep their
.github/workflows/callers first. - Tag notable states (
v1.0.0, …) as human-readable restore points.