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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 45 additions & 2 deletions .github/workflows/vercel-production-alias.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 20 additions & 6 deletions docs/operations/data-maintenance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 71 additions & 0 deletions docs/operations/production-verification-2026-08-30.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions src/lib/catalog-api/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export async function getCatalogApiService(): Promise<CatalogApiService> {
recordCounts: publicCounts,
rawCounts,
publicCounts,
evaluatedForDate: isJson ? today : release.evaluatedForDate,
}, today)
}

Expand Down
7 changes: 4 additions & 3 deletions src/lib/catalog/json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ export class JsonCatalogRepository implements CatalogRepository {
recordCounts: publicCounts,
rawCounts: getCatalogRecordCounts(rawBundle),
publicCounts,
evaluatedForDate: today,
}, today).comparePrograms(uniqueIds)
}

Expand Down Expand Up @@ -242,7 +243,7 @@ export class JsonCatalogRepository implements CatalogRepository {
nextCursor,
total: items.length,
facets: { cities },
release: deriveCatalogRelease(data),
release: { ...deriveCatalogRelease(data), evaluatedForDate: today },
}
}

Expand Down Expand Up @@ -317,7 +318,7 @@ export class JsonCatalogRepository implements CatalogRepository {
universities: result.universityOptions,
cities: result.cityOptions,
},
release: deriveCatalogRelease(data),
release: { ...deriveCatalogRelease(data), evaluatedForDate: today },
}
}

Expand Down Expand Up @@ -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 },
}
}

Expand Down
60 changes: 60 additions & 0 deletions tests/unit/catalog-evaluation-date.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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 })

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)
expect(result.release.dataCheckedThrough).toBe(result.release.dataDate)
await expect(repository.getRelease()).resolves.toEqual(snapshotRelease)
expect(JSON.stringify(await repository.getBundle())).toBe(before)
},
)
})
42 changes: 39 additions & 3 deletions tests/unit/release-workflow-safety.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand Down
Loading