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
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
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 |
app-build-deploy-dev.yml |
contents: read, deployments: write, id-token: write |
app-build-deploy-release.yml |
contents: write, deployments: write |
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 ...). The notification links to the caller repo'spromote-production.yml, so name your promote caller 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 frommain, and the resolved tag's commit must be onmain. 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.
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.
- 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 promote callerpromote-production.yml— the release notification links to it.
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.