diff --git a/.deploy/mta.yaml b/.deploy/mta.yaml index fcc129d85..fbd549634 100644 --- a/.deploy/mta.yaml +++ b/.deploy/mta.yaml @@ -10,7 +10,7 @@ ID: tutorials-ims # Bump this on each release you deploy — it's the version shown by `cf mtas` # and in the mtar filename (tutorials-ims_.mtar). Deploy is manual: # `cd .deploy && mbt build && cf deploy mta_archives/tutorials-ims_.mtar -e ../deploy/.mtaext -f`. -version: 1.41.3 +version: 1.41.4 # Top-level parameters (overridable per-env via deploy/.mtaext). parameters: diff --git a/.github/workflows/rebuild-content-qa.yml b/.github/workflows/rebuild-content-qa.yml index d9ad1b6cf..022af26cd 100644 --- a/.github/workflows/rebuild-content-qa.yml +++ b/.github/workflows/rebuild-content-qa.yml @@ -381,21 +381,23 @@ jobs: - name: Build island fingerprint manifest run: npm run build:island-manifest - # Generated CSS (#2896 follow-up, mirrors rebuild-content.yml). Commit - # ac6b7e39f stopped committing sap-fundamental.css (build:css, postcss) - # and fundamental-styles-icon.css (build:icon-subset) — both are now - # .gitignored build outputs. head.html does `resources.Get | minify` on - # the icon subset, so on a clean CI checkout where the file is absent the - # QA Hugo build fails at head.html:27 ("wrong type ...; got - # resource.Resource"). This workflow drives /tmp/hugo directly rather than - # `npm run build:all`, so — like the feed fetchers above — it must - # regenerate them BEFORE the Hugo build. build:css + build:icon-subset - # produce byte-identical output to the deploy's build:all, so QA and prod - # agree on the fingerprint. (QA regression #2912.) - - name: Build fingerprinted CSS (sap-fundamental + icon subset) + # [#2920] CSS on the REBUILD path must FOLLOW the deployed approuter (mirrors + # rebuild-content.yml). sap-fundamental.css is the one fingerprinted stylesheet + # NOT Hugo-minified (head.html:73), so its /css/.css URL == sha256 of the + # on-disk bytes; building it from HEAD source here made QA bake a hash the + # DEPLOYED approuter doesn't serve whenever CSS source changed since the last + # deploy → froze QA publishing. QA is a feature of PRODUCTION: /tutorials-qa/* + # is dressed by the MAIN (prod) approuter (xs-app.json → srv-qa-api), so the + # sync fetches from the PROD approuter (secrets.APPROUTER_URL_PROD), matching + # the asset guard below (#2923). + # fundamental-styles-icon.css stays a source build: Hugo `| minify`s it so it + # self-heals. Fail-open to build:css from source if the approuter is unreachable. + - name: Sync sap-fundamental CSS from deployed approuter (+ icon subset) run: | npm run build:icon-subset - npm run build:css + node scripts/sync-approuter-css.cjs --approuter-url "$APPROUTER_URL" + env: + APPROUTER_URL: ${{ secrets.APPROUTER_URL_PROD }} - name: Build QA Hugo site # Hugo was downloaded to /tmp/hugo (line 44). The build:qa npm script diff --git a/.github/workflows/rebuild-content.yml b/.github/workflows/rebuild-content.yml index 68dc3dbc7..b89f60590 100644 --- a/.github/workflows/rebuild-content.yml +++ b/.github/workflows/rebuild-content.yml @@ -813,20 +813,29 @@ jobs: env: CAP_BASE_URL: ${{ steps.srv.outputs.srv_url }} - # Generated CSS (#2896 follow-up). sap-fundamental.css (build:css, postcss) - # and fundamental-styles-icon.css (build:icon-subset) are build outputs, no - # longer committed — they are .gitignored. This workflow drives /tmp/hugo - # directly rather than `npm run build:all`, so — exactly like the feed - # fetchers above — it must regenerate them explicitly BEFORE the Hugo build, - # or Hugo fingerprints a missing/stale file and the published content - # references a /css/.css the deployed approuter doesn't serve (the - # approuter-asset guard then blocks publish). build:css + build:icon-subset - # produce byte-identical output to the deploy's build:all, so both publish - # paths agree on the fingerprint. - - name: Build fingerprinted CSS (sap-fundamental + icon subset) + # [#2920] CSS on the REBUILD path must FOLLOW the deployed approuter, not + # race ahead of it. sap-fundamental.css (build:css, postcss) is the ONE + # fingerprinted stylesheet that is NOT Hugo-minified (head.html:73 is a bare + # `| fingerprint`), so its /css/.css URL == sha256 of the on-disk bytes. + # Post-#1659 a content rebuild never ships CSS to the approuter (static only + # changes on a full MTA deploy), so building sap-fundamental.css from HEAD + # source here made the baked HTML reference a hash the DEPLOYED approuter does + # not serve whenever CSS source changed since the last deploy → the + # approuter-asset guard (below) blocked publish, freezing all content + # publishing (the recurring 2026-10 saga, #2920). Fix: fetch the bytes the + # approuter CURRENTLY serves so Hugo fingerprints to the SAME hash → guard + # always passes, pages always styled, regardless of deploy lag. The DEPLOY + # path (build:all → build:css from source) is unchanged and still rotates the + # hash on deploy; this makes content-publish follow it. Fail-open: a brand-new + # env with nothing deployed falls back to build:css from source. + # fundamental-styles-icon.css stays a source build: Hugo `| minify`s it + # (head.html:27), so it self-heals from whatever on-disk bytes are present. + - name: Sync sap-fundamental CSS from deployed approuter (+ icon subset) run: | npm run build:icon-subset - npm run build:css + node scripts/sync-approuter-css.cjs --approuter-url "$APPROUTER_URL" + env: + APPROUTER_URL: ${{ steps.url.outputs.approuter_url }} - name: Build Hugo site run: /tmp/hugo --source hugo --minify diff --git a/hugo/data/whats_new.json b/hugo/data/whats_new.json index 4175b76dd..567548a62 100644 --- a/hugo/data/whats_new.json +++ b/hugo/data/whats_new.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-10-25T13:42:26.548Z", + "generatedAt": "2026-10-26T08:50:18.626Z", "repos": [ { "key": "tutorials-ims", @@ -8,6 +8,71 @@ } ], "entries": [ + { + "id": "tutorials-ims#2928", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2928, + "title": "fix(tutorials): tag/level chips link to /tutorial-navigator/ not /tutorials/", + "summary": "Tag and experience-level chips on tutorial pages now correctly link to the Tutorial Navigator, resolving 404 errors when filtering by tag or level from a tutorial.", + "category": "Fix", + "mergedAt": "2026-10-25T17:15:57Z", + "week": "2026-W43", + "weekStart": "2026-10-19", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2928" + }, + { + "id": "tutorials-ims#2927", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2927, + "title": "fix(ci): remove duplicate env: key in QA rebuild workflow", + "summary": "Fixed a YAML validation error in the QA content rebuild workflow that prevented any QA rebuilds from starting.", + "category": "Maintenance", + "mergedAt": "2026-10-25T14:46:34Z", + "week": "2026-W43", + "weekStart": "2026-10-19", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2927" + }, + { + "id": "tutorials-ims#2926", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2926, + "title": "fix(ci): prune legacy assets/-prefixed entries from asset retention", + "summary": "Cleared stale legacy CSS entries from the asset-retention list, stopping the post-deploy asset smoke gate from falsely flagging healthy deployments.", + "category": "Fix", + "mergedAt": "2026-10-25T14:13:15Z", + "week": "2026-W43", + "weekStart": "2026-10-19", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2926" + }, + { + "id": "tutorials-ims#2924", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2924, + "title": "Content rebuild no longer freezes after a CSS source change", + "summary": "Content rebuilds now fetch the stylesheet bytes the live approuter serves rather than rebuilding from HEAD, so a CSS source change between deploys no longer blocks tutorial publishing.", + "category": "Fix", + "mergedAt": "2026-10-25T13:54:22Z", + "week": "2026-W43", + "weekStart": "2026-10-19", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2924" + }, + { + "id": "tutorials-ims#2925", + "repo": "tutorials-ims", + "label": "Developer Portal", + "number": 2925, + "title": "release v1.41.3 DEV to main promotion", + "summary": "Patch release 1.41.3 promoting CI fixes from DEV to main.", + "category": "Maintenance", + "mergedAt": "2026-10-25T13:45:20Z", + "week": "2026-W43", + "weekStart": "2026-10-19", + "url": "https://github.com/sap-tutorials/tutorials-ims/pull/2925" + }, { "id": "tutorials-ims#2923", "repo": "tutorials-ims", diff --git a/hugo/layouts/partials/tutorial-meta.html b/hugo/layouts/partials/tutorial-meta.html index 30d948444..d2042b7aa 100644 --- a/hugo/layouts/partials/tutorial-meta.html +++ b/hugo/layouts/partials/tutorial-meta.html @@ -14,11 +14,11 @@ {{ with .Params.displayTags }} - {{/* #2112 — Overview tags now deep-link to /tutorials/?tag= for parity with the + {{/* #2112 — Overview tags now deep-link to /tutorial-navigator/?tag= for parity with the header chips (u1-object-page.html). Zip displayTags with displayTagSlugs; fall back to a plain span when a slug is missing. "License" stays label-matched + skipped. */}} {{ $slugs := $.Params.displayTagSlugs }} - {{ range $i, $label := . }}{{ if ne $label "License" }}{{ $slug := "" }}{{ if and $slugs (lt $i (len $slugs)) }}{{ $slug = index $slugs $i }}{{ end }}{{ if $slug }}{{ $label }}{{ else }}{{ $label }}{{ end }}{{ end }}{{ end }} + {{ range $i, $label := . }}{{ if ne $label "License" }}{{ $slug := "" }}{{ if and $slugs (lt $i (len $slugs)) }}{{ $slug = index $slugs $i }}{{ end }}{{ if $slug }}{{ $label }}{{ else }}{{ $label }}{{ end }}{{ end }}{{ end }} {{ end }} {{ if in .Params.tags "tutorial>license" }} diff --git a/hugo/layouts/tutorials/u1-object-page.html b/hugo/layouts/tutorials/u1-object-page.html index 1b2b09376..b0ed9b8b4 100644 --- a/hugo/layouts/tutorials/u1-object-page.html +++ b/hugo/layouts/tutorials/u1-object-page.html @@ -252,11 +252,11 @@

{{ .Title }}{{ if in .Params.ta {{ with .Params.description }}

{{ . }}

{{ end }}
- {{ with .Params.level }}🎓{{ . }}{{ end }} - {{ with .Params.experienceLevel }}🎓{{ . }}{{ end }} + {{ with .Params.level }}🎓{{ . }}{{ end }} + {{ with .Params.experienceLevel }}🎓{{ . }}{{ end }} {{ with .Params.time }}⏱{{ . }} min{{ end }} {{/* Prefer human-readable displayTags zipped with displayTagSlugs; fall back to raw primaryTag. - #161 — chips are anchors that deep-link to /tutorials/?tag=. The "License" label + #161 — chips are anchors that deep-link to /tutorial-navigator/?tag=. The "License" label is intentionally label-matched (not slug-matched) to preserve existing chip-strip behaviour; see docs/superpowers/specs/2026-06-01-clickable-tutorial-tags-design.md. */}} {{ if .Params.displayTags }} @@ -266,14 +266,14 @@

{{ .Title }}{{ if in .Params.ta {{ $slug := "" }} {{ if and $slugs (lt $i (len $slugs)) }}{{ $slug = index $slugs $i }}{{ end }} {{ if $slug }} - {{ $label }} + {{ $label }} {{ else }} {{ $label }} {{ end }} {{ end }} {{ end }} {{ else if .Params.primaryTag }} - {{ .Params.primaryTag }} + {{ .Params.primaryTag }} {{ end }} {{/* No progress ring for step-less tutorials (e.g. Devtoberfest validation tutorials, #2127) — nothing to complete, so "0/0" would be misleading. */}} diff --git a/mta-mcp.yaml b/mta-mcp.yaml index c74875f39..07ad0a6c2 100644 --- a/mta-mcp.yaml +++ b/mta-mcp.yaml @@ -4,7 +4,7 @@ _schema-version: 3.3.0 # This file lives at repo root → path: gen/srv-mcp (root-relative, no ../). # before-all runs from ROOT (no cd ..) — mbt uses the descriptor's directory as cwd. ID: tutorials-mcp -version: 1.1.3 +version: 1.1.4 # Destinations are subaccount-level, so DEV and PROD (sharing one subaccount) # need DISTINCT destination names. `dest-main-srv` defaults to the PROD name; diff --git a/scripts/lib/asset-retention.cjs b/scripts/lib/asset-retention.cjs index 13233a88d..660e2b531 100644 --- a/scripts/lib/asset-retention.cjs +++ b/scripts/lib/asset-retention.cjs @@ -39,6 +39,15 @@ function mergeRetention({ currentFiles, retainedManifest, nowMs, windowMs }) { const toDownload = []; for (const e of prior) { if (current.has(e.file)) continue; // already in this build + // Legacy carry-forward prune (2026-10-25). Old Vite layouts emitted island + // CSS as separate `assets/-.css` files; the current pipeline + // injects island CSS via JS (vite-plugin-css-injected-by-js) and emits NO + // `assets/`-prefixed bundles. Such an entry can never match a current file + // and its carry-forward download (`/css/assets/…` or `/js/assets/…`) always + // 404s, so it lingers forever as a prior-build entry and the post-deploy + // asset-retention smoke gate flaps on it. Drop these permanently — nothing + // the current build produces or references lives under `assets/`. + if (e.file.startsWith('assets/')) continue; const last = lastOf(e); const inPriorBuild = hasLast(e) && priorBuildMs !== null && e.lastSeenMs === priorBuildMs; // Keep if it was current in the immediately-prior build (unconditional), or diff --git a/scripts/sync-approuter-css.cjs b/scripts/sync-approuter-css.cjs new file mode 100644 index 000000000..e33cdc688 --- /dev/null +++ b/scripts/sync-approuter-css.cjs @@ -0,0 +1,174 @@ +#!/usr/bin/env node +/** + * sync-approuter-css.cjs (#2920) + * + * WHY THIS EXISTS + * ---------------- + * A content rebuild (rebuild-content.yml / rebuild-content-qa.yml) publishes + * tutorial/page HTML to HANA. The approuter serves the fingerprinted stylesheet + * `/css/sap-fundamental..css` as STATIC, and (post-#1659, Phase C.4) that + * static only changes on a full MTA deploy — a content rebuild never ships CSS. + * + * `sap-fundamental.css` is generated from source (gitignored since #2896) and is + * the ONE fingerprinted stylesheet that is NOT Hugo-minified (head.html:73 is a + * bare `| fingerprint`, no `| minify`), so its /css/.css URL == sha256 of + * the on-disk bytes. Therefore if a rebuild builds it from HEAD source while the + * deployed approuter still serves an OLDER build, the baked HTML references a + * hash the approuter does not serve → the approuter-asset guard blocks publish + * (the recurring 2026-10 freeze, #2920) OR, worse, pages publish unstyled. + * + * Every OTHER fingerprinted stylesheet is either committed to git (cannot drift: + * rebuild and deploy read identical bytes) or Hugo-minified (self-heals: Hugo + * re-minifies+re-hashes whatever on-disk bytes are present at build time). So + * `sap-fundamental.css` is the entire drift surface. + * + * WHAT THIS DOES + * ---------------- + * Instead of regenerating `sap-fundamental.css` from HEAD source on the rebuild + * path, fetch the bytes the DEPLOYED approuter currently serves and write them to + * `hugo/assets/css/sap-fundamental.css`. Hugo then fingerprints to the SAME hash + * the approuter serves → baked HTML always references a stylesheet the approuter + * has → guard always passes, pages always styled, regardless of deploy lag. + * + * The DEPLOY path is unchanged: `build:all` still runs `build:css` from source, + * which is what rotates the hash on deploy. This script runs ONLY on the rebuild + * path, making content-publish FOLLOW the deploy instead of racing ahead of it. + * + * FAIL-OPEN: if the approuter is unreachable or serves no discoverable + * sap-fundamental href (e.g. a brand-new env with nothing deployed yet), fall + * back to building from source so a fresh environment still works. + * + * Usage: + * node scripts/sync-approuter-css.cjs --approuter-url + * [--page ] page to read the href from (default "/") + * [--out ] output css path (default hugo/assets/css/sap-fundamental.css) + * + * Exit codes: 0 synced-or-fell-back-cleanly · 1 hard failure (integrity mismatch). + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { execFileSync } = require('child_process'); + +const REPO_ROOT = path.resolve(__dirname, '..'); +const DEFAULT_OUT = path.join(REPO_ROOT, 'hugo', 'assets', 'css', 'sap-fundamental.css'); +// Pages to try, in order, when discovering the served href. The homepage is +// approuter static and always carries the head partial; tutorials are HANA-served +// but also reference the same shared CSS. Multiple candidates tolerate a route +// that 404s/redirects on one surface. +const DEFAULT_PAGES = ['/', '/browse/', '/tutorials/']; +const HREF_RE = /\/css\/sap-fundamental\.([a-f0-9]{64})\.css/i; +// A browser-like UA: the PROD approuter sits behind Akamai, which 403s unknown +// (node/curl) agents. The cfapps origin does not, but passing a browser UA keeps +// this correct whether pointed at the origin or the edge host. +const UA = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' + + '(KHTML, like Gecko) Chrome/124.0 Safari/537.36'; + +function log(msg) { process.stderr.write(`[sync-approuter-css] ${msg}\n`); } + +function parseArgs(argv) { + const out = { approuterUrl: '', page: '', outFile: DEFAULT_OUT }; + for (let i = 2; i < argv.length; i++) { + const a = argv[i]; + if (a === '--approuter-url') out.approuterUrl = argv[++i]; + else if (a === '--page') out.page = argv[++i]; + else if (a === '--out') out.outFile = path.resolve(argv[++i]); + } + return out; +} + +async function fetchWithRetry(url, { asBuffer = false } = {}) { + const attempts = 3; + let lastErr; + for (let i = 0; i < attempts; i++) { + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), 20_000); + try { + // redirect:'manual' — an auth-gate 30x must read as non-200 (→ fall back), + // never silently followed into a login page we'd then hash. + const res = await fetch(url, { + method: 'GET', + redirect: 'manual', + headers: { 'User-Agent': UA }, + signal: ac.signal, + }); + clearTimeout(timer); + if (res.status !== 200) return { ok: false, status: res.status }; + const body = asBuffer ? Buffer.from(await res.arrayBuffer()) : await res.text(); + return { ok: true, status: 200, body }; + } catch (e) { + clearTimeout(timer); + lastErr = e; + } + } + return { ok: false, status: 0, error: lastErr && (lastErr.message || String(lastErr)) }; +} + +function buildFromSource() { + log('falling back to build:css from source (approuter CSS unavailable)'); + // npm is npm.cmd on Windows, npm on POSIX. Resolve explicitly rather than + // shell:true (which triggers Node DEP0190 and would be injection-prone with + // dynamic args — these are fixed literals, but keep it clean). + const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + execFileSync(npmCmd, ['run', 'build:css'], { cwd: REPO_ROOT, stdio: 'inherit' }); +} + +async function main() { + const { approuterUrl, page, outFile } = parseArgs(process.argv); + if (!approuterUrl) { + log('no --approuter-url given → build from source'); + buildFromSource(); + return 0; + } + const base = approuterUrl.replace(/\/+$/, ''); + const pages = page ? [page] : DEFAULT_PAGES; + + // 1) Discover the served sap-fundamental href from a rendered page. + let href = ''; + for (const p of pages) { + const r = await fetchWithRetry(base + p); + if (r.ok && r.body) { + const m = HREF_RE.exec(r.body); + if (m) { href = m[0]; log(`discovered served CSS via ${p}: ${href}`); break; } + } else { + log(`page ${p} → HTTP ${r.status}${r.error ? ` (${r.error})` : ''}`); + } + } + if (!href) { + log('could not discover a served sap-fundamental href on any candidate page'); + buildFromSource(); + return 0; + } + const expectedHash = HREF_RE.exec(href)[1].toLowerCase(); + + // 2) Fetch those bytes. + const got = await fetchWithRetry(base + href, { asBuffer: true }); + if (!got.ok || !got.body) { + log(`fetch of ${href} → HTTP ${got.status}${got.error ? ` (${got.error})` : ''}; building from source`); + buildFromSource(); + return 0; + } + + // 3) Integrity: the bytes MUST hash to the fingerprint in their own URL. A + // mismatch means a corrupt/edge-mangled response — NEVER write that; it is a + // hard failure (building from source here would reintroduce drift silently). + const actualHash = crypto.createHash('sha256').update(got.body).digest('hex'); + if (actualHash !== expectedHash) { + log(`INTEGRITY FAILURE: fetched bytes hash ${actualHash.slice(0, 12)} ≠ url hash ${expectedHash.slice(0, 12)}`); + return 1; + } + + // 4) Write into the Hugo assets dir so Hugo fingerprints to the SAME hash. + fs.mkdirSync(path.dirname(outFile), { recursive: true }); + fs.writeFileSync(outFile, got.body); + log(`wrote ${got.body.length} bytes → ${path.relative(REPO_ROOT, outFile)} (hash ${expectedHash.slice(0, 12)}, matches approuter)`); + return 0; +} + +main() + .then((code) => process.exit(code)) + .catch((e) => { log(`unexpected error: ${e && e.stack ? e.stack : e}`); process.exit(1); }); diff --git a/test/smoke/clickable-chips.smoke.test.ts b/test/smoke/clickable-chips.smoke.test.ts index 3566c95f1..8cead30f1 100644 --- a/test/smoke/clickable-chips.smoke.test.ts +++ b/test/smoke/clickable-chips.smoke.test.ts @@ -1,8 +1,9 @@ // test/smoke/clickable-chips.smoke.test.ts // // Issue #161 — verifies the U1 Object Page renders experience and -// topic/product chips as anchors deep-linking to /tutorials/?level=... -// and /tutorials/?tag=... respectively. Pattern follows +// topic/product chips as anchors deep-linking to /tutorial-navigator/?level=... +// and /tutorial-navigator/?tag=... respectively (the navigator reads these as +// deep-link entry params; /tutorials/ has no listing route and 404s). Pattern follows // test/smoke/license-icon.test.js. Quote-stripping tolerant — the Hugo // minifier removes quotes from safe attribute values, so regexes accept // both `class="op-chip..."` and `class=op-chip...` forms; see @@ -33,12 +34,12 @@ describe(`Clickable chips on /tutorials/${SLUG}/ (#161)`, () => { /]*class=["']?op-chip op-chip--link["']?[^>]*>[^<]*]*op-chip__icon[^>]*>[^<]*<\/span>[^<]*<\/a>/, ) expect(anchor, 'expected an experience chip anchor').toBeTruthy() - expect(html).toMatch(/href=["']?[^"' >]*\/tutorials\/\?level=[a-z]+["']?/) + expect(html).toMatch(/href=["']?[^"' >]*\/tutorial-navigator\/\?level=[a-z]+["']?/) }) it('renders at least one topic/product chip as an anchor with ?tag=', () => { expect(html).toMatch( - /]*class=["']?op-chip op-chip--tag op-chip--link["']?[^>]*href=["']?[^"' >]*\/tutorials\/\?tag=[^"' >]+["']?/, + /]*class=["']?op-chip op-chip--tag op-chip--link["']?[^>]*href=["']?[^"' >]*\/tutorial-navigator\/\?tag=[^"' >]+["']?/, ) }) diff --git a/test/unit/asset-retention.test.js b/test/unit/asset-retention.test.js index 534095ad0..032961c4e 100644 --- a/test/unit/asset-retention.test.js +++ b/test/unit/asset-retention.test.js @@ -64,6 +64,26 @@ describe('mergeRetention', () => { const r = mergeRetention({ currentFiles: ['a-11111111.js'], retainedManifest: retained, nowMs: now, windowMs }); expect(r.toDownload).toEqual([]); // 60h > 48h window, not in prior build → pruned }); + + // ── Regression (2026-10-25): legacy `assets/`-prefixed island CSS must be pruned ── + // Old Vite layouts emitted island CSS as `assets/-.css`; the current + // pipeline injects island CSS via JS and emits NO `assets/`-prefixed bundles. Such + // an entry can never match a current file and its carry-forward download always + // 404s, so it lingered forever as a "prior build" entry and the post-deploy + // asset-retention smoke gate flapped on it. mergeRetention must drop it outright — + // even when it looks like it was in the immediately-prior build. + it('prunes a legacy assets/-prefixed entry even if it was in the prior build', () => { + const retained = [ + { file: 'assets/app-space-CK7ssr7N.css', firstSeenMs: now - 5 * HOUR, lastSeenMs: now - 1 * HOUR }, + { file: 'navigator-C76FZhtj.js', firstSeenMs: now - 1 * HOUR, lastSeenMs: now - 1 * HOUR }, + ]; + const r = mergeRetention({ currentFiles: ['a-11111111.js'], retainedManifest: retained, nowMs: now, windowMs }); + // The assets/-prefixed CSS is gone from both the manifest and the download queue… + expect(r.toDownload).not.toContain('assets/app-space-CK7ssr7N.css'); + expect(r.manifest.find(e => e.file === 'assets/app-space-CK7ssr7N.css')).toBeUndefined(); + // …while a normal prior-build bundle still carries forward. + expect(r.toDownload).toContain('navigator-C76FZhtj.js'); + }); }); // ── Regression (issue #2533): manifest must not advertise assets that were never