From ab7bd92fe1eddfcf61a208d673c44cd387e2548d Mon Sep 17 00:00:00 2001 From: Duncan Leo Date: Tue, 10 Nov 2026 23:01:03 -0800 Subject: [PATCH 1/3] feat: publish approved combined timetables through Pages Co-Authored-By: Codex --- .github/workflows/pages-deploy.yml | 126 +++++++++++++++--- docs/gtfs-timetable-release.md | 94 ++++++++++++- .../__tests__/combinedPagesFixture.ts | 4 +- .../src/commands/gtfsCombinedPages.test.ts | 108 +++++++++++++-- scripts/export-pages-combined-timetable.mjs | 52 ++++++-- scripts/prepare-pages-site-pull.mjs | 57 ++++++++ 6 files changed, 397 insertions(+), 44 deletions(-) create mode 100644 scripts/prepare-pages-site-pull.mjs diff --git a/.github/workflows/pages-deploy.yml b/.github/workflows/pages-deploy.yml index cd981750..0efc3006 100644 --- a/.github/workflows/pages-deploy.yml +++ b/.github/workflows/pages-deploy.yml @@ -15,6 +15,8 @@ on: - 'package*.json' - 'packages/**' - 'scripts/build-pages-artifact.mjs' + - 'scripts/export-pages-combined-timetable.mjs' + - 'scripts/prepare-pages-site-pull.mjs' - 'turbo.json' workflow_dispatch: @@ -28,10 +30,21 @@ concurrency: cancel-in-progress: false jobs: - build: + deploy: + if: github.ref == 'refs/heads/main' + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest + defaults: + run: + shell: bash + env: + COMBINED_TIMETABLE_ENABLED: ${{ vars.MRTDOWN_COMBINED_TIMETABLE_ENABLED }} steps: - uses: actions/checkout@v6 + with: + persist-credentials: false - uses: actions/setup-node@v7 with: @@ -41,8 +54,68 @@ jobs: - name: Install dependencies run: npm ci + - name: Check combined timetable gate + run: | + case "$COMBINED_TIMETABLE_ENABLED" in + ""|false|true) ;; + *) echo "::error::Expected MRTDOWN_COMBINED_TIMETABLE_ENABLED to be true, false or unset"; exit 1 ;; + esac + + - name: Acquire approved combined timetable inputs + if: env.COMBINED_TIMETABLE_ENABLED == 'true' + env: + RELEASE_URL: ${{ secrets.MRTDOWN_COMBINED_TIMETABLE_INPUT_URL }} + RELEASE_TOKEN: ${{ secrets.MRTDOWN_COMBINED_TIMETABLE_INPUT_TOKEN }} + RELEASE_SHA256: ${{ vars.MRTDOWN_COMBINED_TIMETABLE_INPUT_SHA256 }} + run: | + umask 077 + if [[ "$RELEASE_URL" != https://* || -z "$RELEASE_TOKEN" || ! "$RELEASE_SHA256" =~ ^[a-f0-9]{64}$ ]]; then + echo "::error::A private HTTPS release URL, bearer token and pinned archive SHA-256 are required" + exit 1 + fi + input_dir="$RUNNER_TEMP/combined-release" + archive="$RUNNER_TEMP/combined-release-inputs.tar.gz" + mkdir "$input_dir" + status=$(curl --fail --show-error --silent --proto '=https' \ + --max-time 120 --max-filesize 67108864 \ + --header "Authorization: Bearer ${RELEASE_TOKEN}" \ + --output "$archive" --write-out '%{http_code}' "$RELEASE_URL") + if [[ "$status" != 200 ]]; then + echo "::error::Private release download must return HTTP 200 directly" + exit 1 + fi + if ! printf '%s %s\n' "$RELEASE_SHA256" "$archive" | sha256sum --check --status; then + echo "::error::Private release archive does not match the pinned SHA-256" + exit 1 + fi + tar -xzf "$archive" --no-same-owner --no-same-permissions -C "$input_dir" + - name: Build Pages artifact - run: npm run pages:build + run: | + if [[ "$COMBINED_TIMETABLE_ENABLED" == true ]]; then + input_dir="$RUNNER_TEMP/combined-release" + npm run pages:build -- \ + --gtfs-file "$input_dir/schedule.zip" \ + --gtfs-capture "$input_dir/capture.json" \ + --gtfs-combined-config "$input_dir/combined-pages.json" + else + npm run pages:build + fi + + - name: Prepare authenticated site pull + if: env.COMBINED_TIMETABLE_ENABLED == 'true' + env: + MRTDOWN_COMBINED_TIMETABLE_SOURCE_REVIEW: ${{ secrets.MRTDOWN_COMBINED_TIMETABLE_SOURCE_REVIEW }} + MRTDOWN_SITE_PULL_URL: ${{ secrets.MRTDOWN_SITE_PULL_URL }} + MRTDOWN_SITE_INTERNAL_API_TOKEN: ${{ secrets.MRTDOWN_SITE_INTERNAL_API_TOKEN }} + run: | + if [[ "$MRTDOWN_SITE_PULL_URL" != https://* || -z "$MRTDOWN_SITE_INTERNAL_API_TOKEN" ]]; then + echo "::error::An HTTPS site pull URL and internal API token are required" + exit 1 + fi + node scripts/prepare-pages-site-pull.mjs pages-dist \ + "$RUNNER_TEMP/combined-release/release-index.json" \ + "$RUNNER_TEMP/pages-site-pull.json" - uses: actions/configure-pages@v6 @@ -51,22 +124,33 @@ jobs: with: path: pages-dist - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - needs: build - steps: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v5 - trigger-site-pull: - if: github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - needs: deploy - steps: + - name: Verify the public release before triggering the site pull + if: env.COMBINED_TIMETABLE_ENABLED == 'true' + env: + PAGES_URL: ${{ steps.deployment.outputs.page_url }} + run: | + for attempt in {1..12}; do + ready=true + for name in manifest.json combined-timetable-release-index.json archive.zip; do + if ! curl --fail --silent --max-time 10 --proto '=https' \ + --output "$RUNNER_TEMP/live-$name" "${PAGES_URL%/}/$name" || + ! cmp -s "pages-dist/$name" "$RUNNER_TEMP/live-$name"; then + ready=false + break + fi + done + if [[ "$ready" == true ]]; then + exit 0 + fi + sleep 10 + done + echo "::error::The deployed Pages release is not yet visible at the public URL" + exit 1 + - name: Trigger mrtdown-site pull workflow env: MRTDOWN_SITE_PULL_URL: ${{ secrets.MRTDOWN_SITE_PULL_URL }} @@ -82,9 +166,19 @@ jobs: exit 1 fi - curl --fail-with-body --show-error --silent \ + payload=() + if [[ "$COMBINED_TIMETABLE_ENABLED" == true ]]; then + payload=(--json "@$RUNNER_TEMP/pages-site-pull.json") + fi + status=$(curl --fail --show-error --silent --max-time 30 \ --user-agent "mrtdown-data-pages-deploy/1.0" \ --request POST \ --header "Accept: application/json" \ --header "Authorization: Bearer ${MRTDOWN_SITE_INTERNAL_API_TOKEN}" \ - "$MRTDOWN_SITE_PULL_URL" + --output "$RUNNER_TEMP/site-pull-response.json" --write-out '%{http_code}' \ + "${payload[@]}" "$MRTDOWN_SITE_PULL_URL") + if [[ "$status" != 202 ]]; then + echo "::error::Site pull did not return HTTP 202" + exit 1 + fi + jq -er '.workflowRunId | strings | select(length > 0)' "$RUNNER_TEMP/site-pull-response.json" diff --git a/docs/gtfs-timetable-release.md b/docs/gtfs-timetable-release.md index 955bc642..bed45062 100644 --- a/docs/gtfs-timetable-release.md +++ b/docs/gtfs-timetable-release.md @@ -259,11 +259,10 @@ source-order checks. It reacquires the official LTA licence and API terms for this attempt and uses the build clock to check the review and any embargo. Release verification checks that each boardable platform exists at the mapped station and line and has no boarding -restriction. The shared verifier accepts a service outside the platform's -canonical defaults when the approved mapping lists it for that platform. Pages -requires boardable services to match explicit canonical platform service lists -until the site's departure index supports reviewed allocations. A blocked build -preserves the previous output. +restriction. Pages and the site's departure index use the shared verifier, +which accepts a service outside the platform's canonical defaults when the +approved mapping lists it for that platform. A blocked build preserves the +previous output. Both archives contain `data/combined-timetable/{manifest,timetable,release}.json`. The download page and release manifest carry LTA attribution and the approved @@ -277,6 +276,87 @@ site applies its own source-order and canonical compatibility checks at import. Combined and legacy timetable packaging are mutually exclusive. Default builds and the fixture export omit combined timetable output. +### Deploying the combined release through GitHub Actions + +The main-branch Pages workflow uses the repository variable +`MRTDOWN_COMBINED_TIMETABLE_ENABLED`. Leave it unset or set to `false` until +publication and production import are authorized. When set to `true`, every +routine deployment rebuilds the pinned release against the canonical data it +ships. A missing input, changed terms, incompatible topology or mismatched +release digest stops the job before upload. Keep the variable enabled and the +inputs available for subsequent deployments. Clearing the variable publishes a +GTFS-free archive on the next successful deploy. + +Prepare a `tar.gz` archive of regular files at its root, outside Git and the +Pages tree. Include `schedule.zip`, the licence-reviewed `capture.json`, +`combined-pages.json` and its referenced review inputs. Include the trusted +`release-index.json` and the exact approved `release.json` as +`release-evidence.json`, and add this field to the configuration: + +```json +{ + "approvedRelease": { + "index": "release-index.json", + "evidence": "release-evidence.json" + } +} +``` + +The builder checks fresh terms and release eligibility, then verifies and +preserves the original evidence bytes. This keeps both index digests stable +across deployments of the same release. Use an immutable private HTTPS object +that serves the archive directly with bearer authentication. Keep the raw +archive, review inputs and source review in private storage. PR checks use +synthetic timetables and Pages previews use the default build. + +Restrict the `github-pages` environment to `main` before storing the release +credentials. Configure the following on that environment while the gate is off. + +| Setting | Value | +| --- | --- | +| Secret `MRTDOWN_COMBINED_TIMETABLE_INPUT_URL` | Private archive URL | +| Secret `MRTDOWN_COMBINED_TIMETABLE_INPUT_TOKEN` | Read-only bearer token for that archive | +| Variable `MRTDOWN_COMBINED_TIMETABLE_INPUT_SHA256` | SHA-256 of the exact compressed archive | +| Secret `MRTDOWN_COMBINED_TIMETABLE_SOURCE_REVIEW` | Production-approved `scheduled-timetable-source-review` JSON | + +The workflow continues to use `MRTDOWN_SITE_PULL_URL` and +`MRTDOWN_SITE_INTERNAL_API_TOKEN` for the site's authenticated task endpoint. +Before uploading, it checks that the exact release index and source review bind +the generated artifact. After deploying, it waits for the public manifest, +release index and ZIP to match the build. The endpoint forwards +`{ combinedReleaseIndex, sourceReview }` to the signed workflow. Its HTTP 202 +and workflow ID confirm queueing. Later deployments can reuse that review for +the same retained release. + +Before enabling the gate: + +1. Deploy the compatible site and database migrations. Query production's + `scheduled_timetables` read-only and bind the review to the retained lineage + and exact candidate manifest and capture digests. An empty chain needs a + production baseline approval with a null predecessor. For an existing chain, + use the exact incumbent and the applicable source-order decision. Record + the actual approver and approval time. +2. Account for the production and staging QStash pull schedules and any preview + consumers that use the public archive. A pull carrying an empty body cannot + import a combined ZIP. Pause affected scheduled pulls for the cutover if + needed, and verify the durable schedule configuration supplies the trusted + index before resuming. Check that the site's schedule sync preserves that + configuration on redeploy. +3. Rehearse the production callback path with this bundle and the deployed + timeout configuration. Staging recorded two 15-minute parse callback + timeouts while PostgreSQL continued building the index. Confirm that the + projection completes within the callback deadline with headroom, or verify + that a retry can complete after a timeout without duplicate or partial + promotion. Hold cutover if neither behavior is verified. + +After dispatch, follow the returned signed workflow run through canonical +promotion and lease release. For a timed-out parse step, inspect its retry +state and the database's active projection transaction and pull lease before +submitting another request. Verify the exact retained receipt, a completed +projector v2 row, expected trip/date coverage, Expo platform A with LTA +attribution, and a departure after midnight on the final service date. +Queueing and a retained receipt alone do not establish completion. + ## Packaging a release for Pages The Pages builder can include a reviewed timetable alongside the canonical @@ -329,8 +409,8 @@ as an independently mapped schedule rather than live predictions. Without GTFS flags, the build stays offline and omits timetable files, index entries and links. The fixture export never receives the real timetable. A stray `scheduled-timetable/` directory under source data is excluded from copying; only -the checked exporter creates that reserved output. Current deployment workflows -do not pass GTFS inputs, and real mapping and release approvals remain outstanding. +the checked exporter creates that reserved output. The Pages deployment workflow +supports the gated combined release path described above. Against the retained October capture and full version-3 draft, inspection preserved the existing artifact digests and 1,502,097 assigned / 990,143 unassigned diff --git a/packages/cli/src/commands/__tests__/combinedPagesFixture.ts b/packages/cli/src/commands/__tests__/combinedPagesFixture.ts index d174a1b3..7c992b68 100644 --- a/packages/cli/src/commands/__tests__/combinedPagesFixture.ts +++ b/packages/cli/src/commands/__tests__/combinedPagesFixture.ts @@ -25,7 +25,7 @@ async function writeJson(path: string, value: unknown) { export async function combinedPagesFixture( root: string, fixtureDataDir: string, - { includeFeedInfo = true, conflictingBoarding = false } = {}, + { includeFeedInfo = true, reviewedBoarding = false } = {}, ) { const { inputs, capture } = combinedArtifactFixture(); const dataDir = join(root, 'data'); @@ -93,7 +93,7 @@ export async function combinedPagesFixture( } const sourceTables = inputs.context.tables; - if (!conflictingBoarding) { + if (!reviewedBoarding) { for (const call of sourceTables['stop_times.txt']) { // The synthetic source allocation at D only needs alighting access. if (call.trip_id === 'UNUSUAL' && call.stop_id === 'Q') { diff --git a/packages/cli/src/commands/gtfsCombinedPages.test.ts b/packages/cli/src/commands/gtfsCombinedPages.test.ts index d3f10b20..271770c1 100644 --- a/packages/cli/src/commands/gtfsCombinedPages.test.ts +++ b/packages/cli/src/commands/gtfsCombinedPages.test.ts @@ -90,18 +90,9 @@ describe('Pages combined timetable integration', () => { expect(fetcher).not.toHaveBeenCalled(); }); - it('keeps the previous export when the site cannot index a reviewed boarding allocation', async () => { - const { options } = await fixture({ conflictingBoarding: true }); - await mkdir(options.outDir); - await writeFile(join(options.outDir, 'keep.txt'), 'Previous output'); - await expect(buildPagesArtifact(options)).rejects.toThrow( - 'Combined timetable boarding access differs from canonical topology for SVC at D/platform-D', - ); - expect(await readdir(options.outDir)).toEqual(['keep.txt']); - }); - it('packages the approved service dates and emits the release index outside both archives', async () => { - const { options, topology, manifestSha256, fetcher } = await fixture(); + const { root, options, topology, manifestSha256, config, fetcher } = + await fixture({ reviewedBoarding: true }); await buildPagesArtifact(options); const read = (path: string) => readFile(join(options.outDir, path)); const index = JSON.parse((await read(indexName)).toString('utf8')); @@ -121,6 +112,21 @@ describe('Pages combined timetable integration', () => { startDate: '2026-11-01', endDateExclusive: '2026-12-01', }); + const allocatedPlatforms = Object.values(verified.artifact.patterns) + .flatMap(({ calls }) => calls) + .filter(({ stationId }) => stationId === 'D') + .flatMap(({ platforms }) => platforms); + expect(allocatedPlatforms).toContainEqual( + expect.objectContaining({ + platformId: 'platform-D', + accessByService: expect.arrayContaining([ + expect.objectContaining({ + serviceId: 'SVC', + passengerBoardingEligible: true, + }), + ]), + }), + ); expect( Object.keys(zip) .filter((name) => name.startsWith('data/combined-timetable/')) @@ -159,6 +165,86 @@ describe('Pages combined timetable integration', () => { ); expect(fetcher).toHaveBeenCalledTimes(2); + const approvedIndex = join(root, 'release-index.json'); + const approvedEvidence = join(root, 'release-evidence.json'); + await copyFile(join(options.outDir, indexName), approvedIndex); + await copyFile( + join(options.outDir, 'combined-timetable/release.json'), + approvedEvidence, + ); + await writeFile( + options.gtfsCombinedConfig, + JSON.stringify({ + ...config, + approvedRelease: { + index: 'release-index.json', + evidence: 'release-evidence.json', + }, + }), + ); + vi.setSystemTime(new Date('2026-11-12T00:00:00Z')); + await buildPagesArtifact(options); + expect(await read(indexName)).toEqual(await readFile(approvedIndex)); + expect(await read('combined-timetable/release.json')).toEqual( + await readFile(approvedEvidence), + ); + expect(fetcher).toHaveBeenCalledTimes(4); + + const sourceReview = { + schemaVersion: 1, + documentKind: 'scheduled-timetable-source-review', + decision: 'baseline', + predecessor: null, + successor: { + manifestSha256, + snapshotSha256: verified.manifest.capture.sha256, + }, + reviewer: 'Synthetic production reviewer', + reviewedAt: combinedReleaseInstant, + reason: 'Synthetic baseline against an empty history', + }; + const payloadPath = join(root, 'pull.json'); + const preparePull = (review: unknown) => + execFileAsync( + process.execPath, + [ + '--import', + 'data:text/javascript,Date.now=()=>Date.parse("2026-11-12T00:00:00Z")', + fileURLToPath( + new URL( + '../../../../scripts/prepare-pages-site-pull.mjs', + import.meta.url, + ), + ), + options.outDir, + approvedIndex, + payloadPath, + ], + { + env: { + ...process.env, + MRTDOWN_COMBINED_TIMETABLE_SOURCE_REVIEW: JSON.stringify(review), + }, + }, + ); + await expect( + preparePull({ + ...sourceReview, + successor: { manifestSha256, snapshotSha256: '0'.repeat(64) }, + }), + ).rejects.toThrow('Production source review does not bind this release'); + await preparePull(sourceReview); + expect(JSON.parse(await readFile(payloadPath, 'utf8'))).toEqual({ + combinedReleaseIndex: index, + sourceReview, + }); + + fetcher.mockImplementation(async () => new Response('Changed terms')); + await expect(buildPagesArtifact(options)).rejects.toThrow( + 'Release review does not match current terms', + ); + expect(await read(indexName)).toEqual(await readFile(approvedIndex)); + fetcher.mockClear(); await buildPagesArtifact({ dataDir: options.dataDir, diff --git a/scripts/export-pages-combined-timetable.mjs b/scripts/export-pages-combined-timetable.mjs index 3a136303..0e85c15b 100644 --- a/scripts/export-pages-combined-timetable.mjs +++ b/scripts/export-pages-combined-timetable.mjs @@ -1,10 +1,12 @@ import { mkdir, readFile, realpath, writeFile } from 'node:fs/promises'; import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; import { GtfsReconciliationIdentitySchema, ScheduledTimetableArtifactMetadataSchema, } from '@mrtdown/core'; import { + CombinedTimetableReleaseIndexSchema, hashBytes, inspectCombinedTimetableRelease, readEntity, @@ -39,6 +41,10 @@ const configSchema = ScheduledTimetableArtifactMetadataSchema.pick({ draft: recordPath, mappingApproval: recordPath, releaseReview: recordPath, + approvedRelease: z + .object({ index: recordPath, evidence: recordPath }) + .strict() + .optional(), }) .strict(); @@ -81,18 +87,31 @@ export async function prepareCombinedTimetableExport({ canonicalCommit, correctionRevision, holidayCalendarId, + approvedRelease, ...recordPaths } = configSchema.parse(await readJson(gtfsCombinedConfig)); + const resolveInput = async (name) => { + const path = resolve(dirname(gtfsCombinedConfig), name); + await assertInputPath(path); + return path; + }; const records = Object.fromEntries( await Promise.all( - Object.entries(recordPaths).map(async ([key, name]) => { - const path = resolve(dirname(gtfsCombinedConfig), name); - await assertInputPath(path); - return [key, await readJson(path)]; - }), + Object.entries(recordPaths).map(async ([key, name]) => [ + key, + await readJson(await resolveInput(name)), + ]), ), ); const { draft, mappingApproval, releaseReview, ...policies } = records; + const approvedIndex = approvedRelease + ? CombinedTimetableReleaseIndexSchema.parse( + await readJson(await resolveInput(approvedRelease.index)), + ) + : undefined; + const approvedEvidence = approvedRelease + ? await readFile(await resolveInput(approvedRelease.evidence)) + : undefined; /** Rebuild and verify the release before writing the site's three bundle files. */ return async (dataDir) => { @@ -144,13 +163,30 @@ export async function prepareCombinedTimetableExport({ `Combined timetable publication blocked: ${release.blockers.join('; ')}`, ); } - const { index, files } = bundle; + // Recheck current eligibility above, then publish the exact reviewed evidence. + if (approvedEvidence) { + const fresh = JSON.parse(bundle.files.releaseBytes.toString('utf8')); + const approved = JSON.parse(approvedEvidence.toString('utf8')); + if ( + !isDeepStrictEqual(approved, { + ...fresh, + releaseInstant: approved.releaseInstant, + }) + ) { + throw new Error( + 'Approved release evidence differs from the fresh check', + ); + } + } + const index = approvedIndex ?? bundle.index; + const files = { + ...bundle.files, + releaseBytes: approvedEvidence ?? bundle.files.releaseBytes, + }; const { manifest } = verifyCombinedTimetableReleaseBundle( index, files, topology, - // The site index still requires canonical platform service lists. - { requireCanonicalBoarding: true }, ); const directory = resolve(dataDir, 'combined-timetable'); await mkdir(directory); diff --git a/scripts/prepare-pages-site-pull.mjs b/scripts/prepare-pages-site-pull.mjs new file mode 100644 index 00000000..5ce82154 --- /dev/null +++ b/scripts/prepare-pages-site-pull.mjs @@ -0,0 +1,57 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { ScheduledTimetableSourceReviewSchema } from '@mrtdown/core'; +import { CombinedTimetableReleaseIndexSchema, hashBytes } from '@mrtdown/fs'; + +const indexName = 'combined-timetable-release-index.json'; + +async function readIndex(path) { + return CombinedTimetableReleaseIndexSchema.parse( + JSON.parse(await readFile(path, 'utf8')), + ); +} + +async function preparePull(pagesDir, approvedIndexPath, payloadPath) { + const [combinedReleaseIndex, approvedIndex, manifestBytes, releaseBytes] = + await Promise.all([ + readIndex(join(pagesDir, indexName)), + readIndex(approvedIndexPath), + readFile(join(pagesDir, 'combined-timetable/manifest.json')), + readFile(join(pagesDir, 'combined-timetable/release.json')), + ]); + if ( + combinedReleaseIndex.manifestSha256 !== approvedIndex.manifestSha256 || + combinedReleaseIndex.releaseSha256 !== approvedIndex.releaseSha256 || + hashBytes(manifestBytes) !== approvedIndex.manifestSha256 || + hashBytes(releaseBytes) !== approvedIndex.releaseSha256 + ) { + throw new Error('Pages release does not match the approved release index'); + } + + // biome-ignore lint/suspicious/noUndeclaredEnvVars: protected Actions environment secret. + const reviewJson = process.env.MRTDOWN_COMBINED_TIMETABLE_SOURCE_REVIEW; + let sourceReview; + try { + sourceReview = ScheduledTimetableSourceReviewSchema.parse( + JSON.parse(reviewJson ?? ''), + ); + } catch { + throw new Error('A valid production timetable source review is required'); + } + const { capture } = JSON.parse(manifestBytes.toString('utf8')); + if ( + sourceReview.successor.manifestSha256 !== approvedIndex.manifestSha256 || + sourceReview.successor.snapshotSha256 !== capture.sha256 || + Date.parse(sourceReview.reviewedAt) < Date.parse(capture.retrieved_at) || + Date.parse(sourceReview.reviewedAt) > Date.now() + ) { + throw new Error('Production source review does not bind this release'); + } + await writeFile( + payloadPath, + JSON.stringify({ combinedReleaseIndex, sourceReview }), + { flag: 'wx', mode: 0o600 }, + ); +} + +await preparePull(...process.argv.slice(2)); From dcc9854ae332fd3416615b4baa657d21a1c91e86 Mon Sep 17 00:00:00 2001 From: Duncan Leo Date: Tue, 10 Nov 2026 23:05:22 -0800 Subject: [PATCH 2/3] fix: make Pages archive checksum portable Co-Authored-By: Codex --- .github/workflows/pages-deploy.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pages-deploy.yml b/.github/workflows/pages-deploy.yml index 0efc3006..99bcbdd7 100644 --- a/.github/workflows/pages-deploy.yml +++ b/.github/workflows/pages-deploy.yml @@ -84,7 +84,8 @@ jobs: echo "::error::Private release download must return HTTP 200 directly" exit 1 fi - if ! printf '%s %s\n' "$RELEASE_SHA256" "$archive" | sha256sum --check --status; then + actual_sha256=$(sha256sum "$archive") + if [[ "${actual_sha256%% *}" != "$RELEASE_SHA256" ]]; then echo "::error::Private release archive does not match the pinned SHA-256" exit 1 fi From d36c98206f7582df32cf92ecdc4b4bbafaf920d2 Mon Sep 17 00:00:00 2001 From: Duncan Leo Date: Tue, 10 Nov 2026 23:11:17 -0800 Subject: [PATCH 3/3] fix: mark Pages retry counter unused Co-Authored-By: Codex --- .github/workflows/pages-deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pages-deploy.yml b/.github/workflows/pages-deploy.yml index 99bcbdd7..5b79ff99 100644 --- a/.github/workflows/pages-deploy.yml +++ b/.github/workflows/pages-deploy.yml @@ -134,7 +134,7 @@ jobs: env: PAGES_URL: ${{ steps.deployment.outputs.page_url }} run: | - for attempt in {1..12}; do + for _ in {1..12}; do ready=true for name in manifest.json combined-timetable-release-index.json archive.zip; do if ! curl --fail --silent --max-time 10 --proto '=https' \