Build Provenance¶
Verification status
All four implementations were re-checked on 27 September 2026, against
a7fe76f, 0143ea2 and 3c44b9e, by reading every workflow that carries the
env: block โ eight files, unchanged in number.
The two columns that used to be the odd ones out no longer are. Until
19 September 2026 the CPSV Editor and the Linked Data Explorer handed their build
to a vendor container, which put their env: block on a different step from the
RONL Business API's. All four now build on the runner, so the rule below has one
answer rather than two โ and the reasoning that produced two is kept, because it
is what a repository adopting Static Web Apps will meet by default.
Answering "which build am I looking at?" from inside the running app
Every IOU application shows a version in its changelog. That version comes from
package.json or changelog.json and is written by hand at release time, so it
identifies a release โ not a build of that release. Those are not the same
thing, and the difference matters exactly when something is behaving oddly and the
first question is whether the environment is even serving what you think it is.
Three cases where a version string cannot answer that:
- Acceptance and production can serve different builds of the same version, because they deploy from different branches at different times.
- Redeploying unchanged code produces a new artifact carrying an identical version. Nothing in the running app distinguishes the two.
- A release can be rebuilt after a workflow change, a dependency resolution difference, or a re-run of a failed job โ same source, different artifact.
Shipped across three applications in September 2026, and to a fourth โ the RONL public site โ on 10 September 2026. All four have since been exercised in production, the last two on 12 September 2026.
What it looks like¶
One recessive monospace line directly beneath the changelog heading:
The full 40-character SHA sits on the title attribute, so it can be copied for a
lookup without cluttering the display. With nothing injected the line reads
local build. It is never blank, and it never resembles a deployed artifact
when it is not one.
Why two values rather than one¶
| Value | Answers |
|---|---|
| Commit SHA | What source was built? |
| Run number | Which build of that source is this? |
The SHA alone is a code id, not a build id โ two deployments of the identical commit share it. The run number is what makes the pair unique per artifact.
In a called workflow the run number is the caller's. Since the Linked Data Explorer
(v2026.09.7) and the RONL Business API (v2026.09.11) deploy production through
promote-to-production.yml, every production build id carries the promotion's run
number, and every deploy in one promotion shares it: on 27 September 2026 the Linked
Data Explorer's production frontend bundle and its backend's /v1/health both read
build 4148c9a ยท #2, and the RONL Business API's public-site bundle and backend both
read 2443adc and 3 โ Promote to Production runs #2 and #3. The pair is still unique
per promotion, which is what it is for; it now names which promotion rather than
which execution of that deploy workflow. Acceptance is unchanged โ its workflows are
not called โ and so is the CPSV Editor, whose production workflow still triggers itself
(build 7d154ba ยท #102).
Half-configured counts as untracked¶
A run number with no SHA renders local build, not #412: showing a run number
with no commit behind it implies a provenance the bundle does not have. A SHA with
no run number is equally untracked, because it cannot distinguish two builds of one
commit.
Blank and whitespace-only values are treated as absent, because Vite substitutes an
empty string rather than undefined in some build configurations โ without that
rule the panel renders build ยท #.
Never derive the SHA from git at build time
No git rev-parse in a build script. A build id that silently fails to resolve is
worse than none โ it lies. The values are passed in from the workflow, where they
are always available.
This began as a necessity: until 19 September 2026 two of the applications built
inside a vendor container where neither git nor .git was guaranteed to exist. All
four now build on the runner, where .git is present โ and the rule stays
regardless, because one source for the build id is the property worth keeping, not
the container that forced it. The CPSV Editor's workflow says exactly that in a
comment beside the env: block.
The same feature, four implementations¶
The module and its tests ported unchanged. Everything else had to be re-derived per deployable, which is not the same as per repository โ see the fourth column.
| CPSV Editor | RONL Business API (frontend) | RONL Business API (public site) | Linked Data Explorer | |
|---|---|---|---|---|
| Language | JavaScript | TypeScript | TypeScript | TypeScript |
| Tests | 5 | 8 | 8, plus 4 on the footer | 8 |
| Monorepo | no | yes (packages/frontend) |
yes (packages/public-site) |
yes (packages/frontend) |
| Surface | changelog tab | lazily-loaded changelog drawer | site footer โ no changelog | changelog full page |
| Who builds | the runner (Oryx, until 19 Sep 2026) | the runner | the runner | the runner (Oryx, until 19 Sep 2026) |
env: goes on |
build step (deploy step, until 19 Sep 2026) | build step | build step | build step (deploy step, until 19 Sep 2026) |
| String lands in | lazy chunk ChangelogTab-*.js |
lazy chunk ChangelogPanelContent-*.js |
main index-*.js |
main index-*.js |
The Surface row was called Changelog UI while every adopter had a changelog. The public site has none: it is a citizen-facing site whose foot already carried its origin and version, so the build id joins them on that line rather than acquiring a panel of its own. Step 4 of the checklist below โ match the existing layout โ is what decides this, and it is why the module ports but the markup never does.
Where the env: block goes, and why it moves¶
This is the single most important difference, and getting it wrong produces a change that passes every test and puts nothing in the artifact.
The rule is not per-repository preference, and it is not per-repository at all โ it
follows directly from who runs the build, which is a property of a workflow.
RONL Business API is the proof: its frontend and its public site live in one
repository, are built by two separate pairs of workflows, and could perfectly well
have answered this question differently. They do not, but nothing about sharing a
repository guaranteed that. Read skip_app_build in the workflow you are editing;
do not infer it from a sibling package.
Where the runner builds, the variables go on the build step โ which, since
19 September 2026, is all four. Each runs its build as a step of its own and passes
skip_app_build: true to the Static Web Apps action, which then only uploads
dist/:
- name: Build frontend for ACC
working-directory: packages/frontend
env:
VITE_BUILD_SHA: ${{ github.sha }}
VITE_BUILD_RUN: ${{ github.run_number }}
run: npm run build:acc
Where Oryx builds, they go on the deploy step instead. No workflow in these
repositories is in this shape any more โ app_build_command appears nowhere in any of
the three โ but it is the default a new Static Web Apps workflow arrives in, and it is
what the CPSV Editor and the Linked Data Explorer looked like until 19 September 2026.
There is no build step at all; the action builds inside its own container and forwards
the runner's environment into it:
- name: Build And Deploy
uses: Azure/static-web-apps-deploy@<pinned-sha> # v1
env:
VITE_BUILD_SHA: ${{ github.sha }}
VITE_BUILD_RUN: ${{ github.run_number }}
with:
app_location: '/packages/frontend'
output_location: 'dist'
app_build_command: 'npm run build:acc'
Whether Oryx forwards the runner's environment into its container could not be answered locally. It was settled by a deployed preview.
skip_app_build is the tell, and it is the same flag that governs pinning
A workflow that sets skip_app_build: true builds on the runner; one that
does not hands the build to Oryx. That single flag decides which step the env:
block belongs on โ and it is the same flag that decides whether lockfile
integrity covers what ships or only what is tested. See
Supply-Chain Pinning, where it
appears for the second reason.
Read it in the workflow you are editing, not in a sibling and not in this page:
the Linked Data Explorer sets it in both frontend workflows and in neither
ropa-site workflow, which is correct โ that package is static files with no build
and so no env: block to place.
Vite specifics¶
The VITE_ prefix is not optional โ Vite exposes only variables carrying its
envPrefix to import.meta.env, and anything else is invisible to the bundle. A
Create React App project would need REACT_APP_ and process.env; Next.js would
need NEXT_PUBLIC_.
Vite merges VITE_-prefixed variables from process.env over the mode file
(.env.acceptance, .env.production). No env file needs editing, and none of the
three repositories defines these variables in one โ CI is deliberately the only
source, so every local run falls through to local build.
Two implementation decisions worth keeping¶
A separate module, not logic in the component. The fallback rules become testable without rendering anything; most of each test file exercises paths that would otherwise need a mounted component.
Read the environment inside the function, never at module scope. This is the one that bites:
A module-scope capture is evaluated a single time when the module is first imported
and cannot be stubbed per test afterwards, which makes the fallback path untestable
without vi.resetModules gymnastics. Reading inside the function lets vi.stubEnv
and vi.unstubAllEnvs work cleanly.
Verifying it¶
A build-time injection is precisely the kind of change that passes unit tests and ships an artifact containing nothing. Unit tests alone are insufficient.
Build both directions and grep the whole of dist/ โ not just the entry bundle.
In RONL Business API the changelog is deliberately code-split, so the string lands
in a ChangelogPanelContent-*.js chunk; grepping only index.js returns nothing
and looks exactly like failure. Confirm the chunk hash changes between the two
builds โ if it does not, the second build did not run.
The CPSV Editor joined it in v2026.09.2, when its four heaviest tabs were
lazy-loaded to cut the entry chunk from 685.71 to 392.74 kB. ChangelogTab is one of
them and the only importer of the build-info module, so the string moved out of the main
bundle into ChangelogTab-*.js โ and this page's comparison table went on saying main
bundle for two releases, because nothing about a correct build id changed. Confirmed on
11 September 2026 with a marker build of v2026.09.4: the injected SHA and run number
appear in dist/assets/ChangelogTab-*.js and nowhere else. A code-splitting change
elsewhere in the app moves where to grep, which is one more reason to grep all of
dist/.
Grep for the injected values, not the rendered label. build 570fd98 ยท #412 is
assembled at runtime from `build ${shortSha} ยท #${run}`, so that string is in no
artifact, however correct the build. What ships is the full SHA and the run
number as two separate literals, next to the template. Searching for the label a
user would read returns nothing on a perfectly good build and looks exactly like the
failure this check exists to catch.
Check the exit code, not a grep of the output. Grepping for PASS/FAIL misses
failure modes the chosen pattern does not match. A command either succeeded or it
did not, and no pattern can filter that away. Watch the same trap in shell chains: a
failed cd in cd dir && npm run lint means lint never ran, and the non-zero exit
is the cd. That produced a false "clean build" reading during development โ the
build never executed and the grep examined the previous build's dist/.
Then check the deployed preview. Only that proves the workflow env: block
reaches the builder; the local greps prove the code path and nothing more.
github.sha on a pull request is not a commit in your branch¶
On a pull request, github.sha is the merge commit GitHub synthesises, not the
head of the branch. The SHA shown on a preview deployment therefore matches no
commit in the branch history and cannot be found with git log.
This is correct โ that synthesised commit is genuinely what got built โ but it will
be reported as a bug unless the pull request says so. On a push to an integration
branch, github.sha is the real commit. Observed:
| Preview (synthesised) | After merge (real commit) | |
|---|---|---|
| RONL Business API (frontend) | build 1224298 ยท #265 |
build 66940d9 ยท #266 |
| RONL Business API (public site) | build 34002e8 ยท #76 |
build 0068444 ยท #77 |
| Linked Data Explorer | build b669689 ยท #186 |
build 9db0ab3 ยท #188 |
The public-site pair was read out of the deployed bundles, not derived from the
workflow runs: fetch the page, follow its /assets/index-*.js, and the two injected
literals sit next to the template that renders them. That is worth doing once per
adopter, because it is the only check that distinguishes "the workflow ran green"
from "the values reached the artifact".
Known gaps¶
-
Production has now run in all four. All eight workflow files carry the
env:block. Two applications promoted v2026.09.2 tomainon 9 September 2026 and both production workflows ran green:Commit Run Changelog should read CPSV Editor (Deploy PROD) bbda38988 build bbda389 ยท #88Linked Data Explorer 007b35039 build 007b350 ยท #39The CPSV Editor has promoted twice more since, both on 11 September: v2026.09.3 as
build f7e127a ยท #92and v2026.09.4 asbuild f5bae6a ยท #94, each from Deploy PROD (white-sky). Two more followed: v2026.09.5 on 15 September asbuild e1c482e ยท #97, from the run, and v2026.09.6 on 19 September asbuild 2723db1 ยท #100โ read out of the deployed bundle, where the lazyChangelogTab-*.jschunk carries the full SHA and100as Vite emitted them, template literals rather than quoted strings.The Linked Data Explorer has promoted twice more since, both on 11 September: v2026.09.3 as
build 35a44f8 ยท #41and v2026.09.4 asbuild be6bc54 ยท #44, each from Deploy Frontend to Production. The gaps in the run numbers are the pull-request runs of the same workflow, which build previews rather than production. v2026.09.4 is also the first promotion under the widenedpaths:filter, where a change to the root lockfile alone redeploys the frontend.Its v2026.09.5 promotion on 19 September 2026 is
build ec4792f ยท #51โ and that one was read out of the deployed bundle rather than derived from the run: the production/assets/index-*.jscarries"ec4792f09c289f8fee6c68da5184fd775a27ccc9"and"51"as the two injected literals.Of those four strings, two were read off the running application โ the CPSV Editor's
build bbda389 ยท #88and the Linked Data Explorer'sbuild 007b350 ยท #39, both confirmed by eye on 9 September. The later ones are derived from the workflow runs, which is the weaker check: it distinguishes the workflow ran green from nothing at all, where a glance at the application distinguishes it from the values reached the artifact.The RONL Business API closed its own gap on 12 September 2026. Its frontend production workflow had last run on 17 July, before this feature existed, and its public site had never had a production run at all. Its promotion of v2026.09.6 that morning exercised both at once, and both were confirmed by eye rather than inferred:
Commit Run Read on the running application RONL Business API (frontend) 04840ed12 build 04840ed ยท #12in the caseworker changelogRONL Business API (public site) 04840ed2 publiek.open-regels.nl ยท v2026.09.6 ยท build 04840ed ยท #2in the footerTwo details from that first run are worth carrying to the next adopter. The same commit produced two different run numbers, 12 and 2, because the run number counts executions of a workflow and not commits โ which is exactly why the pair is a pair. That was true of every deploy until the promotion workflows; since then the production deploys of one promotion share its run number, as above. And the public site's footer is client-rendered, so grepping the prerendered HTML for the string finds nothing and reads as failure; the check is the rendered page, or the injected literals inside
/assets/index-*.js.The promotion of v2026.09.7 the same afternoon moved both on again, to run 13 and run 3 at
311d732. - The line describes the frontend bundle being viewed, not the backend behind it. A backend needs its own answer, and the Linked Data Explorer's backend has one since v2026.09.5:/v1/healthreports abuildblock with the same shape and semantics as the frontend'sBuildInfoโ tracked only when both SHA and run are present,local buildotherwise, never affectingstatus. The identity travels in adeploy/build-info.jsonboth backend workflows write into the artifact, not in an App Service setting, because settings persist across deploys and can describe a build that is no longer the one running. The post-deploy check then waits untilbuild.shaequals the deployed commit, so a deploy that left the previous artifact serving fails instead of passing. On 19 September 2026 production's backend reportedbuild ec4792f ยท #19โ the same commit as its frontend, from a different workflow, with a different run number, for the reason given above. The RONL Business API's backend has had the same since v2026.09.11:build-info.jsonwritten by both backend workflows (sha,run,runId), reported underdata.buildin its wrapped/v1/health, and a post-deploy loop that fails unlessbuild.shaequals the deployed commit.
Adding this to another application¶
The fourth adopter, the RONL public site, followed these six steps exactly as they were written here and needed no deviation. Step 2 was again the one that decided everything, and step 4 was the one that produced a visibly different result.
- Confirm the bundler first.
VITE_andimport.meta.envare Vite-specific. - Find out who builds โ the runner, or a container the deploy action owns. This
decides which step the
env:block belongs on, and it is the decision most likely to be wrong.skip_app_buildis the tell. - Port the
buildInfomodule and its tests unchanged. This part genuinely transfers. - Match the existing changelog layout rather than importing another application's markup.
- Verify with real builds in both directions, grepping all of
dist/. - Confirm on the deployed preview. Nothing local substitutes for it.