Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,11 @@ jobs:
# "TypeError: fetch failed" since no local CAP runs in CI. The
# build is target-aware so qa fetches its own QA-channel catalog.
CAP_BASE_URL: ${{ steps.cap-url.outputs.url }}
# APPROUTER_URL for asset-hash-retention (Task 5): allows mta.yaml's
# before-all retention step to fetch the currently-deployed approuter's
# _retained-assets.json and carry hashed bundles forward. Maps per-env
# secrets: prod→APPROUTER_URL_PROD, qa→APPROUTER_URL_QA, else→APPROUTER_URL_DEV.
APPROUTER_URL: ${{ steps.env.outputs.target == 'prod' && secrets.APPROUTER_URL_PROD || (steps.env.outputs.target == 'qa' && secrets.APPROUTER_URL_QA || secrets.APPROUTER_URL_DEV) }}

- name: Upload MTAR artifact
uses: actions/upload-artifact@v4
Expand Down
11 changes: 11 additions & 0 deletions docs/developers/architecture/build.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,17 @@ During `build:all`, the fetch step calls `GET /build/homepage-shelves` from the

Admin shell (`build:admin`) and QA pipeline (`fetch-tutorials:qa` → `build:qa` → `publish-content:qa`) are not in `build:all` — they're run independently or via `qa:full` for the QA loop. Tutorials must be fetched at least once before `dev` or `build:hugo` (otherwise `hugo/content/tutorials/` is empty).

### Asset-hash retention (#1604 follow-up)

After `build:hugo` (and before `mbt build`), `scripts/retain-asset-bundles.cjs` unions the current build's content-hashed JS and CSS files in `hugo/public/js/` and `hugo/public/css/` with bundles carried forward from the live approuter. The result is written to `hugo/public/_retained-assets.json` — a served manifest of all retained bundles; the approuter builder copies `hugo/public/` into `approuter/static/`, so the manifest ends up exposed at `/_retained-assets.json` for the next build's retention step to read. Each entry is `{ file, firstSeenMs }`.

Key properties:

- **48-hour window** — any prior bundle whose `firstSeenMs` is within 48 hours of the current build timestamp is carried forward (downloaded from the live approuter and placed into the appropriate `hugo/public/js/` or `hugo/public/css/` directory). Bundles older than 48 h are pruned from the manifest.
- **Fail-open** — if the live approuter is unreachable or the prior manifest fetch fails, the step completes using only current files and exits 0; no build is blocked.
- **Safe to union** — content-hashed filenames are immutable (the hash is derived from file content), so adding prior bundles alongside current ones can never overwrite live files. The union prevents `<script src>` references baked into cached HTML from new deploy rotates the hashes.
- **Local deploy requires `APPROUTER_URL`** — Carry-forward depends on fetching the prior manifest from a deployed approuter; CI sets this per environment, but a local `build:all`/`mbt build` deploy must `export APPROUTER_URL=<deployed-approuter-url>` beforehand, or retention will carry only the current build's bundles (fail-open, no error).

### Parsers (scripts/parsers/)

The fetch step (`scripts/fetch-tutorials.ts`) hands raw markdown + repo metadata to `composeTutorial()` (`compose.ts`), which orchestrates format detection, content transforms, and Hugo frontmatter emission. The same module set is bundled into `srv-qa/lib/parsers.bundle.mjs` (via `prebuild:parsers-bundle`) and re-used at runtime by the QA srv to render author-pushed drafts without re-running Hugo.
Expand Down
23 changes: 11 additions & 12 deletions docs/superpowers/plans/2026-08-11-asset-hash-retention.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

**Goal:** Retain prior content-hashed JS/CSS island bundles across deploys for ≥ the HTML edge-cache TTL, so edge-cached HTML never 404s on a bundle hash that a later deploy deleted.

**Architecture:** A build-time step, run after the island bundles are produced and before the Hugo build copies them into `hugo/public`, unions the current build's hashed bundles with recent prior bundles. Prior bundles are discovered from a small `_retained-assets.json` manifest that the currently-deployed approuter serves, downloaded from that same approuter, and re-emitted (pruned by age) so the set rolls forward deploy-over-deploy. Content-hashed filenames are immutable, so unioning is always safe.
**Architecture:** A build-time step, run **after** the Hugo build (which both copies Vite's hashed JS into `hugo/public/js` and emits Hugo-fingerprinted CSS into `hugo/public/css`), unions the current build's hashed JS+CSS with recent prior bundles. Prior bundles are discovered from a small `_retained-assets.json` manifest that the currently-deployed approuter serves, downloaded from that same approuter into `hugo/public`, and re-emitted (pruned by age) so the set rolls forward deploy-over-deploy. `hugo/public` is copied verbatim into the approuter droplet, so retained bundles ship. Content-hashed filenames are immutable, so unioning is always safe.

**Tech Stack:** Node.js CJS build scripts (native `fetch`), Vitest unit tests, npm scripts, mbt `before-all` (root `mta.yaml`).

Expand All @@ -15,7 +15,8 @@
- Node.js: use native `fetch` (no axios/node-fetch). CJS (`.cjs`) build scripts, matching `scripts/*.cjs`.
- Prefer `fetch` with an `AbortController` timeout; never let a network failure fail the build — retention is **fail-open** (warn, proceed with whatever was gathered).
- Retention window: **48 hours** (≥ the ~24h `s-maxage` on HTML, doubled for overlapping deploys/skew). Configurable via `RETENTION_WINDOW_HOURS` env, default `48`.
- Hashed-bundle filename shape (same regex as `deploy-mta.cjs` Step 2.5): `<name>-<hash>.<ext>` where hash matches `[A-Za-z0-9_-]{8,}` and ext ∈ {`js`,`css`}.
- Hashed-bundle filename shapes — detect **both**: Vite islands `<name>-<hash>.<js|css>` (dash separator, `<hash>` base62 ≥ 8 chars, effectively always containing an uppercase letter or digit) **and** Hugo-fingerprinted CSS `<name>.<hash>.css` (dot separator, `<hash>` a long lowercase-hex SHA, ≥ 8 chars). Committed unhashed files (`consent-trustarc.js`, `consent.js`, `featured-rail.js`, bare `ui5-bootstrap.js`) must NOT match.
- Injection point: retention runs **after** `build:hugo` / the Hugo build, operating on `hugo/public/js` and `hugo/public/css`, writing the manifest to `hugo/public/_retained-assets.json`. This is the only stage where both Vite-hashed JS and Hugo-fingerprinted CSS coexist, and `hugo/public` is what the approuter builder copies to the droplet.
- Dual build paths must both be wired: local `build:all` (→ `.deploy/mta.yaml`) and CI root `mta.yaml` before-all (→ deploy.yml).
- Windows dev host: scripts run under Git Bash / Node; use `path` join, never hard-coded `/`.

Expand Down Expand Up @@ -298,20 +299,19 @@ git commit -m "feat(retention): CLI to carry forward prior hashed bundles + emit

In `package.json` `scripts`, add:
```json
"retain:assets": "node scripts/retain-asset-bundles.cjs"
"retain:assets": "node scripts/retain-asset-bundles.cjs --js-dir hugo/public/js --css-dir hugo/public/css --manifest-out hugo/public/_retained-assets.json"
```

- [ ] **Step 2: Insert into `build:all` before `build:hugo`**
- [ ] **Step 2: Insert into `build:all` immediately AFTER `build:hugo`**

In the `build:all` chain, change `... && npm run build:island-manifest && npm run build:hugo && ...` to:
In the `build:all` chain, insert `npm run retain:assets` immediately **after** `npm run build:hugo` (retention operates on the Hugo output in `hugo/public`, so it must run once Hugo has produced both the copied JS and the fingerprinted CSS):
```
... && npm run build:island-manifest && npm run retain:assets && npm run build:hugo && ...
... && npm run build:hugo && npm run retain:assets && ...
```
(Exact current `build:all` value is in `package.json`; insert `npm run retain:assets &&` immediately before `npm run build:hugo`.)

- [ ] **Step 3: Verify build:all still parses and orders correctly**

Run: `node -e "const s=require('./package.json').scripts; const b=s['build:all']; const i=b.indexOf('retain:assets'), h=b.indexOf('build:hugo'), m=b.indexOf('build:island-manifest'); if(!(m<i && i<h)) throw new Error('retain:assets must sit between island-manifest and build:hugo'); console.log('order OK');"`
Run: `node -e "const b=require('./package.json').scripts['build:all']; const r=b.indexOf('retain:assets'), h=b.indexOf('build:hugo'); if(!(h<r && r>=0)) throw new Error('retain:assets must come AFTER build:hugo'); console.log('order OK');"`
Expected: `order OK`.

- [ ] **Step 4: Commit**
Expand All @@ -333,11 +333,10 @@ git commit -m "build(retention): run retain:assets in build:all before build:hug

- [ ] **Step 1: Insert the retention command after the hugo-apps build, before Hugo**

In root `mta.yaml` `before-all` `commands`, the hugo-apps build is the parallel block ending with `wait $p2` (islands → `hugo/static/js`), and the Hugo build is the `/tmp/hugo --source hugo --minify` line. Insert **between** them:
In root `mta.yaml` `before-all` `commands`, the Hugo build is the `/tmp/hugo --source hugo --minify` line (it also runs the QA build in parallel). Insert the retention command **immediately after** the command that completes the Hugo build (so `hugo/public/js` + `hugo/public/css` exist), and before the approuter module copies `hugo/public` into the droplet:
```yaml
- bash -c "cd .. && npm run retain:assets"
```
(Place it as its own command after the `wait` that completes the hugo-apps build and before the `/tmp/hugo --source hugo` command, so `hugo/static/js|css` carry the retained set before Hugo copies `static/` → `public/`.)

- [ ] **Step 2: Validate YAML**

Expand All @@ -346,8 +345,8 @@ Expected: `YAML OK`.

- [ ] **Step 3: Confirm ordering in the before-all**

Run: `yq -r '.build-parameters."before-all"[].commands[]' mta.yaml 2>/dev/null | grep -nE "hugo-apps run build|retain:assets|source hugo"`
Expected: `retain:assets` line number is greater than the `hugo-apps run build` line and less than the `--source hugo` line.
Run: `yq -r '.build-parameters."before-all"[].commands[]' mta.yaml 2>/dev/null | grep -nE "source hugo|retain:assets"`
Expected: the `retain:assets` line number is greater than the `--source hugo` line.

- [ ] **Step 4: Commit**

Expand Down
5 changes: 5 additions & 0 deletions mta.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ build-parameters:
# locking. Saves ~30-60s on every run.
# QA fetch was moved to deploy.yml workflow step (see fetch comment above).
- bash -c 'set -e; /tmp/hugo --source hugo --minify & p1=$!; /tmp/hugo --source hugo --config ../hugo.qa.toml --minify & p2=$!; wait $p1; wait $p2'
# Asset-hash retention (#1640): preserve hashed JS + fingerprinted CSS
# in a JSON manifest before the approuter static copy wipes old bundles.
# Must run after Hugo emits public/js + public/css and before approx
# static copy (line 88 below).
- npm run retain:assets
- npx tsx scripts/verify-qa-build.ts hugo/public-qa
- mkdir -p approuter/static/qa
- cp -r hugo/public-qa/. approuter/static/qa/
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"clean:island-bundles": "node scripts/clean-island-bundles.cjs",
"build:apps": "npm run vendor:mediapipe && npm run vendor:imgly && npm run vendor:animegan && npm run clean:island-bundles && npm --prefix hugo-apps run build",
"build:island-manifest": "node scripts/build-island-manifest.cjs",
"retain:assets": "node scripts/retain-asset-bundles.cjs --js-dir hugo/public/js --css-dir hugo/public/css --manifest-out hugo/public/_retained-assets.json",
"postbuild:apps": "tsx scripts/check-build-collisions.ts && tsx scripts/check-icon-imports.ts && tsx scripts/check-island-ui5-imports.ts && tsx scripts/check-xs-app-mta.ts && tsx scripts/check-public-endpoints.ts && tsx scripts/check-srv-qa-cp-list.ts && tsx scripts/check-srv-qa-route-drift.ts && tsx scripts/check-srv-qa-dep-parity.ts && tsx scripts/check-slug-lookups.ts && tsx scripts/check-ui5-controller-extensions.ts && tsx scripts/check-kg-meta-formatters-mirror.ts && tsx scripts/check-csrf-clients.ts && npm run check:graphql-breaking",
"build:explore-manifest": "tsx scripts/build-explore-manifest.ts",
"build:explore": "npm --prefix app/explore install --no-audit --no-fund && npm --prefix app/explore run build && npm run build:explore-manifest",
Expand All @@ -78,7 +79,7 @@
"build:display": "cd app/display-app && npm install && npm run build",
"copy-joule-vendor": "node scripts/copy-joule-vendor.mjs",
"check-deploy-cap-target": "node scripts/check-deploy-cap-target.cjs",
"build:all": "npm run prebuild && npm run fetch-tutorials -- --regenerate && npm run fetch-advocates && npm run fetch-homepage-shelves && npm run fetch-verb-definitions && npm run fetch-shelf-definitions && npm run fetch-featured-topics && npm run fetch-topic-clusters && npm run fetch-topics-gallery && npm run build:css && npm run build:apps && npm run build:island-manifest && npm run build:analytics-explorer && npm run copy-joule-vendor && npm run build:explore && npm run build:hugo && npm run build:highlight && npm run build:display && npm run build:sdl",
"build:all": "npm run prebuild && npm run fetch-tutorials -- --regenerate && npm run fetch-advocates && npm run fetch-homepage-shelves && npm run fetch-verb-definitions && npm run fetch-shelf-definitions && npm run fetch-featured-topics && npm run fetch-topic-clusters && npm run fetch-topics-gallery && npm run build:css && npm run build:apps && npm run build:island-manifest && npm run build:analytics-explorer && npm run copy-joule-vendor && npm run build:explore && npm run build:hugo && npm run retain:assets && npm run build:highlight && npm run build:display && npm run build:sdl",
"build:deploy": "npm run check-deploy-cap-target && npm run build:all",
"deploy": "node scripts/deploy-mta.cjs",
"build:admin": "npm --prefix app/admin-shell run build",
Expand Down
29 changes: 29 additions & 0 deletions scripts/lib/asset-retention.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
'use strict';

/**
* Compute the retained bundle set for a build.
* Immutable content-hashed filenames → unioning is always safe.
* @param {{currentFiles:string[], retainedManifest:{file:string,firstSeenMs:number}[], nowMs:number, windowMs:number}} args
* @returns {{toDownload:string[], manifest:{file:string,firstSeenMs:number}[]}}
*/
function mergeRetention({ currentFiles, retainedManifest, nowMs, windowMs }) {
const current = new Set(currentFiles);
const priorByFile = new Map((retainedManifest || []).map(e => [e.file, e.firstSeenMs]));

// Current files keep their original firstSeenMs if we've seen them before.
const manifest = currentFiles.map(file => ({
file,
firstSeenMs: priorByFile.has(file) ? priorByFile.get(file) : nowMs,
}));

const toDownload = [];
for (const { file, firstSeenMs } of retainedManifest || []) {
if (current.has(file)) continue; // already in this build
if (nowMs - firstSeenMs > windowMs) continue; // expired → prune
manifest.push({ file, firstSeenMs });
toDownload.push(file);
}
return { toDownload, manifest };
}

module.exports = { mergeRetention };
98 changes: 98 additions & 0 deletions scripts/retain-asset-bundles.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
'use strict';

const { readdirSync, existsSync, writeFileSync } = require('node:fs');
const { join } = require('node:path');
const { mergeRetention } = require('./lib/asset-retention.cjs');

// Hash detection: support TWO formats from the build pipeline:
// 1. Vite bundles: <name>-<hash>.(js|css) — dash separator, hash ≥ 8 chars of [A-Za-z0-9_-]
// containing at least one uppercase letter or digit (filters committed files like consent-trustarc.js).
// 2. Hugo-fingerprinted CSS: <name>.<hash>.css — dot separator, hash a long lowercase-hex string
// of ≥ 32 chars (e.g., sap-fundamental.9f8e7d6c...64hex...css). Covers stale-HTML→deleted-CSS incidents.
const VITE_HASHED_RE = /-(?=.*[0-9A-Z])[A-Za-z0-9_-]{8,}\.(js|css)$/;
const HUGO_HASHED_RE = /\.[0-9a-f]{32,}\.css$/;

function isHashedFile(filename) {
return VITE_HASHED_RE.test(filename) || HUGO_HASHED_RE.test(filename);
}

function collectHashedFiles(dir) {
if (!existsSync(dir)) return [];
return readdirSync(dir).filter(isHashedFile);
}

function parseArgs(argv) {
const out = { jsDir: 'hugo/static/js', cssDir: 'hugo/static/css', manifestOut: 'hugo/static/_retained-assets.json' };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--js-dir') out.jsDir = argv[++i];
else if (a === '--css-dir') out.cssDir = argv[++i];
else if (a === '--manifest-out') out.manifestOut = argv[++i];
}
return out;
}

async function fetchJson(url, timeoutMs = 15000) {
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), timeoutMs);
try {
const res = await fetch(url, { signal: ac.signal });
if (!res.ok) return null;
return await res.json();
} catch { return null; } finally { clearTimeout(t); }
}

async function downloadTo(url, dest, timeoutMs = 30000) {
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), timeoutMs);
try {
const res = await fetch(url, { signal: ac.signal });
if (!res.ok) return false;
const buf = Buffer.from(await res.arrayBuffer());
writeFileSync(dest, buf);
return true;
} catch { return false; } finally { clearTimeout(t); }
}

async function main(opts = {}) {
const args = { ...parseArgs(process.argv.slice(2)), ...opts };
const approuter = opts.approuterUrl ?? process.env.APPROUTER_URL ?? '';
const windowMs = opts.windowMs ?? (Number(process.env.RETENTION_WINDOW_HOURS) || 48) * 3600_000;
const nowMs = opts.nowMs ?? Date.now();

const jsFiles = collectHashedFiles(args.jsDir).map(f => ({ file: f, dir: args.jsDir, kind: 'js' }));
const cssFiles = collectHashedFiles(args.cssDir).map(f => ({ file: f, dir: args.cssDir, kind: 'css' }));
const allFiles = [...jsFiles, ...cssFiles];
const currentFiles = allFiles.map(x => x.file);

let retainedManifest = [];
if (approuter) {
const prior = await fetchJson(`${approuter.replace(/\/$/, '')}/_retained-assets.json`);
if (Array.isArray(prior)) retainedManifest = prior;
else console.warn('[retain-assets] no usable prior manifest — starting fresh (fail-open).');
} else {
console.warn('[retain-assets] APPROUTER_URL unset — no carry-forward this build.');
}

const { toDownload, manifest } = mergeRetention({ currentFiles, retainedManifest, nowMs, windowMs });

// Map downloaded files back to their kind/dir for download placement.
const fileMetadata = new Map(allFiles.map(f => [f.file, f]));

// Carry forward: download each in-window prior bundle into its dir.
let ok = 0, miss = 0;
for (const file of toDownload) {
const meta = fileMetadata.get(file) || { kind: file.endsWith('.css') ? 'css' : 'js', dir: file.endsWith('.css') ? args.cssDir : args.jsDir };
const got = await downloadTo(`${approuter.replace(/\/$/, '')}/${meta.kind}/${file}`, join(meta.dir, file));
if (got) ok++; else { miss++; console.warn(`[retain-assets] could not fetch carried ${file} — skipping (fail-open).`); }
}

writeFileSync(args.manifestOut, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
console.log(`[retain-assets] current=${currentFiles.length} carried=${ok} missed=${miss} manifest=${manifest.length} → ${args.manifestOut}`);
}

module.exports = { collectHashedFiles, parseArgs, main };

if (require.main === module) {
main().catch(e => { console.warn('[retain-assets] fail-open:', e.message); process.exit(0); });
}
Loading
Loading