CI/CD¶
RONL Business API runs thirteen GitHub Actions workflows: an
acceptance/production pair for each of four packages; two scanning workflows —
the supply-chain audit gate and a Semgrep scan; two that watch dependencies
rather than a commit — the daily dependency-audit and the release SBOM; and the
promotion workflow that orchestrates the four production deploys. They hold
twenty-four jobs. Twenty of them choose a runner, and every one of those
names ubuntu-24.04, never ubuntu-latest; the other four are the promotion's
calls into the production workflows, whose own jobs are among the twenty. That
label is one GitHub moves to a new Ubuntu release on its own schedule, so
pinning it means a change of OS release arrives as a diff here rather than
silently. The label pins the release, not the image — GitHub
rebuilds the image about weekly, and a hosted runner cannot pin it by digest.
acc and main carry the same thirteen files, and deliberately different shapes
Verified against main at 2443adc on 26 September 2026 (v2026.09.12):
.github/workflows/ is identical on origin/acc and origin/main.
main was once four workflows behind and carried none of the pinning or
gating described below — closing that gap was a CI-alignment programme that
finished in v2026.09.7, and main has its own main promotion gate ruleset.
The two branches now differ in how deployment is triggered, not in which
files exist. On main a single promotion workflow runs and calls the four
production deploys in order; on acc the four acceptance workflows still
fire independently on a push and on a pull request. That asymmetry is a
decision, not drift — see How a promotion reaches
production.
Workflow overview¶
| File | Trigger | Target | Deploys? |
|---|---|---|---|
zizmor.yml |
PR, push to acc/main |
— | No — the audit gate |
semgrep.yml |
PR, push to acc/main |
— | No — the scan job |
dependency-audit.yml |
schedule (05:17 UTC daily), workflow_dispatch, PR touching its own files |
— | No — the dependency-audit job |
sbom.yml |
push to main, workflow_dispatch, PR touching the SBOM tooling |
— | No — the release-sbom job |
promote-to-production.yml |
push to main (no paths filter), workflow_dispatch |
— | No — it calls the four below |
azure-backend-acc.yml |
push + PR to acc |
acc.api.open-regels.nl |
Yes — except on a pull request |
azure-backend-prod.yml |
workflow_call, workflow_dispatch |
api.open-regels.nl |
Yes |
azure-frontend-acc.yml |
push + PR to acc |
acc.mijn.open-regels.nl |
Yes |
azure-frontend-prod.yml |
workflow_call, workflow_dispatch |
mijn.open-regels.nl |
Yes |
azure-publicsite-acc.yml |
push + PR to acc |
acc.publiek.open-regels.nl |
Yes |
azure-publicsite-prod.yml |
workflow_call, workflow_dispatch |
publiek.open-regels.nl |
Yes |
azure-pa-demo-acc.yml |
push + PR to acc |
acc.plato.open-regels.nl |
Yes |
azure-pa-demo-prod.yml |
workflow_call, workflow_dispatch |
plato.open-regels.nl |
Yes |
All eight Azure workflows support workflow_dispatch, and for the four
production ones that is now the only manual route into Azure — and the
escape hatch if the promotion workflow itself is broken. Neither scanning
workflow supports it: both are meant to run from the events themselves and
nothing else. dependency-audit.yml and sbom.yml do, so an audit or an SBOM
can be produced on demand.
The four production workflows no longer trigger on a push
They carry workflow_call: and workflow_dispatch: and nothing more. A
push to main starts promote-to-production.yml, which decides which of
them to call and in what order. They never had a pull_request trigger,
which is why audit is still the only requirable check on main.
How a promotion reaches production¶
A promotion is one thing, so since v2026.09.10 it is one workflow run.
push to main
↓
promote-to-production.yml (no paths filter — it must always start)
↓
changes scripts/promotion-targets.sh decides which of the four are needed
↓
backend azure-backend-prod.yml, alone and first
↓
frontend · pa-demo · public-site in parallel, once the backend is done
The failure it fixes¶
Until v2026.09.10 a push to main fired four deploy workflows at once and
nothing sequenced them. The backend job is the slowest of the four — it runs the
full backend suite before it packages anything, while a Static Web App deploy is
a build and an upload — so the frontends reliably finished first. A frontend
calling a route the deployed backend does not serve yet gets a 404, and on the
public site that is worse than transient: its build prerenders against the
live API, so a prerender inside that window bakes the failure into the
deployed output.
An earlier change had removed the manual "push, then run the deploy script" step without adding ordering, which shrank the window from until someone remembers to the minutes the backend needs and made it nobody's decision to get right.
How the ordering is expressed¶
The three site jobs each needs: [changes, backend] and run only when
needs.backend.result is in ["success", "skipped"] — a skipped backend
(nothing backend-shaped changed) and a successful one both mean the deployed
backend serves what these sites expect, while cancelled or failure means
nobody knows and the sites must not go. Every job also carries !cancelled(),
without which GitHub skips a job after a failed need regardless of what its
condition says.
The whole graph fails safe towards deploying. Each job runs when changes
said true or when changes did not succeed at all, so a git failure there
means a full deploy rather than a free pass.
The four calls use GitHub's self-repository syntax rather than the
workspace-relative ./ form, which resolves against the runner's filesystem and
can pick up something an earlier step wrote there — zizmor's self-repository
audit flags the latter, and audit is a required check.
Secrets are named, never inherited¶
The backend call passes no secrets at all: it authenticates with OIDC
against a repository variable, and repository variables need no passing.
secrets: inherit would have handed each site workflow every secret the
repository holds — including the Keycloak VM's SSH key and the Semgrep token —
to deploy one static site. Each site call therefore names exactly its own
Static Web Apps token.
Proving it without promoting¶
workflow_dispatch takes a dry_run input that defaults to true. The four
calls are resolved when the run is parsed, before any job starts, so a dry run
still fails loudly on a bad workflow reference, a missing secret or an
unsatisfiable needs graph — it simply deploys nothing.
If the promotion workflow breaks, nothing deploys — silently
The four production workflows are not required checks on main; main
requires audit alone. So a promotion whose orchestrating workflow fails to
parse, or fails at changes, produces a red run and no deployment, and
nothing else reports it.
The escape hatch is that all four keep workflow_dispatch and can be run
alone from the Actions tab.
acc is deliberately not restructured
Acceptance keeps the same four-way shape on a push to acc. Its workflows
also carry the pull_request trigger whose job names the acc
supply-chain gate ruleset requires by name, and a reusable workflow's check
renames every required context — so converting them would silently detach
four required checks. Acceptance traffic is also us, which makes the
ordering problem there far less costly.
Scanning, audit and SBOM workflows¶
None of the four deploys anything. The two scanners carry no paths: filter:
both must reach every pull request regardless of what it touched. The other two
run on a pull request only when it touches their own tooling.
audit — the required check¶
zizmor.yml has one job, and it runs nine steps, all of them blocking:
| # | Step | What it does |
|---|---|---|
| 1 | Checkout | persist-credentials: false |
| 2 | Set up Node 24.21.0 |
For the config validator only — see Node runtime |
| 3 | Run zizmor | Workflow static analysis, with zizmor itself pinned to 1.29.0 — maintained by Renovate, which maps the action to the image ghcr.io/zizmorcore/zizmor, not bumped by hand |
| 4 | Validate renovate.json |
renovate-config-validator --strict |
| 5 | Lockfile matches package.json |
npm ci --dry-run --ignore-scripts — resolves and validates without writing node_modules |
| 6 | npm ci |
So the three steps below run this repository's tooling rather than a version named in the workflow |
| 7 | npm run check-format |
Prettier, via the root script the pre-push hook also runs |
| 8 | npm run check-shared |
@ronl/shared holds declarations, not logic |
| 9 | npm run check-supply-chain |
Every digest resolved against the GitHub API, and the register in SECURITY-PIPELINE.md compared against the workflows |
Steps 4 through 9 carry if: always(). That makes them run after an earlier
failure, so one run reports on every half of the policy instead of stopping at
the first — it does not make them non-blocking. The job still fails.
The thing that would make a step non-blocking is continue-on-error, and it is
the wrong tool here: it rewrites the step's reported conclusion as well as the
job's, and the honest outcome is not exposed by the REST API at all, so a
finding appears only in the log while every check reads success.
check-supply-chain ran that way from its adoption until v2026.09.7 promoted it
to blocking.
Step 7 is in this job rather than in a deploy workflow for the same reason step 8
is: audit has no paths filter and is the required check, so a
documentation-only pull request reaches it too.
Step 5 exists because a lockfile out of step with package.json was already
detectable, but only as an EUSAGE error inside the formatter's npm ci, where
it does not read as a lockfile problem. On 25 September 2026 three dependency
pull requests were merged back to back without rebasing between them; each had
computed its lockfile against an older acc, each was green against its own
base, and together they produced a lockfile matching no package.json (fixed in
05d76bd). --ignore-scripts keeps the root postinstall, which copies the
lockfile into a node_modules a dry run never creates, from failing the step for
the wrong reason. Its limit is stated beside it: the check runs on the pull
request's merge commit, so it proves the lockfile consistent with that base,
not with a base that moves afterwards. Merge dependency pull requests one at a
time, each rebased onto the merged acc first.
scan — Semgrep, required on acc¶
semgrep.yml runs Semgrep Code and Supply Chain across the whole monorepo in a
single job. All six workspaces resolve through the one root package-lock.json,
so there is a single lockfile to read and no per-workspace fan-out to keep in
step with the workspace list.
It covers what check-supply-chain structurally cannot: that step verifies
GitHub Actions digest pins resolve to the versions their comments claim, and
says nothing at all about the packages in the lockfile.
scan is a required status check on acc, promoted once its baseline had
been triaged — a workflow that runs but cannot block is advice, not a gate.
That promotion was a ruleset change, not a change to this file. It is not
required on main; see Required checks and branch
rules.
Its concurrency group is the one deliberate divergence from every other workflow
here: pull-request runs cancel, pushes to acc and main do not. Those pushes
are what write the Semgrep Cloud baseline, and cancelling one mid-upload leaves
the dashboard describing a scan that never finished.
dependency-audit — daily, on acc and main¶
Every other gate runs on a commit, so a new advisory against unchanged code was
seen by nothing, and Dependabot watches acc, not the main production deploys
from. dependency-audit.yml runs at 05:17 UTC daily — off the hour, because
GitHub queues scheduled runs and the top of the hour is busiest — and on
workflow_dispatch. It also runs on a pull request that touches the workflow or
scripts/audit-tree.mjs, so a change to the audit is exercised before it merges;
the issue step is skipped there.
- Both branches, from their lockfiles. The job fetches
accandmain, checks out each in turn and runsnpm audit --package-lock-onlyagainst it, so no install is needed. - It fails on a high or critical advisory in production dependencies
(
--omit=dev) on either branch. Everything else — lower severities and dev-only advisories — is reported but does not fail it. - Findings are grouped by advisory, not by package.
npm auditreports one entry per affected package, so one advisory on a widely used package reads as dozens of findings;audit-tree.mjscollapses them. - One tracking issue. A scheduled failure that only fails a job reports to an Actions page nobody opens, so the job opens, updates and closes a single issue titled Daily dependency audit: production high or critical advisory.
- "Could not run" is never "clean". The script exits 0 clean, 1 on a finding, 2 when the audit could not run; anything higher is clamped to 2, and 2 is handled with the weight of a finding.
- The script is copied to
$RUNNER_TEMPfirst. Checking out a branch replaces the working tree, including a script that exists on the branch under review before it exists onaccormain; the first run lost it at the first checkout and read the missing module as a finding. - Node is an exact literal,
'24.20.0', not.nvmrc: the job reads both branches, which need not share an.nvmrc, and the tool reading a lockfile should not change with the tree it reads. Renovate maintains it.
The job is called dependency-audit, not audit
audit is zizmor.yml's job and the required check. Required checks match
by name, so a second job called audit made the required context
ambiguous — one passing and one failing check under one name, which no
ruleset can satisfy — and its first run blocked #206 outright.
release-sbom — an SBOM for every release¶
A promotion to main is the release here: there are no tags and no GitHub
Releases. Each release keeps a CycloneDX SBOM of its production dependencies
in two copies, because neither is enough alone:
- Committed, as
docs/sbom/<name>-<version>.cdx.json, written bynpm run sbom(scripts/write-sbom.mjs) withnpm sbom --package-lock-only --omit=dev. It describes the lockfile rather than whatever is innode_modules.npm run sbomis a bump-release step, after the version bump, since the filename carries the version. This copy answers a question about a version that shipped a year ago. - Uploaded by
sbom.ymlas a workflow artifact, which a scanner can fetch without a checkout. A public repository keeps artifacts ninety days at most, which is why the committed copy exists.
The script has three modes: writing; --check, strict — the file must exist and
match — for where the release is cut; and --verify-release, which sbom.yml
runs on a push to main: a missing document fails, drift only warns, because a
promotion carries every commit merged into acc since the release was cut. Both
comparisons ignore serialNumber and metadata.timestamp, which change on every
run. Node is pinned as the same '24.20.0' literal as the daily audit, for the
same reason.
Node runtime¶
One source of truth. Both App Service plans run NODE|22-lts, and until
v2026.09.7 eight workflows asked for node-version: '20' — building the
deployed artifact on a major the host does not run.
- The eight deploy workflows read
node-version-file: .nvmrc. .nvmrccarries an exact22.23.2.- The root
engines.nodeis>=22, andengines.npmis>=10.0.0. dependency-audit.ymlandsbom.ymlname an exact'24.20.0': they read lockfiles — both branches', in the audit's case — and should not change Node with the tree they read.zizmor.ymlnames an exact'24.21.0', deliberately on a different major: itsrenovate-config-validatorstep needs Node 24, becauserenovatedeclaresengines.node ^24.11.0. npm accepts that mismatch with anEBADENGINEwarning rather than refusing, which is how the validator once ran unsupported and green. Pointing it at.nvmrcinstead would run it on Node 22, whichrenovatedoes not support. Renovate maintains this pin behind the same fourteen-day cooldown as everything else — v24.21.0 was released sixteen days before the v2026.09.11 bump that adopted it, checked against the Node release index rather than taken from thestability-daysstatus, which reads not met on lock-file maintenance branches that are in fact compliant.
engines is a floor, not the runtime — and Renovate no longer raises it
Renovate's rangeStrategy bump applies to engines as well as to
dependencies, and it once raised engines.node to >=22.23.2 and
engines.npm to >=10.9.9 — a floor no Node 22 release satisfies,
since 22.23.2 bundles npm 10.9.8. Nothing enforces engines here, so a
raised floor produces EBADENGINE warnings on a slightly older toolchain
and drifts the root away from packages/backend and App Service's
NODE|22-lts. engines now uses rangeStrategy widen, which leaves a
range untouched when the new version already satisfies it: >=22 stays
>=22, and the exact runtime stays where it belongs, in .nvmrc.
.nvmrc is also in every deploy workflow's path filter, which it was not
until v2026.09.9 — see Path filters.
Majors that are deferred, and majors that wait¶
Two majors are held by a Renovate rule that sets enabled: false and carries,
in its description, the reason and the condition that ends it:
- Node 24. Both App Services run
NODE|22-ltsagainst an.nvmrcof22.23.2, so the tree is consistent today; taking 24 in.nvmrcalone would build the backend on a major the host does not run. The rule ends by switching both App Services toNODE|24-ltsfirst, then removing the rule in the same change as the.nvmrcbump. App Service offers the runtime at the major only — see Backend deployment. - Ubuntu 26.04. The runner pins exist to stop drift, not to be newest, and
ubuntu-lateststill resolves to 24.04. The rule ends whenubuntu-latestmoves; then every workflow moves in one pull request, the way 24.04 arrived.
Every other major waits as a Dependency Dashboard checkbox rather than being
disabled, so a person still sees it. When one is approved, a second rule decides
which version may be offered: for the npm manager, allowedVersions is
!/^\d+\.0\.0$/, so the earliest a major can arrive is X.0.1. It is scoped
to npm because the same pattern against Docker tags or the runner datasource
would mean something else; the recorded cost is that a package publishing X.0.0
and never a patch is never offered that major, which takes a per-package
exception with its reason.
The local development stack in docker-compose.yml pins all five images by tag
and digest, maintained by Renovate through docker:pinDigests in
renovate.json's extends. The compose files under deployment/vm/ are
deliberately left unpinned: nothing in this repository applies them, so a digest
there would record a value no deploy consults (#196).
The package-manager cooldown¶
Renovate holds an update for fourteen days before proposing it, but that covers only the updates Renovate proposes. Lock-file maintenance hands the refresh to npm, which is where the transitive tree actually moves, and Renovate's own documentation says its cooldown cannot apply there.
A root .npmrc closes that gap:
npm itself will then not resolve a version published less than fourteen days ago. Four properties of it were measured rather than assumed, and each one matters:
| Context | Behaviour |
|---|---|
| npm 11.10 or newer | Honours it on install and update |
npm ci |
Ignores it on purpose — so CI, which only ever runs npm ci, cannot fail on it |
| npm 10.9.8 (bundled with Node 22.23.2) | Ignores it without a warning — which is why scripts/check-deps.sh warns when npm is older than 11.10 |
| The backend deploy | Not covered: it installs in its own deploy/ folder, and npm reads a project .npmrc only from the project root |
An urgent security fix may skip the cooldown, as the guideline allows — set the
flag to zero on that one command line, never in the file, and say why in the
pull request. Renovate's security pull requests already do this themselves,
retrying without the cutoff when npm answers ETARGET.
What each pipeline runs¶
Every pipeline that has something to test runs the suite before it builds, and a failing test blocks the deploy.
| Workflow | Lint | Type-check | Tests | Extra gates |
|---|---|---|---|---|
azure-backend-* |
✅ | – | ✅ | Lints the OpenAPI document; verifies dist/index.js exists, packages from the lockfile, then deploys and verifies the deploy took effect |
azure-frontend-* |
✅ | – | ✅ | @ronl/pa-cockpit's 476 tests, then a performance budget, each its own step |
azure-publicsite-* |
✅ | ✅ | ✅ | Prerender + bundle-cleanliness gate, inside the build |
azure-pa-demo-* |
✅ | ✅ | ✅ | Playwright E2E, then the bundle gate inside the build |
@ronl/pa-cockpit has no deploy workflow of its own — it is a library both the
frontend and the demo consume — so until v2026.09.6 its tests ran nowhere in CI,
even though a change to it already triggered the frontend build through that
workflow's path filter. Both frontend workflows now run them, ahead of the
frontend's own suite: a break there explains a break here.
azure-pa-demo-acc.yml is the only workflow in the repository that runs an
end-to-end suite. It installs a Chromium browser first, since no other workflow
here needs Playwright, and runs it before the build. The demo needs no
backend, database or Keycloak — Playwright starts its own dev server and that is
the whole environment. See PA-demo suite.
Static-site deploy shape¶
The three static sites share one shape:
npm ci
↓
Build @ronl/shared (frontend and pa-demo only)
↓
Lint → Type-check → Unit tests → [E2E, pa-demo only]
↓
Build for the target environment
↓
Azure/static-web-apps-deploy (skip_app_build: true)
↓
[frontend only] Wait, then verify HTTP 200
skip_app_build: true matters more than it looks: the deploy action uploads an
artifact this pipeline built, rather than building one inside a floating
vendor container. See
Supply-chain gate → What this does not protect.
Backend pipeline¶
npm ci → Build @ronl/shared → Lint → Lint the OpenAPI document → Unit tests
↓
tsc → Verify dist/index.js
↓
Prepare deployment package (incl. openapi/openapi.json) → Create zip → Upload artifact
↓
azure/login (OIDC) → az webapp deploy
↓
Liveness check (5 × 10s on /v1/health/live)
↓
Verify the deploy took effect (12 × 15s, comparing /v1/health build.sha)
Lint the OpenAPI document runs npm run lint:openapi in both backend
workflows: it builds openapi/openapi.json and lints it with Spectral against the
vendored NL API Design Rules 2.2.1 ruleset, before the tests, so a broken rule
fails fast and by name. The tests then include the coverage gate that compares
the document with the routes actually served, and the artifact carries
openapi/openapi.json for GET /v1/openapi.json — see
API specification.
Since v2026.09.10 the backend is deployed by the workflow, not by hand. Three parts of that are worth knowing, because each replaced something that had gone wrong:
- Authentication is OIDC, not a publish profile. SCM basic auth is disabled
on both App Services — measured on the resources rather than assumed
(
basicPublishingCredentialsPolicies/scmallow=false) — so a publish-profile deploy would be rejected outright. The workflow does what the hand-run scripts already did,az webapp deployover an ARM token, with a machine identity instead of a human session. That removes the failure that once stopped a release reaching acceptance: an expiredaz login, discovered after the merge. The acceptance and production identities are separate and each scoped to its own App Service, so a mistake in the acceptance workflow cannot reach production. - The bundle installs from the lockfile. It used to be
npm install --productionin a directory holding apackage.jsonand no lockfile, so it re-resolved every caret range at deploy time. On 29 August 2026 an acceptance deploy shipped@anthropic-ai/sdkfreshly jumped 42 minor versions anduuidfive majors, none of it matching what any build had verified. The install now runsnpm ci --omit=dev --workspace=@ronl/backendin a staging copy holding the root manifest and lockfile. A production dependency the lockfile records under the workspace rather than hoisted —altcha-libis one — is merged intodeploy/node_modulesafterwards; the genuinely ambiguous case, where the same package is also hoisted, fails rather than guessing. - The deploy proves itself. A
build-info.jsonin the artifact records the commit and run that produced it,/v1/healthreports it, and the workflow polls that value until it matches the commit just deployed. The previous build keeps answering while Azure starts the new one, so a liveness check passes against either — without this, a deploy that silently left the old artifact serving would have passed every check above it.
On acceptance those four steps are gated if: github.event_name !=
'pull_request', so a pull request builds and tests the backend without shipping
it and the build check stays required.
Since v2026.09.7 azure-backend-acc.yml also runs on a pull request to acc,
so the backend suite gates the branch that caused a failure rather than
reporting it on acc after the merge.
azure-backend-prod.yml deliberately has no pull_request trigger — and since
v2026.09.10 no push trigger either. Every commit reaching main is promoted
from acc and has already run this suite on its own pull request, so re-running
it on the promotion says nothing new, while audit, which is required on
main, reports there on every pull request regardless of base. The Linked Data
Explorer excludes the trigger from its production workflow for the same reason:
its production environment no longer carries a required reviewer, which was
removed on 24 September 2026, so both repositories rest the exclusion on the
promotion argument alone. Neither environment in this repository has any
protection rule at all.
Path filters¶
Workflows trigger only when relevant files change. On the acceptance side
that is an ordinary paths: filter on a push; on pull_request it is not,
and the section after next explains why. On the production side the filters
no longer sit on a trigger at all — they moved into a script.
| Acceptance workflow | Paths |
|---|---|
azure-backend-acc.yml |
packages/backend/**, packages/shared/**, package-lock.json, package.json, .nvmrc, own file |
azure-frontend-acc.yml |
packages/frontend/**, packages/shared/**, packages/pa-cockpit/**, .nvmrc, own file |
azure-pa-demo-acc.yml |
packages/pa-demo/**, packages/shared/**, packages/pa-cockpit/**, .nvmrc, own file |
azure-publicsite-acc.yml |
packages/public-site/**, .nvmrc, own file |
zizmor.yml, semgrep.yml |
none, deliberately — both scanners must run on every pull request |
The production filters live in a script¶
scripts/promotion-targets.sh is the one place that answers for all four
production deploys. It reads changed paths on stdin and writes one
<target>=true|false line per target, which the promotion workflow uses
directly as its outputs: source.
| Target | Matches (anchored at the repository root) |
|---|---|
backend |
packages/backend/, packages/shared/, azure-backend-prod.yml, package-lock.json, package.json, .nvmrc |
frontend |
packages/frontend/, packages/shared/, packages/pa-cockpit/, azure-frontend-prod.yml, .nvmrc |
pa_demo |
packages/pa-demo/, packages/shared/, packages/pa-cockpit/, azure-pa-demo-prod.yml, .nvmrc |
public_site |
packages/public-site/, azure-publicsite-prod.yml, .nvmrc |
Each pattern mirrors the paths: filter its acceptance sibling still carries,
and the acceptance copy is the reference — the two have to be kept in step by
hand.
Three details in it are load-bearing:
- It is a file rather than a
run:block precisely so the decision can be run locally against a real commit range. Arun:block can be read but not run, and this decision is the only thing standing between a promotion and a deploy that does not happen. --allis the fail-safe. Three cases reach it: aworkflow_dispatch, which carries nobefore; a first or force push, whosebeforeis all zeros; and abeforethe clone cannot resolve. A promotion whose range cannot be read is not a promotion that changed nothing.promote-to-production.ymlis in none of the patterns. Each deploy workflow lists itself so that a change to it gets exercised by running it; the promotion workflow runs on every promotion already, so listing it would mean editing a comment in it redeployed all four sites.
The diff is taken with --no-renames, because with rename detection a moved
file appears under its new name only — and a file moved out of
packages/backend/ would then not deploy the backend it was removed from.
GitHub's own path filters count both names; so does this.
.nvmrc is in all eight deploy filters since v2026.09.9, and its absence was a
real hole rather than an oversight to tidy: .nvmrc sets the Node version every
one of these workflows builds, tests and ships on, so a Node bump used to build
nothing, test nothing and deploy nothing — and the new version then reached the
next unrelated deploy untested. Each pattern was checked to match .nvmrc at
the repository root only.
Why the pull_request filter moved into a job¶
This is the part worth copying into another repository, because the reasoning is not obvious from either half on its own.
A required status check must report on every pull request. GitHub waits for a check it has been told to require. It does not reason about relevance.
A workflow whose trigger filters it out never starts, and so reports
nothing. A paths: filter on pull_request is evaluated before the run
exists. There is no skipped run, no neutral conclusion, no check at all — so a
required build check would leave every pull request that does not touch its
paths waiting forever, unmergeable.
A job skipped by its own if: reports success. That is the escape: the
workflow starts, the expensive job does not run, and the check reports green.
So each of the four ACC deploy workflows now:
- drops
paths:from itspull_requesttrigger entirely — it starts on every pull request toacc; - runs a small
changesjob first, which asks the GitHub API for the pull request's files and matches them against one regular expression mirroring the push filter (renames counted under both names, as GitHub's own filter does); - gates the build and close jobs on that job's
relevantoutput.
Since v2026.09.10 the same job also emits a second, independent output,
preview, which decides whether this pull request gets a deployed copy — see
Pull-request previews.
The gate is deliberately fail-safe. The jobs run when changes says relevant
and when changes did not succeed at all, so a failed API lookup means a
full build rather than a free pass. The push trigger keeps its own paths:
filter — nothing is required on a push, so none of this applies there.
Required checks match by job name
The ruleset names Build and Deploy ACC Frontend, not the workflow or the
job id. Renaming one of those jobs silently detaches the requirement, so the
rename and the ruleset change belong in the same pull request.
The root lockfile and manifest are in the backend filters only. Every workspace resolves through them, so a hoisted dependency can move under any of them and a lockfile-only change would otherwise be built and tested by nothing. Widening the other three filters the same way is a decision of its own: each of those claims a Static Web Apps staging environment on every pull request that matches, and widening them would make every lockfile-only pull request claim three previews.
The plan ceiling is not what it was when this rule was written
The frontend app was on the Free plan, three staging environments, when five open pull requests exhausted it on 28 August 2026. Read from Azure on 15 September 2026, all three acceptance apps are Standard — ten environments — and it is the production frontend that is Free. The argument for keeping the filters narrow is now about what a lockfile-only change should legitimately deploy, not about running out of room.
packages/pa-cockpit/** appears in two filters because both the frontend and
the demo consume that package; a cockpit change that triggered neither would
version and deploy nothing.
packages/shared/** is in the demo's filter even though its imports from shared
are type-only and erased before the bundler sees them. A shared-only change
cannot alter the demo's compiled output — but without the filter, a breaking
type change would never run the demo's type-check, and would surface later at an
unrelated pull request.
A package.json-only change still triggers the backend build
packages/backend/** matches package.json, so adding a script or bumping
engines fires a full backend build even though no source changed. Since the
habit is to run the deploy script whenever that workflow fires, this invites
a deploy of byte-identical code. The question to ask is not did the workflow
run but:
Empty output means there is nothing to deploy.
Concurrency¶
The four acceptance workflows declare a concurrency: group keyed
${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
with cancel-in-progress: true.
The four production workflows and the promotion set false, so runs queue —
interrupting a live production deploy to start another is worse than waiting.
They also key their group on a literal naming the app rather than on
${{ github.workflow }}, and that is not cosmetic: inside a called workflow
that expression resolves to the caller's name, so four groups equal to the
promotion's own would deadlock every deploy against the job waiting for it. The
promotion's own group is the bare literal promote-to-production, for the same
reason.
Without this, two merges minutes apart sent two deployments at the same Azure
environment and Azure picked a loser, reporting Deployment Canceled on a
job that had done nothing wrong.
The group key must distinguish the event, not just the ref
Keyed on github.ref alone, the pull_request(closed) teardown and the
push deploy that a merge fires simultaneously land in the same group and
cancel each other at random — observed as two acceptance deploys skipped and
one preview left standing. Keying pull requests on their number and pushes
on the ref keeps the two apart.
azure-backend-acc.yml gained a group of its own when it started running on
pull requests, keyed the same way: without it a branch pushed twice in quick
succession runs the backend's whole suite twice over.
azure-backend-prod.yml carried none while a push to main was its only
automatic trigger — one push, one run. It has one now (deploy-backend-prod-…,
queueing), because workflow_dispatch remains the escape hatch for deploying
the backend alone and that can collide with the backend deploy a promotion is
already running. Two az webapp deploy calls at the same App Service is the
failure it prevents.
Branch strategy¶
feature/* → PR to acc → audit + path-matched deploys → merge → main
↓ ↓
ACC deploys promote-to-production
↓
backend, then the three sites
A pull request is required to land on either branch, and its required
checks must pass — a repository ruleset enforces both on acc and on main. A
direct push is rejected. Squash and rebase merging are disabled repo-wide, because
changelog entries name commits by SHA and both alternatives rewrite those
hashes.
Releases therefore land through a pull request rather than a local fast-forward.
Required checks and branch rules¶
acc |
main |
|
|---|---|---|
| Ruleset | acc supply-chain gate |
main promotion gate |
| Required status checks | audit, scan, and the four build checks |
audit |
| Pull request | Required, 0 approvals, merge method merge only |
Required, 0 approvals, merge method merge only |
deletion |
Blocked | Blocked |
non_fast_forward |
Blocked | Blocked |
The four build checks on acc are build (the backend), Build and Deploy ACC
Frontend, Build and Deploy ACC PA Demo and Build and Deploy ACC Public
Site. They were promoted together with scan once each of their workflows
could report on every pull request — see Why the pull_request filter moved
into a job.
main stays on audit alone, and that makes it the weaker branch in this one
respect. It is deliberate: none of the production workflows has a
pull_request trigger at all — and since v2026.09.10 they have no push
trigger either — so there is no build check there to require. Every commit
reaching main is promoted from acc, where the full set has already reported
on its own pull request.
The cost of that is recorded rather than glossed: because these are not required checks, a promotion that deploys nothing looks the same to the branch as one that deploys everything. See If the promotion workflow breaks, nothing deploys — silently.
The two rulesets differ in one further parameter, deliberately:
require_extra_approval_for_unattributed_changes is true on acc and
false on main, because the promotion that created main carried commits
under three author identities against a ruleset requiring zero approvals — the
flag would have demanded an approval nobody could give. It is preserved rather
than harmonised, and written down in both places so that the next person to
compare them does not read it as drift.
Classic branch protection gives the wrong answer here
It still reports allow_force_pushes: true on both branches. That is a
vestigial second layer rather than a hole: the ruleset's non_fast_forward
rule is what refuses the push. Reading the classic protection endpoint alone
is misleading.
Pull-request previews¶
Each preview deploys to its own Static Web Apps staging environment — but since v2026.09.10 only when someone asks for one.
A preview is opt-in¶
Every pull request that touched an app's paths used to deploy a public copy of
it. Most showed nothing a build had not already proved, and each one is a public
URL and a slot on a plan with a ceiling this repository has hit. The clearest
case: three Renovate security pull requests claimed eight preview
environments between them, because each path filter matches its own
package.json and a dependency bump therefore looks exactly like a source
change.
A preview is now created only when both hold:
- the pull request changed something other than a manifest, and
- it carries the
previewlabel.
Adding the label starts a run — labeled is in the workflow's trigger types.
Both conditions gate the deploy step, not the job
That distinction is the whole design. build_and_deploy_job is the only
place the frontend, PA demo and public site are linted, type-checked,
unit-tested and built on a pull request. Gating the job would have made
labelling a precondition for verifying the code at all — and because a
skipped job reports success, the required check would have passed having
tested nothing.
The two decisions fail safe in opposite directions, deliberately. The build filter errs towards building when the GitHub API call fails, because an error must never be a free pass. The preview decision withholds, because the risk it guards is publishing a copy of an unreviewed branch rather than failing to test one.
A preview can reach the acceptance backend¶
Until v2026.09.10 it could not. A preview gets an ephemeral origin that is not
in the backend's CORS_ORIGIN allowlist, and there is a new one per pull
request, so it cannot be listed — which meant a preview could only demonstrate
that static pages render, on an environment the pull request had already paid to
build and deploy.
CORS_ORIGIN is handed to cors as an array matched by exact equality, so
adding an origin per pull request would have meant an App Service settings write
and a restart of the shared acceptance backend for every preview. So origin
becomes a function, and three properties of it matter:
- Matched on the app's stable slug, not on the domain. A
*.azurestaticapps.netpattern would let any Azure Static Web App in the world make credentialed cross-origin requests to the tier. Azure derives a preview hostname from the app's stable slug, soCORS_PREVIEW_SLUGScarries slugs and the pattern anchors on them. - Never in production, enforced in code rather than by trusting the setting to be empty — so a value that finds its way onto the production App Service changes nothing about what production accepts.
- The guard reads the deployment environment, not
NODE_ENV. Acceptance deliberately runsNODE_ENV=production, so keying on that would have treated it as production and refused every preview, leaving the feature silently doing nothing.
Verified live on both tiers afterwards: the three registered slugs allowed on acceptance, refused on production, and five near-miss origins — another tenant's app, a slug that merely starts the same, a look-alike domain, the app's own default hostname, and a non-numeric environment — refused on both.
Previews that outlive their pull request¶
A preview is deleted by a close job when the pull request closes, and that job
cannot catch everything: GitHub does not run pull_request workflows while a
pull request has a merge conflict, closing included. On 12 September 2026
three Renovate security pull requests left eight previews behind across three
apps. Nothing reported them; they were found by listing environments from Azure
by hand on 15 September, and again on 20 September when they were still there.
npm run check-previews (scripts/check-previews.sh) now asks from the other
end: it lists the environments Azure actually has, compares them with the pull
requests GitHub has open, and names every orphan. It finds the apps by their
repositoryUrl rather than by workflow filename, so an app added later is
checked without editing the script, and it reads every subscription
az account list returns, because these six apps span two of them.
Anything unchecked is reported rather than passed over, and that rule earned its
place while the script was being written: an expired Azure refresh token made
az staticwebapp list fail, and with stderr discarded it returned an empty list
— indistinguishable from a subscription holding no apps. So the session is
proven with a real ARM call rather than az account show, which reads cached
state and succeeds against a token that expired days ago; a subscription that
cannot be read fails the command; and finding no apps at all fails too, because
it means nothing was compared. Like check-mirror, it never deletes — it
prints the exact command and stops.
The plan ceiling¶
There is a staging-environment ceiling per app, and it was hit: when the three frontends were on the Free plan, three environments each, a one-file change redeployed all three sites and five open pull requests exhausted the quota on 28 August 2026. Read from Azure on 15 September 2026, the three acceptance apps are on Standard — ten environments — and it is the production frontend that is Free.
A dependency pull request is not automatically preview-free, which
renovate.json once claimed: only lockfile-only and root-only ones are. One
editing packages/pa-cockpit/** is relevant to both frontend-acc and
pa-demo-acc, since both workflows consume that package — though under the
label rule above it now claims a preview on neither unless someone asks.
Required GitHub secrets and variables¶
| Secret | Used by |
|---|---|
AZURE_STATIC_WEB_APPS_API_TOKEN_ACC |
Frontend |
AZURE_STATIC_WEB_APPS_API_TOKEN_PUBLIC_SITE_ACC |
Public site |
AZURE_STATIC_WEB_APPS_API_TOKEN_PA_DEMO_ACC |
PA-demo |
GITHUB_TOKEN |
Preview comments and teardown (scoped per job) |
Production deploys use the matching _PROD token names, passed explicitly by
promote-to-production.yml — one token per site call, never secrets: inherit.
The backend workflows need no secret at all. They authenticate with OIDC, so what they read are repository variables, which need no passing into a called workflow:
| Variable | Used by |
|---|---|
AZURE_CLIENT_ID_ACC |
azure-backend-acc.yml |
AZURE_CLIENT_ID_PROD |
azure-backend-prod.yml |
AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID |
both |
Each backend job grants itself id-token: write to mint the token azure/login
exchanges for an ARM token, and the promotion grants the same on the call,
because a called workflow cannot hold more permission than its caller.
There is no publish profile, and AZURE_WEBAPP_PUBLISH_PROFILE_* is dead
Those secrets date from March 2026 and nothing reads them. SCM basic auth is disabled on both App Services, so a publish-profile deploy would be rejected — which is why the backend deploys over OIDC instead.
Storing a token without breaking the deploy¶
Use scripts/set-secret.sh. Piping a token straight out of the Azure CLI into
gh secret set stores a trailing newline — 120 bytes where the key is 119.
Both halves are the documented way to do their job; the composition is what goes
wrong. It cost the public site its first production deploy, and the failure
named nothing: every step passed, then "An unknown exception has occurred"
with a DeploymentId printed first, which reads as an upload that began and
failed rather than an authentication that never happened.
The script reads the value from stdin, strips leading and trailing whitespace, refuses an empty result, and reports the byte count it stored. The byte count is the point: a secret cannot be read back, so nothing about a stored secret can afterwards confirm or deny a stray newline. Stdin is read with a sentinel rather than plain command substitution, which strips trailing newlines itself and would therefore store the right value while reporting the wrong count — for exactly the case this exists to catch. The value is never echoed, never passed as an argument, and never written to a file.
GITHUB_TOKEN is read-only by default, with pull-requests: write granted
only to the six jobs that comment on pull requests, pull-requests: read on the
four changes jobs that look up a pull request's changed files, and
permissions: {} on the three that only tear down a preview.
Rolling back¶
Backend — workflow_dispatch azure-backend-prod.yml from the Actions tab
against the previous commit; it deploys the backend alone. Or use the Azure
Portal: App Service → Deployment Center → select a previous deployment →
Redeploy. The hand-run deploy-backend-to-{acc,prod}.sh scripts still exist
as a break-glass path, but they resolve dependencies on a developer machine
against semver ranges with no lockfile — which is why the
supply-chain register keeps them as a
named exception rather than treating the workflow as having closed it.
Static sites — Azure Static Web Apps keeps deployment history. Azure Portal: Static Web App → Environments → select a previous deployment → Promote.
Related¶
- Supply-chain gate — pinning, least privilege,
and the
auditgate - Testing — Overview — what the suites the pipelines run actually cover