Skip to content

Supply-Chain Pinning

Verification status

All three repositories' claims were re-checked on 27 September 2026, against a7fe76f, 0143ea2 and 3c44b9e: every uses: reference listed and counted, rulesets read from the API, .nvmrc, .npmrc and every runs-on: read from the workflow files, and renovate.json read rule by rule.

The week to 26 September moved production and monitoring rather than pinning. The Linked Data Explorer and the RONL Business API now deploy production through a single promote-to-production.yml on a push to main; the RONL Business API's backend deploys from CI, from the lockfile; the Linked Data Explorer moved to Node 24; every audit job checks that the lockfile matches package.json; and Renovate no longer proposes an npm X.0.0. The 19 September batch — the runner builds what ships, one exact Node version per repository, pinned runner images, a package-manager cooldown, build checks required on acc — still stands, and its superseded reasoning is still kept below, marked as history.

Nothing a pipeline downloads or executes may float. No latest, no empty versions — a hash, digest or verified checksum wherever one exists.

This page describes how that policy is enforced for GitHub Actions — the uses: references a workflow downloads and runs — why it is enforced inside each repository rather than at the organisation level, and, just as importantly, what it deliberately does not protect. It is cross-cutting: the mechanism is the same in every repository that has adopted it, and the same four files are copied into the next one.

Three neighbouring topics have pages of their own, because each is a control in its own right rather than a part of this one:

Page What it covers
Branch Protection The rulesets that turn these checks into a gate, and which checks each branch requires
Dependency Scanning The other half of the supply chain — the npm tree, and the code, scanned by Semgrep
The GitLab Mirror The second copy no runner can reach, and the release-time check that watches it

Why in-repo rather than org-level

github.com/ictu already enforces hash-pinned GitHub Actions at the organisation level. The IOU repositories are not in that organisation — they live under github.com/sgort, where their pull requests, issues and every gate on this page run, and are mirrored to the open-regels.nl GitLab instance — so they inherit none of that enforcement.

The recorded decision is to build the controls inside each repository, where they travel with the code regardless of which remote hosts it, rather than migrating the repositories into the ictu organisation. Two alternatives were considered and rejected: pinning and upgrading to the latest majors in one step (which couples "make immutable" with "change runner behaviour", leaving a broken deploy undiagnosable between the two), and forking the actions into an IOU-owned namespace (disproportionate for a handful of actions, and it relocates the trust problem rather than solving it).

GitLab holds a mirror only — for the repositories on this page

The three application repositories covered here run all their CI/CD on GitHub Actions and contain no .gitlab-ci.yml, so "extend the policy to GitLab" is vacuous for them: their entire attack surface is the GitHub workflows.

The corollary is that the mirror is outside every gate on this page — see The GitLab Mirror.

The Norm Editor is the exception, and it is not covered by this page. Its pipeline is GitLab CI, it builds and pushes its own Docker images to Azure Container Registry rather than handing a bundle to a vendor action, and none of the five mechanisms below exists there in the same form — there are no uses: references to digest-pin, no zizmor equivalent wired in, and no acc branch to protect, because main is its only integration branch. Extending the policy to it is a separate piece of work against a different CI system, not a fifth row in the table below.


Adoption status

Repository Pinned workflows audit gate Renovate acc ruleset
CPSV Editor (ttl-editor) — pilot ✅ ✅ ✅ ✅ acc supply-chain gate
RONL Business API ✅ ✅ ✅ ✅ acc supply-chain gate, and main promotion gate on main
Linked Data Explorer ✅ ✅ ✅ ✅ acc supply-chain gate, and main promotion gate on main
IOU Architecture Docs (this site) ❌ ❌ ❌ ❌

The CPSV Editor is the pilot, adopted in v2026.08.2; RONL Business API followed within the same week, and Linked Data Explorer in v2026.08.7 — taking its findings from 40 to 0 across twenty action references in six deployment workflows. All three rulesets are named acc supply-chain gate, target refs/heads/acc, are active, and carry zero bypass actors; each requires a pull request, a passing audit check, a passing scan — the Semgrep job described in Dependency Scanning, required in the CPSV Editor and the Linked Data Explorer since v2026.09.3 and in the RONL Business API since 19 September 2026 — and, since that same date, each repository's build and deploy checks. The full per-branch list is on Branch Protection.

Adoption is not uniform, and the differences are worth knowing rather than flattening:

CPSV Editor RONL Business API Linked Data Explorer
Action references pinned 17 / 17 39 / 39 31 / 31²
Workflows carrying them 7 13 11
Action majors v7 (since v2026.09.0) v7 v7 (since v2026.09.2)
Blocks deletion / non-fast-forward no yes (since v2026.09.7) yes
Merge method restricted in the ruleset no — repository setting only yes yes
skip_app_build set on both deploy steps set on all six deploy steps set on both frontend deploy steps
Backend deployed by CI n/a yes — az webapp deploy over OIDC, since 20 September 2026¹ yes — azure/webapps-deploy

¹ Not the way first expected, and not for the reason once written here. The standing theory was that azure/webapps-deploy authenticates over SCM basic auth, which Azure now disables by default. A test in v2026.08.34 was read as disproving it, and this page said so. Measured per App Service on 20 September 2026, it held for these two: SCM basic auth is off on ronl-business-api-acc and ronl-business-api-prod, and on in the Linked Data Explorer's production backend, in the same subscription, so it is per-app configuration. A publish-profile deploy would be rejected, and the workflow deploys with az webapp deploy over OIDC instead, the same ARM-token route the hand-run scripts had always used. Those scripts, deploy-backend-to-{acc,prod}.sh, remain as the break-glass path, and they still run npm install without a lockfile. See below.

² Plus three local reusable-workflow calls (uses: ./.github/workflows/…) in its promotion workflow, which name a file in the same commit and carry no digest to pin. The RONL Business API has four such calls on top of its 39.

All three now set skip_app_build, and the row above is the one that moved on 19 September 2026. Until then the CPSV Editor and the Linked Data Explorer set it nowhere, and the consequence was the sharpest gap on this page: where the flag is unset, Oryx builds the production bundle inside the floating vendor container, so lockfile integrity covers only what is tested. The RONL Business API had already closed it; the other two followed. The two ropa-site workflows are the remaining exception and correctly so — that package is a static index.html plus a staticwebapp.config.json, with nothing to build.

Two of the three gate main as well. The Linked Data Explorer's main promotion gate ruleset was created on 9 September 2026, before the first promotion pull request was opened: deletion and non-fast-forward blocked, a pull request with merge commits only, audit and scan required, zero bypass actors. It mirrored the acc ruleset exactly, save for one parameter, deliberately: require_extra_approval_for_unattributed_changes is true on acc and false on main, where GitHub's default of true — stored when the parameter was omitted from the create call — would have required an approval no single-maintainer repository can give, and deadlocked the promotion. Read a ruleset back after writing it; the create response's shape does not show the defaults it filled in.

That mirroring ended on 19 September 2026. The build and deploy checks were added to acc and not to main, so acc now requires strictly more in both repositories, and a promotion pull request is held to less than the pull requests it carries. That is deliberate rather than an oversight: no production deploy check reports on every promotion pull request — the Linked Data Explorer's production site workflows keep a path-filtered pull_request trigger, and its backend, like all four of the RONL Business API's production workflows, now runs only when promote-to-production.yml calls it — and a required check that never reports wedges a pull request permanently — see how a path-filtered workflow became requireable.

The RONL Business API created its own main promotion gate on 12 September 2026, on the same pattern and for a sharper reason: until that day main carried only classic protection — a pull request required, zero required status checks, allow_force_pushes on and enforce_admins off — so the branch that deploys production was the less protected of its two. It requires audit and still not scan, which its acc ruleset has required since 19 September 2026. The original reason, that a required check that never reports on a pull request wedges it permanently, still holds for production, in a new shape. Since 23 September 2026 the four *-prod workflows have no push or pull_request trigger at all: promote-to-production.yml calls them as reusable workflows on a push to main, deciding which to run in scripts/promotion-targets.sh, so no production check ever reports on a promotion pull request. The four *-acc workflows do trigger on pull_request, and their checks are required.

That leaves the CPSV Editor as the one repository whose ruleset targets refs/heads/acc only, so its main is not covered by the guarantees an acc pull request gets. That is a decision rather than a gap: its main requires a pull request but no status checks, weighed and kept on 11 September 2026 (ttl-editor#131). The argument against it stands, and is worth keeping in view — the promotion pull request is the one carrying changes into production, and "already checked on acc" is true of the commits, not of the merge. If it is revisited, audit and scan are the two that could be required today: both trigger on every pull request, so neither can go missing on any base. The deploy check cannot, as main stands — Deploy PROD (white-sky) keeps paths-ignore on its pull_request trigger, so a documentation-only promotion never starts it, and a required check that never reports wedges the pull request. That constraint is no longer structural, only unapplied: the changes-job pattern that made the acc deploy checks requireable on 19 September 2026 would work here too, and was simply not extended to the production workflows. See how a path-filtered workflow became requireable.

The rulesets are also not identical in shape, which the table's last two rows record. The CPSV Editor's is now the only one that does not block branch deletion and non-fast-forward pushes; the RONL Business API's acc gained both on 12 September 2026, a month after its main got them — two rulesets in one repository differing in a way nobody had decided. And while all three end up allowing merge commits only, two of them say so in the ruleset while the CPSV Editor relies on the repository-level setting alone — see Merge method. All three reach the same place; only two are belt and braces.

Ruleset shapes re-verified on 27 September 2026 for all three, with gh api repos/<owner>/<repo>/rules/branches/<branch> — which reports the effective rules from every ruleset at once, where reading one ruleset, or the classic protection endpoint alone, gives the wrong answer. Pin counts were re-derived the same day by listing uses: references at a7fe76f, 0143ea2 and 3c44b9e.

This documentation repository is a known gap, deliberately deferred. Its requirements.txt uses >= floors for five of six packages and its workflow runs pip install --upgrade pip, so both the dependencies and the installer float, with no lockfile or hash file. It has looser dependency integrity than any repository currently in scope. The decision to defer was taken on 2026-08-26 and recorded so it stays deliberate rather than forgotten; the fix, when it is taken up, is a requirements.in compiled by pip-compile --generate-hashes and installed with pip install --require-hashes.


The concrete risk

A step written uses: some/action@v1 executes whatever code that tag points at today. Whoever controls the tag controls the pipeline — including the step holding the deployment token.

Azure/static-web-apps-deploy illustrates the problem exactly. It publishes v1 as both a 2021 tag (1a947af…) and a 2024 branch head (4d27395…), 28 commits and 3.5 years apart, and GitHub does not document how it resolves an ambiguous ref. So @v1 was ambiguous and partly mutable.

For a two-environment setup the consequence is sharper than it first appears. The acceptance and production workflows resolve the same ref independently, at their own run times. A ref moving between an acc deploy and the later production deploy sends different action code to each environment from identical repository content — leaving no trace in git history. Acceptance silently stops being a faithful rehearsal of production.


The four files

Four files, copied into each repository in this order. The GitHub setting that turns them from checks into a gate — the branch ruleset — is the fifth piece, and it lives on Branch Protection.

1. .github/zizmor.yml — the policy

rules:
  unpinned-uses:
    config:
      policies:
        '*': hash-pin

A commit hash for every namespace, with no exemption for first-party actions/*. zizmor 1.29.0 already enforces this by default, so today the file changes no findings. It is committed deliberately: the policy belongs in the repository rather than in a tool default that a future release could quietly relax.

2. Pinned workflows

Every uses: is a 40-character commit SHA followed by a # vX.Y.Z comment. The comment is functional, not decorative — Renovate parses it to know which version a digest represents, and rewrites it on update.

Pins are taken at the then-current major and not upgraded, so adopting the policy is behaviour-preserving. Version upgrades arrive separately, as reviewed Renovate pull requests. That separation is what lets the first live run prove the pinning worked, without a simultaneous upgrade muddying the result.

The separation then does its job: in the CPSV Editor, actions/checkout and actions/setup-node have since moved from v3.7.0 and v4.4.0 to v7.0.1 (3d3c42e…) and v7.0.0 (820762…), each as its own reviewed pull request under the cooldown. The pin is not a freeze — it is a record of exactly which bytes run, changed only by a diff someone approved.

Each workflow also declares least privilege — permissions: contents: read at workflow level, with a deploy job adding only pull-requests: write for the Static Web Apps action's PR comments, and a close-PR job taking an empty permissions: {} block.

The pattern has since grown, one grant at a time and each for a named reason. The daily audit adds issues: write, for the tracking issue it opens and closes. The RONL Business API's backend deploy adds id-token: write, for the OIDC token azure/login exchanges, and its promotion workflow grants it on the call as well, because a called workflow cannot hold more than its caller. The same promotion passes each site exactly one Static Web Apps token, by name, and the backend no secret at all: "secrets: inherit would have handed it every secret the repository has to use none of them."

actions/checkout sets persist-credentials: false. Before this, a live GITHUB_TOKEN was written into .git/config and mounted into a closed-source third-party container on every run. That is a real hole closed, not a cosmetic lint fix; the deploy action authenticates with explicitly passed tokens instead.

3. .github/workflows/zizmor.yml — the gate

Runs zizmor under job name audit, on every pull request and on pushes to acc and main.

The branches filter had to go, and the reason is worth reading

Until September 2026 the audit triggered on pull_request only for acc and main. Combined with the ruleset making audit a required check on acc, that produced a pull request which could never merge — and which looked perfectly healthy while it did.

A stacked pull request, based on a feature branch rather than on acc, matched no trigger and so accumulated no audit at all. Because the ruleset applies only while the base is acc, GitHub reported the pull request as CLEAN with zero checks. It read as ready and was not. The moment its parent merged, GitHub auto-retargeted it onto acc, the ruleset began applying, the required check was missing — and a retarget emits no pull_request event, so nothing ever backfilled it. Permanently blocked. (If one is ever stuck this way: gh pr close <n> && gh pr reopen <n>; reopened is in the default types set, and by then the base is acc.)

A paths filter is the same hole in another dimension — it lets a pull request skip the gate by touching nothing watched, where a branches filter lets it skip by targeting an unwatched base. The audit carries neither. push stays filtered, because acc and main are the only branches whose post-merge state is worth re-auditing.

All three repositories now run the audit on every pull request — the CPSV Editor in v2026.08.3, RONL Business API and the Linked Data Explorer following in early September, each independently reaching the same shape. It was not a theoretical fix in any of them: it cost four rounds of manual intervention in the Linked Data Explorer and blocked a pull request outright in RONL Business API before the filter came off.

Do not copy this shape into a deploy workflow. Those fail in the opposite direction: an absent filter on the audit makes a required check silently missing, while an absent filter on a deploy silently exhausts a bounded pool of staging environments. The rule is audit widely, deploy narrowly.

Three zizmor inputs are deliberate:

Input Value Why
version '1.29.0' The action defaults to latest. A supply-chain gate that pulls an unpinned tool on every run would defeat itself. The action resolves this through an internal digest table and runs a genuine container digest pin
advanced-security false The default uploads SARIF and requires security-events: write; this job is contents: read only. It also means fork PRs work, since there is no upload step to fail
annotations true Surfaces findings inline on the diff. Mutually exclusive with advanced-security — the action errors if both are true

The gate lands after the tree already reports zero findings, so it arrives green rather than red. Measured on the pilot: 16 findings → 0, verified at every intermediate step.

Stage unpinned-uses excessive-permissions artipacked Total
Before 8 6 2 16
After digest pins + persist-credentials: false 4 6 0 10
After permissions: blocks 4 0 0 4
After pinning the deploy action 0 0 0 0

The gate also validates renovate.json

The gate enforced that every action reference is a commit hash but had nothing to say about the file that keeps those hashes current — and pinning without automated updates decays into an unpatched tree, so a Renovate that has silently stopped running is precisely the supply-chain failure this audit exists to catch.

That is not hypothetical. In the Linked Data Explorer, five keys used as JSON comments were rejected as invalid configuration and Renovate stopped opening pull requests as a precaution. Nothing in CI noticed; the repository looked green while half its policy was inert.

A second step now runs renovate-config-validator, with four deliberate choices:

Choice Why
Runs in the existing audit job It is therefore covered by the current required status check, with no ruleset change
if: always() One run reports on both halves of the policy, rather than a zizmor failure hiding a config failure
--strict Also fails on configuration Renovate would silently auto-migrate. That is how baseBranches, renamed upstream to baseBranchPatterns, was caught rather than living on as a deprecated key that still "worked"
No filename argument Passing one switches the validator into global config mode, which applies different rules than the repository config the file actually is — it validates happily and tells you nothing useful

The tool version is pinned inline like everything else here, and Renovate does not maintain it: it is an npx argument, not a manifest entry, so it is bumped by hand.

The zizmor version is the opposite case, although this page and all three repositories' own records described it the same way until mid-September 2026. It is the version: input of zizmorcore/zizmor-action — 1.29.0 in the Linked Data Explorer — and Renovate's github-actions manager maps that input to the Docker image ghcr.io/zizmorcore/zizmor, so Renovate does maintain it. It normally moves in the same github actions group pull request as the action bump it depends on, and it has to: the action only runs zizmor versions in its own digest table, so zizmor 1.30.1 needs zizmor-action v0.6.4. The two clear the cooldown separately, though, so a group branch can carry the image update alone for a while — and the audit fails until the action catches up. That failure is correct, not a flake.

A Set up Node 24 step precedes both. Renovate declares engines.node ^24.11.0 while the runner defaults to Node 22; npm accepts that mismatch with a warning rather than refusing, so the validator ran unsupported and reported green — the kind of mismatch that keeps working right up until it abruptly does not, at which point the gate fails for a reason unrelated to anything anyone changed. It is placed before the zizmor step deliberately: a step following a failed one is skipped, so putting it after would leave the validator's always() condition running on whatever Node the runner defaulted to.

The gate also checks the lockfile against package.json

Since 25 September 2026 each audit job runs npm ci --dry-run --ignore-scripts as its own step, Lockfile matches package.json, before the install the formatter needs. npm ci already refused a mismatched lockfile, but only as an EUSAGE three steps into a job about pinning, where it did not read as a lockfile problem. --ignore-scripts because a dry run still runs postinstall.

It came from an incident: in the RONL Business API, three Renovate pull requests merged back to back without rebasing, each green against its own base, and left acc with a lockfile matching no package.json. The step's stated limit is the one that incident turned on — it checks each pull request's own merge commit, and no ruleset here requires a branch to be up to date (strict_required_status_checks_policy is false in all five), so dependency pull requests are merged one at a time, each rebased onto the merged acc first.

4. renovate.json — keeping the pins alive

A pin that is never updated is a pin that rots. Renovate maintains the digests under a cooldown:

  • helpers:pinGitHubActionDigests — maintains digests and rewrites the version comment to match.
  • minimumReleaseAge: "14 days" — the cooldown, giving vendors and researchers time to find problems before adoption.
  • internalChecksFilter: "strict" — suppresses the pull request entirely until the age is genuinely met, rather than raising one that fails a check.
  • prConcurrentLimit: 5 — a cap on how many dependency pull requests are open at once.
  • vulnerabilityAlerts with minimumReleaseAge: null — the fast route for security advisories.

Three rules followed from the RONL Business API's fixes of 14 September. The Linked Data Explorer carries all three since v2026.09.5; the CPSV Editor carries the first, since its own v2026.09.5:

  • Lock-file maintenance is exempt from the pull-request limits — prConcurrentLimit: 0 and prHourlyLimit: 0 on its rule alone. Holding majors behind approval was meant to keep a slot free for it, and could not: the concurrent count includes every open Renovate pull request, security ones included.
  • Minor updates of pre-1.0 packages wait for approval, like majors. Semver gives 0.x no compatibility promise, but Renovate classifies 0.4 → 0.5 as minor.
  • engines floors are widened, not bumped. rangeStrategy: "bump" had rewritten engines.node to the newest release three times; the floor is a minimum the repository chooses, not a version to chase.

Since 25 September 2026 none of the three is offered an npm X.0.0. A packageRules entry scoped to the npm manager sets allowedVersions: "!/^\\d+\\.0\\.0$/", so the earliest a new major can arrive is its first patch release, and majors still wait for Dependency Dashboard approval on top. It is scoped to npm deliberately: the same pattern against a Docker tag or the runner label would mean something else. Two majors are deferred outright with a written reason and exit condition: the runner's ubuntu 26.04 in all three, and in the RONL Business API node 24 as well, while both its App Services run NODE|22-lts.

The vulnerabilityAlerts rule is the one most cooldown policies omit, and its absence is why people disable such policies mid-incident: without it the cooldown would delay exactly the updates that must not wait. It fires off GitHub's Dependabot alerts feed, so those must be enabled — while Dependabot security updates must stay off, or two bots race on the same manifests with only one of them respecting the cooldown.

The concurrency cap is not tidiness — it was a collision. Renovate's default prConcurrentLimit is ten, and the Static Web Apps staging ceiling is also ten. The two numbers being equal meant a full Renovate queue consumed every staging environment and the next pull request opened by a human was refused outright: ten open dependency pull requests held all ten slots, and two unrelated pull requests had their deploy fail on arrival. It self-perpetuated, too — merging two freed two slots, and Renovate opened two new pull requests into them within the minute. Capping at five leaves five permanently available for human work. Deliberately not solved by paying for a higher tier: a bigger ceiling moves the number at which the same collision happens rather than removing it.

The cooldown stops at the manifest

minimumReleaseAge holds back the updates Renovate proposes. It does not reach the transitive tree, and Renovate's own documentation says why: for lockFileMaintenance it is "not possible, as we delegate to the package manager to perform the required changes". All three repositories run lock-file maintenance weekly, so until 19 September 2026 a weekly refresh could pull in a transitive version published that morning, and so could any npm install a developer ran locally.

Renovate's recommendation, and ICTU's guideline, is to configure the cooldown in the package manager as well — for npm, min-release-age in .npmrc. All three repositories now carry a root .npmrc setting min-release-age=14, matching the 14 days minimumReleaseAge already holds Renovate to. Renovate reads it, and for its own update pull requests applies whichever cutoff is stricter.

Renovate's renovate/stability-days status is not evidence for a lock-file refresh. The RONL Business API's weekly refresh in v2026.09.11 moved 62 packages, and every version it introduced was at least 14 days old by the npm registry's own publish dates — while the status on the same branch read "Updates have not met minimum release age requirement", because lock-file maintenance is flagged rather than evaluated. The repository's own conclusion: "The measurement is the thing to read, not the status." It reached the same one for its Node pin, checked against the Node release index after the status had read "not met" on three compliant branches.

What it covers is narrower than it looks, and each limit was measured rather than assumed:

Limit Consequence
npm ci ignores it by design (npm/cli#9281) CI only ever runs npm ci, so CI cannot fail on the cooldown — and cannot enforce it either. The setting governs npm install, npm update and lock-file maintenance
npm older than 11.10 ignores it silently No warning, no error, no effect. Whether a repository is covered therefore depends on which npm its Node bundles
Node 22.23.2 bundles npm 10.9.8 So the RONL Business API is not covered by its own setting on a machine following .nvmrc — and its Node 24 move is deferred on record until its App Services switch
Node 24.20.0 and 24.21.0 both bundle npm 11.19 So the CPSV Editor and, since its move to 24.21.0 on 23 September 2026, the Linked Data Explorer are covered
scripts/check-deps.sh warns on npm below 11.10 At every dev-server start and every push, in all three — the gap is surfaced rather than left to be discovered

The cooldown may be skipped for an urgent security fix, as the guideline allows: set the flag to zero days on that one command line, never in the file, and say why in the pull request. See ICTU Dependency Guideline, recommendation R6.

What is exempted, and why each exemption is written down

Dependency Held Reason
Azure/static-web-apps-deploy Entirely Renovate's github-tags datasource resolves @v1 to the 2021 tag while the workflows pin the branch, so a routine-looking digest update would silently revert the production deploy step to 3.5-year-old code — and the cooldown offers no protection whatsoever, the target commit being years old
tailwindcss Major only Still held after the Vite migration, for a different reason. It originally blamed react-scripts, and v4 now resolves cleanly. What blocks it is postcss.config.js declaring tailwindcss as a PostCSS plugin — v4 moved that plugin into @tailwindcss/postcss, which is what the failing pull request could not compile. Under Vite the idiomatic setup is the @tailwindcss/vite plugin rather than a ported PostCSS pipeline, so it is a piece of work with its own acceptance run, and the rule is removed as part of it
eslint Major only eslint 10 resolves and passes — npm prints "ERESOLVE overriding peer dependency" and installs anyway — while three plugins run outside their declared peer range (eslint-plugin-react caps at ^9.7, jsx-a11y and import at ^9). A green build is not evidence here, which is exactly why it needs a rule. Re-checked when eslint-plugin-react names ^10 in its peers

Two properties make these exemptions honest rather than convenient. Only the major is held, so minor and patch updates keep flowing. And each rule is written to be removed by a specific future event rather than left as an open-ended exception nobody revisits. One already has been: typescript was held because react-scripts peer-required ^3.2.1 || ^4, so Renovate could not even generate a lockfile for v7 — and the rule went with react-scripts in the Vite migration, exactly as it said it would. tailwindcss is the instructive case the other way: its original reason went too, and it stayed held for a new reason, written down as a new reason rather than left standing on a stale one.

What this means day to day

push to a feature branch   → nothing runs (workflows trigger on acc/main only)
open a PR against acc      → audit + scan + Build and Deploy run
audit fails                → merge blocked by the ruleset
scan fails                 → merge blocked, in all three since 19 Sep 2026
build or tests fail        → merge blocked on acc, in all three since 19 Sep 2026
all required checks pass   → merge allowed
direct push to acc         → rejected: a pull request is required

Two consequences worth stating plainly:

Releases go through a pull request. Any release flow that lands on the protected branch with git checkout acc && git merge --ff-only plus a direct push is blocked — a locally created commit has never passed audit. Each repository's /bump-release was changed accordingly; see Development Workflow.

Renovate's own pull requests are gated by the policy Renovate maintains. The bot raises them against acc like any contributor. Observed on the first ones: audit passing in 11–13 seconds alongside renovate/stability-days reporting that the minimum release age was met. That status describes the updates Renovate proposes; for a lock-file refresh it is no evidence either way — see the cooldown stops at the manifest.


What this does not protect

Each repository keeps a SECURITY-PIPELINE.md exceptions register. A register that claims total coverage produces a permanent unfixable finding at the first audit, and the predictable response is to weaken the gate — so the register is what allows the gate to stay strict honestly.

The Static Web Apps container still cannot be pinned — what changed on 19 September 2026 is that it no longer builds what ships, only uploads it. Azure/static-web-apps-deploy is a three-line wrapper whose action.yml declares runs: using: docker, image: "Dockerfile", and that Dockerfile is FROM mcr.microsoft.com/appsvc/staticappsclient:stable. Pinning the action makes the wrapper immutable and leaves the payload floating. Unreachable from our side; it would require Microsoft publishing digest-pinned image references, or IOU forking the action.

One flag decided how badly this bit, and all three now set it

skip_app_build: true points app_location at an already-built dist/, so the container uploads an artifact the pipeline built itself on a pinned setup-node via npm ci. The RONL Business API set it on all six of its deploy steps first; the CPSV Editor and the Linked Data Explorer followed on 19 September 2026, each adding an explicit build step to their workflows. (References to the action carrying action: 'close' build nothing and are unaffected.)

Until then, the CPSV Editor and the Linked Data Explorer set it nowhere, and the consequence is worth keeping on record because it is the failure mode any repository adopting Static Web Apps inherits by default: Oryx ran inside the floating image and built the production bundle there, making the image the build toolchain that produced the deployed artifact, not merely an upload step. Lockfile integrity covered only what was tested, in two of the three — the majority position was the weaker one.

The two ropa-site workflows still do not set the flag. That is correct: the package is static files with no build, so there is no install for Oryx to re-resolve.

npm ci integrity now covers what ships. package-lock.json carries a sha512 per package and npm ci verifies it; where skip_app_build is set, the verified install is the one that produces the deployed bytes, and since 19 September 2026 that is every deployable in all three repositories.

What it looked like when it did not — and how to recognise it

The install that produced the deployed bytes was not npm ci. Oryx ran npm install, which honours a lockfile that agrees with package.json and quietly re-resolves one that does not, where npm ci would fail. The CPSV Editor's matched its lockfile in the deploy run examined on 13 September 2026 — up to date, audited 542 packages — which was the likely outcome rather than a guaranteed one.

The tell in a deploy log is a line reading Oryx Version: … followed by Downloading and extracting 'nodejs' version '…' and Running 'npm install'. If those appear, the artifact was not built by the job that tested it, whatever the workflow file says about setup-node.

Each repository now names its Node version once, exactly, in a file every deploy workflow that installs Node reads. This was a general gap until 19 September 2026, and it had two halves: the version the tests ran on, and the version that shipped. Both are closed.

Repository .nvmrc Read by The shipped bundle is built on
CPSV Editor 24.20.0 both Static Web Apps workflows, via node-version-file the same — built on the runner, uploaded with skip_app_build: true
Linked Data Explorer 24.21.0 (from 22.23.2 on 23 September 2026) all four deploy workflows that install Node, and the promotion's changes job — the two ROPA site workflows upload static files and set up no Node the same
RONL Business API 22.23.2 all eight deploy workflows the same

setup-node decides only what the runner uses, so the pin is worth having only once the runner is what builds. Both halves landed together, which is the right order: pinning the interpreter while a vendor container re-chose it would have been ceremony.

What the pins were before, and why the shape changed

The CPSV Editor read a bare '24' — a major, so whichever 24.x patch was current at run time — governing lint and tests only, while Oryx built the shipped bundle on a Node it chose itself (22.22.0, in run 34622800899). The Linked Data Explorer carried three exact literals in three workflow files — 20.20.2 frontend, 22.23.2 backend, 24.19.0 audit — with nothing keeping them in step, which its own #113 recorded; its frontend shipped on Oryx's 22.22.0 rather than the 20.20.2 its tests ran on (run 34612031473). The RONL Business API was first to the file, at 22.22.0, since bumped to 22.23.2.

One file beats three literals, and that is the maintainable half rather than a stylistic preference: Renovate's node manager parses .nvmrc, so the file stays current as a reviewed pull request, where hand-written literals rot silently and separately. Prefer the file.

One trap came with it. A paths: allowlist that does not name .nvmrc means a Node bump builds and deploys nothing — the version changes and no artifact moves — and once the build checks became required on acc, the same pull request showed every required build check skipped, and was mergeable (found on linked-data-explorer#80, which changes .nvmrc alone). Every allowlist that builds on Node now names it: the acceptance workflows' push filters and changes patterns, and the production rules in scripts/promotion-targets.* in the Linked Data Explorer and the RONL Business API. The CPSV Editor filters with a paths-ignore denylist, so a .nvmrc change already builds there.

The RONL Business API's case also shows what the gap actually costs. Both its App Service plans run NODE|22-lts, while eight workflows built the deployed artifact on Node 20 — so the artifact was built on one major and served by another, silently, until #36 closed. Its engines.node moved to >=22 in the same change, because a floor of >=20.13.0 permits precisely the mismatch being removed. The Linked Data Explorer now has the same shape the other way round: its engines.node moved to >=22.23.2 in the change that took .nvmrc to 24.21.0, so the declared floor sits a major below the version it builds and ships on.

One deliberate exception in each, and it is not the shared file. Every zizmor.yml sets its own exact literal — 24.20.0 in the CPSV Editor, 24.21.0 in the other two — deliberately separate from .nvmrc, because its renovate-config-validator step needs Node 24 whatever the application runs on. renovate declares engines.node ^24.11.0, and npm accepts a mismatch with a warning rather than refusing, so the validator had been running unsupported and green. That pin is load-bearing and must not be swept into the shared file; in the CPSV Editor and the Linked Data Explorer it happens to equal .nvmrc today, which is coincidence rather than coupling, and in the RONL Business API, on 22.23.2, it is the whole point. The daily dependency-audit.yml and sbom.yml carry a third literal, 24.20.0 in all three, for a related reason: they read main as well as acc, and the two need not share an .nvmrc. Renovate's node manager maintains all of them.

What still floats, and what is now decided rather than open. Checked on 27 September 2026 at a7fe76f, 0143ea2 and 3c44b9e:

  • The App Service runtime can be pinned to a major and no further — decided, not open. az webapp list-runtimes --os linux offers exactly NODE|22-lts, NODE|24-lts and NODE|26: no patch version, no digest, no setting that takes one. What remains reachable is keeping the host's major in step with .nvmrc's, and the ordering is part of the pin — switch the App Service first, then merge the .nvmrc bump. No pull-request check runs against an App Service, so nothing enforces that order. The RONL Business API's two stay on NODE|22-lts against .nvmrc 22.23.2, with Node 24 deferred in renovate.json until they switch. The Linked Data Explorer's moved to NODE|24-lts for its .nvmrc 24.21.0. All four were read from Azure on 27 September
  • The Linked Data Explorer's backend is the exception to "switch first, then merge": libxmljs2 builds against NAN rather than N-API, so its binary is bound to one major, and between the switch and a rebuilt deploy the backend cannot load it. Since 23 September both of its backend workflows therefore ask the deployed app to validate a DMN, and fail on the native-load signature — a check that passed on the Node 24.21.0 production promotion of 26 September. A green health check, and a build.sha equal to the merge commit, had not been enough: both said so while DMN validation was broken for every user.
  • Container images the repository does not apply. The RONL Business API's local docker-compose.yml now pins all five images by tag and digest, and Renovate maintains them (docker:pinDigests). The compose files under deployment/vm/, which the Keycloak and Skosmos VMs run, are deliberately not pinned yet — no workflow applies them — and still carry quay.io/natlibfi/skosmos:latest, postgres:16-alpine and quay.io/keycloak/keycloak:23.0 without a digest (#196, open).

Two more were on that list until 19 September 2026 and are now closed:

  • The runner image, which ran on ubuntu-latest in every job — 6 in the CPSV Editor, 12 in the Linked Data Explorer, 13 in the RONL Business API — and moved whenever GitHub moved it. Every job in all three now names ubuntu-24.04: 9, 18 and 20 jobs respectively on 27 September 2026, the counts having grown with the changes jobs that made the build checks requireable, the daily audit and SBOM jobs, and the promotion workflows. (Counted by runs-on:. The promotion's reusable-workflow calls are jobs that take their runner from the workflow they call, so a job count including them reads 9, 21 and 24.) Renovate will not move the pin on its own: a disabled rule in each renovate.json defers ubuntu 26.04 with its reason and the condition for revisiting it. The only surviving ubuntu-latest string in any of them is a comment in semgrep.yml explaining why its Python install needs a venv.
  • The Linked Data Explorer's backend deploy package, which copied package.json into the deploy directory without package-lock.json and ran npm install --production --omit=dev there, so the backend that shipped re-resolved every caret range at deploy time — after npm ci had tested the locked tree. Both backend workflows now stage the package and run npm ci --omit=dev --workspace=@linked-data-explorer/backend --no-audit --no-fund against the workspace root's lockfile, which is the only lockfile there is.

The RONL Business API's backend moved too, and left a door open. Since 20 September 2026 both backend workflows stage the root package.json and package-lock.json and run npm ci --omit=dev --workspace=@ronl/backend --no-audit --no-fund, then deploy over OIDC, which closed #34 and #35. The hand-run deploy-backend-to-{acc,prod}.sh remain as the break-glass path, and they still run npm install --production --omit=dev in a deploy/ folder with no lockfile, which the root .npmrc cannot reach either. Its own register keeps the exception open on exactly that ground: "The exception closes when they are retired, not when the workflow lands."

ICTU Dependency Guideline records these against the recommendations they miss, and linked-data-explorer#119 tracks the work.

zizmor validates pin format, never pin truth. A wrong or hostile digest with a plausible # v7.0.1 comment passes zizmor, Prettier and human review alike. Nothing re-checked that a digest resolves to the tag it claims — until check-supply-chain, below.

The register drifts — and has. Renovate updates workflow pins and never touches SECURITY-PIPELINE.md, and nothing checks that the two agree. This was written as a prediction in August 2026 and was true within a week: as of v2026.09.0 the CPSV Editor's register still listed actions/checkout at a37ce91… (v3.7.0) and actions/setup-node at 49933ea… (v4.4.0), while the workflows had moved to 3d3c42e… (v7.0.1) and 820762… (v7.0.0). Its node-version: '20' exception was likewise stale the moment the workflows took Node 24. The register has since been reconciled in all three repositories, and check-supply-chain now fails the audit in each of them if it drifts again.

Where the digests on this page come from

The pins quoted here are read from the workflow files, not from any repository's register, precisely because the two are known to disagree. When they conflict, the workflow is what runs.

Those last two gaps were the motivation for the check-supply-chain preflight, which shipped in September 2026 and now runs in all three repositories — see below.

It drifted, was caught by a documentation review, and was reconciled by hand

Between the v7 action upgrades and 30 August 2026, RONL Business API's register still listed the superseded v4 digests for actions/checkout, actions/setup-node and actions/upload-artifact — every gate green throughout. A quieter second drift came with it: setup-node had gone from ×8 to ×9 when a config-validator step was added, and the renovate@44.50.3 pin that step introduced was missing from the table entirely. A count is as easy to falsify as a digest, and neither the audit nor review catches it.

It was reconciled in v2026.08.34 and matches today at 39 uses: references across 13 workflows, digests agreeing — re-counted on 27 September 2026 at 3c44b9e, the count having grown with the Semgrep workflow, azure/login for the OIDC backend deploy, and the daily-audit, SBOM and promotion workflows. That first reconciliation was manual and prompted by a docs review rather than by any check in the repository, which is the argument for the preflight rather than against it. The preflight now exists and blocks in all three, so "the register matches the workflows" is checked on every pull request rather than assumed.

One inconsistency survives inside that register, in prose rather than in the table: its Keeping this register true section still quotes the old 30 uses: references across 9 workflows headline. The check reads the first match in the file and so still binds on the real headline and stays green — but two numbers in one document disagree, and that is worth fixing in the repository rather than here.

A deploy secret can be wrong in a way no check here sees. Piping a credential from the Azure CLI straight into gh secret set — az staticwebapp secrets list … -o tsv | gh secret set NAME, both halves the documented way to do their job — stores a trailing newline: 120 bytes where the key is 119. The RONL Business API's public site then passed every build step and failed its first production deploy with "An unknown exception has occurred", naming neither the token nor the app (#97), and a stored secret cannot be read back to confirm it. That repository now carries scripts/set-secret.sh, which reads the value from stdin only, strips surrounding whitespace, and reports the byte count it stored. It is a script to remember to use, not a gate, and the other two repositories do not carry it.


Evidence it works — and a cautionary tale

The gate caught a real breakage on its first live run, and the failure is more instructive than the success.

During review, token: '' was added to the zizmor action as "optional hardening" — the input defaults to ${{ github.token }}, and zeroing it looked consistent with the workflow's own least-privilege logic. Every local check passed: zizmor reported zero findings, Prettier was clean, two independent reviews approved. In CI it failed in seven seconds:

error: invalid value '' for '--gh-token <GH_TOKEN>': GitHub token cannot be empty

The action passes the input as an environment variable, and zizmor's --gh-token is env-backed through clap — which distinguishes unset (fine) from set-but-empty (rejected) at argument parsing, before any audit runs. online-audits: false does not avoid it. The default was restored, with a comment in the workflow recording the failure so the same hardening is not retried.

Three lessons worth keeping:

  1. The only change with no functional justification was the one that broke it. Everything load-bearing — digests, permissions, persist-credentials: false — worked first time.
  2. It was invisible to local tooling by construction. zizmor validates format, Prettier validates syntax; neither executes the action. Only a real run could surface it.
  3. Verify against a real pipeline before declaring done. Static analysis proved the configuration was well-formed, not that it ran.

Replicating this in the next repository

Copy the four files, in this order:

  1. .github/zizmor.yml — verbatim.
  2. renovate.json — verbatim except the packageRules guard, which is specific to Azure/static-web-apps-deploy. Keep it only if that action is used.
  3. .github/workflows/zizmor.yml — verbatim. Land it after the tree already reports zero findings, so the gate arrives green.
  4. SECURITY-PIPELINE.md — as a template. Its exceptions are repository- specific and must be re-derived, not copied.

Then, in order:

  1. Pin the existing workflows and add permissions: blocks until zizmor reports 0 findings.
  2. Merge to the default branch before installing Renovate — it reads config only from the default branch, and installing first makes it onboard with defaults: no cooldown, no digest pinning, no guard.
  3. Install Renovate, scoped to that repository only.
  4. Enable Dependabot alerts only.
  5. Create the ruleset with both required_status_checks and pull_request.

Two traps

Audit scope differs between local and CI. The gate passes neither inputs: nor collect:, so it audits the whole repository using action defaults — wider than the .github/workflows/ scope typically used for a local baseline. That made no difference in the pilot, which has no composite actions or dependabot.yml. It will differ in a repository that does.

The release command must be changed at the same time. A /bump-release that still fast-forwards acc locally and pushes will be blocked the first time it runs after the ruleset lands. Change it in the same pass, not after the failure.

Do not give the audit a branches filter. It is the obvious symmetry with the deploy workflows and it is wrong — see the gate. Stacked pull requests then report CLEAN with zero checks and block permanently once GitHub retargets them.

Set prConcurrentLimit below the staging ceiling. Renovate's default is ten. If the hosting tier also allows ten staging environments, a full dependency queue consumes every one of them and human pull requests are refused. Leave headroom deliberately; raising the tier only moves the number at which the collision happens.


Once a change is ready to commit, Code Standards covers what lint, format, hooks and CI enforce in each repository.


check-supply-chain — the preflight zizmor cannot be

The two gaps above — pin truth and register agreement — are now checked by a script rather than left as known limitations. It shipped in the CPSV Editor in v2026.09.1 and was adopted by the other two within days.

What it checks

Check What it catches
Pin truth Every digest is resolved against the GitHub API and compared with the version its trailing comment claims. Annotated tags are dereferenced to their commit
Register agreement The Pinned table is compared with the workflows — digests, version strings, the (×N) multiplicities, and the "N uses: references across M workflows" headline

The multiplicities are not decoration. In RONL Business API setup-node silently went from ×8 to ×9 when the config-validator step was added, and the register still said ×8 with every gate green. That is the drift this catches.

Where it runs, and where it blocks

Repository State
CPSV Editor ✅ blocking, in the audit job
Linked Data Explorer ✅ blocking, since v2026.09.2
RONL Business API ✅ blocking, since v2026.09.7

All three block. The RONL Business API's ran non-blocking from adoption until 12 September 2026, for a stated reason rather than out of caution: Renovate rewrites workflow pins and never touches SECURITY-PIPELINE.md, so every action-bump pull request fails the register half until the register is updated by hand, and blocking on that would fail a required check on routine dependency updates — which is how gates get resented and then bypassed.

What retired that argument was evidence, not a tool change (#83): its own zizmor-action v0.6.2 → v0.6.4 bump had the register fixed on Renovate's branch and the check green there before the merge, which is the habit below. The Linked Data Explorer had promoted its step on the same evidence in v2026.09.2.

The same pull request supplied the argument against waiting longer, and it is the more interesting half. Before the register was fixed, the check reported a real finding — the workflow pinning v0.6.4 while the register recorded only v0.6.2 — while the step, the job and the checks list all read success. continue-on-error 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 nothing outside that one log knew. A check nobody can see fail is a check that has to be remembered, which is the condition the register drifted in to begin with.

The habit the register depends on

Renovate rewrites a workflow's digest and its version comment together, honestly and correctly. It was predicted that this made the register safe — the pair moves as one, so pin truth still holds. Pin truth does hold. Register agreement does not, and a real Renovate pull request said so:

[register] actions/checkout: workflow pins 3d3c42e5aac5… (v7.0.1) but
           SECURITY-PIPELINE.md records only 11d5960a3267… (v4.4.0), a37ce9120846… (v3.7.0)

The check is right and the register is stale — exactly the drift it exists to catch, caught on the branch rather than after the merge. So each repository's SECURITY-PIPELINE.md records the rule:

When a Renovate pull request bumps an action, update the register on that pull request's branch — before merging it

Not afterwards. The check runs on the pull request, so a register fixed after the merge leaves that pull request red for its whole life. It also makes the step impossible to promote: if no bump can ever present a green result, blocking would mean no action ever gets updated.

The Linked Data Explorer exercised this twice — on the checkout v7.0.1 and setup-node v7.0.0 bumps — before promoting its step. In both, the register moved on the bump's own branch, the check went green there, and the pull request merged green. That is the evidence its promotion rested on, and the RONL Business API's zizmor-action v0.6.4 bump supplied the same evidence there on 12 September 2026.

If it proves flaky the answer is --offline, never continue-on-error

The known cost is a network call inside a required job. --offline drops the pin-truth half and keeps register agreement blocking. continue-on-error looks equivalent and is not: it rewrites the step's reported conclusion as well as the job's, so a failing check reports success rather than reporting a failure that does not block. One is a narrower check; the other is a check that lies.

It was proved by making it fail

A green run proves nothing about a check that might be silently inert. Acceptance was a planted wrong digest carrying a plausible comment, a digest changed without updating the register, and a transport failure with a deliberately invalid token — which produced four API-error findings rather than silence.

Its assumptions were also wrong in a way this repository could not show. Run against the other two before either adopted it, it reported findings that turned out to be its own bugs: an action can legitimately be pinned at two digests mid-upgrade, and a register may annotate a version cell (v1 (branch head)) where a workflow comment cannot. Rows are now matched by digest and versions compared on the leading token. Neither bug was visible in the repository that wrote the script, which pins every action once and annotates nothing — the same shape as the defect the script exists to catch.