Versioned immutable asset releases
Day-to-day operation of release channels is covered in the Egress Operations Runbook.
Status: implemented and rolled out. Publishing landed in sync-to-gcs.yml
on 24 Aug 2026; the resolver and SDK began reading release paths on 25 Aug; and
140 of 142 router entries were moved onto a release channel on 26 Aug. Sections
written before rollout are marked as such rather than rewritten, so the
reasoning behind each decision stays readable. Day-to-day operation is in the
Egress Operations Runbook.
The layout, build-id format, manifest schema, retention window and scope
recorded here are the agreed decisions, not open questions. Every fact about
current behaviour was verified against live HTTP headers, live bucket
listings, and the shipped code on the same date.
One operational step is not automated and must be run once by a
maintainer: the retention lifecycle rule (§3) and, after the workflow change
merges, ./scripts/deploy-workflow.sh --yes to propagate it to the env
branches.
Read Storefront asset delivery and the egress surface first — it is the factual baseline this proposal builds on.
1. What problem this solves
Storefront scripts/ and css/ are served from a stable URL whose content
changes on every deploy:
https://storage.googleapis.com/finmatch-p/scripts/finance-container.js
https://storage.googleapis.com/finmatch-p/css/finmatch.css
Because the bytes at that URL change, the cache header has to be a
compromise — public, max-age=900, stale-while-revalidate=3600 on
finmatch-p — and every returning shopper re-validates or refetches roughly
every 15 minutes per object. A cold visitor pulls ~700 KB of script and CSS
(finance-container.js alone is 174,454 B).
Versioned immutable URLs remove the compromise: content at a versioned URL
never changes, so public, max-age=31536000, immutable is safe, and
freshness moves entirely to the one small mutable resolver response
(getMerchantConfig), which already has the right caching model (30 s
buckets + ETag/304).
When written, this step only published the immutable copies, and nothing read them until Step 3 teaches the resolver and SDK to point at a version. Existing env-bucket publishing is unchanged and keeps serving all live traffic.
2. What a release is
A release is an immutable snapshot of the storefront runtime asset tree
(scripts/ + css/) from exactly one env-branch commit, plus a manifest
describing it.
- One release per push to
finmatch-pthat touchesscripts/orcss/. - Releases are never overwritten, patched, or deleted while any merchant can still resolve to them.
- Releases contain no config.
configs/is admin/API-owned, mutable, and single-writer; freezing a copy of it into a release would create exactly the mirror-drift class of problem the Jun 2026 cleanup removed (source of truth §1). Config freshness is handled separately by content-hash query strings (Step 5).
Build identifier
<env>-<YYYYMMDD>-<HHMM>-<short-sha> e.g. p-20260824-2137-301e047d
Rationale: sync-to-gcs.yml already computes git rev-parse --short HEAD
and a commit timestamp for build-info.json, so this needs no new counter,
no new state, and no second writer. It maps one-to-one back to a commit for
incident triage.
The time component matters for exactly that triage. With date and sha only,
two releases on the same day sort alphabetically by hash, so a directory
listing does not tell you which one is newer — the wrong property to discover
during an incident. HHMM makes the listing strictly chronological.
Rejected alternatives: a monotonic counter needs stored state and a writer; a bare hash loses all time context; semver implies compatibility guarantees that do not exist for a continuously deployed asset tree.
A re-run of the workflow on an unchanged commit produces the same build id
and therefore the same immutable path. The publish step must be
idempotent: re-publishing identical bytes to an existing release path is
a no-op, and publishing different bytes to an existing release path is a
hard failure (see §6, guardrail RELEASE_IMMUTABLE).
3. Bucket and path naming
Two candidates. Both were checked against the constraints that actually bite; the recommendation is Option B for the first implementation.
Option A — dedicated bucket
gs://finmatch-releases/<build-id>/scripts/finance-container.js
gs://finmatch-releases/<build-id>/css/finmatch.css
gs://finmatch-releases/<build-id>/release-manifest.json
Option B — versioned prefix inside the existing env bucket
gs://finmatch-p/releases/<build-id>/scripts/finance-container.js
gs://finmatch-p/releases/<build-id>/css/finmatch.css
gs://finmatch-p/releases/<build-id>/release-manifest.json
What separates them
| Constraint (verified) | Option A (new bucket) | Option B (env-bucket prefix) |
|---|---|---|
Per-origin CORS allow-list. gs://finmatch-p returns access-control-allow-origin: https://<merchant-domain> per allowed origin, not *. ES module imports are always CORS-mode fetches, so an origin missing from the list breaks the storefront outright. The list is applied by merchant-api to a hardcoded CORS_TARGET_BUCKETS = ['finmatch-finance-marketing-assets', 'finmatch-shared', 'finmatch-p', 'finmatch-s', 'finmatch-t']. | Needs a merchant-api code change plus a POST /admin/reconcile-cors run before the first merchant can load a release. Until then every release URL fails CORS for every merchant. | Inherited unchanged. No code change, no reconcile. |
| Bucket creation, IAM, public read | New bucket in europe-west2; grant the GCS_CREDENTIALS service account write; grant allUsers object read; add to the Check bucket permissions loop in sync-to-gcs.yml. | Nothing. |
| Object accumulation | Needs a lifecycle rule from day one. | Needs the same lifecycle rule, scoped to the releases/ prefix. |
| Interaction with existing sync steps | None. | gsutil -m rsync only runs against configs; cp -r targets scripts and css; the setmeta reapply globs are gs://finmatch-p/scripts/** and gs://finmatch-p/css/**. None of these touch or re-header releases/**. Verified against the current workflow. |
| Later CDN backing | Cleaner: one backend bucket holding only immutable objects. | Workable but the backend bucket would also cover mutable scripts/, css/, configs/. |
| Blast radius if the publish step misbehaves (as assessed before rollout) | Confined to a bucket nothing read at the time. | Writes into the bucket that serves production, under a new prefix. |
Decision: Option B, with Option A as the end state. Option B removes the CORS blocker, which is the one constraint that can break live storefronts rather than merely delay the programme, and it needs no infrastructure sign-off beyond the workflow diff. Migrating to Option A later is cheap because Step 3 makes the base URL a resolver-supplied value, not something burned into the SDK: repoint the channel, and merchants follow.
Why not gs://finmatch-shared/releases/…?
Raised during sign-off, since finmatch-shared is already on the CORS
allow-list (verified: it returns
access-control-allow-origin: https://<merchant-domain> for a real merchant
origin) and would therefore also need no code change. It was rejected on a
safety property, not on aesthetics.
Env modules take their environment from the resolver and fall back to reading
their own module URL when the resolver has not told them. Running that helper
against each candidate path, with no runtime block present:
| Module served from | resolved env | isProductionRuntime |
|---|---|---|
finmatch-p/scripts/… (today) | p | true |
finmatch-p/releases/<id>/scripts/… | p | true |
finmatch-shared/releases/<id>/scripts/… | shared | false |
isProductionRuntime false switches off the Propensio and Snap shared
rate-card top-ups, and Propensio has 23 products in the shared catalogue and
0 in the finmatch-p env rate card — so a Propensio merchant would
silently lose every Propensio option. That state is reachable whenever the
runtime block is absent: a browser running a cached SDK (max-age=300, stale-while-revalidate=86400), a resolver not yet deployed, or a failed
resolver call falling back to cached config. Nesting under the env bucket
fails safe in all three; nesting under shared fails silent.
Naming the path gs://finmatch-shared/releases/finmatch-p/<id>/ does not
rescue it: the match is unanchored and takes the first hit, so the bucket
name still wins.
Environment-neutrality, the actual motivation, is already delivered without
the path: channel pointers carry an absolute baseUrl, and a pointer's
environment is validated against the merchant's live environment, so a
sandbox preview on t can load a release hosted under finmatch-p/releases/.
The build id also carries the origin.
If Option A is preferred later, it must be sequenced as: create bucket →
add to CORS_TARGET_BUCKETS → deploy merchant-api → reconcile CORS →
verify a real merchant origin gets an access-control-allow-origin header
on a release URL → only then publish anything Step 3 can consume.
Lifecycle / retention
Releases are additive, so the bucket grows by roughly one asset tree
(~700 KB) per finmatch-p deploy that touches scripts/ or css/.
Agreed rule: delete objects under releases/ older than 90 days.
180 was the first proposal, on the assumption that deleting a pinned release
would break a storefront. It does not: if a release is pruned while a
merchant still points at it, the critical scripts 404 and the SDK retries
them from the env bucket, leaving the page exactly as it is today. That path
is covered by an explicit test (automatic fallback when the release 404s,
in which all 16 scripts recover from the env bucket with zero redirects), so
a mistaken prune degrades rather than breaks. Given that, longer retention
buys very little; 90 days is ample history for triage at roughly 700 KB per
deploy that touches scripts/ or css/.
Applying it is a one-off manual step, and gsutil lifecycle set REPLACES
the bucket's entire lifecycle configuration. Read the current config first
and merge, never set blind:
gsutil lifecycle get gs://finmatch-p > /tmp/finmatch-p-lifecycle.json # keep this
# merge the rule below into the existing "rule" array, then:
gsutil lifecycle set /tmp/finmatch-p-lifecycle-merged.json gs://finmatch-p
{ "action": { "type": "Delete" },
"condition": { "age": 90, "matchesPrefix": ["releases/"] } }
CI deliberately does not apply this: a workflow that rewrote bucket lifecycle on every push could silently drop an unrelated rule.
4. Release manifest
One file per release, inside the versioned (therefore immutable) path:
<base-url>/release-manifest.json
{
"schemaVersion": 1,
"buildId": "p-20260824-757fb7c2",
"env": "p",
"branch": "finmatch-p",
"commit": "757fb7c2",
"builtAt": "2026-08-24T13:41:02Z",
"baseUrl": "https://storage.googleapis.com/finmatch-p/releases/p-20260824-757fb7c2",
"cacheControl": "public, max-age=31536000, immutable",
"entrypoints": {
"container": "scripts/finance-container.js",
"css": "css/finmatch.css"
},
"assets": {
"scripts/finance-container.js": { "bytes": 174454, "md5": "J3lQ2J91tVgnU6FV2JAmGg==" },
"css/finmatch.css": { "bytes": 133278, "md5": "RKnPpmn6szfIRkpjthLWGg==" }
}
}
Field notes:
schemaVersion— bumped on any breaking shape change. Readers must ignore unknown fields so the manifest can grow additively.commit/builtAt/branch— the same three factsbuild-info.jsonalready carries, so a release is traceable to a commit without a second version registry.baseUrl— absolute, so a consumer never has to reconstruct it from a bucket name and prefix. This is the value Step 3 hands to the SDK asruntime.baseUrl.assets—bytesandmd5exactly as GCS reports them (x-goog-hash: md5=…), keyed by path relative to the release root, covering every published directory. This makes a release verifiable after the fact and is what theRELEASE_IMMUTABLEguardrail compares against on a re-run (§6), including the asset count so a file that disappears is caught too.entrypoints— named rather than positional, so the SDK's tiered loader does not have to hardcode filenames that differ between the modern (finance-container.js) and legacy (embed-lender-calc.js) paths.
No mutable pointer file in the bucket
There is deliberately no latest.json or channels.json in the release
bucket. Channel → version pointers live in merchant-router.json, whose
single writer is merchant-api (Step 3). A mutable pointer object in a
bucket that CI also writes would create a second writer for routing state —
the precise failure mode documented in
source of truth §4 (Jun 2026 router-drift incident).
5. Cache headers
| Path | Cache-Control | Why |
|---|---|---|
releases/<build-id>/** | public, max-age=31536000, immutable | Content at the URL can never change. |
existing scripts/, css/ | unchanged (public, max-age=900, stale-while-revalidate=3600 on p; public, max-age=10, must-revalidate on s/t) | Releases run alongside existing publishing; this step changes nothing about what live traffic is served. |
build-info.json | unchanged (no-cache) | Deploy marker, not an asset. |
6. CI touchpoints
All changes are inside .github/workflows/sync-to-gcs.yml on
finmatch-shared, then propagated to env branches with
./scripts/deploy-workflow.sh --yes (the file is drift-checked by
workflow-drift-check.yml and by an in-workflow guardrail, so an env-branch
edit fails the build).
Insertion point
Inside the existing Sync environment branches to GCS step, after the
scripts/ and css/ cp -r and the setmeta reapply, and before the
build-info.json upload. That ordering means a release is only published
once the mutable env copy for the same commit has already succeeded, so a
release can never describe a state the env bucket never reached.
The diff, as implemented
# ── Versioned immutable release (read by merchants on a release channel) ──
if [ "${{ github.ref_name }}" = "finmatch-p" ] && { [ -d "scripts" ] || [ -d "css" ]; }; then
BUILD_ID="p-$(git log -1 --format=%cd --date=format:'%Y%m%d')-$(git rev-parse --short HEAD)"
RELEASE_PREFIX="gs://${{ github.ref_name }}/releases/${BUILD_ID}"
IMMUTABLE_HEADER="public, max-age=31536000, immutable"
echo "DEBUG: Publishing release ${BUILD_ID} to ${RELEASE_PREFIX}"
# Guardrail RELEASE_IMMUTABLE: a re-run on the same commit must not change
# bytes already published at an immutable URL. The published manifest already
# records an md5 per asset (§4), so compare against that rather than
# re-deriving it — and cover every directory the publish step uploads, not
# just scripts/.
if gsutil -q stat "${RELEASE_PREFIX}/release-manifest.json" 2>/dev/null; then
echo "INFO: release ${BUILD_ID} already published; verifying byte-identity"
gsutil cat "${RELEASE_PREFIX}/release-manifest.json" > /tmp/published-manifest.json
DRIFT=0
# Membership first, as an exact set comparison: catches both a new file and
# a file that has disappeared, without comparing counts as strings.
jq -r '.assets | keys[]' /tmp/published-manifest.json | sort > /tmp/published-assets.txt
for DIR in scripts css; do
[ -d "$DIR" ] && find "$DIR" -type f -printf '%p\n'
done | sort > /tmp/local-assets.txt
if ! diff -u /tmp/published-assets.txt /tmp/local-assets.txt > /tmp/asset-set-diff.txt; then
echo "::error::asset set changed for ${BUILD_ID}:"
cat /tmp/asset-set-diff.txt
DRIFT=1
fi
# Then content, for every asset present in both.
while IFS= read -r REL; do
[ -f "$REL" ] || continue
LOCAL_MD5="$(openssl dgst -md5 -binary "$REL" | base64)"
PUBLISHED_MD5="$(jq -r --arg k "$REL" '.assets[$k].md5 // ""' /tmp/published-manifest.json)"
if [ -n "$PUBLISHED_MD5" ] && [ "$LOCAL_MD5" != "$PUBLISHED_MD5" ]; then
echo "::error::${REL} differs from the bytes published at ${BUILD_ID} (local ${LOCAL_MD5}, published ${PUBLISHED_MD5})"
DRIFT=1
fi
done < /tmp/local-assets.txt
if [ "$DRIFT" != "0" ]; then
echo "::error::Immutable release paths must never change."
exit 1
fi
echo "OK: release ${BUILD_ID} unchanged, nothing to publish"
else
[ -d "scripts" ] && gsutil -m -h "Cache-Control:${IMMUTABLE_HEADER}" cp -r scripts "${RELEASE_PREFIX}/"
[ -d "css" ] && gsutil -m -h "Cache-Control:${IMMUTABLE_HEADER}" cp -r css "${RELEASE_PREFIX}/"
# Manifest is generated from what was actually uploaded, not from the
# working tree, so it can never describe files that failed to publish.
python3 - "$BUILD_ID" "$RELEASE_PREFIX" <<'PY' > /tmp/release-manifest.json
# ... walks `gsutil ls -L` output for the prefix, emits the §4 schema ...
PY
gsutil -h "Cache-Control:${IMMUTABLE_HEADER}" -h "Content-Type:application/json" \
cp /tmp/release-manifest.json "${RELEASE_PREFIX}/release-manifest.json"
fi
fi
Guardrail interactions (all verified against the current workflow)
| Guardrail | Effect on this step |
|---|---|
Guardrail - block protected runtime config writes | No interaction. Releases contain no configs/ file, and the protected regex is anchored at repo-root paths. |
Guardrail - block ghost runtime edits on finmatch-shared | No interaction. The publish step is gated on github.ref_name == 'finmatch-p'; nothing is added under root scripts/, css/, configs/ on finmatch-shared. |
Guardrail - fail if sync-to-gcs workflow drifted | Requires ./scripts/deploy-workflow.sh --yes after the finmatch-shared merge, before the next env-branch push. |
Guardrail - env branches must not carry scripts/finmatch-sdk.js | No interaction. The SDK is not part of a release (see §7). |
Pre/post Snapshot protected runtime object hashes | No interaction; it only inspects configs/finmatch-merchant-config.json. |
Clean up .DS_Store files | Globs are depth-limited (gs://<bucket>/*/.DS_Store, */*/.DS_Store) and would not reach releases/<build-id>/scripts/. A .DS_Store committed under scripts/ would be published into a release; the existing cp -r has the same exposure today. |
snap-rate-card-test.yml | No interaction; path-filtered to configs/finmatch-rate-card.json and scripts/snap-rate-card-test.js. |
Secrets and permissions
Option B needs no new secret or IAM grant: GCS_CREDENTIALS already
writes gs://finmatch-p. Option A needs a write grant for that service
account on the new bucket plus allUsers object read, and the bucket added
to the Check bucket permissions loop.
7. What is deliberately not in a release
- The canonical SDK (
finmatch-finance-marketing-assets/s/scripts/finmatch-sdk.js). Its URL is burned into merchant pages and cannot be versioned. It must stay a small, short-cache bootstrap that resolves a version rather than being one (asset delivery §5). configs/— see §2.- The legacy header-loader shims under
gs://finmatch-finance-marketing-assets/{p,s,t}/scripts/. Retiring those needs merchant outreach and is out of scope for this programme.
8. Blockers Step 3 must clear before anything reads a release
These were found while verifying this proposal and are the reason Step 2 and Step 3 are not fully independent. Publishing releases (this step) is safe in isolation; consuming them is not, until these are resolved.
8.1 Env detection by import.meta.url (7 sites, 2 files)
Storefront modules decide production-only behaviour by sniffing their own URL:
const scriptUrl = new URL(import.meta.url);
const isFinmatchP =
scriptUrl.hostname === 'storage.googleapis.com' &&
scriptUrl.pathname.includes('/finmatch-p/');
Under gs://finmatch-p/releases/<build-id>/scripts/… the path still
contains /finmatch-p/, so Option B happens to keep these gates working —
but that is a coincidence, not a design, and it would break immediately
under Option A (gs://finmatch-releases/… has no /finmatch-p/ segment).
Current sites, on finmatch-p:
| File | Line | What it gates |
|---|---|---|
scripts/finance-container.js | ~160 | Propensio shared rate-card top-up |
scripts/finance-container.js | ~218 | Snap shared rate-card top-up |
scripts/finance-container.js | ~280 | shared vs env-local lender-overrides |
scripts/finance-container.js | ~289 | relative ../configs/lender-overrides.json |
scripts/finmatch-finance-app.js | ~678 | isFinmatchPRuntime() (rate-card candidates, and one further gate at ~762) |
scripts/finmatch-finance-app.js | ~908 | shared vs env-local lender-styling |
scripts/finmatch-finance-app.js | ~915 | relative ../configs/lender-styling.json |
The same sniffing also means a sandbox preview with "Preview link loads:
Test build" cannot exercise the two rate-card top-ups, because assets then
come from gs://finmatch-t/. That is called out in the Step 1 PR.
Required fix (Step 3): the resolver states the runtime environment
explicitly and the SDK exposes it (window.finmatch.runtime.environment);
env modules prefer that value and fall back to import.meta.url sniffing
only when it is absent.
8.2 Relative config URLs resolved against the module URL
new URL('../configs/lender-overrides.json', import.meta.url) and the
lender-styling.json equivalent resolve relative to wherever the module
was loaded from. Under a release path that becomes
…/releases/<build-id>/configs/lender-overrides.json, which does not exist.
Only finmatch-s takes these branches today, so it is latent rather than
live — but finmatch-s must not be moved onto releases until the SDK
supplies absolute config URLs. Step 5 groundwork (resolver-supplied,
content-hashed config URLs) is the fix.
8.3 Per-origin CORS
See §3. Applies to Option A only.
8.4 Legacy shim cache busting
finmatch-header-loader.js appends v=${stored.version || Date.now()} to
the SDK URL, so any legacy snippet that never stored a version busts the
SDK cache on every shim execution. This does not block releases, but it does
mean SDK cache-hit rate cannot be assumed until the shim population is
audited (asset delivery §2).
9. Verification
The publish step was extracted from the workflow and executed against the
real finmatch-p working tree with a gsutil stand-in backed by a local
directory, so the shell logic itself is tested rather than reasoned about.
Five runs:
| Run | Scenario | Result |
|---|---|---|
| 1 | first publish | 79 assets published, Cache-Control: public, max-age=31536000, immutable recorded on scripts/, css/ and the manifest; manifest emitted with the §4 shape |
| 2 | re-run, same commit, unchanged tree | release … already published and unchanged; nothing to do, exit 0, nothing re-uploaded |
| 3 | re-run with css/finmatch.css modified | exit 1, naming the file and both md5s |
| 4 | re-run with a script added | exit 1, set diff showing +scripts/zz-new-module.js |
| 5 | re-run after reverting both | clean no-op again |
| 6 | env sync reported a failure | release skipped with a warning, nothing published |
Timestamps are rendered with TZ=UTC git log --date=format-local:…. Plain
%cd renders in the commit's own timezone, which would have stamped a commit
made at 23:37+05:30 as 23:37Z — out by five and a half hours in both the
build id and builtAt.
Cross-checked against production: the manifest's md5 for
scripts/finance-container.js is 9EVpPBGdgo8sMDalJOT+ww==, byte-identical
to the x-goog-hash on the live gs://finmatch-p/scripts/finance-container.js.
So a manifest describes exactly what production serves.
Still to confirm on the first real run, since a local stand-in cannot prove them:
- fetch a built asset at its immutable URL and assert
HTTP 200plus the immutablecache-controlfrom GCS itself; - assert the mutable env paths are untouched —
gs://finmatch-p/scripts/**still onmax-age=900, stale-while-revalidate=3600, same generations.
No traffic metric was expected at this step, because nothing read these paths until a channel pointer names one.
10. Sign-off record (24 Aug 2026)
- Option B —
gs://finmatch-p/releases/<build-id>/. - Build id
<env>-<YYYYMMDD>-<HHMM>-<short-sha>. - Manifest schema §4, including no mutable pointer object in the bucket.
- Retention: delete
releases/**after 90 days, applied manually per §3. -
finmatch-ponly.finmatch-sneeds the absolute-config-URL work in §8.2 first, and there is no reason to take that on before the mechanism has proven itself on production. -
./scripts/deploy-workflow.sh --yesis run by the maintainer after the workflow change merges.
11. Related docs
- Storefront asset delivery and the egress surface — verified baseline, cache headers, egress drivers
- Source of Truth Architecture — single-writer rules
- Rate cards overview — catalogue SSOT and the Step 4 programme
Deploys and pinned merchants
A merchant on a release channel is pinned to a version. Publishing a new release does not move them, so a deploy would reach the unversioned env bucket — and therefore every merchant not on a channel — while release-channel merchants kept running the previous build from behind a year-long immutable cache. A bug fix would never arrive.
sync-to-gcs therefore advances any channel that already has a pointer to the
build it has just published, on pushes to finmatch-p. The rule is narrow by
design:
| Situation | Behaviour |
|---|---|
| No channel has a pointer | Exits cleanly, changes nothing. Adopting release channels stays an operator decision made through merchant-api |
| A channel has a pointer | Advanced to the new BUILD_ID, and the merchant count on release channels is logged |
| The release was not published | Fails before touching any channel |
| Any HTTP status other than 200 | Fails loudly. curl -sS exits 0 on a 500, so the status is checked explicitly |
HTTP 200 but no channels object | Fails loudly. A JSON error body has no channels key, and defaulting it to {} would be indistinguishable from "none in use" |
channels present but empty | Exits cleanly — this is the genuine "none in use" case |
CI cannot read internal-api-secret | Fails with the manual PUT to run instead |
This requires the CI service account behind GCS_CREDENTIALS to hold
roles/secretmanager.secretAccessor on internal-api-secret. Until that grant
exists the step fails rather than silently skipping, because a silent skip is
exactly the failure mode it is there to prevent.
What this means for the caching win
Advancing on every deploy means shoppers do re-download assets after a deploy —
the same as today, since a deploy changes the bytes either way. The win is
between deploys: a returning shopper fetches nothing at all, where previously
they revalidated 20-odd files every 15 minutes. Measured on
absolutemotocross.co.uk after moving it to a release channel: 895,043 bytes on
a cold visit, then zero on every subsequent visit.