Skip to content

chore: promote acc to main — publish v2026.09.8 - #229

Merged
sgort merged 21 commits into
mainfrom
acc
Sep 26, 2026
Merged

sgort merged 21 commits into
mainfrom
acc

Conversation

@sgort

@sgort sgort commented Sep 26, 2026

Copy link
Copy Markdown
Owner

Promotes acc to main, publishing v2026.09.8: 12 commits (11 in the release entry plus the bump itself, #228).

What this release is

It is almost entirely the #119 CI-posture and dependency work, per the ICTU recommendations:

  • A release SBOM, CycloneDX, committed under docs/sbom/ and uploaded by sbom.yml (ci: generate a release SBOM, committed and uploaded (#119) #227).
  • A daily dependency audit of both acc and main, which fails on a high or critical advisory in production dependencies and keeps one tracking issue.
  • A lockfile-sync check in the audit job, so a lockfile that disagrees with package.json fails under its own name.
  • Renovate majors policy: never offer an X.0.0, and a recorded deferral for ubuntu 26.04.
  • Two Semgrep findings closed: matchTag in quality.service.ts now takes a closed union, and the drift guard uses a constant format string.
  • concurrently v10 (root dev dependency; affects npm run dev:full only).

The one runtime change in production is quality.service.ts, which only tightens types. Behaviour is unchanged.

What the sequencer will do with this range

I ran the same scripts/promotion-targets.mjs locally against origin/main..origin/acc (16 changed files):

backend=true
frontend=true
ropa_site=false

The backend deploys first, then the frontend. The ROPA site is skipped because nothing in its filter changed.

frontend=true follows from the release, not from frontend code. The only frontend file in the range is packages/frontend/src/changelog.json, and the root package.json and package-lock.json sit in its filter too. That is expected: the release entry is what the frontend shows.

First promotion with the release SBOM

This is the first push to main since sbom.yml landed. On the promotion it runs --verify-release: it fails if docs/sbom/linked-data-explorer-2026.09.8.cdx.json is missing, and only warns if the lockfile has moved since. The document is committed, and write-sbom.mjs --check passed against it when the release was cut. Its uploaded artifact is the one to fetch for this version.

Opening this pull request deploys a production preview

As decided in #210, the frontend matches its pull_request path filter here, so it builds a preview on the production Static Web App, served from acc. The ROPA site does not match, so it builds no preview.

Verified before opening this

On acc at 0143ea2, after #228 merged:

check result
Deploy Backend to Acceptance success
Deploy Frontend to Acceptance success
Supply-chain audit success
Semgrep success
live ACC backend /v1/health version 2026.09.8, build.sha 0143ea2…, healthy, TriplyDB and Operaton up

GitLab mirror: acc fast-forwarded to 0143ea2; main matches at 99e29f9.

Not in this release

Renovate #221 (node 24.21.0 in workflows), #223 (@types/n3, yaml) and #226 (@bpmn-io/properties-panel) were left out on purpose and stay open for the next release.

renovate Bot and others added 21 commits September 24, 2026 17:19
…t string

Semgrep finding 979721020, javascript.lang.security.audit.unsafe-formatstring,
low:

    console.error(`  ${workflow}: pull_request paths the ${target} pattern
                  misses:`, missed);

A second argument is what makes this a finding rather than a template
literal. With `missed` passed alongside it, Node hands the first argument
to util.format, so it becomes a FORMAT string -- and `workflow` and
`target` are interpolated into it. A `%s` inside either would consume
`missed` and forge the line. The same interpolation on line 15 is not
flagged and should not be: single-argument console.error never processes
specifiers.

Not exploitable here. Both values come from the hardcoded SITES array four
lines above, so nothing outside this file reaches the format string. The
fix is one line and the assumption is the part worth removing: "the inputs
happen to be literals today" is what stops being true when someone derives
that list from a directory read.

Values move to arguments behind a constant format string, with %j for the
path list -- %o printed `[length]: 1` alongside the paths.

The guard still reports correctly. Mutating the ropa-site pattern to force
a failure:

    .github/workflows/azure-ropa-site-prod.yml: pull_request paths the
    ropa_site pattern misses: ["packages/ropa-site/probe"]

npm run test:scripts: 24 + 24 pass. check-format clean.
… two ReDoS findings it caused

Semgrep's detect-non-literal-regexp flagged both RegExps in matchTag, which interpolate its `tag` parameter. The parameter was `string`, so nothing but the call sites stopped data reaching a pattern; all three pass literals — 'decision', 'uitvoeringsregel' and 'inputData'.

The parameter is now a MeasuredTag union of exactly those three, so the interpolation is a compile-time constant and the compiler enforces it rather than a comment asserting it. Both lines carry a scoped nosemgrep naming the rule, why it is safe, and what would end that: widening the type back to string. Same shape as RonlAttr in the frontend's ronlAttributes.ts, which suppresses the same rule for the same reason.

Neither pattern has a nested quantifier, so there was no backtracking risk either way; the finding was about the interpolation, and that is what this closes. The findings clear from the Semgrep dashboard on the next scan of acc.
Every other gate here runs on a commit, so a new advisory against unchanged code is seen by nothing; Dependabot alerts watch the default branch, acc, and not the main that production deploys from. ICTU recommendation 10.

.github/workflows/dependency-audit.yml runs at 05:17 UTC and on demand, reading each branch's lockfile with npm audit --package-lock-only, installing nothing. It fails on a high or critical advisory in production dependencies and reports the rest, and it opens, updates and closes one tracking issue so a scheduled failure reaches someone.

scripts/audit-tree.mjs groups findings by ADVISORY rather than by package: npm audit reports one entry per affected package, and on 2026-09-24 linked-data-explorer's 28 moderate entries were three advisories, 24 of them the same @tiptap/core reached through its extensions. A count that overstates the problem by an order of magnitude is a count that gets ignored. An audit that cannot run exits 2 and is treated as a finding, never as a clean tree.

Node is pinned as an exact literal rather than read from .nvmrc: this job audits both branches, and main need not carry the same .nvmrc as acc. Measured before merging -- linked-data-explorer 3 advisories, ttl-editor none, ronl-business-api 7 with one high -- and those match what Dependabot reports.
… remove

The audit loop checks out origin/acc and origin/main in turn, which replaces the working tree — including scripts/audit-tree.mjs itself, which exists on the branch under review before it exists on either target. The first run lost the script at the first checkout and node exited 1 for a missing module, which the step then read as "a high or critical advisory was found": a failure reported as a finding that did not exist.

The script is now copied to $RUNNER_TEMP before the loop and run from there. Each iteration resets its own status, and anything above 2 is clamped to 2, so "the audit could not run" can never be mistaken for a finding or for a clean tree. The reasoning is in the workflow, next to the loop that needs it.
`audit` is zizmor.yml's job and a required status check here. Required checks match by name, so this workflow's second job of that name made the required context ambiguous: one passing check and one failing check under one name, which no ruleset can satisfy. Its first run blocked sgort/ronl-business-api#206 outright.

The job is now `dependency-audit`, with the reason recorded next to it, and SECURITY-PIPELINE.md names the check alongside the rest of the audit's specification. Same lesson as the two jobs that were both called "Build and Deploy Job", renamed for this reason in #184 — which is where the rule this broke was written down.
ICTU recommendation 7 asks for the risk of a major to be assessed, and for the first or second patch release to be waited for. Majors already wait for Dependency Dashboard approval here, which is where the assessment happens; nothing said which version may be offered once someone approves.

A packageRules entry now sets allowedVersions to !/^\d+\.0\.0$/ for the npm manager, so Renovate never proposes X.0.0 and the earliest a major can arrive is X.0.1, the first patch.

Scoped to npm deliberately: allowedVersions applies to every update type of a matched package, and the same pattern against Docker tags (18-alpine) or the github-runner datasource (ubuntu 26.04) would mean something else. The pattern matches X.0.0 alone, which no minor or patch release can look like.

Verified before merging: renovate-config-validator --strict accepts the config, and the stored pattern was tested against fourteen versions with no mismatch. It blocks 1.0.0, 2.0.0, 3.0.0, 10.0.0 and 0.0.0, and allows everything else — including every major queued today (express 5.2.1, @tiptap 3.31.3, react-router 7.18.0), so nothing waiting is affected by this.

The cost is recorded in the rule: a package that publishes X.0.0 and never a patch is never offered that major, and the remedy is a per-package exception carrying its reason.
ICTU recommendation 7 asks for a major to be assessed rather than taken or ignored, and for the decision to be recorded. These two were assessed on 24 September 2026 and held, each as a rule that disables the update and carries its reason and the condition that ends it — the shape ttl-editor already uses for Tailwind 4 and ESLint 10.

ubuntu 26.04: the runner pins exist to stop the image drifting under us, not to be newest. `ubuntu-latest` still resolves to 24.04, so taking 26.04 would put every job ahead of GitHub's own default. Checked the same day: the ubuntu26 images are published and the label carries no beta marker, so this is a choice, not an impossibility. Revisit when `ubuntu-latest` moves.

Not deferred by rule, and deliberately so: react-router-dom 7, vitest 5, jsdom 30, vite 8 with @vitejs/plugin-react 6, typescript 7, eslint 10, the React monorepo and the grouped workspace majors. Disabling those would hide them; they stay queued behind Dependency Dashboard approval, where a person sees them.
`npm ci` refuses to install when package-lock.json and package.json disagree, so this was already detectable — but only as an EUSAGE error inside "Install dependencies for the formatter", three steps into a job about pinning, where it does not read as a lockfile problem.

It happened on 25 September 2026 in ronl-business-api: three dependency pull requests merged back to back, each with a lockfile computed against an older acc, produced a lockfile matching no package.json. Every pull request had been green against its own base.

A `npm ci --dry-run` step now runs before that install. It resolves and validates without writing node_modules, costs seconds, and fails with the cause in its own name. Verified both ways before merging: it passes here, and it fails with EUSAGE against the broken tree that prompted it.

What it cannot do is recorded next to it: the check runs on the pull request's own merge commit, so it proves the lockfile is consistent with THAT base. A pull request green against a stale base, merging into a base that has moved, is not caught by anything on the pull request. Merge dependency pull requests one at a time, and rebase each onto the merged acc first.
The new step failed on every pull request, and not for the reason it exists: npm ci --dry-run still runs lifecycle scripts, and the root postinstall (scripts/write-deps-marker.mjs) copies package-lock.json into node_modules, which a dry run never creates. On a clean checkout that is ENOENT, reported under a step named "Lockfile matches package.json" — precisely the confusion the step was added to remove.

It passed when it was written because the machine it ran on already had node_modules. CI does not, which is the condition that mattered and the one that was not tested.

--ignore-scripts skips the postinstall. Whether the lockfile satisfies package.json is decided before any script runs, so the check keeps its meaning.
ICTU recommendation 10 asks for SBOMs of released versions, kept analysable. When an advisory lands against something that shipped months ago, the question is what that version contained, and only a document written at the time answers it.

scripts/write-sbom.mjs writes docs/sbom/<name>-<version>.cdx.json: CycloneDX, production dependencies only — that is what the released artifact holds — and --package-lock-only, so it needs no install and describes the lockfile rather than whatever is in node_modules. npm run sbom is a step in bump-release, after the version bump, since the filename carries the version.

.github/workflows/sbom.yml runs on a push to main, on demand, and on a pull request that touches the tooling. A promotion to main IS the release here: there are no tags and no GitHub Releases. It uploads the document as an artifact and, on a promotion, asserts that the released version has one.

Two copies, because neither suffices alone. This repository is public, so GitHub caps artifact retention at ninety days; the committed copy is what answers a question about a version that shipped a year ago. The artifact is what a scanner or a Dependency-Track import can fetch without a checkout.

Three modes, and the distinction is the point: writing; --check, strict, which belongs where the release is cut; and --verify-release, which a promotion can honestly assert — a missing document fails, drift only warns, because a promotion carries every commit merged since the release and the lockfile may legitimately have moved. Both comparisons ignore serialNumber and metadata.timestamp, which change on every run.

Verified before merging: the strict check fails on a stale document and passes on a fresh one; --verify-release warns on drift and fails on a missing file; Prettier reformats the document and both still pass, because they compare parsed JSON rather than bytes.
Flips the 2026.09.8 changelog entry to Released: eleven commits since v2026.09.7, scope backend (the only package change is quality.service.ts). Root and backend package.json and their package-lock.json entries move to 2026.09.8; the frontend stays at 2026.09.7. Adds the release SBOM, docs/sbom/linked-data-explorer-2026.09.8.cdx.json.
@github-actions

Copy link
Copy Markdown

Azure Static Web Apps: Your stage site is ready! Visit it here: https://witty-beach-0a7cfa203-229.westeurope.3.azurestaticapps.net

@sgort
sgort merged commit 4148c9a into main Sep 26, 2026
13 checks passed

This branch was successfully deployed

1 active deployment
acceptance — 0143ea2f Deployed Sep 26, 2026 by sgort via deploy #299
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant