diff --git a/.github/workflows/browser-demos-pages.yml b/.github/workflows/browser-demos-pages.yml index 6b24955cfc..bbf3e4e0d9 100644 --- a/.github/workflows/browser-demos-pages.yml +++ b/.github/workflows/browser-demos-pages.yml @@ -76,68 +76,82 @@ jobs: packages/registry packages/registry/program-packages.json ' - - name: Isolate the canonical bottled browser product + - name: Isolate browser package preparation run: | set -euo pipefail product_cache="$RUNNER_TEMP/pages-canonical-bottle-cache" test ! -e "$product_cache" - # WHY: an empty runner-local cache proves this deployment can recover - # the complete canonical package generation. Reusing another job's - # cache could hide an unavailable or stale public product archive. + # WHY: keep unrelated gallery package resolution from inheriting + # mutable state. The shell itself is fetched and verified by digest + # in the next step and never enters this cache. echo "WASM_POSIX_BINARY_CACHE_ROOT=$product_cache" >> "$GITHUB_ENV" + - name: Prepare the exact published transitional Homebrew shell + run: | + set -euo pipefail + transition_root="$RUNNER_TEMP/homebrew-pages-rev22-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + test ! -e "$transition_root" + bash scripts/dev-shell.sh npx tsx --test \ + scripts/inspect-transitional-homebrew-pages-shell.test.ts + bash scripts/dev-shell.sh bash \ + scripts/prepare-transitional-homebrew-pages-shell.sh \ + --out "$transition_root" + echo "WASM_POSIX_TRANSITIONAL_PAGES_SHELL_ROOT=$transition_root" \ + >> "$GITHUB_ENV" + - name: Prepare browser demo assets run: | set -euo pipefail - # WHY: --fetch-only prevents a missing package archive from being - # replaced by a source build. The current gallery still compiles its - # repository-owned support-program bundle and derives VFS images; - # neither path can replace the canonical shell bound below. + # WHY: --fetch-only prevents a missing gallery package archive from + # becoming an unrelated source build. The transition flag preserves + # the exact rev22 shell and bootstrap prepared above while the rest + # of the current gallery is assembled. bash scripts/dev-shell.sh env \ "WASM_POSIX_BINARY_CACHE_ROOT=$WASM_POSIX_BINARY_CACHE_ROOT" \ + "WASM_POSIX_TRANSITIONAL_PAGES_SHELL_ROOT=$WASM_POSIX_TRANSITIONAL_PAGES_SHELL_ROOT" \ ./run.sh --fetch-only \ - --require-sealed-homebrew-selection prepare-browser + --transitional-pages-homebrew-shell prepare-browser - - name: Bind the canonical bottled shell product + - name: Bind the exact transitional bottled shell product id: shell_product run: | set -euo pipefail image=$(bash scripts/resolve-binary.sh programs/shell.vfs.zst) - # WHY: prepare-browser already admitted and atomically staged the - # bottle member. Repeating selection or extraction here could make - # inspection and the bytes actually served by Vite disagree. bootstrap="$PWD/apps/browser-demos/public/homebrew-bootstrap.zip" - report="$RUNNER_TEMP/homebrew-main-shell-public-product.json" + report="$WASM_POSIX_TRANSITIONAL_PAGES_SHELL_ROOT/inspection.json" for path in "$image" "$bootstrap"; do test -f "$path" test ! -L "$path" done - bash scripts/verify-homebrew-main-shell-artifact-lock.sh \ - --lock homebrew/main-shell-lazy-artifact-lock.json \ - --expected-source-date-epoch 0 \ - --artifact "$image" - test ! -e "$report" - # WHY: keep the inspector's rejection cases beside the only - # production workflow that relies on its admission decision. - bash scripts/dev-shell.sh npx tsx --test \ - scripts/inspect-homebrew-main-shell-public-product.test.ts - bash scripts/dev-shell.sh npx tsx \ - scripts/inspect-homebrew-main-shell-public-product.ts \ - --image "$image" \ - --homebrew-bootstrap-archive "$bootstrap" \ - --homebrew-bootstrap-spec \ - homebrew/main-shell-brew-package-tree.json \ - --homebrew-runtime-support \ - homebrew/main-shell-homebrew-runtime-support.json \ - --out "$report" + cmp "$WASM_POSIX_TRANSITIONAL_PAGES_SHELL_ROOT/shell.vfs.zst" \ + "$image" + cmp "$WASM_POSIX_TRANSITIONAL_PAGES_SHELL_ROOT/homebrew-bootstrap.zip" \ + "$bootstrap" jq -e ' .schema == 1 and - .kind == "kandelo-homebrew-main-shell-public-product" and - (.image.sha256 | test("^[0-9a-f]{64}$")) and - (.image.bytes > 0) and + .kind == + "kandelo-transitional-homebrew-pages-shell-inspection" and + .lifecycle == "transitional" and + .exact_current_main == false and + .guest_prefix == "/home/linuxbrew/.linuxbrew" and + (.shell.sha256 | test("^[0-9a-f]{64}$")) and + (.shell.bytes > 0) and (.homebrew_bootstrap.sha256 | test("^[0-9a-f]{64}$")) and (.homebrew_bootstrap.bytes > 0) and - .homebrew_bootstrap.activation_root == "/usr/bin/brew" and + (.gallery_compatibility | map(.package)) == [ + "lamp", "nginx-php-vfs", "nginx-vfs", "node-vfs", + "wordpress" + ] and + all(.gallery_compatibility[]; + (.sha256 | test("^[0-9a-f]{64}$")) and .bytes > 0) and + (.source_projection_compatibility | + map(.package + ":" + .arch)) == [ + "kandelo-sdk:wasm32", "mariadb-test:wasm32", + "mariadb-vfs:wasm32", "mariadb-vfs:wasm64", + "redis-vfs:wasm32", "rootfs:wasm32" + ] and + all(.source_projection_compatibility[]; + (.sha256 | test("^[0-9a-f]{64}$")) and .bytes > 0) and (.bottle_mirror.plan_url | startswith("https://github.com/") and endswith("/kandelo-homebrew-bottle-mirror-plan.json")) and @@ -146,7 +160,7 @@ jobs: { echo "image=$image" echo "bootstrap=$bootstrap" - echo "image_sha256=$(jq -er '.image.sha256' "$report")" + echo "image_sha256=$(jq -er '.shell.sha256' "$report")" echo "bootstrap_sha256=$(jq -er \ '.homebrew_bootstrap.sha256' "$report")" echo "bootstrap_bytes=$(jq -er \ @@ -199,7 +213,7 @@ jobs: cp -R docs-site/.vitepress/dist apps/browser-demos/dist/guide cp -R host/docs apps/browser-demos/dist/api - - name: Boot the canonical bottled Pages shell in Chromium + - name: Boot the transitional bottled Pages shell in Chromium working-directory: apps/browser-demos env: VITE_BASE: /kandelo/ @@ -210,15 +224,37 @@ jobs: KANDELO_HOMEBREW_MAIN_SHELL_BOOTSTRAP_BYTES: ${{ steps.shell_product.outputs.bootstrap_bytes }} KANDELO_HOMEBREW_MAIN_SHELL_TRANSPORT_MODE: public KANDELO_HOMEBREW_MAIN_SHELL_MIRROR_PLAN_URL: ${{ steps.shell_product.outputs.mirror_plan_url }} + KANDELO_HOMEBREW_MAIN_SHELL_EXPECTED_PREFIX: /home/linuxbrew/.linuxbrew KANDELO_PLAYWRIGHT_SERVE_DIST: "1" KANDELO_TEST_BASE_URL: http://127.0.0.1:5401/kandelo/ run: | set -euo pipefail + report="$RUNNER_TEMP/transitional-homebrew-pages-playwright.json" + test ! -e "$report" + # WHY: dev-shell intentionally starts from a controlled environment. + # Forward every acceptance input explicitly so a missing variable + # cannot silently turn this production proof into a skipped test. bash ../../scripts/dev-shell.sh env \ + "VITE_BASE=$VITE_BASE" \ "WASM_POSIX_BINARY_CACHE_ROOT=$WASM_POSIX_BINARY_CACHE_ROOT" \ + "KANDELO_BROWSER_DEMO_INPUTS=$KANDELO_BROWSER_DEMO_INPUTS" \ + "KANDELO_HOMEBREW_MAIN_SHELL_STRICT=$KANDELO_HOMEBREW_MAIN_SHELL_STRICT" \ + "KANDELO_HOMEBREW_MAIN_SHELL_SHA256=$KANDELO_HOMEBREW_MAIN_SHELL_SHA256" \ + "KANDELO_HOMEBREW_MAIN_SHELL_BOOTSTRAP_SHA256=$KANDELO_HOMEBREW_MAIN_SHELL_BOOTSTRAP_SHA256" \ + "KANDELO_HOMEBREW_MAIN_SHELL_BOOTSTRAP_BYTES=$KANDELO_HOMEBREW_MAIN_SHELL_BOOTSTRAP_BYTES" \ + "KANDELO_HOMEBREW_MAIN_SHELL_TRANSPORT_MODE=$KANDELO_HOMEBREW_MAIN_SHELL_TRANSPORT_MODE" \ + "KANDELO_HOMEBREW_MAIN_SHELL_MIRROR_PLAN_URL=$KANDELO_HOMEBREW_MAIN_SHELL_MIRROR_PLAN_URL" \ + "KANDELO_HOMEBREW_MAIN_SHELL_EXPECTED_PREFIX=$KANDELO_HOMEBREW_MAIN_SHELL_EXPECTED_PREFIX" \ + "KANDELO_PLAYWRIGHT_SERVE_DIST=$KANDELO_PLAYWRIGHT_SERVE_DIST" \ + "KANDELO_TEST_BASE_URL=$KANDELO_TEST_BASE_URL" \ + "PLAYWRIGHT_JSON_OUTPUT_FILE=$report" \ npx playwright test \ test/kandelo-homebrew-main-shell.spec.ts \ - --project=chromium + --project=chromium --reporter=json + jq -e ' + .stats.expected == 1 and .stats.unexpected == 0 and + .stats.flaky == 0 and .stats.skipped == 0 + ' "$report" >/dev/null - name: Enforce the GitHub Pages published-site size limit run: node scripts/check-pages-publish-size.mjs apps/browser-demos/dist 1000000000 diff --git a/apps/browser-demos/playwright-test-target.test.ts b/apps/browser-demos/playwright-test-target.test.ts new file mode 100644 index 0000000000..3a96512fb1 --- /dev/null +++ b/apps/browser-demos/playwright-test-target.test.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + configuredPlaywrightTestBaseUrl, + usesManagedPlaywrightServer, +} from "./playwright-test-target"; + +test("normalizes a safe external deployment root", () => { + assert.equal(configuredPlaywrightTestBaseUrl({}), undefined); + assert.equal( + configuredPlaywrightTestBaseUrl({ + KANDELO_TEST_BASE_URL: "https://automattic.github.io/kandelo", + }), + "https://automattic.github.io/kandelo/", + ); +}); + +test("starts Vite only for the configured local Playwright target", () => { + assert.equal(usesManagedPlaywrightServer(undefined, 5401), true); + assert.equal( + usesManagedPlaywrightServer("http://127.0.0.1:5401/kandelo/", 5401), + true, + ); + assert.equal( + usesManagedPlaywrightServer( + "https://automattic.github.io/kandelo/", + 5401, + ), + false, + ); + assert.equal( + usesManagedPlaywrightServer("http://127.0.0.1:5402/kandelo/", 5401), + false, + ); +}); + +test("rejects ambiguous or credential-bearing deployment roots", () => { + for (const value of [ + "", + " /kandelo/", + "file:///tmp/kandelo/", + "https://user@example.test/kandelo/", + "https://example.test/kandelo/?demo=shell", + "https://example.test/kandelo/#shell", + ]) { + assert.throws( + () => configuredPlaywrightTestBaseUrl({ + KANDELO_TEST_BASE_URL: value, + }), + /KANDELO_TEST_BASE_URL/, + ); + } +}); diff --git a/apps/browser-demos/playwright-test-target.ts b/apps/browser-demos/playwright-test-target.ts new file mode 100644 index 0000000000..5664b5084e --- /dev/null +++ b/apps/browser-demos/playwright-test-target.ts @@ -0,0 +1,50 @@ +export interface PlaywrightTestTargetEnvironment { + KANDELO_TEST_BASE_URL?: string; +} + +/** + * Return the external deployment root used by browser acceptance tests. + * + * A trailing slash is significant for a GitHub Pages project URL: resolving + * `?demo=shell` against `/kandelo/` must stay below that project path. + */ +export function configuredPlaywrightTestBaseUrl( + env: PlaywrightTestTargetEnvironment, +): string | undefined { + const raw = env.KANDELO_TEST_BASE_URL; + if (raw === undefined) return undefined; + if (raw.trim() !== raw || raw.length === 0) { + throw new Error("KANDELO_TEST_BASE_URL must not be empty or padded"); + } + let url: URL; + try { + url = new URL(raw); + } catch { + throw new Error("KANDELO_TEST_BASE_URL must be an absolute HTTP URL"); + } + if ( + (url.protocol !== "http:" && url.protocol !== "https:") || + url.username !== "" || url.password !== "" || + url.search !== "" || url.hash !== "" + ) { + throw new Error( + "KANDELO_TEST_BASE_URL must be an HTTP(S) deployment root without " + + "credentials, query parameters, or a fragment", + ); + } + if (!url.pathname.endsWith("/")) url.pathname += "/"; + return url.href; +} + +export function usesManagedPlaywrightServer( + configuredBaseUrl: string | undefined, + port: number, +): boolean { + if (configuredBaseUrl === undefined) return true; + const url = new URL(configuredBaseUrl); + return ( + url.protocol === "http:" && + url.hostname === "127.0.0.1" && + Number(url.port || "80") === port + ); +} diff --git a/apps/browser-demos/playwright.config.ts b/apps/browser-demos/playwright.config.ts index c66fafa075..7d5576c83e 100644 --- a/apps/browser-demos/playwright.config.ts +++ b/apps/browser-demos/playwright.config.ts @@ -2,11 +2,20 @@ import { defineConfig } from "@playwright/test"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { shouldReuseExistingPlaywrightServer } from "./playwright-server-policy"; +import { + configuredPlaywrightTestBaseUrl, + usesManagedPlaywrightServer, +} from "./playwright-test-target"; import { HOMEBREW_CLOSED_ACCEPTANCE_VITE_MODE } from "./lib/homebrew-closed-acceptance"; import { playwrightWebServerEnvironment } from "./playwright-closed-acceptance"; const __dirname = dirname(fileURLToPath(import.meta.url)); const port = Number(process.env.KANDELO_PLAYWRIGHT_PORT ?? 5401); +const configuredTestBaseUrl = configuredPlaywrightTestBaseUrl(process.env); +const startManagedWebServer = usesManagedPlaywrightServer( + configuredTestBaseUrl, + port, +); const serveSealedDist = process.env.KANDELO_PLAYWRIGHT_SERVE_DIST === "1"; const configuredViteMode = process.env.KANDELO_PLAYWRIGHT_VITE_MODE?.trim(); if ( @@ -76,7 +85,7 @@ export default defineConfig({ timeout: 120_000, workers: process.env.CI ? 1 : undefined, use: { - baseURL: `http://127.0.0.1:${port}`, + baseURL: configuredTestBaseUrl ?? `http://127.0.0.1:${port}`, // Nix dev-shell build/linker paths are for toolchain commands, not // downloaded Playwright browser binaries. WebKitGTK reads more host // environment than Chromium/Firefox and can crash before navigation. @@ -84,7 +93,10 @@ export default defineConfig({ screenshot: "only-on-failure", trace: process.env.CI ? "retain-on-failure" : "off", }, - webServer: { + // WHY: an external acceptance test must exercise the deployed bytes. A + // local Vite server would be unused and could obscure which product the run + // actually proved while consuming runner time and resources. + webServer: startManagedWebServer ? { command: serveSealedDist ? `npx vite preview${viteModeArgument} --config ${join(__dirname, "vite.config.ts")} --host 127.0.0.1 --port ${port} --strictPort` : `npx vite${viteModeArgument} --config ${join(__dirname, "vite.config.ts")} --host 127.0.0.1 --port ${port} --strictPort`, @@ -92,7 +104,7 @@ export default defineConfig({ env: webServerEnvironment, reuseExistingServer: shouldReuseExistingPlaywrightServer(process.env), timeout: 30_000, - }, + } : undefined, projects: [ { name: "chromium", diff --git a/apps/browser-demos/test/kandelo-homebrew-main-shell.spec.ts b/apps/browser-demos/test/kandelo-homebrew-main-shell.spec.ts index b8373b139f..1433c9ff28 100644 --- a/apps/browser-demos/test/kandelo-homebrew-main-shell.spec.ts +++ b/apps/browser-demos/test/kandelo-homebrew-main-shell.spec.ts @@ -6,6 +6,7 @@ import { parseHomebrewVfsMaterializationPolicy } from "../../../host/src/homebre import { corsProxyTargetUrl } from "../../../host/src/networking/cors-proxy-url"; import { assertMainShellOperationalRuntimeFetches } from "../../../scripts/homebrew-main-shell-image-contract"; import { DEFAULT_BROWSER_CORS_PROXY_URL } from "../lib/browser-cors-proxy"; +import { configuredPlaywrightTestBaseUrl } from "../playwright-test-target"; import { isShellVfsImageUrl, isVfsImageUrl, @@ -30,6 +31,20 @@ const closedMirrorRoot = process.env.KANDELO_PLAYWRIGHT_CLOSED_ACCEPTANCE_ROOT; const transportMode = process.env.KANDELO_HOMEBREW_MAIN_SHELL_TRANSPORT_MODE; const mirrorPlanUrl = process.env.KANDELO_HOMEBREW_MAIN_SHELL_MIRROR_PLAN_URL; +const configuredTestBaseUrl = configuredPlaywrightTestBaseUrl(process.env); +const expectedGuestPrefix = + process.env.KANDELO_HOMEBREW_MAIN_SHELL_EXPECTED_PREFIX ?? + "/opt/kandelo/homebrew"; +// WHY: immutable rev22 bytes predate Kandelo's final guest path. The +// Linuxbrew-shaped value is a temporary compatibility assertion for those +// exact bytes; Kandelo is not Linux and `/opt/kandelo/homebrew` remains the +// only final product target. +if ( + expectedGuestPrefix !== "/opt/kandelo/homebrew" && + expectedGuestPrefix !== "/home/linuxbrew/.linuxbrew" +) { + throw new Error("Homebrew main-shell test received an unsupported prefix"); +} const runtimeSupport = parseHomebrewRuntimeSupportContract( JSON.parse( readFileSync( @@ -494,7 +509,10 @@ async function bootExactShellPage(page: Page): Promise { } }); - await page.goto("/?demo=shell", { waitUntil: "domcontentloaded" }); + const pageUrl = configuredTestBaseUrl === undefined + ? "/?demo=shell" + : new URL("?demo=shell", configuredTestBaseUrl).href; + await page.goto(pageUrl, { waitUntil: "domcontentloaded" }); await page.waitForTimeout(2_000); const overlay = page.locator("vite-error-overlay"); if (await overlay.count()) { @@ -711,7 +729,7 @@ test("a fresh exact shell activates brew support atomically after independent ba await runParentShellProbe( page, 'printf \'HOMEBREW_MAIN_SHELL_PATH:%s:%s\\n\' "$0" "${PATH%%:*}"', - "HOMEBREW_MAIN_SHELL_PATH:bash:/opt/kandelo/homebrew/bin", + `HOMEBREW_MAIN_SHELL_PATH:bash:${expectedGuestPrefix}/bin`, ); let lazyRows = await readLazyDownloadRows(page); expect(lazyRows).toEqual([]); @@ -857,8 +875,8 @@ printf 'HOMEBREW_ATOMIC_RUNTIME_ACTIVATED\n' page, ` set -eu -test "$(/usr/bin/brew --prefix)" = /opt/kandelo/homebrew -probe=/opt/kandelo/homebrew/Library/Homebrew/cmd/kandelo-env-probe.sh +test "$(/usr/bin/brew --prefix)" = ${expectedGuestPrefix} +probe=${expectedGuestPrefix}/Library/Homebrew/cmd/kandelo-env-probe.sh cat > "$probe" <<'KANDELO_BREW_ENV_PROBE' homebrew-kandelo-env-probe() { printf '%s\n' "$HOMEBREW_KANDELO_BOTTLE_TAG" @@ -894,7 +912,7 @@ printf 'HOMEBREW_OPERATIONAL_RUNTIME_OK\n' const repeatBrewPriorSources = new Set(lazyRows.map(({ source }) => source)); await runTerminalCommand( page, - 'test "$(/usr/bin/brew --prefix)" = /opt/kandelo/homebrew && ' + + `test "$(/usr/bin/brew --prefix)" = ${expectedGuestPrefix} && ` + "printf 'HOMEBREW_RUNTIME_REUSE_OK\\n'", "HOMEBREW_RUNTIME_REUSE_OK", 240_000, diff --git a/docs/homebrew-publishing.md b/docs/homebrew-publishing.md index bbaf83d28f..d41bb68731 100644 --- a/docs/homebrew-publishing.md +++ b/docs/homebrew-publishing.md @@ -3612,14 +3612,73 @@ image metadata. This is an exact-byte copy: callers that need a canonical configuration should track one JSON source rather than regenerate equivalent JSON in each image builder. +### Transitional Rev22 Pages Deployment + +The Pages deployment temporarily reuses one already-published ABI 42 shell +rather than waiting for another complete bottle campaign. The exact archive, +shell image, bootstrap archive, bootstrap files, and immutable bottle-mirror +plan are recorded in +`homebrew/transitional-pages-shell-rev22-lock.json`. The deployment downloads +those public files without GitHub credentials and verifies every byte count +and SHA-256 digest before it exposes the image to the package resolver. + +This is reuse, not a rebuild or a claim that historical bytes came from the +current `main` commit. The inspection report records +`exact_current_main: false`, the historical Kandelo commit and workflow run, +and the final condition that removes this lane. Replacing an asset behind the +mutable `binaries-abi-v42` release tag does not replace the locked product: +the changed digest is rejected. The bottle-mirror release itself is immutable. + +Rev22 predates Kandelo's final guest-prefix contract. Its embedded Homebrew +files use `/home/linuxbrew/.linuxbrew` as a temporary compatibility path. +That path does not mean Kandelo is Linux and must not become a new Kandelo +convention. The final target remains `/opt/kandelo/homebrew`. Delete the lock, +fetcher, `run.sh` transition flag, and workflow branch as soon as a canonical +Kandelo-prefix shell is deployable. + +The current workflow still uses current host, browser, service-worker, and UI +code. It assembles the full current Pages tree, then boots the exact rev22 +shell in Chromium. Every strict Playwright input is passed explicitly through +the development shell. The JSON result must report one executed test, zero +skipped tests, and zero failures before deployment. The acceptance test proves +that the shell begins mostly lazy, materializes the bootstrap and Ruby cohort +on first use, executes `brew --prefix`, and reuses the materialized runtime. +The dated live result and exact reproduction inputs are committed in +[`2026-08-03-live-pages-homebrew-rev22.json`](measurements/2026-08-03-live-pages-homebrew-rev22.json). + +This lane does not re-admit every published bottle, wait for unrelated Formula +builds, or invoke the source-rootfs bridge. It is deliberately bounded to the +existing immutable product bytes needed to ship the in-guest `brew` proof. + +Five conventional gallery recipes changed cache identity after the assets now +served by Pages were published: `lamp`, `nginx-php-vfs`, `nginx-vfs`, +`node-vfs`, and `wordpress`. Rebuilding them is unrelated to changing the +shell. The transition lock therefore records each old package archive and its +one VFS member by digest and byte count. Those members were also compared with +the live Pages assets and are byte-for-byte identical. During this bounded +cutover, Pages installs those exact members under their normal resolver-owned +package identities. That preserves the existing WordPress, LAMP, Nginx, and +Node demos while keeping unrelated recipe drift off the shell critical path. +This compatibility set is part of the rev22 transition and must be removed +with the rest of the lane; it is not a general stale-package fallback. + +The descriptor-prefix host correction also changes files that six +conventional package variants list as broad build inputs: `kandelo-sdk`, +`mariadb-test`, both `mariadb-vfs` architectures, `redis-vfs`, and `rootfs`. +That consumer correction cannot change their already-built bytes, but the +legacy cache contract gives the rows new identities and correctly refuses to +silently accept the old archives. The transition lock therefore binds each +old archive and output member explicitly, and the Pages job installs those +exact outputs as local generations under the new source identity. This avoids +six unrelated rebuilds without weakening the normal stale-archive check. + ### Dormant Exact-Main Source Bridge The source bridge described below was activation scaffolding. The canonical -Homebrew shell CI now selects the bottled product, and the Pages publisher -fetches only admitted canonical archives. The implementation remains -temporarily for diagnosis and historical comparison, but no production -workflow invokes it and it cannot satisfy the artifact-lock or public-mirror -gates. +Homebrew shell CI selects the bottled product. The temporary rev22 Pages lane +above selects exact locked published bytes. The source-bridge implementation +remains for diagnosis and historical comparison, but no production workflow +invokes it and it cannot satisfy either product path. During the ABI 42 activation window, required CI could not consume bottles built from a pull-request checkout and then call them main-built merely because that @@ -3708,13 +3767,11 @@ proof's critical path. This pre-merge shard compiles but never deploys; pull requests still cannot invoke the Pages publisher. The Pages publisher remains the full-gallery build gate, but its browser boot -checks the shell route rather than booting every gallery entry. It consumes the -canonical bottled product from a fresh package cache with source fallback -disabled, binds the exact public shell, bootstrap, and mirror plan, and proves -first-use `brew` materialization in Chromium before deployment. It deliberately -does not invoke the internal source bridge or provide that bridge's exact event -repository and SHA, `pages-exact-main-v1` isolation attestation, empty -current-ABI file index, or unmaterialized resolver workspace. +checks the shell route rather than booting every gallery entry. During the +bounded rev22 transition it binds the exact locked shell, bootstrap, and mirror +plan described above, while unrelated gallery packages use a fresh cache with +source fallback disabled. It proves first-use `brew` materialization in +Chromium before deployment and does not invoke the internal source bridge. The lane stages and inspects only the distinct bridge recipe before beginning canonical installation. Before any mutation it verifies the exact GitHub @@ -3756,19 +3813,17 @@ turns producer failures or unexpected skips into failures. Marking a draft pull request ready reruns the same ordered path without invoking close-time staging cleanup. -Pages uses a fresh resolver cache and -`./run.sh --fetch-only --require-sealed-homebrew-selection prepare-browser`. -It verifies the selected shell against the sealed artifact lock, stages the -bootstrap from the same sealed Formula selection, reads the embedded immutable -mirror plan without eagerly downloading its payloads, and boots the assembled -`/kandelo/` tree through the existing public-transport Chromium acceptance. -Missing canonical archives, a missing public mirror, or any -shell/bootstrap/plan identity drift stops deployment. The source-rootfs bridge -cannot stand in for that product artifact and can be deleted in a later -cleanup. The first cutover deliberately does not wait for the complete public -lifecycle. Its Pages job is the publication gate: it must anonymously recover -the canonical product, boot the exact assembled site in Chromium, keep `brew` -deferred until first use, and run a real in-guest `brew` command. +Pages uses a fresh resolver cache and runs `prepare-browser` with fetch-only +resolution plus the explicit transitional-shell flag. It anonymously fetches +the locked package archives, safely extracts only the named members, reads the +embedded immutable mirror plan without eagerly downloading its payloads, and +boots the assembled `/kandelo/` tree through the existing public-transport +Chromium acceptance. A missing public asset or any shell, bootstrap, or plan +identity drift stops deployment. The source-rootfs bridge cannot stand in for +that product artifact. The first cutover deliberately does not wait for the +complete public lifecycle. Its Pages job is the publication gate: it must boot +the exact assembled site in Chromium, keep `brew` deferred until first use, +and run a real in-guest `brew` command. Tap/install/upgrade/remove/reboot and memory-soak coverage remain independent follow-up work. diff --git a/docs/measurements/2026-08-03-live-pages-homebrew-rev22.json b/docs/measurements/2026-08-03-live-pages-homebrew-rev22.json new file mode 100644 index 0000000000..f0e5bdd2e2 --- /dev/null +++ b/docs/measurements/2026-08-03-live-pages-homebrew-rev22.json @@ -0,0 +1,67 @@ +{ + "schema": 1, + "kind": "kandelo-live-pages-homebrew-acceptance-evidence", + "date": "2026-08-03", + "started_at": "2026-08-03T15:39:33.757Z", + "live_base_url": "https://automattic.github.io/kandelo/", + "test": "test/kandelo-homebrew-main-shell.spec.ts", + "project": "chromium", + "product": { + "kernel_abi": 42, + "guest_prefix": "/home/linuxbrew/.linuxbrew", + "guest_prefix_status": "temporary-rev22-compatibility-path", + "final_guest_prefix": "/opt/kandelo/homebrew", + "shell": { + "sha256": "d82a397d9b441269b6ea8a0fe1a37d82983a52bd6ed40b8d7387a16c3037218c", + "bytes": 5752199 + }, + "homebrew_bootstrap": { + "sha256": "6b94235c4463a7ae03104decb20910fb660af4d2313fc0c87a84ef02acde440c", + "bytes": 5081250 + }, + "bottle_mirror": { + "collection_sha256": "fd15162a8c9c06e6d7936af470cd16ba916528708356750751b55bac567a0ce2", + "plan_sha256": "405d59443b28137a5f00d6405ca07281e1ff5924742ffb8a666d37872063b39a", + "plan_bytes": 19373, + "asset_count": 36, + "plan_url": "https://github.com/Kandelo-dev/homebrew-tap-core/releases/download/homebrew-shell-bottles-sha256-fd15162a8c9c06e6d7936af470cd16ba916528708356750751b55bac567a0ce2/kandelo-homebrew-bottle-mirror-plan.json" + } + }, + "playwright": { + "stats": { + "expected": 1, + "skipped": 0, + "unexpected": 0, + "flaky": 0 + }, + "test_status": "passed", + "test_duration_ms": 40551, + "report_duration_ms": 42449.751000000004 + }, + "reproduction": { + "working_directory": "apps/browser-demos", + "wrapper": "bash ../../scripts/dev-shell.sh env", + "environment": { + "CI": "1", + "KANDELO_BROWSER_DEMO_INPUTS": "main", + "KANDELO_HOMEBREW_MAIN_SHELL_STRICT": "1", + "KANDELO_HOMEBREW_MAIN_SHELL_SHA256": "d82a397d9b441269b6ea8a0fe1a37d82983a52bd6ed40b8d7387a16c3037218c", + "KANDELO_HOMEBREW_MAIN_SHELL_BOOTSTRAP_SHA256": "6b94235c4463a7ae03104decb20910fb660af4d2313fc0c87a84ef02acde440c", + "KANDELO_HOMEBREW_MAIN_SHELL_BOOTSTRAP_BYTES": "5081250", + "KANDELO_HOMEBREW_MAIN_SHELL_TRANSPORT_MODE": "public", + "KANDELO_HOMEBREW_MAIN_SHELL_MIRROR_PLAN_URL": "https://github.com/Kandelo-dev/homebrew-tap-core/releases/download/homebrew-shell-bottles-sha256-fd15162a8c9c06e6d7936af470cd16ba916528708356750751b55bac567a0ce2/kandelo-homebrew-bottle-mirror-plan.json", + "KANDELO_HOMEBREW_MAIN_SHELL_EXPECTED_PREFIX": "/home/linuxbrew/.linuxbrew", + "KANDELO_TEST_BASE_URL": "https://automattic.github.io/kandelo/", + "PLAYWRIGHT_JSON_OUTPUT_FILE": "" + }, + "command": [ + "npx", + "playwright", + "test", + "test/kandelo-homebrew-main-shell.spec.ts", + "--project=chromium", + "--reporter=json" + ], + "harness_note": "The measured run started a local Vite process, but KANDELO_TEST_BASE_URL made the test navigate to the live Pages deployment. The committed Playwright configuration now validates an external deployment root and omits that unused local server." + } +} diff --git a/homebrew/transitional-pages-shell-rev22-lock.json b/homebrew/transitional-pages-shell-rev22-lock.json new file mode 100644 index 0000000000..18592a8e1c --- /dev/null +++ b/homebrew/transitional-pages-shell-rev22-lock.json @@ -0,0 +1,207 @@ +{ + "schema": 1, + "kind": "kandelo-transitional-homebrew-pages-shell-lock", + "lifecycle": "transitional", + "removal_condition": "canonical-kandelo-prefix-shell-is-deployable", + "kernel_abi": 42, + "guest_prefix": "/home/linuxbrew/.linuxbrew", + "runtime_support": { + "activation_group": "homebrew-runtime-support", + "formula": "kandelo-dev/tap-core/ruby", + "cache_key_sha256": "bd3129e6d39b077e104b98a99d6539939ee38f353b83612fc0aef0581a41f3db" + }, + "historical_build": { + "kandelo_commit": "00e30e1aeb9a1b691b2fd8ffcf0cf7ef561aab3c", + "workflow_run": "https://github.com/Automattic/kandelo/actions/runs/30532812285" + }, + "package_release": { + "repository": "Automattic/kandelo", + "tag": "binaries-abi-v42", + "authority": "mutable-tag-digest-pinned-public-readback", + "shell_archive": { + "name": "shell-0.1.0-rev22-abi42-wasm32-25d260da.tar.zst", + "sha256": "a5849d480eceacfcd262a42472f8f819e8148234144ea98951c98b300b945a5d", + "bytes": 5741544, + "member": "artifacts/shell.vfs.zst", + "member_sha256": "d82a397d9b441269b6ea8a0fe1a37d82983a52bd6ed40b8d7387a16c3037218c", + "member_bytes": 5752199 + }, + "bootstrap_archive": { + "name": "homebrew-bootstrap-6.0.3-4-g4ead861-rev3-abi42-wasm32-1ec4e97d.tar.zst", + "sha256": "e20513cd821a2a42cdd41d53bdd35bebe9d602749b1680d59e83ddb096d0e35a", + "bytes": 4525796, + "members": { + "homebrew-bootstrap.zip": { + "path": "artifacts/homebrew-bootstrap.zip", + "sha256": "6b94235c4463a7ae03104decb20910fb660af4d2313fc0c87a84ef02acde440c", + "bytes": 5081250 + }, + "homebrew-brew.env": { + "path": "artifacts/homebrew-brew.env", + "sha256": "2eb3f05703b6a6f23feabda24f622bacd068115c7f74a0eac51bb4085e9eec5a", + "bytes": 210 + } + } + } + }, + "gallery_compatibility": { + "reason": "reuse-currently-deployed-exact-assets-during-shell-cutover", + "verified_live_base_url": "https://automattic.github.io/kandelo/", + "assets": [ + { + "package": "lamp", + "output": "lamp.vfs.zst", + "archive": { + "name": "lamp-0.1.0-rev11-abi42-wasm32-71448393.tar.zst", + "sha256": "384cb023f783703b05eb3b1980b3e90b12ec9c3495c5ea54e49b9aa98843e768", + "bytes": 48278014, + "member": "artifacts/lamp.vfs.zst", + "member_sha256": "86ef484285c902bdeb4091d13e802aa277a14d34e27ccc92bee0880adb6e0e4b", + "member_bytes": 48309635 + } + }, + { + "package": "nginx-php-vfs", + "output": "nginx-php.vfs.zst", + "archive": { + "name": "nginx-php-vfs-0.1.0-rev2-abi42-wasm32-35a5f89c.tar.zst", + "sha256": "8744746da36918ecca67df07aefa744bfd4eaca01ff85679dba962fe36110729", + "bytes": 12825571, + "member": "artifacts/nginx-php.vfs.zst", + "member_sha256": "76088469b91a032fab6e474f8d5b9dab4d7d0d9566c53848e6bfe96e33b117b5", + "member_bytes": 12832781 + } + }, + { + "package": "nginx-vfs", + "output": "nginx.vfs.zst", + "archive": { + "name": "nginx-vfs-0.1.0-rev2-abi42-wasm32-a9f6fb18.tar.zst", + "sha256": "967f4001e0babed2b45e0811645afbde5b6e99bed88c757248351a7a4f421f46", + "bytes": 6567769, + "member": "artifacts/nginx.vfs.zst", + "member_sha256": "09a59c1b8193788cca51080d08dacf8a270037861736b208b37abee3513b8032", + "member_bytes": 6581010 + } + }, + { + "package": "node-vfs", + "output": "node-vfs.vfs.zst", + "archive": { + "name": "node-vfs-0.1.0-rev14-abi42-wasm32-80d64061.tar.zst", + "sha256": "06050821968ce4e8ba13ff62d2450c8ace3751ebde4999af1fc98368ba3cbef8", + "bytes": 16022896, + "member": "artifacts/node-vfs.vfs.zst", + "member_sha256": "608d14f5083ee4d67f18e6f78184686bcafac863c09c8891075eb1485b9d08ec", + "member_bytes": 16044470 + } + }, + { + "package": "wordpress", + "output": "wordpress.vfs.zst", + "archive": { + "name": "wordpress-7.0-rev12-abi42-wasm32-ff4ab900.tar.zst", + "sha256": "11b81a11212d5dbdfad70cf0a0a29e379751cfcc9222722876df2d9fb3367411", + "bytes": 45328743, + "member": "artifacts/wordpress.vfs.zst", + "member_sha256": "e9478eab08c137b2aad21a6987ffb1ef70b07d042579cf8b7e096c09e6925e6b", + "member_bytes": 45333591 + } + } + ] + }, + "source_projection_compatibility": { + "reason": "reuse-exact-output-after-consumer-only-host-input-change", + "assets": [ + { + "package": "kandelo-sdk", + "arch": "wasm32", + "output": "kandelo-sdk.vfs.zst", + "archive": { + "name": "kandelo-sdk-0.1.0-rev4-abi42-wasm32-5d2aa9c9.tar.zst", + "sha256": "0eea22ab7a8455ebee1f88fed634563254dcb4e8ebf62a867c061e5097a2159d", + "bytes": 1767314, + "member": "artifacts/kandelo-sdk.vfs.zst", + "member_sha256": "b1899c10f3af8b3c476e06b9439166e9f486cad01da5f40bcd402fc585247791", + "member_bytes": 1771655 + } + }, + { + "package": "mariadb-test", + "arch": "wasm32", + "output": "mariadb-test.vfs.zst", + "archive": { + "name": "mariadb-test-0.1.0-rev5-abi42-wasm32-a967a54c.tar.zst", + "sha256": "11711651a40ef3bbf54fb6f6988f66319144d6caed6ef1c92803aeb793c3abc6", + "bytes": 6129068, + "member": "artifacts/mariadb-test.vfs.zst", + "member_sha256": "478d53caaeaff8ae4c6b11af07bff8a685440fa60aa588ed2f762929c6b31cbd", + "member_bytes": 6138084 + } + }, + { + "package": "mariadb-vfs", + "arch": "wasm32", + "output": "mariadb-vfs.vfs.zst", + "archive": { + "name": "mariadb-vfs-0.1.0-rev6-abi42-wasm32-2eb50e31.tar.zst", + "sha256": "0c4ef01a090785edde1c310a6faba594a86baee067ec249d8f04eac3ff7375a3", + "bytes": 4542777, + "member": "artifacts/mariadb-vfs.vfs.zst", + "member_sha256": "f8038fcec717fab792d2e96b17a6a1ce74586976758c80f0946316f0c4fe39e2", + "member_bytes": 4556707 + } + }, + { + "package": "mariadb-vfs", + "arch": "wasm64", + "output": "mariadb-vfs.vfs.zst", + "archive": { + "name": "mariadb-vfs-0.1.0-rev6-abi42-wasm64-884db9a8.tar.zst", + "sha256": "42e63d56269d59e31a74ace448d2c1c7c867407db254e49c8ea7cbbc9f8b8bb1", + "bytes": 4723234, + "member": "artifacts/mariadb-vfs.vfs.zst", + "member_sha256": "5a081eddc59a8f241e1d3f08f6de594fe324c7d7c0d93822a5befaaa2e3d0628", + "member_bytes": 4741108 + } + }, + { + "package": "redis-vfs", + "arch": "wasm32", + "output": "redis.vfs.zst", + "archive": { + "name": "redis-vfs-0.1.0-rev2-abi42-wasm32-f16e5aa9.tar.zst", + "sha256": "800f5f511314424b1aa7fa95e1d351cd6beba09c1258f25700e10d42fb076ad3", + "bytes": 1263856, + "member": "artifacts/redis.vfs.zst", + "member_sha256": "63c4a8b4ea1ea88d1b28cd5a8adc2f4a455852e0ad6a9991b4cbcf15587cc974", + "member_bytes": 1263054 + } + }, + { + "package": "rootfs", + "arch": "wasm32", + "output": "rootfs.vfs", + "archive": { + "name": "rootfs-0.1.0-rev9-abi42-wasm32-8712ba10.tar.zst", + "sha256": "7d147e51c505f4c7e5b4a3bc7fbb26b6a6455bf133f84f8e2968d60cbe45262b", + "bytes": 289184, + "member": "artifacts/rootfs.vfs", + "member_sha256": "59990afa0367fd74bacd085bf98ede56afa94a4bc183f35c14fcea3e2839d4c0", + "member_bytes": 16787687 + } + } + ] + }, + "bottle_mirror": { + "repository": "Kandelo-dev/homebrew-tap-core", + "tag": "homebrew-shell-bottles-sha256-fd15162a8c9c06e6d7936af470cd16ba916528708356750751b55bac567a0ce2", + "immutable": true, + "asset_count": 36, + "plan": { + "name": "kandelo-homebrew-bottle-mirror-plan.json", + "sha256": "405d59443b28137a5f00d6405ca07281e1ff5924742ffb8a666d37872063b39a", + "bytes": 19373 + } + } +} diff --git a/run.sh b/run.sh index b24900bed4..7b28932afa 100755 --- a/run.sh +++ b/run.sh @@ -25,6 +25,9 @@ # Refuse a pending local Homebrew selection. # Release workflows use this before serving # the browser bootstrap asset. +# --transitional-pages-homebrew-shell +# Internal Pages lane for the exact, published +# rev22 Homebrew shell during prefix cutover. # --source-rootfs-shell Internal GitHub Pages-only bridge. Requires # the exact Pages job identity, provenance, # empty file index, fresh cache, and clean @@ -72,6 +75,7 @@ FETCH_ONLY_ARGS=() ALREADY_MATERIALIZED=0 SOURCE_ROOTFS_SHELL=0 REQUIRE_SEALED_HOMEBREW_SELECTION=0 +TRANSITIONAL_PAGES_HOMEBREW_SHELL=0 USE_PR_STAGING=0 NEW_ARGS=() for a in "$@"; do @@ -91,6 +95,9 @@ for a in "$@"; do --require-sealed-homebrew-selection) REQUIRE_SEALED_HOMEBREW_SELECTION=1 ;; + --transitional-pages-homebrew-shell) + TRANSITIONAL_PAGES_HOMEBREW_SHELL=1 + ;; --pr-staging) USE_PR_STAGING=1 ;; @@ -123,6 +130,7 @@ export WASM_POSIX_FETCH_ONLY=$([ "${#FETCH_ONLY_ARGS[@]}" -gt 0 ] && echo 1 || e export WASM_POSIX_ALREADY_MATERIALIZED=$ALREADY_MATERIALIZED export WASM_POSIX_SOURCE_ROOTFS_SHELL=$SOURCE_ROOTFS_SHELL export WASM_POSIX_REQUIRE_SEALED_HOMEBREW_SELECTION=$REQUIRE_SEALED_HOMEBREW_SELECTION +export WASM_POSIX_TRANSITIONAL_PAGES_HOMEBREW_SHELL=$TRANSITIONAL_PAGES_HOMEBREW_SHELL if [ "${WASM_POSIX_USE_PR_STAGING:-0}" = "1" ]; then USE_PR_STAGING=1 fi @@ -146,6 +154,20 @@ if [ "$REQUIRE_SEALED_HOMEBREW_SELECTION" -eq 1 ]; then exit 2 } fi +if [ "$TRANSITIONAL_PAGES_HOMEBREW_SHELL" -eq 1 ]; then + [ "$#" -eq 1 ] && [ "${1:-}" = "prepare-browser" ] || { + err "--transitional-pages-homebrew-shell is valid only for prepare-browser." + exit 2 + } + if [ "${#FETCH_ONLY_ARGS[@]}" -eq 0 ] || + [ "$ALREADY_MATERIALIZED" -eq 1 ] || + [ "$SOURCE_ROOTFS_SHELL" -eq 1 ] || + [ "$REQUIRE_SEALED_HOMEBREW_SELECTION" -eq 1 ] || + [ "$USE_PR_STAGING" -eq 1 ]; then + err "--transitional-pages-homebrew-shell requires isolated fetch-only preparation." + exit 2 + fi +fi validate_ci_browser_source_authority() { [ -n "$CI_BROWSER_SOURCE_AUTHORITY" ] || return 0 @@ -158,6 +180,7 @@ validate_ci_browser_source_authority() { [ "${#FETCH_ONLY_ARGS[@]}" -eq 0 ] || [ "$SOURCE_ROOTFS_SHELL" -ne 0 ] || [ "$REQUIRE_SEALED_HOMEBREW_SELECTION" -ne 0 ] || + [ "$TRANSITIONAL_PAGES_HOMEBREW_SHELL" -ne 0 ] || [ "$USE_PR_STAGING" -ne 0 ]; then err "Internal browser source authority requires isolated CI preparation." return 2 @@ -346,6 +369,24 @@ pkg_output_rel() { "$xtask" build-deps --arch "$arch" output-path "$pkg" "$wasm" 2>/dev/null } +# pkg_resolver_rel [arch] +# +# Print the complete path accepted by resolve-binary.sh. The wasm32 resolver +# retains a historical default-architecture shorthand; make every caller use +# this helper so wasm64 can never be mistaken for that default. +pkg_resolver_rel() { + local pkg=$1 + local wasm=$2 + local arch=${3:-wasm32} + local rel + rel=$(pkg_output_rel "$pkg" "$wasm" "$arch") || return 1 + if [ "$arch" = "wasm32" ]; then + printf 'programs/%s\n' "$rel" + else + printf 'programs/%s/%s\n' "$arch" "$rel" + fi +} + # pkg_local_output_path [arch] pkg_local_output_path() { local pkg=$1 @@ -387,16 +428,9 @@ pkg_has_output() { local pkg=$1 local wasm=$2 local arch=${3:-wasm32} - local rel - rel=$(pkg_output_rel "$pkg" "$wasm" "$arch") || return 1 - if [ "$arch" = "wasm32" ]; then - # `has_resolvable programs/` injects `wasm32/` per the - # default-arch shim (matches host/src/binary-resolver.ts). No - # explicit arch segment needed. - has_resolvable "programs/$rel" - else - has_resolvable "programs/$arch/$rel" - fi + local resolver_rel + resolver_rel=$(pkg_resolver_rel "$pkg" "$wasm" "$arch") || return 1 + has_resolvable "$resolver_rel" } has_kernel() { has_resolvable kernel.wasm || has_valid_kernel_file "$REPO_ROOT/host/wasm/kandelo-kernel.wasm"; } @@ -2385,6 +2419,17 @@ BROWSER_EXTERNAL_GALLERY_PKGS=(cpython python-vfs perl perl-vfs ruby erlang erla # would make browser readiness depend on the registry we are retiring. BROWSER_FETCH_SKIP_PKGS=(spidermonkey node homebrew-bootstrap) +# These five conventional VFS packages are byte-for-byte the same as the +# currently deployed Pages assets, but their recipe cache keys changed after +# publication. The temporary rev22 lane installs their reviewed archive +# members directly so unrelated gallery rebuilds do not block the shell. +TRANSITIONAL_PAGES_GALLERY_COMPAT_PKGS=( + lamp nginx-php-vfs nginx-vfs node-vfs wordpress +) +TRANSITIONAL_PAGES_SOURCE_PROJECTION_COMPAT_PKGS=( + kandelo-sdk mariadb-test mariadb-vfs redis-vfs rootfs +) + # All targets needed for the Kandelo browser UI and retained browser labs. # Each entry's `has_X` short-circuits when its release binary is in # `binaries/`, so this loop is a no-op on a fully-fetched checkout. @@ -2400,6 +2445,13 @@ build_browser() { } prepare_browser_homebrew_bootstrap() { + if [ "$TRANSITIONAL_PAGES_HOMEBREW_SHELL" -eq 1 ]; then + # WHY: this temporary Pages lane has already staged the bootstrap from + # the exact historical package archive. Running the ordinary selector + # here would replace it with a different, incomplete campaign result. + verify_transitional_pages_homebrew_shell + return 0 + fi # WHY: generic Prepare Merge has already authenticated this materialized # image as the explicit source-rootfs bridge. That bridge truthfully has # no Homebrew selection, so asking it for a bootstrap bottle would turn a @@ -2427,12 +2479,17 @@ fetch_browser_binaries() { local disabled_pkgs local fetch_args=() disabled_pkgs="${BROWSER_EXTERNAL_GALLERY_PKGS[*]} ${BROWSER_FETCH_SKIP_PKGS[*]}" - if [ "$SOURCE_ROOTFS_SHELL" -eq 1 ]; then - # WHY: the selected bridge has already installed its inspected bytes - # under the canonical browser path. A full-registry fetch must not - # directly resolve the bottle-backed shell and replace that selection. + if [ "$SOURCE_ROOTFS_SHELL" -eq 1 ] || + [ "$TRANSITIONAL_PAGES_HOMEBREW_SHELL" -eq 1 ]; then + # WHY: one exact shell has already been installed under the canonical + # package identity. A full-registry fetch must not replace it through + # ordinary shell selection while the browser closure is assembled. disabled_pkgs="$disabled_pkgs shell" fi + if [ "$TRANSITIONAL_PAGES_HOMEBREW_SHELL" -eq 1 ]; then + disabled_pkgs="$disabled_pkgs ${TRANSITIONAL_PAGES_GALLERY_COMPAT_PKGS[*]}" + disabled_pkgs+=" ${TRANSITIONAL_PAGES_SOURCE_PROJECTION_COMPAT_PKGS[*]}" + fi if [ "${#FETCH_ONLY_ARGS[@]}" -gt 0 ]; then fetch_args+=("${FETCH_ONLY_ARGS[@]}") fi @@ -2449,6 +2506,327 @@ fetch_browser_binaries() { "$REPO_ROOT/scripts/fetch-binaries.sh" "${fetch_args[@]}" } +transitional_pages_gallery_rows() { + local report="$1" + jq -er ' + .gallery_compatibility as $assets | + if + ($assets | type) == "array" and + ($assets | map(.package + ":" + .output)) == [ + "lamp:lamp.vfs.zst", + "nginx-php-vfs:nginx-php.vfs.zst", + "nginx-vfs:nginx.vfs.zst", + "node-vfs:node-vfs.vfs.zst", + "wordpress:wordpress.vfs.zst" + ] and + all($assets[]; + (.archive_sha256 | type) == "string" and + (.archive_sha256 | test("^[0-9a-f]{64}$")) and + (.sha256 | type) == "string" and + (.sha256 | test("^[0-9a-f]{64}$")) and + (.bytes | type) == "number" and .bytes > 0 and + (.archive_url | type) == "string" and + (.archive_url | + startswith("https://github.com/Automattic/kandelo/releases/")) and + .verified_live_base_url == + "https://automattic.github.io/kandelo/") + then + $assets[] | + [.package, .output, .sha256, (.bytes | tostring)] | @tsv + else + error("invalid transitional gallery compatibility set") + end + ' "$report" +} + +transitional_pages_source_projection_rows() { + local report="$1" + jq -er ' + .source_projection_compatibility as $assets | + if + ($assets | type) == "array" and + ($assets | map(.package + ":" + .arch + ":" + .output)) == [ + "kandelo-sdk:wasm32:kandelo-sdk.vfs.zst", + "mariadb-test:wasm32:mariadb-test.vfs.zst", + "mariadb-vfs:wasm32:mariadb-vfs.vfs.zst", + "mariadb-vfs:wasm64:mariadb-vfs.vfs.zst", + "redis-vfs:wasm32:redis.vfs.zst", + "rootfs:wasm32:rootfs.vfs" + ] and + all($assets[]; + (.archive_sha256 | type) == "string" and + (.archive_sha256 | test("^[0-9a-f]{64}$")) and + (.sha256 | type) == "string" and + (.sha256 | test("^[0-9a-f]{64}$")) and + (.bytes | type) == "number" and .bytes > 0 and + (.archive_url | type) == "string" and + (.archive_url | + startswith("https://github.com/Automattic/kandelo/releases/"))) + then + $assets[] | + [.package, .arch, .output, .sha256, (.bytes | tostring)] | @tsv + else + error("invalid source projection compatibility set") + end + ' "$report" +} + +verify_transitional_pages_homebrew_shell() { + local root="${WASM_POSIX_TRANSITIONAL_PAGES_SHELL_ROOT:-}" + local report="$root/inspection.json" + local image="$root/shell.vfs.zst" + local bootstrap="$root/homebrew-bootstrap.zip" + local resolved browser_bootstrap actual_sha actual_bytes path + local gallery_rows projection_rows package arch output resolver_rel + local expected_sha expected_bytes output_rel + case "$root" in + /*) ;; + *) + err "Transitional Pages shell root must be an absolute path." + return 2 + ;; + esac + for path in "$report" "$image" "$bootstrap"; do + [ -f "$path" ] && [ ! -L "$path" ] || { + err "Transitional Pages shell input is not a regular file: $path" + return 1 + } + done + jq -e ' + .schema == 1 and + .kind == "kandelo-transitional-homebrew-pages-shell-inspection" and + .lifecycle == "transitional" and + .removal_condition == + "canonical-kandelo-prefix-shell-is-deployable" and + .exact_current_main == false and + .kernel_abi == 42 and + .guest_prefix == "/home/linuxbrew/.linuxbrew" and + .bottle_mirror.immutable == true + ' "$report" >/dev/null || { + err "Transitional Pages shell inspection report is invalid." + return 1 + } + gallery_rows="$(transitional_pages_gallery_rows "$report")" || { + err "Transitional Pages gallery compatibility report is invalid." + return 1 + } + projection_rows="$( + transitional_pages_source_projection_rows "$report" + )" || { + err "Transitional Pages source projection report is invalid." + return 1 + } + actual_sha="$(shasum -a 256 "$image" | awk '{print $1}')" + actual_bytes="$(wc -c <"$image" | tr -d '[:space:]')" + [ "$actual_sha" = "$(jq -er '.shell.sha256' "$report")" ] && + [ "$actual_bytes" = "$(jq -er '.shell.bytes' "$report")" ] || { + err "Transitional Pages shell changed after inspection." + return 1 + } + actual_sha="$(shasum -a 256 "$bootstrap" | awk '{print $1}')" + actual_bytes="$(wc -c <"$bootstrap" | tr -d '[:space:]')" + [ "$actual_sha" = "$(jq -er '.homebrew_bootstrap.sha256' "$report")" ] && + [ "$actual_bytes" = "$(jq -er '.homebrew_bootstrap.bytes' "$report")" ] || { + err "Transitional Pages Homebrew bootstrap changed after inspection." + return 1 + } + resolved="$(bash "$REPO_ROOT/scripts/resolve-binary.sh" \ + programs/shell.vfs.zst)" || { + err "Transitional Pages shell is not resolver-selected." + return 1 + } + [ -f "$resolved" ] && [ ! -L "$resolved" ] && + cmp -s "$image" "$resolved" || { + err "Resolver selected different shell bytes." + return 1 + } + while IFS=$'\t' read -r package output expected_sha expected_bytes; do + case "$package:$output" in + lamp:lamp.vfs.zst | \ + nginx-php-vfs:nginx-php.vfs.zst | \ + nginx-vfs:nginx.vfs.zst | \ + node-vfs:node-vfs.vfs.zst | \ + wordpress:wordpress.vfs.zst) ;; + *) + err "Transitional Pages gallery report has an unsafe path." + return 1 + ;; + esac + path="$root/gallery/$package/$output" + [ -f "$path" ] && [ ! -L "$path" ] || { + err "Transitional Pages gallery input is missing: $path" + return 1 + } + actual_sha="$(shasum -a 256 "$path" | awk '{print $1}')" + actual_bytes="$(wc -c <"$path" | tr -d '[:space:]')" + [ "$actual_sha" = "$expected_sha" ] && + [ "$actual_bytes" = "$expected_bytes" ] || { + err "Transitional Pages gallery input changed: $package" + return 1 + } + output_rel="$(pkg_output_rel "$package" "$output" wasm32)" || { + err "Could not derive resolver path for $package." + return 1 + } + resolved="$(bash "$REPO_ROOT/scripts/resolve-binary.sh" \ + "programs/$output_rel")" || { + err "Transitional Pages gallery package is not selected: $package" + return 1 + } + [ -f "$resolved" ] && [ ! -L "$resolved" ] && + cmp -s "$path" "$resolved" || { + err "Resolver selected different gallery bytes for $package." + return 1 + } + done <<<"$gallery_rows" + while IFS=$'\t' read -r \ + package arch output expected_sha expected_bytes; do + case "$package:$arch:$output" in + kandelo-sdk:wasm32:kandelo-sdk.vfs.zst | \ + mariadb-test:wasm32:mariadb-test.vfs.zst | \ + mariadb-vfs:wasm32:mariadb-vfs.vfs.zst | \ + mariadb-vfs:wasm64:mariadb-vfs.vfs.zst | \ + redis-vfs:wasm32:redis.vfs.zst | \ + rootfs:wasm32:rootfs.vfs) ;; + *) + err "Transitional source projection report has an unsafe path." + return 1 + ;; + esac + path="$root/source-projection/$arch/$package/$output" + [ -f "$path" ] && [ ! -L "$path" ] || { + err "Transitional source projection input is missing: $path" + return 1 + } + actual_sha="$(shasum -a 256 "$path" | awk '{print $1}')" + actual_bytes="$(wc -c <"$path" | tr -d '[:space:]')" + [ "$actual_sha" = "$expected_sha" ] && + [ "$actual_bytes" = "$expected_bytes" ] || { + err "Transitional source projection input changed: $package $arch" + return 1 + } + resolver_rel="$(pkg_resolver_rel "$package" "$output" "$arch")" || { + err "Could not derive resolver path for $package $arch." + return 1 + } + resolved="$(bash "$REPO_ROOT/scripts/resolve-binary.sh" \ + "$resolver_rel")" || { + err "Transitional source projection is not selected: $package $arch" + return 1 + } + [ -f "$resolved" ] && [ ! -L "$resolved" ] && + cmp -s "$path" "$resolved" || { + err "Resolver selected different bytes for $package $arch." + return 1 + } + done <<<"$projection_rows" + browser_bootstrap="$REPO_ROOT/apps/browser-demos/public/homebrew-bootstrap.zip" + [ -f "$browser_bootstrap" ] && [ ! -L "$browser_bootstrap" ] && + cmp -s "$bootstrap" "$browser_bootstrap" || { + err "Browser bootstrap differs from the inspected transitional bytes." + return 1 + } +} + +install_transitional_pages_homebrew_shell() { + [ "$TRANSITIONAL_PAGES_HOMEBREW_SHELL" -eq 1 ] || return 0 + local root="${WASM_POSIX_TRANSITIONAL_PAGES_SHELL_ROOT:-}" + local image="$root/shell.vfs.zst" + local bootstrap="$root/homebrew-bootstrap.zip" + local browser_bootstrap="$REPO_ROOT/apps/browser-demos/public/homebrew-bootstrap.zip" + local temporary="$browser_bootstrap.transitional-${BASHPID:-$$}" + local report="$root/inspection.json" + local gallery_rows projection_rows package arch output + local expected_sha expected_bytes source xtask install_session + [ -f "$image" ] && [ ! -L "$image" ] && + [ -f "$bootstrap" ] && [ ! -L "$bootstrap" ] || { + err "Transitional Pages shell was not prepared before browser setup." + return 1 + } + bash "$REPO_ROOT/scripts/install-local-shell-artifact.sh" \ + "$image" \ + "transitional-pages-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}" + bash "$REPO_ROOT/scripts/activate-local-shell-build-override.sh" "$image" + gallery_rows="$(transitional_pages_gallery_rows "$report")" || { + err "Transitional Pages gallery compatibility report is invalid." + return 1 + } + projection_rows="$( + transitional_pages_source_projection_rows "$report" + )" || { + err "Transitional Pages source projection report is invalid." + return 1 + } + xtask="$(pkg_xtask_bin)" || return 1 + while IFS=$'\t' read -r package output expected_sha expected_bytes; do + case "$package:$output" in + lamp:lamp.vfs.zst | \ + nginx-php-vfs:nginx-php.vfs.zst | \ + nginx-vfs:nginx.vfs.zst | \ + node-vfs:node-vfs.vfs.zst | \ + wordpress:wordpress.vfs.zst) ;; + *) + err "Transitional Pages gallery report has an unsafe path." + return 1 + ;; + esac + source="$root/gallery/$package/$output" + [ -f "$source" ] && [ ! -L "$source" ] || { + err "Transitional Pages gallery input is missing: $source" + return 1 + } + install_session="transitional-pages-${GITHUB_RUN_ID:-local}-" + install_session+="${GITHUB_RUN_ATTEMPT:-1}-$package" + WASM_POSIX_LOCAL_INSTALL_SOURCE="$source" \ + WASM_POSIX_LOCAL_INSTALL_SESSION="$install_session" \ + "$xtask" build-deps --arch wasm32 \ + --binaries-dir "$REPO_ROOT/local-binaries" \ + install-local-artifact "$package" "$output" + done <<<"$gallery_rows" + while IFS=$'\t' read -r \ + package arch output expected_sha expected_bytes; do + case "$package:$arch:$output" in + kandelo-sdk:wasm32:kandelo-sdk.vfs.zst | \ + mariadb-test:wasm32:mariadb-test.vfs.zst | \ + mariadb-vfs:wasm32:mariadb-vfs.vfs.zst | \ + mariadb-vfs:wasm64:mariadb-vfs.vfs.zst | \ + redis-vfs:wasm32:redis.vfs.zst | \ + rootfs:wasm32:rootfs.vfs) ;; + *) + err "Transitional source projection report has an unsafe path." + return 1 + ;; + esac + source="$root/source-projection/$arch/$package/$output" + [ -f "$source" ] && [ ! -L "$source" ] || { + err "Transitional source projection input is missing: $source" + return 1 + } + install_session="transitional-pages-${GITHUB_RUN_ID:-local}-" + install_session+="${GITHUB_RUN_ATTEMPT:-1}-$package-$arch" + WASM_POSIX_LOCAL_INSTALL_SOURCE="$source" \ + WASM_POSIX_LOCAL_INSTALL_SESSION="$install_session" \ + "$xtask" build-deps --arch "$arch" \ + --binaries-dir "$REPO_ROOT/local-binaries" \ + install-local-artifact "$package" "$output" + done <<<"$projection_rows" + if [ -e "$browser_bootstrap" ] || [ -L "$browser_bootstrap" ] || + [ -e "$temporary" ] || [ -L "$temporary" ]; then + err "Transitional Pages lane cannot replace an existing bootstrap path." + return 1 + fi + # WHY: VFS metadata deliberately names this same-origin URL. Publish only + # the already-inspected bytes, and never let the ordinary selector replace + # them with a sibling campaign result. + cp -- "$bootstrap" "$temporary" + cmp -s "$bootstrap" "$temporary" || { + err "Could not stage the exact transitional bootstrap bytes." + return 1 + } + mv -- "$temporary" "$browser_bootstrap" + verify_transitional_pages_homebrew_shell +} + build_all() { build_kernel build_sysroot @@ -2956,6 +3334,9 @@ cmd_prepare_browser() { # instead of source-building the canonical bottle recipe. install_source_rootfs_shell_vfs fi + if [ "$TRANSITIONAL_PAGES_HOMEBREW_SHELL" -eq 1 ]; then + install_transitional_pages_homebrew_shell + fi # Resolve the small lazy bootstrap first. A pending or unavailable # Formula selection should fail before the much larger gallery fetch and @@ -2980,6 +3361,11 @@ cmd_prepare_browser() { verify_source_rootfs_shell_runtime_browser_closure fi build_browser + if [ "$TRANSITIONAL_PAGES_HOMEBREW_SHELL" -eq 1 ]; then + # General gallery preparation may resolve many unrelated packages. + # Prove none of those operations changed the selected shell product. + verify_transitional_pages_homebrew_shell + fi if [ "$SOURCE_ROOTFS_SHELL" -eq 1 ]; then # WHY: browser preparation can resolve many transitive packages. Prove # none of those steps replaced the selected bridge before releasing @@ -3198,6 +3584,8 @@ cmd_list() { echo " pages-exact-main-v1 attestation, an" echo " empty file index, fresh cache, and" echo " unmaterialized package workspace." + echo " --transitional-pages-homebrew-shell INTERNAL: Pages-only exact rev22" + echo " Homebrew shell during prefix cutover." echo " --pr-staging Use the current PR's staging binary" echo " index unless WASM_POSIX_BINARY_INDEX_URL" echo " is already set." diff --git a/scripts/ci-check-pages-deployment.sh b/scripts/ci-check-pages-deployment.sh index 22293355a9..a058cbecee 100755 --- a/scripts/ci-check-pages-deployment.sh +++ b/scripts/ci-check-pages-deployment.sh @@ -122,25 +122,28 @@ projection_line="$(step_line "Verify browser package projection is current")" musl_line="$( step_line "Fetch musl for repository-owned browser support programs" )" -isolation_line="$(step_line "Isolate the canonical bottled browser product")" +isolation_line="$(step_line "Isolate browser package preparation")" +transition_line="$(step_line "Prepare the exact published transitional Homebrew shell")" prepare_browser_line="$(step_line "Prepare browser demo assets")" -shell_product_line="$(step_line "Bind the canonical bottled shell product")" +shell_product_line="$(step_line "Bind the exact transitional bottled shell product")" browser_build_line="$(step_line "Build browser demos for GitHub Pages")" guide_build_line="$(step_line "Build user guide for the complete Pages tree")" api_build_line="$(step_line "Build API docs for the complete Pages tree")" assembly_line="$(step_line "Add documentation to the complete Pages tree")" -sealed_boot_line="$(step_line "Boot the canonical bottled Pages shell in Chromium")" +sealed_boot_line="$(step_line "Boot the transitional bottled Pages shell in Chromium")" size_line="$(step_line "Enforce the GitHub Pages published-site size limit")" freshness_line="$(step_line "Confirm this is the newest Pages run")" deploy_line="$(step_line "Deploy to gh-pages")" [ -n "$musl_line" ] && [ -n "$projection_line" ] && [ -n "$isolation_line" ] && + [ -n "$transition_line" ] && [ -n "$prepare_browser_line" ] && [ -n "$shell_product_line" ] && [ "$musl_line" -lt "$prepare_browser_line" ] && [ "$projection_line" -lt "$prepare_browser_line" ] && [ "$projection_line" -lt "$isolation_line" ] && - [ "$isolation_line" -lt "$prepare_browser_line" ] && + [ "$isolation_line" -lt "$transition_line" ] && + [ "$transition_line" -lt "$prepare_browser_line" ] && [ "$prepare_browser_line" -lt "$shell_product_line" ] && [ -n "$browser_build_line" ] && [ "$shell_product_line" -lt "$browser_build_line" ] && @@ -176,7 +179,7 @@ grep -Fq 'build-deps program-index-check' <<<"$projection_block" && fail "the Pages publisher must verify the generated package projection before preparing assets" isolation_block="$( - step_block "$PAGES_WORKFLOW" "Isolate the canonical bottled browser product" + step_block "$PAGES_WORKFLOW" "Isolate browser package preparation" )" grep -Fq 'product_cache="$RUNNER_TEMP/pages-canonical-bottle-cache"' \ <<<"$isolation_block" && @@ -184,27 +187,44 @@ grep -Fq 'product_cache="$RUNNER_TEMP/pages-canonical-bottle-cache"' \ grep -Fq \ 'echo "WASM_POSIX_BINARY_CACHE_ROOT=$product_cache" >> "$GITHUB_ENV"' \ <<<"$isolation_block" || - fail "the Pages publisher must establish one fresh canonical package cache" + fail "the Pages publisher must establish one fresh gallery package cache" + +transition_block="$( + step_block \ + "$PAGES_WORKFLOW" \ + "Prepare the exact published transitional Homebrew shell" +)" +grep -Fq 'scripts/inspect-transitional-homebrew-pages-shell.test.ts' \ + <<<"$transition_block" && + grep -Fq 'scripts/prepare-transitional-homebrew-pages-shell.sh' \ + <<<"$transition_block" && + grep -Fq \ + 'WASM_POSIX_TRANSITIONAL_PAGES_SHELL_ROOT=$transition_root' \ + <<<"$transition_block" || + fail "Pages must fetch, inspect, and expose the exact transitional shell" prepare_browser_block="$( step_block "$PAGES_WORKFLOW" "Prepare browser demo assets" )" grep -Fq 'bash scripts/dev-shell.sh env \' <<<"$prepare_browser_block" && grep -Fq '"WASM_POSIX_BINARY_CACHE_ROOT=$WASM_POSIX_BINARY_CACHE_ROOT" \' \ + <<<"$prepare_browser_block" && + grep -Fq \ + '"WASM_POSIX_TRANSITIONAL_PAGES_SHELL_ROOT=$WASM_POSIX_TRANSITIONAL_PAGES_SHELL_ROOT" \' \ <<<"$prepare_browser_block" || - fail "browser preparation must retain the canonical cache inside dev-shell" + fail "browser preparation must retain exact transition inputs inside dev-shell" grep -Fxq ' ./run.sh --fetch-only \' \ <<<"$prepare_browser_block" && grep -Fq \ - ' --require-sealed-homebrew-selection prepare-browser' \ + ' --transitional-pages-homebrew-shell prepare-browser' \ <<<"$prepare_browser_block" || - fail "browser preparation must require sealed bottle inputs" + fail "browser preparation must preserve the exact transitional shell" prepare_browser_last="$( awk 'NF { line = $0 } END { print line }' <<<"$prepare_browser_block" )" [ "$prepare_browser_last" = \ - ' --require-sealed-homebrew-selection prepare-browser' ] || - fail "canonical browser preparation must be the final failure-propagating command" + ' --transitional-pages-homebrew-shell prepare-browser' ] || + fail "transitional browser preparation must be the final failure-propagating command" if grep -Fq -- '--source-rootfs-shell' "$PAGES_WORKFLOW" || grep -Fq 'WASM_POSIX_SOURCE_ROOTFS_SHELL_' "$PAGES_WORKFLOW" || grep -Fq -- '--allow-stale' "$PAGES_WORKFLOW"; then @@ -212,7 +232,9 @@ if grep -Fq -- '--source-rootfs-shell' "$PAGES_WORKFLOW" || fi shell_product_block="$( - step_block "$PAGES_WORKFLOW" "Bind the canonical bottled shell product" + step_block \ + "$PAGES_WORKFLOW" \ + "Bind the exact transitional bottled shell product" )" grep -Fq 'id: shell_product' <<<"$shell_product_block" && grep -Fq \ @@ -221,27 +243,28 @@ grep -Fq 'id: shell_product' <<<"$shell_product_block" && grep -Fq \ 'bootstrap="$PWD/apps/browser-demos/public/homebrew-bootstrap.zip"' \ <<<"$shell_product_block" && - grep -Fq 'scripts/verify-homebrew-main-shell-artifact-lock.sh' \ - <<<"$shell_product_block" && - grep -Fq 'scripts/inspect-homebrew-main-shell-public-product.ts' \ + grep -Fq \ + 'WASM_POSIX_TRANSITIONAL_PAGES_SHELL_ROOT/inspection.json' \ <<<"$shell_product_block" && - grep -Fq 'homebrew/main-shell-brew-package-tree.json' \ + grep -Fq \ + 'kandelo-transitional-homebrew-pages-shell-inspection' \ <<<"$shell_product_block" && - grep -Fq 'homebrew/main-shell-homebrew-runtime-support.json' \ + grep -Fq '.exact_current_main == false' <<<"$shell_product_block" && + grep -Fq '(.gallery_compatibility | map(.package)) == [' \ <<<"$shell_product_block" && grep -Fq 'mirror_plan_url=$(jq -er' <<<"$shell_product_block" || - fail "Pages must bind the canonical shell, bootstrap, and embedded mirror plan" + fail "Pages must bind the exact transitional shell, gallery, and bootstrap" +grep -Fq '(.source_projection_compatibility |' \ + <<<"$shell_product_block" && + grep -Fq '"mariadb-vfs:wasm64"' <<<"$shell_product_block" || + fail "Pages must bind the exact transitional source projection set" if grep -Fq 'programs/homebrew-bootstrap/' "$PAGES_WORKFLOW" || grep -Fq 'fetch-selection-release' <<<"$shell_product_block" || grep -Fq 'scripts/extract-homebrew-support-data-bottle.ts' \ <<<"$shell_product_block"; then fail "Pages must use the one prepared Formula-bottle bootstrap asset" fi -grep -Fq 'npx tsx --test \' <<<"$shell_product_block" && - grep -Fq 'scripts/inspect-homebrew-main-shell-public-product.test.ts' \ - <<<"$shell_product_block" || - fail "Pages must run the public-product inspector rejection tests" -if grep -Fq 'recover-homebrew-bottle-mirror' <<<"$shell_product_block"; then +if grep -Fq 'recover-homebrew-bottle-mirror' <<<"$transition_block$shell_product_block"; then fail "Pages inspection must not eagerly download the complete bottle mirror" fi @@ -287,9 +310,12 @@ expected_guide_build_commands=$' set -euo pipefail\n node --te fail "the Pages guide must run strict source checks, build, then output checks" sealed_boot_block="$( - step_block "$PAGES_WORKFLOW" "Boot the canonical bottled Pages shell in Chromium" + step_block \ + "$PAGES_WORKFLOW" \ + "Boot the transitional bottled Pages shell in Chromium" )" grep -Fq 'VITE_BASE: /kandelo/' <<<"$sealed_boot_block" && + grep -Fq '"VITE_BASE=$VITE_BASE" \' <<<"$sealed_boot_block" && grep -Fq 'KANDELO_BROWSER_DEMO_INPUTS: main' \ <<<"$sealed_boot_block" && grep -Fq 'KANDELO_HOMEBREW_MAIN_SHELL_STRICT: "1"' \ @@ -308,12 +334,24 @@ grep -Fq 'VITE_BASE: /kandelo/' <<<"$sealed_boot_block" && grep -Fq \ 'KANDELO_HOMEBREW_MAIN_SHELL_MIRROR_PLAN_URL: ${{ steps.shell_product.outputs.mirror_plan_url }}' \ <<<"$sealed_boot_block" && + grep -Fq \ + 'KANDELO_HOMEBREW_MAIN_SHELL_EXPECTED_PREFIX: /home/linuxbrew/.linuxbrew' \ + <<<"$sealed_boot_block" && grep -Fq 'KANDELO_PLAYWRIGHT_SERVE_DIST: "1"' <<<"$sealed_boot_block" && grep -Fq 'KANDELO_TEST_BASE_URL: http://127.0.0.1:5401/kandelo/' \ <<<"$sealed_boot_block" && grep -Fq 'bash ../../scripts/dev-shell.sh env \' <<<"$sealed_boot_block" && grep -Fq '"WASM_POSIX_BINARY_CACHE_ROOT=$WASM_POSIX_BINARY_CACHE_ROOT" \' \ <<<"$sealed_boot_block" && + grep -Fq \ + '"KANDELO_HOMEBREW_MAIN_SHELL_STRICT=$KANDELO_HOMEBREW_MAIN_SHELL_STRICT" \' \ + <<<"$sealed_boot_block" && + grep -Fq '"PLAYWRIGHT_JSON_OUTPUT_FILE=$report" \' \ + <<<"$sealed_boot_block" && + grep -Fq -- '--project=chromium --reporter=json' \ + <<<"$sealed_boot_block" && + grep -Fq '.stats.expected == 1' <<<"$sealed_boot_block" && + grep -Fq '.stats.skipped == 0' <<<"$sealed_boot_block" && grep -Fq 'test/kandelo-homebrew-main-shell.spec.ts' \ <<<"$sealed_boot_block" || fail "the Pages preview must prove the public bottled shell at the published base" diff --git a/scripts/inspect-transitional-homebrew-pages-shell.test.ts b/scripts/inspect-transitional-homebrew-pages-shell.test.ts new file mode 100644 index 0000000000..f627d0f625 --- /dev/null +++ b/scripts/inspect-transitional-homebrew-pages-shell.test.ts @@ -0,0 +1,165 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; + +import { + createTransitionalPagesShellFetchPlan, + inspectTransitionalPagesShell, +} from "./inspect-transitional-homebrew-pages-shell"; + +const lock = JSON.parse( + readFileSync( + new URL( + "../homebrew/transitional-pages-shell-rev22-lock.json", + import.meta.url, + ), + "utf8", + ), +) as Record; + +test("derives only the locked public release reads", () => { + const plan = createTransitionalPagesShellFetchPlan(lock); + const gallery = plan.gallery_compatibility as Array<{ + archive: { url: string }; + }>; + const sourceProjection = plan.source_projection_compatibility as Array<{ + archive: { url: string }; + }>; + assert.deepEqual( + [ + (plan.shell_archive as { url: string }).url, + (plan.bootstrap_archive as { url: string }).url, + ...gallery.map(({ archive }) => archive.url), + ...sourceProjection.map(({ archive }) => archive.url), + (plan.mirror_plan as { url: string }).url, + ], + [ + "https://github.com/Automattic/kandelo/releases/download/" + + "binaries-abi-v42/" + + "shell-0.1.0-rev22-abi42-wasm32-25d260da.tar.zst", + "https://github.com/Automattic/kandelo/releases/download/" + + "binaries-abi-v42/" + + "homebrew-bootstrap-6.0.3-4-g4ead861-rev3-abi42-wasm32-" + + "1ec4e97d.tar.zst", + "https://github.com/Automattic/kandelo/releases/download/" + + "binaries-abi-v42/" + + "lamp-0.1.0-rev11-abi42-wasm32-71448393.tar.zst", + "https://github.com/Automattic/kandelo/releases/download/" + + "binaries-abi-v42/" + + "nginx-php-vfs-0.1.0-rev2-abi42-wasm32-35a5f89c.tar.zst", + "https://github.com/Automattic/kandelo/releases/download/" + + "binaries-abi-v42/" + + "nginx-vfs-0.1.0-rev2-abi42-wasm32-a9f6fb18.tar.zst", + "https://github.com/Automattic/kandelo/releases/download/" + + "binaries-abi-v42/" + + "node-vfs-0.1.0-rev14-abi42-wasm32-80d64061.tar.zst", + "https://github.com/Automattic/kandelo/releases/download/" + + "binaries-abi-v42/" + + "wordpress-7.0-rev12-abi42-wasm32-ff4ab900.tar.zst", + "https://github.com/Automattic/kandelo/releases/download/" + + "binaries-abi-v42/" + + "kandelo-sdk-0.1.0-rev4-abi42-wasm32-5d2aa9c9.tar.zst", + "https://github.com/Automattic/kandelo/releases/download/" + + "binaries-abi-v42/" + + "mariadb-test-0.1.0-rev5-abi42-wasm32-a967a54c.tar.zst", + "https://github.com/Automattic/kandelo/releases/download/" + + "binaries-abi-v42/" + + "mariadb-vfs-0.1.0-rev6-abi42-wasm32-2eb50e31.tar.zst", + "https://github.com/Automattic/kandelo/releases/download/" + + "binaries-abi-v42/" + + "mariadb-vfs-0.1.0-rev6-abi42-wasm64-884db9a8.tar.zst", + "https://github.com/Automattic/kandelo/releases/download/" + + "binaries-abi-v42/" + + "redis-vfs-0.1.0-rev2-abi42-wasm32-f16e5aa9.tar.zst", + "https://github.com/Automattic/kandelo/releases/download/" + + "binaries-abi-v42/" + + "rootfs-0.1.0-rev9-abi42-wasm32-8712ba10.tar.zst", + "https://github.com/Kandelo-dev/homebrew-tap-core/releases/" + + "download/homebrew-shell-bottles-sha256-" + + "fd15162a8c9c06e6d7936af470cd16ba916528708356750751b55bac567a0ce2/" + + "kandelo-homebrew-bottle-mirror-plan.json", + ], + ); +}); + +test("rejects an expanded or redirected transition lock", () => { + const projectionAssets = ( + candidate: Record, + ): Array> => { + const projection = candidate.source_projection_compatibility as { + assets: Array>; + }; + return projection.assets; + }; + const projectionArchive = ( + candidate: Record, + index = 0, + ): Record => + projectionAssets(candidate)[index]!.archive as Record; + const cases: Array<(candidate: Record) => void> = [ + (candidate) => { + candidate.unreviewed = true; + }, + (candidate) => { + (candidate.package_release as Record).repository = + "someone/else"; + }, + (candidate) => { + (candidate.package_release as Record).authority = + "trust-the-tag"; + }, + (candidate) => { + candidate.guest_prefix = "/opt/kandelo/homebrew"; + }, + (candidate) => { + (candidate.bottle_mirror as Record).immutable = false; + }, + (candidate) => { + projectionAssets(candidate).pop(); + }, + (candidate) => { + projectionAssets(candidate)[0]!.package = "wrong-package"; + }, + (candidate) => { + projectionAssets(candidate)[0]!.arch = "wasm64"; + }, + (candidate) => { + projectionAssets(candidate)[0]!.output = "wrong-output.vfs.zst"; + }, + (candidate) => { + projectionArchive(candidate).member = "artifacts/wrong.vfs.zst"; + }, + (candidate) => { + projectionArchive(candidate).member_sha256 = "invalid"; + }, + (candidate) => { + projectionArchive(candidate).member_bytes = 0; + }, + ]; + for (const mutate of cases) { + const candidate = structuredClone(lock); + mutate(candidate); + assert.throws( + () => createTransitionalPagesShellFetchPlan(candidate), + /invalid|unsupported fields/, + ); + } +}); + +test("checks package identity before parsing candidate image bytes", () => { + assert.throws( + () => + inspectTransitionalPagesShell({ + lock, + shellArchive: new Uint8Array([1]), + image: new Uint8Array([2]), + bootstrapArchive: new Uint8Array([3]), + bootstrapZip: new Uint8Array([4]), + bootstrapEnvironment: new Uint8Array([5]), + mirrorPlan: new Uint8Array([6]), + galleryCompatibility: [], + sourceProjectionCompatibility: [], + }), + /shell package archive differs from its locked identity/, + ); +}); diff --git a/scripts/inspect-transitional-homebrew-pages-shell.ts b/scripts/inspect-transitional-homebrew-pages-shell.ts new file mode 100644 index 0000000000..8e7a90f736 --- /dev/null +++ b/scripts/inspect-transitional-homebrew-pages-shell.ts @@ -0,0 +1,927 @@ +#!/usr/bin/env -S npx tsx + +/** + * Inspect the exact historical Homebrew shell reused by the temporary Pages + * deployment lane. + * + * The image and bootstrap already exist. This tool never rebuilds or repairs + * them. It verifies their pinned bytes and the small set of properties needed + * for the current-host browser proof, then records what Pages will serve. + */ +import { createHash } from "node:crypto"; +import { + lstatSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { ABI_VERSION } from "../host/src/generated/abi"; +import { MemoryFileSystem } from "../host/src/vfs/memory-fs"; + +const SHA256_RE = /^[0-9a-f]{64}$/; +const GIT_SHA_RE = /^[0-9a-f]{40}$/; +const MAX_LOCK_BYTES = 1024 * 1024; +const MAX_IMAGE_BYTES = 64 * 1024 * 1024; +const MAX_PACKAGE_ARCHIVE_BYTES = 64 * 1024 * 1024; +const MAX_BOOTSTRAP_BYTES = 64 * 1024 * 1024; + +interface AssetIdentity { + name: string; + sha256: string; + bytes: number; +} + +interface GalleryCompatibilityAsset { + package: string; + output: string; + archive: AssetIdentity & { + member: string; + member_sha256: string; + member_bytes: number; + }; +} + +interface SourceProjectionCompatibilityAsset + extends GalleryCompatibilityAsset { + arch: "wasm32" | "wasm64"; +} + +interface TransitionalPagesShellLock { + schema: 1; + kind: "kandelo-transitional-homebrew-pages-shell-lock"; + lifecycle: "transitional"; + removal_condition: "canonical-kandelo-prefix-shell-is-deployable"; + kernel_abi: number; + guest_prefix: "/home/linuxbrew/.linuxbrew"; + runtime_support: { + activation_group: "homebrew-runtime-support"; + formula: "kandelo-dev/tap-core/ruby"; + cache_key_sha256: string; + }; + historical_build: { + kandelo_commit: string; + workflow_run: string; + }; + package_release: { + repository: "Automattic/kandelo"; + tag: "binaries-abi-v42"; + authority: "mutable-tag-digest-pinned-public-readback"; + shell_archive: AssetIdentity & { + member: "artifacts/shell.vfs.zst"; + member_sha256: string; + member_bytes: number; + }; + bootstrap_archive: AssetIdentity & { + members: { + "homebrew-bootstrap.zip": { + path: "artifacts/homebrew-bootstrap.zip"; + sha256: string; + bytes: number; + }; + "homebrew-brew.env": { + path: "artifacts/homebrew-brew.env"; + sha256: string; + bytes: number; + }; + }; + }; + }; + gallery_compatibility: { + reason: "reuse-currently-deployed-exact-assets-during-shell-cutover"; + verified_live_base_url: "https://automattic.github.io/kandelo/"; + assets: GalleryCompatibilityAsset[]; + }; + source_projection_compatibility: { + reason: "reuse-exact-output-after-consumer-only-host-input-change"; + assets: SourceProjectionCompatibilityAsset[]; + }; + bottle_mirror: { + repository: "Kandelo-dev/homebrew-tap-core"; + tag: string; + immutable: true; + asset_count: number; + plan: AssetIdentity; + }; +} + +export interface TransitionalPagesShellInspectionInputs { + lock: unknown; + shellArchive: Uint8Array; + image: Uint8Array; + bootstrapArchive: Uint8Array; + bootstrapZip: Uint8Array; + bootstrapEnvironment: Uint8Array; + mirrorPlan: Uint8Array; + galleryCompatibility: Array<{ + package: string; + archive: Uint8Array; + output: Uint8Array; + }>; + sourceProjectionCompatibility: Array<{ + package: string; + arch: "wasm32" | "wasm64"; + archive: Uint8Array; + output: Uint8Array; + }>; +} + +export function createTransitionalPagesShellFetchPlan( + value: unknown, +): Record { + const lock = parseLock(value); + const releaseRoot = + `https://github.com/${lock.package_release.repository}/releases/download/` + + `${lock.package_release.tag}`; + const mirrorRoot = + `https://github.com/${lock.bottle_mirror.repository}/releases/download/` + + `${lock.bottle_mirror.tag}`; + return { + schema: 1, + kind: "kandelo-transitional-homebrew-pages-shell-fetch-plan", + shell_archive: { + ...lock.package_release.shell_archive, + url: `${releaseRoot}/${lock.package_release.shell_archive.name}`, + }, + bootstrap_archive: { + ...lock.package_release.bootstrap_archive, + url: `${releaseRoot}/${lock.package_release.bootstrap_archive.name}`, + }, + gallery_compatibility: lock.gallery_compatibility.assets.map((asset) => ({ + ...asset, + archive: { + ...asset.archive, + url: `${releaseRoot}/${asset.archive.name}`, + }, + })), + source_projection_compatibility: + lock.source_projection_compatibility.assets.map((asset) => ({ + ...asset, + archive: { + ...asset.archive, + url: `${releaseRoot}/${asset.archive.name}`, + }, + })), + mirror_plan: { + ...lock.bottle_mirror.plan, + url: `${mirrorRoot}/${lock.bottle_mirror.plan.name}`, + }, + }; +} + +export function inspectTransitionalPagesShell( + inputs: TransitionalPagesShellInspectionInputs, +): Record { + const lock = parseLock(inputs.lock); + if (lock.kernel_abi !== ABI_VERSION) { + throw new Error( + `transitional shell ABI ${lock.kernel_abi} differs from source ABI ` + + `${ABI_VERSION}`, + ); + } + assertIdentity( + inputs.shellArchive, + lock.package_release.shell_archive, + "shell package archive", + ); + assertBytes( + inputs.image, + lock.package_release.shell_archive.member_sha256, + lock.package_release.shell_archive.member_bytes, + "shell image", + ); + assertIdentity( + inputs.bootstrapArchive, + lock.package_release.bootstrap_archive, + "bootstrap package archive", + ); + const bootstrapZip = + lock.package_release.bootstrap_archive.members["homebrew-bootstrap.zip"]; + const bootstrapEnvironment = + lock.package_release.bootstrap_archive.members["homebrew-brew.env"]; + assertBytes( + inputs.bootstrapZip, + bootstrapZip.sha256, + bootstrapZip.bytes, + "Homebrew bootstrap ZIP", + ); + assertBytes( + inputs.bootstrapEnvironment, + bootstrapEnvironment.sha256, + bootstrapEnvironment.bytes, + "Homebrew environment", + ); + assertIdentity(inputs.mirrorPlan, lock.bottle_mirror.plan, "mirror plan"); + assertGalleryCompatibility(inputs.galleryCompatibility, lock); + assertSourceProjectionCompatibility( + inputs.sourceProjectionCompatibility, + lock, + ); + assertImageContract(inputs.image, lock); + + const releaseRoot = + `https://github.com/${lock.package_release.repository}/releases/download/` + + `${lock.package_release.tag}`; + const mirrorRoot = + `https://github.com/${lock.bottle_mirror.repository}/releases/download/` + + `${lock.bottle_mirror.tag}`; + return { + schema: 1, + kind: "kandelo-transitional-homebrew-pages-shell-inspection", + lifecycle: lock.lifecycle, + removal_condition: lock.removal_condition, + exact_current_main: false, + kernel_abi: lock.kernel_abi, + guest_prefix: lock.guest_prefix, + shell: { + sha256: sha256(inputs.image), + bytes: inputs.image.byteLength, + package_url: + `${releaseRoot}/${lock.package_release.shell_archive.name}`, + package_sha256: sha256(inputs.shellArchive), + }, + homebrew_bootstrap: { + sha256: sha256(inputs.bootstrapZip), + bytes: inputs.bootstrapZip.byteLength, + package_url: + `${releaseRoot}/${lock.package_release.bootstrap_archive.name}`, + }, + gallery_compatibility: lock.gallery_compatibility.assets.map((asset) => ({ + package: asset.package, + output: asset.output, + archive_url: `${releaseRoot}/${asset.archive.name}`, + archive_sha256: asset.archive.sha256, + sha256: asset.archive.member_sha256, + bytes: asset.archive.member_bytes, + verified_live_base_url: + lock.gallery_compatibility.verified_live_base_url, + })), + source_projection_compatibility: + lock.source_projection_compatibility.assets.map((asset) => ({ + package: asset.package, + arch: asset.arch, + output: asset.output, + archive_url: `${releaseRoot}/${asset.archive.name}`, + archive_sha256: asset.archive.sha256, + sha256: asset.archive.member_sha256, + bytes: asset.archive.member_bytes, + })), + bottle_mirror: { + immutable: true, + plan_url: `${mirrorRoot}/${lock.bottle_mirror.plan.name}`, + plan_sha256: sha256(inputs.mirrorPlan), + asset_count: lock.bottle_mirror.asset_count, + }, + runtime_support: lock.runtime_support, + historical_build: lock.historical_build, + }; +} + +function parseLock(value: unknown): TransitionalPagesShellLock { + const root = exactRecord(value, [ + "schema", + "kind", + "lifecycle", + "removal_condition", + "kernel_abi", + "guest_prefix", + "runtime_support", + "historical_build", + "package_release", + "gallery_compatibility", + "source_projection_compatibility", + "bottle_mirror", + ], "transitional shell lock"); + const runtime = exactRecord(root.runtime_support, [ + "activation_group", + "formula", + "cache_key_sha256", + ], "runtime support"); + const historical = exactRecord(root.historical_build, [ + "kandelo_commit", + "workflow_run", + ], "historical build"); + const release = exactRecord(root.package_release, [ + "repository", + "tag", + "authority", + "shell_archive", + "bootstrap_archive", + ], "package release"); + const shell = exactRecord(release.shell_archive, [ + "name", + "sha256", + "bytes", + "member", + "member_sha256", + "member_bytes", + ], "shell archive"); + const bootstrap = exactRecord(release.bootstrap_archive, [ + "name", + "sha256", + "bytes", + "members", + ], "bootstrap archive"); + const members = exactRecord(bootstrap.members, [ + "homebrew-bootstrap.zip", + "homebrew-brew.env", + ], "bootstrap members"); + const bootstrapZip = memberRecord( + members["homebrew-bootstrap.zip"], + "bootstrap ZIP", + ); + const bootstrapEnvironment = memberRecord( + members["homebrew-brew.env"], + "bootstrap environment", + ); + const gallery = exactRecord(root.gallery_compatibility, [ + "reason", + "verified_live_base_url", + "assets", + ], "gallery compatibility"); + const galleryAssets = parseGalleryCompatibilityAssets(gallery.assets); + const sourceProjection = exactRecord( + root.source_projection_compatibility, + ["reason", "assets"], + "source projection compatibility", + ); + const sourceProjectionAssets = parseSourceProjectionCompatibilityAssets( + sourceProjection.assets, + ); + const mirror = exactRecord(root.bottle_mirror, [ + "repository", + "tag", + "immutable", + "asset_count", + "plan", + ], "bottle mirror"); + const plan = assetRecord(mirror.plan, "mirror plan"); + assetRecord(shell, "shell archive"); + assetRecord(bootstrap, "bootstrap archive"); + if ( + root.schema !== 1 || + root.kind !== "kandelo-transitional-homebrew-pages-shell-lock" || + root.lifecycle !== "transitional" || + root.removal_condition !== + "canonical-kandelo-prefix-shell-is-deployable" || + !isPositiveInteger(root.kernel_abi) || + root.guest_prefix !== "/home/linuxbrew/.linuxbrew" || + runtime.activation_group !== "homebrew-runtime-support" || + runtime.formula !== "kandelo-dev/tap-core/ruby" || + !isSha256(runtime.cache_key_sha256) || + typeof historical.kandelo_commit !== "string" || + !GIT_SHA_RE.test(historical.kandelo_commit) || + historical.workflow_run !== + "https://github.com/Automattic/kandelo/actions/runs/30532812285" || + release.repository !== "Automattic/kandelo" || + release.tag !== "binaries-abi-v42" || + release.authority !== "mutable-tag-digest-pinned-public-readback" || + shell.member !== "artifacts/shell.vfs.zst" || + !isSha256(shell.member_sha256) || + !isPositiveInteger(shell.member_bytes) || + bootstrapZip.path !== "artifacts/homebrew-bootstrap.zip" || + bootstrapEnvironment.path !== "artifacts/homebrew-brew.env" || + gallery.reason !== + "reuse-currently-deployed-exact-assets-during-shell-cutover" || + gallery.verified_live_base_url !== + "https://automattic.github.io/kandelo/" || + sourceProjection.reason !== + "reuse-exact-output-after-consumer-only-host-input-change" || + mirror.repository !== "Kandelo-dev/homebrew-tap-core" || + typeof mirror.tag !== "string" || + !/^homebrew-shell-bottles-sha256-[0-9a-f]{64}$/.test(mirror.tag) || + mirror.immutable !== true || + !isPositiveInteger(mirror.asset_count) || + plan.name !== "kandelo-homebrew-bottle-mirror-plan.json" + ) { + throw new Error("transitional shell lock is invalid"); + } + gallery.assets = galleryAssets; + sourceProjection.assets = sourceProjectionAssets; + return root as unknown as TransitionalPagesShellLock; +} + +function parseGalleryCompatibilityAssets( + value: unknown, +): GalleryCompatibilityAsset[] { + const expected = [ + ["lamp", "lamp.vfs.zst", "artifacts/lamp.vfs.zst"], + [ + "nginx-php-vfs", + "nginx-php.vfs.zst", + "artifacts/nginx-php.vfs.zst", + ], + ["nginx-vfs", "nginx.vfs.zst", "artifacts/nginx.vfs.zst"], + ["node-vfs", "node-vfs.vfs.zst", "artifacts/node-vfs.vfs.zst"], + ["wordpress", "wordpress.vfs.zst", "artifacts/wordpress.vfs.zst"], + ] as const; + if (!Array.isArray(value) || value.length !== expected.length) { + throw new Error("gallery compatibility asset set is invalid"); + } + return value.map((entry, index) => { + const record = exactRecord( + entry, + ["package", "output", "archive"], + "gallery compatibility asset", + ); + const archive = exactRecord(record.archive, [ + "name", + "sha256", + "bytes", + "member", + "member_sha256", + "member_bytes", + ], "gallery compatibility archive"); + assetRecord(archive, "gallery compatibility archive"); + const [packageName, output, member] = expected[index]!; + if ( + record.package !== packageName || record.output !== output || + archive.member !== member || !isSha256(archive.member_sha256) || + !isPositiveInteger(archive.member_bytes) + ) { + throw new Error("gallery compatibility asset is invalid"); + } + return { + package: packageName, + output, + archive: archive as unknown as GalleryCompatibilityAsset["archive"], + }; + }); +} + +function parseSourceProjectionCompatibilityAssets( + value: unknown, +): SourceProjectionCompatibilityAsset[] { + const expected = [ + ["kandelo-sdk", "wasm32", "kandelo-sdk.vfs.zst", + "artifacts/kandelo-sdk.vfs.zst"], + ["mariadb-test", "wasm32", "mariadb-test.vfs.zst", + "artifacts/mariadb-test.vfs.zst"], + ["mariadb-vfs", "wasm32", "mariadb-vfs.vfs.zst", + "artifacts/mariadb-vfs.vfs.zst"], + ["mariadb-vfs", "wasm64", "mariadb-vfs.vfs.zst", + "artifacts/mariadb-vfs.vfs.zst"], + ["redis-vfs", "wasm32", "redis.vfs.zst", + "artifacts/redis.vfs.zst"], + ["rootfs", "wasm32", "rootfs.vfs", "artifacts/rootfs.vfs"], + ] as const; + if (!Array.isArray(value) || value.length !== expected.length) { + throw new Error("source projection compatibility asset set is invalid"); + } + return value.map((entry, index) => { + const record = exactRecord( + entry, + ["package", "arch", "output", "archive"], + "source projection compatibility asset", + ); + const archive = exactRecord(record.archive, [ + "name", + "sha256", + "bytes", + "member", + "member_sha256", + "member_bytes", + ], "source projection compatibility archive"); + assetRecord(archive, "source projection compatibility archive"); + const [packageName, arch, output, member] = expected[index]!; + if ( + record.package !== packageName || record.arch !== arch || + record.output !== output || archive.member !== member || + !isSha256(archive.member_sha256) || + !isPositiveInteger(archive.member_bytes) + ) { + throw new Error("source projection compatibility asset is invalid"); + } + return { + package: packageName, + arch, + output, + archive: + archive as unknown as SourceProjectionCompatibilityAsset["archive"], + }; + }); +} + +function assertImageContract( + image: Uint8Array, + lock: TransitionalPagesShellLock, +): void { + const metadata = MemoryFileSystem.readImageMetadata(image); + const root = requireRecord(metadata, "shell image metadata"); + const bootstrap = requireRecord(root.homebrewBootstrap, "Homebrew bootstrap"); + const entrypoint = requireRecord(bootstrap.entrypoint, "brew entrypoint"); + const ownership = requireRecord(bootstrap.ownership, "brew ownership"); + const environment = requireRecord(bootstrap.environment, "brew environment"); + const homebrew = requireRecord(root.homebrew, "Homebrew composition"); + const defaultShell = requireRecord(homebrew.defaultShell, "default shell"); + const materialization = requireRecord( + homebrew.materialization, + "Homebrew materialization", + ); + const mirror = requireRecord(materialization.bottle_mirror, "bottle mirror"); + const runtime = requireRecord( + materialization.runtime_support, + "runtime support", + ); + const packages = Array.isArray(homebrew.packages) ? homebrew.packages : []; + const ruby = packages.filter((value) => { + const record = requireRecord(value, "Homebrew package"); + return record.fullName === lock.runtime_support.formula; + }); + const trees = Array.isArray(root.packageDeferredTrees) + ? root.packageDeferredTrees + : []; + if (trees.length !== 1) { + throw new Error("transitional image has an unexpected bootstrap tree set"); + } + const tree = requireRecord(trees[0], "bootstrap tree"); + const treeArchive = requireRecord(tree.archive, "bootstrap tree archive"); + const activation = requireRecord(tree.activation, "bootstrap activation"); + const atomicGroup = requireRecord(activation.atomicGroup, "activation group"); + if ( + root.kernelAbi !== lock.kernel_abi || + entrypoint.path !== "/usr/bin/brew" || + entrypoint.target !== `${lock.guest_prefix}/bin/brew` || + ownership.prefix !== lock.guest_prefix || + environment.path !== "/etc/homebrew/brew.env" || + environment.sha256 !== + lock.package_release.bootstrap_archive.members["homebrew-brew.env"] + .sha256 || + environment.bytes !== + lock.package_release.bootstrap_archive.members["homebrew-brew.env"] + .bytes || + defaultShell.path !== `${lock.guest_prefix}/bin/bash` || + tree.id !== "homebrew-bootstrap/source-tree" || + tree.state !== "deferred" || + treeArchive.url !== "homebrew-bootstrap.zip" || + treeArchive.sha256 !== + lock.package_release.bootstrap_archive.members["homebrew-bootstrap.zip"] + .sha256 || + treeArchive.bytes !== + lock.package_release.bootstrap_archive.members["homebrew-bootstrap.zip"] + .bytes || + activation.mode !== "first-use" || + atomicGroup.id !== lock.runtime_support.activation_group || + typeof mirror.repository !== "string" || + mirror.repository.toLowerCase() !== + lock.bottle_mirror.repository.toLowerCase() || + mirror.tag !== lock.bottle_mirror.tag || + mirror.asset_count !== lock.bottle_mirror.asset_count || + mirror.manifest_sha256 !== lock.bottle_mirror.plan.sha256 || + mirror.manifest_bytes !== lock.bottle_mirror.plan.bytes || + !Array.isArray(runtime.package_order) || + runtime.package_order.length !== 1 || + runtime.package_order[0] !== lock.runtime_support.formula || + ruby.length !== 1 || + requireRecord(ruby[0], "Ruby package").cacheKeySha !== + lock.runtime_support.cache_key_sha256 + ) { + throw new Error("transitional shell image differs from its locked contract"); + } +} + +function assertGalleryCompatibility( + inputs: TransitionalPagesShellInspectionInputs["galleryCompatibility"], + lock: TransitionalPagesShellLock, +): void { + if (inputs.length !== lock.gallery_compatibility.assets.length) { + throw new Error("gallery compatibility input set is incomplete"); + } + for (let index = 0; index < inputs.length; index += 1) { + const input = inputs[index]!; + const expected = lock.gallery_compatibility.assets[index]!; + if (input.package !== expected.package) { + throw new Error("gallery compatibility input order is invalid"); + } + assertIdentity( + input.archive, + expected.archive, + `${expected.package} compatibility archive`, + ); + assertBytes( + input.output, + expected.archive.member_sha256, + expected.archive.member_bytes, + `${expected.package} compatibility output`, + ); + } +} + +function assertSourceProjectionCompatibility( + inputs: + TransitionalPagesShellInspectionInputs["sourceProjectionCompatibility"], + lock: TransitionalPagesShellLock, +): void { + const expected = lock.source_projection_compatibility.assets; + if (inputs.length !== expected.length) { + throw new Error("source projection compatibility input set is incomplete"); + } + for (let index = 0; index < inputs.length; index += 1) { + const input = inputs[index]!; + const asset = expected[index]!; + if (input.package !== asset.package || input.arch !== asset.arch) { + throw new Error("source projection compatibility input order is invalid"); + } + assertIdentity( + input.archive, + asset.archive, + `${asset.package} ${asset.arch} compatibility archive`, + ); + assertBytes( + input.output, + asset.archive.member_sha256, + asset.archive.member_bytes, + `${asset.package} ${asset.arch} compatibility output`, + ); + } +} + +function assetRecord(value: unknown, label: string): AssetIdentity { + const record = requireRecord(value, label); + if ( + typeof record.name !== "string" || + !/^[A-Za-z0-9][A-Za-z0-9._+-]*$/.test(record.name) || + !isSha256(record.sha256) || + !isPositiveInteger(record.bytes) + ) { + throw new Error(`${label} identity is invalid`); + } + return record as unknown as AssetIdentity; +} + +function memberRecord(value: unknown, label: string): { + path: string; + sha256: string; + bytes: number; +} { + const record = exactRecord(value, ["path", "sha256", "bytes"], label); + if ( + typeof record.path !== "string" || + !isSha256(record.sha256) || + !isPositiveInteger(record.bytes) + ) { + throw new Error(`${label} is invalid`); + } + return record as unknown as { + path: string; + sha256: string; + bytes: number; + }; +} + +function assertIdentity( + bytes: Uint8Array, + identity: AssetIdentity, + label: string, +): void { + assertBytes(bytes, identity.sha256, identity.bytes, label); +} + +function assertBytes( + bytes: Uint8Array, + expectedSha256: string, + expectedBytes: number, + label: string, +): void { + if ( + bytes.byteLength !== expectedBytes || + sha256(bytes) !== expectedSha256 + ) { + throw new Error(`${label} differs from its locked identity`); + } +} + +function exactRecord( + value: unknown, + keys: readonly string[], + label: string, +): Record { + const record = requireRecord(value, label); + if (Object.keys(record).sort().join("\0") !== [...keys].sort().join("\0")) { + throw new Error(`${label} has unsupported fields`); + } + return record; +} + +function requireRecord(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + return value as Record; +} + +function isSha256(value: unknown): value is string { + return typeof value === "string" && SHA256_RE.test(value); +} + +function isPositiveInteger(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0; +} + +function sha256(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +function readRegularFile( + path: string, + label: string, + maxBytes: number, +): Uint8Array { + const stat = lstatSync(path); + if ( + !stat.isFile() || stat.isSymbolicLink() || stat.size < 1 || + stat.size > maxBytes + ) { + throw new Error(`${label} must be one bounded regular file`); + } + return new Uint8Array(readFileSync(path)); +} + +function readCanonicalLock(path: string): unknown { + const bytes = readRegularFile(path, "transition lock", MAX_LOCK_BYTES); + const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + const value = JSON.parse(text) as unknown; + if (`${JSON.stringify(value, null, 2)}\n` !== text) { + // WHY: canonical byte equality rejects duplicate JSON keys and makes the + // reviewed lock the only authority, rather than JSON parser behavior. + throw new Error("transition lock is not canonical JSON"); + } + return value; +} + +interface CliOptions { + lock: string; + shellArchive: string; + image: string; + bootstrapArchive: string; + bootstrapZip: string; + bootstrapEnvironment: string; + mirrorPlan: string; + galleryRoot: string; + report: string; +} + +function parseArgs(args: string[]): CliOptions { + const allowed = new Set([ + "--lock", + "--shell-archive", + "--image", + "--bootstrap-archive", + "--bootstrap-zip", + "--bootstrap-env", + "--mirror-plan", + "--gallery-root", + "--report", + ]); + const values = new Map(); + for (let index = 0; index < args.length; index += 2) { + const key = args[index]; + const value = args[index + 1]; + if ( + key === undefined || value === undefined || !allowed.has(key) || + values.has(key) + ) { + return usage(); + } + values.set(key, value); + } + const required = (key: string): string => { + const value = values.get(key); + if (value === undefined || value.length === 0) return usage(); + return resolve(value); + }; + return { + lock: required("--lock"), + shellArchive: required("--shell-archive"), + image: required("--image"), + bootstrapArchive: required("--bootstrap-archive"), + bootstrapZip: required("--bootstrap-zip"), + bootstrapEnvironment: required("--bootstrap-env"), + mirrorPlan: required("--mirror-plan"), + galleryRoot: required("--gallery-root"), + report: required("--report"), + }; +} + +function usage(): never { + throw new Error( + "usage: inspect-transitional-homebrew-pages-shell.ts " + + "--lock --shell-archive " + + "--image " + + "--bootstrap-archive " + + "--bootstrap-zip " + + "--bootstrap-env " + + "--mirror-plan --gallery-root " + + "--report ", + ); +} + +async function main(): Promise { + const args = process.argv.slice(2); + if ( + args.length === 4 && args[0] === "--lock" && + args[1] !== undefined && args[1].length > 0 && + args[2] === "--fetch-plan" && + args[3] !== undefined && args[3].length > 0 + ) { + const plan = createTransitionalPagesShellFetchPlan( + readCanonicalLock(resolve(args[1])), + ); + writeFileSync( + resolve(args[3]), + `${JSON.stringify(plan, null, 2)}\n`, + { flag: "wx", mode: 0o644 }, + ); + return; + } + const options = parseArgs(args); + const lock = readCanonicalLock(options.lock); + const parsedLock = parseLock(lock); + const report = inspectTransitionalPagesShell({ + lock, + shellArchive: readRegularFile( + options.shellArchive, + "shell package archive", + MAX_PACKAGE_ARCHIVE_BYTES, + ), + image: readRegularFile(options.image, "shell image", MAX_IMAGE_BYTES), + bootstrapArchive: readRegularFile( + options.bootstrapArchive, + "bootstrap package archive", + MAX_PACKAGE_ARCHIVE_BYTES, + ), + bootstrapZip: readRegularFile( + options.bootstrapZip, + "bootstrap ZIP", + MAX_BOOTSTRAP_BYTES, + ), + bootstrapEnvironment: readRegularFile( + options.bootstrapEnvironment, + "bootstrap environment", + 1024, + ), + mirrorPlan: readRegularFile(options.mirrorPlan, "mirror plan", 1024 * 1024), + galleryCompatibility: parsedLock.gallery_compatibility.assets.map( + (asset) => ({ + package: asset.package, + archive: readRegularFile( + join( + options.galleryRoot, + "sources", + "gallery", + `${asset.package}.tar.zst`, + ), + `${asset.package} compatibility archive`, + MAX_PACKAGE_ARCHIVE_BYTES, + ), + output: readRegularFile( + join( + options.galleryRoot, + "gallery", + asset.package, + asset.output, + ), + `${asset.package} compatibility output`, + MAX_IMAGE_BYTES, + ), + }), + ), + sourceProjectionCompatibility: + parsedLock.source_projection_compatibility.assets.map((asset) => ({ + package: asset.package, + arch: asset.arch, + archive: readRegularFile( + join( + options.galleryRoot, + "sources", + "source-projection", + `${asset.package}-${asset.arch}.tar.zst`, + ), + `${asset.package} ${asset.arch} compatibility archive`, + MAX_PACKAGE_ARCHIVE_BYTES, + ), + output: readRegularFile( + join( + options.galleryRoot, + "source-projection", + asset.arch, + asset.package, + asset.output, + ), + `${asset.package} ${asset.arch} compatibility output`, + MAX_IMAGE_BYTES, + ), + })), + }); + writeFileSync(options.report, `${JSON.stringify(report, null, 2)}\n`, { + flag: "wx", + mode: 0o644, + }); +} + +if ( + process.argv[1] !== undefined && + import.meta.url === pathToFileURL(resolve(process.argv[1])).href +) { + await main(); +} diff --git a/scripts/prepare-transitional-homebrew-pages-shell.sh b/scripts/prepare-transitional-homebrew-pages-shell.sh new file mode 100755 index 0000000000..d8ab40507c --- /dev/null +++ b/scripts/prepare-transitional-homebrew-pages-shell.sh @@ -0,0 +1,218 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +LOCK="$REPO_ROOT/homebrew/transitional-pages-shell-rev22-lock.json" + +usage() { + echo "usage: $0 --out " >&2 + exit 2 +} + +out="" +while [ "$#" -gt 0 ]; do + case "$1" in + --out) + [ "$#" -ge 2 ] && [ -z "$out" ] || usage + out="$2" + shift 2 + ;; + *) usage ;; + esac +done +[ -n "$out" ] || usage + +out_parent="$(dirname "$out")" +out_name="$(basename "$out")" +[ -d "$out_parent" ] && [ ! -L "$out_parent" ] || { + echo "prepare-transitional-pages-shell: output parent must be a real directory" >&2 + exit 1 +} +out_parent="$(cd "$out_parent" && pwd -P)" +case "$out_name" in + ""|.|..|*/*) usage ;; +esac +out="$out_parent/$out_name" +[ ! -e "$out" ] && [ ! -L "$out" ] || { + echo "prepare-transitional-pages-shell: output already exists: $out" >&2 + exit 1 +} + +work_root="$(mktemp -d "$out_parent/.kandelo-pages-shell.XXXXXX")" +cleanup() { + if [ -n "${work_root:-}" ] && [ -d "$work_root" ]; then + rm -rf -- "$work_root" + fi +} +trap cleanup EXIT INT TERM +mkdir "$work_root/sources" +mkdir "$work_root/sources/gallery" +mkdir "$work_root/sources/source-projection" +mkdir "$work_root/gallery" +mkdir "$work_root/source-projection" +mkdir "$work_root/source-projection/wasm32" +mkdir "$work_root/source-projection/wasm64" + +fetch_plan="$work_root/fetch-plan.json" +"$REPO_ROOT/node_modules/.bin/tsx" \ + "$REPO_ROOT/scripts/inspect-transitional-homebrew-pages-shell.ts" \ + --lock "$LOCK" \ + --fetch-plan "$fetch_plan" + +download_public_asset() { + local url="$1" + local destination="$2" + # WHY: the deployment claim is public availability. A runner token could + # hide a private or permission-dependent asset, so these reads deliberately + # carry no GitHub or GitHub Packages credentials. + env \ + -u GH_TOKEN \ + -u GITHUB_TOKEN \ + -u HOMEBREW_GITHUB_PACKAGES_TOKEN \ + curl --disable --fail --location --silent --show-error \ + --retry 3 --retry-all-errors \ + --proto '=https' --tlsv1.2 \ + --output "$destination" "$url" +} + +verify_download() { + local path="$1" + local expected_sha256="$2" + local expected_bytes="$3" + local actual_sha256 actual_bytes + actual_sha256="$(shasum -a 256 "$path" | awk '{print $1}')" + actual_bytes="$(wc -c <"$path" | tr -d '[:space:]')" + if [ "$actual_sha256" != "$expected_sha256" ] || + [ "$actual_bytes" != "$expected_bytes" ]; then + echo "prepare-transitional-pages-shell: downloaded bytes differ from lock" >&2 + return 1 + fi +} + +fetch_one() { + local selector="$1" + local destination="$2" + local url expected_sha256 expected_bytes + url="$(jq -er "$selector.url" "$fetch_plan")" + expected_sha256="$(jq -er "$selector.sha256" "$fetch_plan")" + expected_bytes="$(jq -er "$selector.bytes" "$fetch_plan")" + download_public_asset "$url" "$destination" + verify_download "$destination" "$expected_sha256" "$expected_bytes" +} + +shell_archive="$work_root/sources/shell-package.tar.zst" +bootstrap_archive="$work_root/sources/homebrew-bootstrap-package.tar.zst" +mirror_plan="$work_root/sources/kandelo-homebrew-bottle-mirror-plan.json" +fetch_one '.shell_archive' "$shell_archive" +fetch_one '.bootstrap_archive' "$bootstrap_archive" +fetch_one '.mirror_plan' "$mirror_plan" + +host_target="$(rustc -vV | sed -n 's/^host: //p')" +xtask="${WASM_POSIX_XTASK_BIN:-$REPO_ROOT/target/$host_target/debug/xtask}" +if [ ! -x "$xtask" ]; then + cargo build --quiet -p xtask --target "$host_target" +fi +[ -x "$xtask" ] || { + echo "prepare-transitional-pages-shell: xtask was not built" >&2 + exit 1 +} + +image="$work_root/shell.vfs.zst" +bootstrap_zip="$work_root/homebrew-bootstrap.zip" +bootstrap_env="$work_root/homebrew-brew.env" +"$xtask" archive-extract-member \ + --archive "$shell_archive" \ + --member "$(jq -er '.shell_archive.member' "$fetch_plan")" \ + --out "$image" +"$xtask" archive-extract-member \ + --archive "$bootstrap_archive" \ + --member "$(jq -er \ + '.bootstrap_archive.members["homebrew-bootstrap.zip"].path' \ + "$fetch_plan")" \ + --out "$bootstrap_zip" +"$xtask" archive-extract-member \ + --archive "$bootstrap_archive" \ + --member "$(jq -er \ + '.bootstrap_archive.members["homebrew-brew.env"].path' \ + "$fetch_plan")" \ + --out "$bootstrap_env" + +# WHY: five conventional gallery recipes changed after the currently deployed +# Pages assets were published. Their locked archive members are byte-for-byte +# identical to those live assets, so reusing those exact members preserves the +# non-shell demos without making an unrelated rebuild block the shell cutover. +while IFS=$'\t' read -r package output url expected_sha expected_bytes member; do + case "$package:$output:$member" in + lamp:lamp.vfs.zst:artifacts/lamp.vfs.zst | \ + nginx-php-vfs:nginx-php.vfs.zst:artifacts/nginx-php.vfs.zst | \ + nginx-vfs:nginx.vfs.zst:artifacts/nginx.vfs.zst | \ + node-vfs:node-vfs.vfs.zst:artifacts/node-vfs.vfs.zst | \ + wordpress:wordpress.vfs.zst:artifacts/wordpress.vfs.zst) ;; + *) + echo "prepare-transitional-pages-shell: unsafe gallery asset" >&2 + exit 1 + ;; + esac + archive="$work_root/sources/gallery/$package.tar.zst" + output_dir="$work_root/gallery/$package" + mkdir "$output_dir" + download_public_asset "$url" "$archive" + verify_download "$archive" "$expected_sha" "$expected_bytes" + "$xtask" archive-extract-member \ + --archive "$archive" \ + --member "$member" \ + --out "$output_dir/$output" +done < <( + jq -er '.gallery_compatibility[] | + [.package, .output, .archive.url, .archive.sha256, + (.archive.bytes | tostring), .archive.member] | @tsv' "$fetch_plan" +) + +# WHY: the descriptor-prefix runtime correction changes files listed as broad +# inputs by six conventional package variants, although it cannot change these +# already-built outputs. Bind the old output bytes to the current local source +# identity explicitly instead of rebuilding them or accepting a stale archive. +while IFS=$'\t' read -r package arch output url expected_sha expected_bytes member; do + case "$package:$arch:$output:$member" in + kandelo-sdk:wasm32:kandelo-sdk.vfs.zst:artifacts/kandelo-sdk.vfs.zst | \ + mariadb-test:wasm32:mariadb-test.vfs.zst:artifacts/mariadb-test.vfs.zst | \ + mariadb-vfs:wasm32:mariadb-vfs.vfs.zst:artifacts/mariadb-vfs.vfs.zst | \ + mariadb-vfs:wasm64:mariadb-vfs.vfs.zst:artifacts/mariadb-vfs.vfs.zst | \ + redis-vfs:wasm32:redis.vfs.zst:artifacts/redis.vfs.zst | \ + rootfs:wasm32:rootfs.vfs:artifacts/rootfs.vfs) ;; + *) + echo "prepare-transitional-pages-shell: unsafe projection asset" >&2 + exit 1 + ;; + esac + archive="$work_root/sources/source-projection/$package-$arch.tar.zst" + output_dir="$work_root/source-projection/$arch/$package" + mkdir "$output_dir" + download_public_asset "$url" "$archive" + verify_download "$archive" "$expected_sha" "$expected_bytes" + "$xtask" archive-extract-member \ + --archive "$archive" \ + --member "$member" \ + --out "$output_dir/$output" +done < <( + jq -er '.source_projection_compatibility[] | + [.package, .arch, .output, .archive.url, .archive.sha256, + (.archive.bytes | tostring), .archive.member] | @tsv' "$fetch_plan" +) + +"$REPO_ROOT/node_modules/.bin/tsx" \ + "$REPO_ROOT/scripts/inspect-transitional-homebrew-pages-shell.ts" \ + --lock "$LOCK" \ + --shell-archive "$shell_archive" \ + --image "$image" \ + --bootstrap-archive "$bootstrap_archive" \ + --bootstrap-zip "$bootstrap_zip" \ + --bootstrap-env "$bootstrap_env" \ + --mirror-plan "$mirror_plan" \ + --gallery-root "$work_root" \ + --report "$work_root/inspection.json" + +mv "$work_root" "$out" +work_root="" +trap - EXIT INT TERM +echo "Prepared exact transitional Homebrew shell in $out" diff --git a/scripts/test-homebrew-main-shell-closure.sh b/scripts/test-homebrew-main-shell-closure.sh index dcc8102294..949d44df96 100755 --- a/scripts/test-homebrew-main-shell-closure.sh +++ b/scripts/test-homebrew-main-shell-closure.sh @@ -2644,6 +2644,7 @@ source_authority_probe="$TMP_ROOT/browser-source-authority.sh" printf '[ "${TEST_FETCH_ONLY:-1}" = 0 ] || FETCH_ONLY_ARGS=(--fetch-only)\n' printf 'SOURCE_ROOTFS_SHELL="${TEST_SOURCE_ROOTFS:-0}"\n' printf 'REQUIRE_SEALED_HOMEBREW_SELECTION="${TEST_REQUIRE_SEALED:-0}"\n' + printf 'TRANSITIONAL_PAGES_HOMEBREW_SHELL="${TEST_TRANSITIONAL:-0}"\n' printf 'USE_PR_STAGING="${TEST_PR_STAGING:-0}"\n' printf 'COMMAND_ARGS=(prepare-browser)\n' printf 'case "${TEST_COMMAND_SHAPE:-exact}" in\n' @@ -2667,6 +2668,9 @@ expect_failure "requires isolated CI preparation" \ expect_failure "requires isolated CI preparation" \ env TEST_AUTHORITY=source-rootfs-mirror-state-v1 TEST_REQUIRE_SEALED=1 \ bash "$source_authority_probe" +expect_failure "requires isolated CI preparation" \ + env TEST_AUTHORITY=source-rootfs-mirror-state-v1 TEST_TRANSITIONAL=1 \ + bash "$source_authority_probe" expect_failure "requires isolated CI preparation" \ env TEST_AUTHORITY=source-rootfs-mirror-state-v1 TEST_SOURCE_ROOTFS=1 \ bash "$source_authority_probe" @@ -2700,6 +2704,7 @@ bootstrap_probe="$bootstrap_probe_root/run.sh" printf 'REPO_ROOT=%q\n' "$bootstrap_probe_root" printf 'CI_BROWSER_SOURCE_AUTHORITY="${TEST_AUTHORITY:-}"\n' printf 'REQUIRE_SEALED_HOMEBREW_SELECTION="${TEST_REQUIRE_SEALED:-0}"\n' + printf 'TRANSITIONAL_PAGES_HOMEBREW_SHELL=0\n' printf 'step() { :; }\n' cat "$browser_bootstrap_function" printf 'prepare_browser_homebrew_bootstrap\n' @@ -2749,6 +2754,18 @@ local_output_function="$TMP_ROOT/pkg-local-output-path-function.sh" sed -n '/^pkg_local_output_path()/,/^}/p' "$RUN_SH" >"$local_output_function" grep -Fq 'rel=$(pkg_output_rel "$pkg" "$wasm" "$arch")' "$local_output_function" || fail "local package cleanup must derive output layout from package metadata" +resolver_rel_probe="$TMP_ROOT/pkg-resolver-rel-probe.sh" +{ + printf 'set -euo pipefail\n' + printf 'pkg_output_rel() { printf "artifact.vfs.zst\\n"; }\n' + sed -n '/^pkg_resolver_rel()/,/^}/p' "$RUN_SH" + printf '[ "$(pkg_resolver_rel demo artifact.vfs.zst wasm32)" = ' + printf '"programs/artifact.vfs.zst" ]\n' + printf '[ "$(pkg_resolver_rel demo artifact.vfs.zst wasm64)" = ' + printf '"programs/wasm64/artifact.vfs.zst" ]\n' +} >"$resolver_rel_probe" +bash "$resolver_rel_probe" || + fail "resolver paths must keep non-default architectures explicit" clean_target_function="$TMP_ROOT/clean-target-function.sh" sed -n '/^clean_target()/,/^}/p' "$RUN_SH" >"$clean_target_function" shell_clean_case="$TMP_ROOT/clean-shell-vfs-case.sh" diff --git a/scripts/test-pages-deployment-contract.sh b/scripts/test-pages-deployment-contract.sh index 9bea1b16c2..0ae9d1e074 100755 --- a/scripts/test-pages-deployment-contract.sh +++ b/scripts/test-pages-deployment-contract.sh @@ -127,34 +127,49 @@ expect_mutation_rejected \ 's/submodules: libc\/musl/submodules: libc\/missing/' expect_mutation_rejected \ - "missing canonical package cache root" \ - "must establish one fresh canonical package cache" \ + "missing gallery package cache root" \ + "must establish one fresh gallery package cache" \ 's/^ echo "WASM_POSIX_BINARY_CACHE_ROOT=\$product_cache" >> "\$GITHUB_ENV"\n//m' expect_mutation_rejected \ "cache root lost inside dev-shell" \ - "browser preparation must retain the canonical cache inside dev-shell" \ + "browser preparation must retain exact transition inputs inside dev-shell" \ 's/^ "WASM_POSIX_BINARY_CACHE_ROOT=\$WASM_POSIX_BINARY_CACHE_ROOT" \\\n//m' +expect_mutation_rejected \ + "Pages base path lost inside Chromium dev-shell" \ + "must prove the public bottled shell at the published base" \ + 's/^ "VITE_BASE=\$VITE_BASE" \\\n//m' + expect_mutation_rejected \ "source-fallback browser preparation" \ "canonical Pages product must not activate the source bridge" \ 's#( \./run\.sh --fetch-only \\\n)#$1 --allow-stale \\\n#' expect_mutation_rejected \ - "swallowed canonical preparation failure" \ + "swallowed transitional preparation failure" \ "must be the final failure-propagating command" \ - 's#(--require-sealed-homebrew-selection prepare-browser)#$1 || true#' + 's#(--transitional-pages-homebrew-shell prepare-browser)#$1 || true#' expect_mutation_rejected \ - "work after canonical preparation command" \ + "work after transitional preparation command" \ "must be the final failure-propagating command" \ - 's#(--require-sealed-homebrew-selection prepare-browser\n)#$1 echo continued\n#' + 's#(--transitional-pages-homebrew-shell prepare-browser\n)#$1 echo continued\n#' + +expect_mutation_rejected \ + "missing transitional shell report binding" \ + "must bind the exact transitional shell, gallery, and bootstrap" \ + 's#WASM_POSIX_TRANSITIONAL_PAGES_SHELL_ROOT/inspection\.json#WASM_POSIX_TRANSITIONAL_PAGES_SHELL_ROOT/skipped.json#' + +expect_mutation_rejected \ + "missing transitional gallery binding" \ + "must bind the exact transitional shell, gallery, and bootstrap" \ + 's/\(\.gallery_compatibility \| map\(\.package\)\) == \[/\(.unbound_gallery | map(.package)\) == [/g' expect_mutation_rejected \ - "missing sealed shell artifact check" \ - "must bind the canonical shell, bootstrap, and embedded mirror plan" \ - 's/scripts\/verify-homebrew-main-shell-artifact-lock\.sh/scripts\/skipped-artifact-lock.sh/' + "missing transitional source projection binding" \ + "must bind the exact transitional source projection set" \ + 's/\(\.source_projection_compatibility \|/\(.unbound_source_projection |/' expect_mutation_rejected \ "shell-only Pages build" \ @@ -162,19 +177,19 @@ expect_mutation_rejected \ 's/( - name: Build browser demos for GitHub Pages\n working-directory: apps\/browser-demos\n)/$1 env:\n KANDELO_BROWSER_DEMO_INPUTS: main\n/' expect_mutation_rejected \ - "missing public product inspector" \ - "must bind the canonical shell, bootstrap, and embedded mirror plan" \ - 's/scripts\/inspect-homebrew-main-shell-public-product\.ts/scripts\/skipped-public-product.ts/' + "missing transitional product inspector" \ + "must fetch, inspect, and expose the exact transitional shell" \ + 's/scripts\/prepare-transitional-homebrew-pages-shell\.sh/scripts\/skipped-transitional-shell.sh/' expect_mutation_rejected \ - "missing public product inspector rejection tests" \ - "must run the public-product inspector rejection tests" \ - 's/scripts\/inspect-homebrew-main-shell-public-product\.test\.ts/scripts\/skipped-public-product.test.ts/' + "missing transitional product inspector rejection tests" \ + "must fetch, inspect, and expose the exact transitional shell" \ + 's/scripts\/inspect-transitional-homebrew-pages-shell\.test\.ts/scripts\/skipped-transitional-shell.test.ts/' expect_mutation_rejected \ "eager mirror recovery during inspection" \ "must not eagerly download the complete bottle mirror" \ - 's#( test ! -e "\$report"\n)#$1 npx tsx scripts/recover-homebrew-bottle-mirror.ts\n#' + 's#( test ! -e "\$transition_root"\n)#$1 npx tsx scripts/recover-homebrew-bottle-mirror.ts\n#' expect_mutation_rejected \ "missing hashed shell asset verifier" \ @@ -244,12 +259,12 @@ expect_mutation_rejected \ expect_mutation_rejected \ "bottled preview without Pages base" \ "must prove the public bottled shell at the published base" \ - 's/( - name: Boot the canonical bottled Pages shell in Chromium\n working-directory: apps\/browser-demos\n env:\n) VITE_BASE: \/kandelo\/\n/$1/' + 's/( - name: Boot the transitional bottled Pages shell in Chromium\n working-directory: apps\/browser-demos\n env:\n) VITE_BASE: \/kandelo\/\n/$1/' expect_mutation_rejected \ "bottled preview loses package cache root" \ "must prove the public bottled shell at the published base" \ - 's/( - name: Boot the canonical bottled Pages shell in Chromium[\s\S]*?)^ "WASM_POSIX_BINARY_CACHE_ROOT=\$WASM_POSIX_BINARY_CACHE_ROOT" \\\n/$1/m' + 's/( - name: Boot the transitional bottled Pages shell in Chromium[\s\S]*?)^ "WASM_POSIX_BINARY_CACHE_ROOT=\$WASM_POSIX_BINARY_CACHE_ROOT" \\\n/$1/m' expect_mutation_rejected \ "bottled preview uses the retired source test" \