From 552a9ab58ddb3ba2c7ca3c79cd37331073d24cf8 Mon Sep 17 00:00:00 2001 From: Henrythefoodie <13022037121@163.com> Date: Sun, 30 Aug 2026 10:42:40 +0800 Subject: [PATCH 1/2] fix(release): verify protected deployments and align evaluation dates --- .github/workflows/vercel-production-alias.yml | 47 ++++++++++++++- docs/operations/data-maintenance.md | 26 ++++++-- src/lib/catalog-api/runtime.ts | 1 + src/lib/catalog/json.ts | 7 ++- tests/unit/catalog-evaluation-date.test.ts | 59 +++++++++++++++++++ tests/unit/release-workflow-safety.test.ts | 42 ++++++++++++- 6 files changed, 168 insertions(+), 14 deletions(-) create mode 100644 tests/unit/catalog-evaluation-date.test.ts diff --git a/.github/workflows/vercel-production-alias.yml b/.github/workflows/vercel-production-alias.yml index 2a2b86b..b2f3973 100644 --- a/.github/workflows/vercel-production-alias.yml +++ b/.github/workflows/vercel-production-alias.yml @@ -151,14 +151,57 @@ jobs: steps.ci.outputs.passed == 'true' && steps.credential.outputs.configured == 'true' shell: bash + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} run: | set -euo pipefail release_api="${DEPLOYMENT_URL%/}/api/v1/releases/current" + deployment_host="${DEPLOYMENT_URL#https://}" + deployment_host="${deployment_host%/}" + + # Reuse an existing automation credential through read-only APIs. + # Never disable protection, generate a bypass, or run a package with this token. + project_json="$(curl --fail --silent --show-error \ + --connect-timeout 10 --max-time 30 \ + --header "Authorization: Bearer ${VERCEL_TOKEN}" \ + 'https://api.vercel.com/v9/projects/studyinchina?slug=henry-yangs-projects-c9706eac')" + project_id="$(jq -er 'select(.name == "studyinchina") | .id | select(type == "string" and test("^prj_[A-Za-z0-9]+$"))' <<< "${project_json}")" + team_id="$(jq -er '.accountId | select(type == "string" and test("^team_[A-Za-z0-9]+$"))' <<< "${project_json}")" + deployment_json="$(curl --fail --silent --show-error \ + --connect-timeout 10 --max-time 30 \ + --header "Authorization: Bearer ${VERCEL_TOKEN}" \ + "https://api.vercel.com/v13/deployments/${deployment_host}?teamId=${team_id}")" + if ! jq -e --arg projectId "${project_id}" --arg ownerId "${team_id}" \ + --arg host "${deployment_host}" \ + '.projectId == $projectId and .ownerId == $ownerId + and .url == $host and .readyState == "READY" and .target == "production"' \ + <<< "${deployment_json}" >/dev/null; then + echo 'The immutable URL is not a Ready production deployment of the expected project; the stable alias was not changed.' >&2 + exit 1 + fi + if ! bypass_secret="$(jq -er ' + (.protectionBypass // {}) | to_entries + | map(select(.value.scope == "automation-bypass")) | first | .key + | select(type == "string" and length > 0 and (test("[\\r\\n]") | not)) + ' <<< "${project_json}")"; then + echo 'No existing automation-bypass credential is available. The smoke test will not change Deployment Protection; the stable alias was not changed.' >&2 + exit 1 + fi + # Mask before use; keep the credential in this shell only, never in artifacts. + masked_secret="${bypass_secret//%/%25}" + printf '::add-mask::%s\n' "${masked_secret}" + unset project_json deployment_json masked_secret + for attempt in 1 2 3 4 5 6; do - if curl --fail --silent --show-error "${release_api}" \ + if curl --fail --silent --show-error \ + --connect-timeout 10 --max-time 30 \ + --header "x-vercel-protection-bypass: ${bypass_secret}" \ + "${release_api}" \ | jq -e --arg sha "${DEPLOYMENT_SHA}" \ - '.data.deploymentSha == $sha and .data.id + '.data.deploymentSha == $sha + and (.data.id | type == "string" and length > 0) and (.data.publicCounts.programs | type == "number" and . > 0)' >/dev/null; then + unset bypass_secret exit 0 fi sleep 10 diff --git a/docs/operations/data-maintenance.md b/docs/operations/data-maintenance.md index b8feeca..8cd6abb 100644 --- a/docs/operations/data-maintenance.md +++ b/docs/operations/data-maintenance.md @@ -52,12 +52,26 @@ argument, committed file, issue, log or workflow output. `VERCEL_TOKEN` allows the successful main deployment to reassign the stable `studyinchina.vercel.app` alias. The secret is injected only into the credential -gate and the `vercel alias set` step; checkout, URL validation, smoke tests and -other commands cannot read it. Until it is configured, the Vercel Git integration -can still build a deployment, but the alias workflow fails deliberately: a green -workflow must mean that the immutable deployment and stable alias were both -smoke-tested. A successful deployment and a successful stable-alias promotion -are therefore two separate signals. +gate, authenticated immutable smoke, and alias transaction steps. Checkout, +URL validation and Node setup cannot read it. Until it is configured, the Vercel +Git integration can still build a deployment, but the alias workflow fails +deliberately: a green workflow must mean that the immutable deployment and stable +alias were both smoke-tested. A successful deployment and a successful stable-alias +promotion are therefore two separate signals. + +Immutable deployment URLs can return a Vercel login HTML page with HTTP 200 even +when the deployment itself is Ready. The immutable smoke uses read-only Vercel +APIs to confirm that the URL belongs to the expected project's Ready production +deployment, then reuses an existing [automation-bypass credential](https://vercel.com/docs/deployment-protection/methods-to-bypass-deployment-protection/protection-bypass-automation) +in the recommended HTTP header. It masks the credential before use and never +saves it in an artifact, changes Deployment Protection, generates a bypass, or +installs/runs an npm package with the token. If the token cannot read the project +or no existing automation credential is available, promotion fails closed; an +operator must resolve access through Vercel's protected settings. The JSON, exact +deployment SHA, non-empty release ID and positive public program count checks +remain mandatory. Redirect-following, local proxy and verbose/debug flags must +not be added to the credentialed requests. The stable public alias is still +checked without authentication using system curl. When a successful Production deployment does not match the current `main` SHA, the alias workflow remains a deliberate no-op and records a notice. When it does diff --git a/src/lib/catalog-api/runtime.ts b/src/lib/catalog-api/runtime.ts index 977e1bf..a9d9146 100644 --- a/src/lib/catalog-api/runtime.ts +++ b/src/lib/catalog-api/runtime.ts @@ -47,6 +47,7 @@ export async function getCatalogApiService(): Promise { recordCounts: publicCounts, rawCounts, publicCounts, + evaluatedForDate: isJson ? today : release.evaluatedForDate, }, today) } diff --git a/src/lib/catalog/json.ts b/src/lib/catalog/json.ts index 1bee271..8612ef3 100644 --- a/src/lib/catalog/json.ts +++ b/src/lib/catalog/json.ts @@ -144,6 +144,7 @@ export class JsonCatalogRepository implements CatalogRepository { recordCounts: publicCounts, rawCounts: getCatalogRecordCounts(rawBundle), publicCounts, + evaluatedForDate: today, }, today).comparePrograms(uniqueIds) } @@ -242,7 +243,7 @@ export class JsonCatalogRepository implements CatalogRepository { nextCursor, total: items.length, facets: { cities }, - release: deriveCatalogRelease(data), + release: { ...deriveCatalogRelease(data), evaluatedForDate: today }, } } @@ -317,7 +318,7 @@ export class JsonCatalogRepository implements CatalogRepository { universities: result.universityOptions, cities: result.cityOptions, }, - release: deriveCatalogRelease(data), + release: { ...deriveCatalogRelease(data), evaluatedForDate: today }, } } @@ -361,7 +362,7 @@ export class JsonCatalogRepository implements CatalogRepository { nextCursor, total: result.total, facets: { universities: result.universityOptions }, - release: deriveCatalogRelease(published), + release: { ...deriveCatalogRelease(published), evaluatedForDate: today }, } } diff --git a/tests/unit/catalog-evaluation-date.test.ts b/tests/unit/catalog-evaluation-date.test.ts new file mode 100644 index 0000000..60390f9 --- /dev/null +++ b/tests/unit/catalog-evaluation-date.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { GET as listPrograms } from '@/app/api/v1/programs/route' +import { GET as comparePrograms } from '@/app/api/v1/programs/compare/route' +import { GET as getCurrentRelease } from '@/app/api/v1/releases/current/route' +import { createJsonCatalogRepository } from '@/lib/catalog' + +const evaluationDate = '2040-01-15' + +afterEach(() => { + vi.useRealTimers() +}) + +describe('JSON runtime release evaluation dates', () => { + it('reports the same query clock in list, comparison, and current-release responses without changing the snapshot date', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date(`${evaluationDate}T04:00:00Z`)) + + const repository = createJsonCatalogRepository() + const snapshotRelease = await repository.getRelease() + const list = await (await listPrograms(new Request('https://example.test/api/v1/programs?limit=1'))).json() + const comparison = await (await comparePrograms(new Request( + `https://example.test/api/v1/programs/compare?ids=${encodeURIComponent(list.data[0].id)}`, + ))).json() + const current = await (await getCurrentRelease()).json() + + expect(snapshotRelease.dataDate).not.toBe(evaluationDate) + for (const envelope of [list, comparison, current]) { + expect(envelope.meta.release.evaluatedForDate).toBe(evaluationDate) + expect(envelope.meta.release.dataDate).toBe(snapshotRelease.dataDate) + expect(envelope.meta.release.dataCheckedThrough).toBe(snapshotRelease.dataCheckedThrough) + expect(envelope.meta.release.id).toBe(snapshotRelease.id) + } + expect(current.data.evaluatedForDate).toBe(evaluationDate) + // Future evaluation still masks stale facts; changing metadata must not refresh them. + expect(list.data[0].status).toBe('stale') + expect(list.data[0].durationMonths).toBeNull() + await expect(repository.getRelease()).resolves.toEqual(snapshotRelease) + }) + + it.each(['listInstitutions', 'listPrograms', 'listScholarships'] as const)( + '%s uses the explicit query date, not the clock or source-check date', + async (method) => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date('2039-12-01T04:00:00Z')) + const repository = createJsonCatalogRepository() + const snapshotRelease = await repository.getRelease() + const before = JSON.stringify(await repository.getBundle()) + + const result = await repository[method]({ today: evaluationDate, limit: 1 }) + + expect(result.release.evaluatedForDate).toBe(evaluationDate) + expect(result.release.dataDate).not.toBe(evaluationDate) + expect(result.release.dataCheckedThrough).not.toBe(evaluationDate) + expect(result.release.dataCheckedThrough).toBe(result.release.dataDate) + await expect(repository.getRelease()).resolves.toEqual(snapshotRelease) + expect(JSON.stringify(await repository.getBundle())).toBe(before) + }, + ) +}) diff --git a/tests/unit/release-workflow-safety.test.ts b/tests/unit/release-workflow-safety.test.ts index 6098e40..eb52da3 100644 --- a/tests/unit/release-workflow-safety.test.ts +++ b/tests/unit/release-workflow-safety.test.ts @@ -124,7 +124,43 @@ describe('production release workflow safety', () => { expect(workflow.slice(stableSmoke)).toContain('transaction_committed=true') }) - it('exposes the Vercel token only to the credential gate and alias transaction', () => { + it('authenticates immutable smoke with an existing project credential and read-only APIs', () => { + const workflow = readWorkflow('vercel-production-alias.yml') + const immutableSmoke = workflow.indexOf('Verify immutable deployment release API') + const nodeSetup = workflow.indexOf('Use Node.js 24') + const block = workflow.slice(immutableSmoke, nodeSetup) + const ownershipCheck = block.indexOf('.projectId == $projectId and .ownerId == $ownerId') + const credentialSelection = block.indexOf('bypass_secret=') + const credentialMask = block.indexOf("printf '::add-mask::%s\\n'") + const smokeRequest = block.indexOf('if curl --fail --silent --show-error') + + expect(block).toContain('https://api.vercel.com/v9/projects/studyinchina?slug=henry-yangs-projects-c9706eac') + expect(block).toContain('https://api.vercel.com/v13/deployments/${deployment_host}?teamId=${team_id}') + expect(block).toContain('select(.name == "studyinchina")') + expect(block).toContain('.url == $host and .readyState == "READY" and .target == "production"') + expect(ownershipCheck).toBeGreaterThan(-1) + expect(credentialSelection).toBeGreaterThan(ownershipCheck) + expect(credentialMask).toBeGreaterThan(credentialSelection) + expect(smokeRequest).toBeGreaterThan(credentialMask) + expect(block).toContain('.value.scope == "automation-bypass"') + expect(block).toContain('No existing automation-bypass credential is available.') + expect(block).toContain('The smoke test will not change Deployment Protection') + expect(block.slice(smokeRequest)).toContain('--header "x-vercel-protection-bypass: ${bypass_secret}"') + expect(block.slice(smokeRequest)).not.toContain('VERCEL_TOKEN') + expect(block).not.toMatch(/\b(?:npx|npm|vercel)\s/u) + expect(block).not.toMatch(/--(?:request|location|insecure|proxy|verbose|debug)\b/u) + expect(block).not.toMatch(/GITHUB_ENV|GITHUB_OUTPUT|\btee\b/u) + expect(block).toContain('set -euo pipefail') + expect(block).toContain('--connect-timeout 10 --max-time 30') + expect(block).toContain('jq -e --arg sha "${DEPLOYMENT_SHA}"') + expect(block).toContain('.data.deploymentSha == $sha') + expect(block).toContain('.data.id | type == "string" and length > 0') + expect(block).toContain('.data.publicCounts.programs | type == "number" and . > 0') + expect(block).toContain('the stable alias was not changed.') + expect(block).toContain('exit 1') + }) + + it('exposes the Vercel token only to the credential gate, authenticated smoke, and alias transaction', () => { const workflow = readWorkflow('vercel-production-alias.yml') const bindings = workflow.match( /^\s+VERCEL_TOKEN:\s+\$\{\{ secrets\.VERCEL_TOKEN \}\}$/gmu, @@ -133,14 +169,14 @@ describe('production release workflow safety', () => { const immutableSmoke = workflow.indexOf('Verify immutable deployment release API') const transaction = workflow.indexOf('Promote stable production alias transaction') - expect(bindings).toHaveLength(2) + expect(bindings).toHaveLength(3) expect(workflow.slice(0, credentialGate)).not.toContain( 'VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}', ) expect(workflow.slice(credentialGate, immutableSmoke)).toContain( 'VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}', ) - expect(workflow.slice(immutableSmoke, transaction)).not.toContain( + expect(workflow.slice(immutableSmoke, transaction)).toContain( 'VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}', ) expect(workflow.slice(transaction).match( From d6039a3c93f43892b2efe7599e20c3f312232810 Mon Sep 17 00:00:00 2001 From: Henrythefoodie <13022037121@163.com> Date: Sun, 30 Aug 2026 10:44:05 +0800 Subject: [PATCH 2/2] test(release): narrow metadata and record production audit --- .../production-verification-2026-08-30.md | 71 +++++++++++++++++++ tests/unit/catalog-evaluation-date.test.ts | 1 + 2 files changed, 72 insertions(+) create mode 100644 docs/operations/production-verification-2026-08-30.md diff --git a/docs/operations/production-verification-2026-08-30.md b/docs/operations/production-verification-2026-08-30.md new file mode 100644 index 0000000..18a05ed --- /dev/null +++ b/docs/operations/production-verification-2026-08-30.md @@ -0,0 +1,71 @@ +# Production verification — 2026-08-30 + +## Website and data are separate releases + +The stable site was verified at deployment SHA +`58470c8949fd8b379df637447481568e6599d12a` before this follow-up patch. +`/api/v1/releases/current` reported the JSON backend and release +`json:2026-08-26`, evaluated for 2026-08-30: + +| Public entity | Count | +| --- | ---: | +| Universities | 266 | +| Programs | 1,265 | +| Scholarships | 366 | +| Cities | 62 | + +These are published identity counts, not counts of open applications or +completely reverified records. The public admission-cycle count must not be +interpreted as dated-or-rolling coverage. + +The Chinese/English program lists, Chinese scholarship list, and an Anhui +University program detail returned real application HTML. The redundant tuition +reference warning was absent; the detail retained official-source links. + +The follow-up patch aligns JSON list, comparison, and page-list +`evaluatedForDate` with the actual query clock. Selection already used that clock. +It does not refresh `verifiedAt`, `dataCheckedThrough`, snapshot IDs, or D1 +historical release metadata. + +## Production safety findings + +An immutable Vercel URL can return login HTML with HTTP 200. The alias workflow +now verifies project/team ownership and Ready production state through read-only +Vercel APIs, reuses an existing automation-bypass credential, and requires the +release API's exact deployment SHA. It does not create a bypass or disable +Deployment Protection. The public stable URL is verified without authentication. + +The GitHub repository currently has `CLOUDFLARE_ACCOUNT_ID` and +`INGESTION_ADMIN_TOKEN`, but not `VERCEL_TOKEN` or the dedicated backup credential. +Code validation is not evidence that these unattended workflows have succeeded. +The operator-assisted production promotion does not satisfy an automation SLO. + +## Backup and restore evidence + +Both remote D1 data-only exports completed. Their local compressed copies passed +SHA-256 checks and full decompression. No SQL, snapshots, signed download URLs, or +credentials are included in this report or commit. + +The standard isolated Wrangler restore failed before import with Windows +`workerd spawn UNKNOWN`. A separately labelled Node SQLite offline check then +applied 10 Catalog and 16 Pipeline migrations, imported the complete exports, and +ran the repository's verification scripts. Both databases had zero foreign-key +violations and `integrity_check=ok`; 14/80 triggers were restored and the Catalog +FTS count matched 3,931/3,931. The offline check took 5.30 seconds, which is not a +measured production RTO. + +R2 upload was blocked by the execution safety review and was not retried through +another route. No remote checkpoint or R2 read-back was completed. Explicit +approval for uploading the two production exports to the private +`studyinchina-backups` bucket is pending. No backup/RPO SLO is claimed. + +**Do not switch production to D1:** the restored Catalog active release is still +dated 2026-07-26 and contains 6 institutions, 1,006 programs, no admission cycles, +and 55 scholarships. It is not equivalent to the live JSON catalogue. A fresh +validated Pipeline release, full Shadow comparison, and rollback evidence remain +required before changing the production backend. + +Private local reports are under +`.pipeline-build/manual-backup-2026-08-30/`; the untracked-asset inventory is +`.pipeline-build/untracked-assets-2026-08-30-release.json`. These are deliberately +excluded from Git. Candidate assets were not promoted by this maintenance work. diff --git a/tests/unit/catalog-evaluation-date.test.ts b/tests/unit/catalog-evaluation-date.test.ts index 60390f9..65e0c49 100644 --- a/tests/unit/catalog-evaluation-date.test.ts +++ b/tests/unit/catalog-evaluation-date.test.ts @@ -48,6 +48,7 @@ describe('JSON runtime release evaluation dates', () => { const result = await repository[method]({ today: evaluationDate, limit: 1 }) + if (!result.release) throw new Error('JSON list results must include release metadata') expect(result.release.evaluatedForDate).toBe(evaluationDate) expect(result.release.dataDate).not.toBe(evaluationDate) expect(result.release.dataCheckedThrough).not.toBe(evaluationDate)