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
89 changes: 75 additions & 14 deletions .github/workflows/cloudflare-backup.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,49 +21,82 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@v6
with:
persist-credentials: false

- name: Use Node.js 24
uses: actions/setup-node@v6
with:
node-version: 24
cache: npm

- name: Validate backup configuration before installing dependencies
shell: bash
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/cloudflare/backup-preflight.ts --phase credentials

- name: Install dependencies
run: npm ci

- name: Require Cloudflare backup credentials
- name: Verify read access to both remote D1 databases
shell: bash
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
npx tsx scripts/cloudflare/backup-preflight.ts --phase credentials
set -euo pipefail
verify_database() {
local database="$1"
if ! npx wrangler d1 info "$database" --json >/dev/null; then
echo "::error title=Cloudflare D1 access check failed::Unable to read metadata for ${database}. Verify token scope, account selection, database name and Cloudflare availability. No backup was created."
exit 1
fi
}
verify_database studyinchina-catalog
verify_database studyinchina-pipeline

- name: Export catalog and pipeline databases
shell: bash
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
set -euo pipefail
export_data_tables() {
local database="$1"
local output="$2"
local query
query="SELECT name FROM pragma_table_list WHERE schema = 'main' AND type = 'table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_cf_%' AND name <> 'd1_migrations' ORDER BY name;"
local table_json
table_json="$(npx wrangler d1 execute "$database" --remote --command "$query" --json)"
if ! table_json="$(npx wrangler d1 execute "$database" --remote --command "$query" --json)"; then
echo "::error title=D1 table discovery failed::Unable to enumerate ordinary tables in ${database}. Verify remote access and inspect the Wrangler error above. No backup was uploaded."
exit 1
fi
if ! jq -e 'length > 0 and all(.[]; .success == true)' <<< "$table_json" >/dev/null; then
echo "::error title=D1 table discovery returned an error::Wrangler did not return a successful result for ${database}. No backup was uploaded."
exit 1
fi
local tables=()
mapfile -t tables < <(jq -r '.[] | select(.success == true) | .results[] | .name' <<< "$table_json")
mapfile -t tables < <(jq -r '.[] | .results[] | .name' <<< "$table_json")
if [ "${#tables[@]}" -eq 0 ]; then
echo "No exportable data tables found for $database" >&2
echo "::error title=D1 database has no exportable tables::No ordinary data tables were found for ${database}. No backup was uploaded."
exit 1
fi
local table_args=()
for table in "${tables[@]}"; do
table_args+=(--table "$table")
done
npx wrangler d1 export "$database" --remote --no-schema --skip-confirmation --output="$output" "${table_args[@]}"
test -s "$output"
if ! npx wrangler d1 export "$database" --remote --no-schema --skip-confirmation --output="$output" "${table_args[@]}"; then
echo "::error title=D1 export failed::Unable to export ${database}. No backup was uploaded."
exit 1
fi
if [ ! -s "$output" ]; then
echo "::error title=D1 export is empty::Wrangler returned no data for ${database}. No backup was uploaded."
exit 1
fi
}

# D1 cannot export a database containing FTS5 as one full dump. The
Expand All @@ -77,19 +110,47 @@ jobs:

- name: Verify backup artifacts
shell: bash
run: npx tsx scripts/cloudflare/backup-preflight.ts --phase artifacts --directory "$RUNNER_TEMP"
run: node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/cloudflare/backup-preflight.ts --phase artifacts --directory "$RUNNER_TEMP"

- name: Upload daily and monthly copies
shell: bash
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
set -euo pipefail
upload_object() {
local object="$1"
local file="$2"
shift 2
if ! npx wrangler r2 object put "$object" --file="$file" "$@" --remote; then
echo "::error title=R2 backup upload failed::Unable to upload ${object}. The checkpoint is incomplete and must not be counted toward RPO."
exit 1
fi
}

day="$(date -u +%F)"
month="$(date -u +%Y-%m)"
npx wrangler r2 object put "studyinchina-releases/backups/daily/$day/catalog.sql.gz" --file="$RUNNER_TEMP/catalog.sql.gz" --content-type="application/sql" --content-encoding="gzip" --remote
npx wrangler r2 object put "studyinchina-releases/backups/daily/$day/pipeline.sql.gz" --file="$RUNNER_TEMP/pipeline.sql.gz" --content-type="application/sql" --content-encoding="gzip" --remote
npx wrangler r2 object put "studyinchina-releases/backups/daily/$day/sha256.txt" --file="$RUNNER_TEMP/backup-sha256.txt" --content-type="text/plain" --remote
npx wrangler r2 object put "studyinchina-releases/backups/monthly/$month/catalog.sql.gz" --file="$RUNNER_TEMP/catalog.sql.gz" --content-type="application/sql" --content-encoding="gzip" --remote
npx wrangler r2 object put "studyinchina-releases/backups/monthly/$month/pipeline.sql.gz" --file="$RUNNER_TEMP/pipeline.sql.gz" --content-type="application/sql" --content-encoding="gzip" --remote
npx wrangler r2 object put "studyinchina-releases/backups/monthly/$month/sha256.txt" --file="$RUNNER_TEMP/backup-sha256.txt" --content-type="text/plain" --remote
upload_object "studyinchina-releases/backups/daily/$day/catalog.sql.gz" "$RUNNER_TEMP/catalog.sql.gz" --content-type="application/sql" --content-encoding="gzip"
upload_object "studyinchina-releases/backups/daily/$day/pipeline.sql.gz" "$RUNNER_TEMP/pipeline.sql.gz" --content-type="application/sql" --content-encoding="gzip"
upload_object "studyinchina-releases/backups/daily/$day/sha256.txt" "$RUNNER_TEMP/backup-sha256.txt" --content-type="text/plain"
upload_object "studyinchina-releases/backups/monthly/$month/catalog.sql.gz" "$RUNNER_TEMP/catalog.sql.gz" --content-type="application/sql" --content-encoding="gzip"
upload_object "studyinchina-releases/backups/monthly/$month/pipeline.sql.gz" "$RUNNER_TEMP/pipeline.sql.gz" --content-type="application/sql" --content-encoding="gzip"
upload_object "studyinchina-releases/backups/monthly/$month/sha256.txt" "$RUNNER_TEMP/backup-sha256.txt" --content-type="text/plain"

- name: Explain an incomplete backup
if: ${{ failure() }}
shell: bash
run: |
{
echo '## Cloudflare D1 backup did not complete'
echo
echo 'This run does **not** satisfy the 24-hour RPO and created no verified backup checkpoint.'
echo
echo '- Configuration failure: add or correct the named GitHub Actions repository secret, then rerun.'
echo '- D1 access failure: verify token scope, account, database names and Cloudflare availability.'
echo '- Export/artifact failure: inspect the first failing step; nothing is uploaded before checksum verification.'
echo '- R2 upload failure: treat the checkpoint as incomplete even if some objects were written, then rerun the whole job.'
echo
echo 'Runbook: `docs/backup-and-restore.md#failure-semantics-and-triage`.'
} >> "$GITHUB_STEP_SUMMARY"
38 changes: 33 additions & 5 deletions .github/workflows/vercel-production-alias.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ jobs:
env:
DEPLOYMENT_SHA: ${{ github.event.deployment.sha }}
DEPLOYMENT_URL: ${{ github.event.deployment_status.environment_url }}
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}

steps:
- name: Check out current main
Expand All @@ -48,17 +47,26 @@ jobs:
echo 'matches=false' >> "$GITHUB_OUTPUT"
echo "::notice::Deployment ${DEPLOYMENT_SHA} is not current main ${main_sha}; the stable alias will not be changed."

- name: Detect alias credential
- name: Require stable-alias credential
if: steps.main.outputs.matches == 'true'
id: credential
shell: bash
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
run: |
set -euo pipefail
if [[ -n "${VERCEL_TOKEN}" ]]; then
echo 'configured=true' >> "$GITHUB_OUTPUT"
else
echo 'configured=false' >> "$GITHUB_OUTPUT"
echo '::warning::VERCEL_TOKEN is not configured; the Vercel Git deployment succeeded but the explicit studyinchina.vercel.app alias was not reassigned.'
echo '::error title=Stable production alias was not promoted::VERCEL_TOKEN is not configured. The Vercel deployment may be Ready, but studyinchina.vercel.app was not reassigned or smoke-tested by this workflow. See docs/operations/data-maintenance.md#required-github-actions-secrets.'
{
echo '## Stable production alias was not promoted'
echo
echo 'The deployment succeeded, but this workflow cannot reassign or verify `studyinchina.vercel.app` without the `VERCEL_TOKEN` repository secret.'
echo
echo 'This job fails intentionally so a green check cannot be mistaken for a completed production promotion.'
} >> "$GITHUB_STEP_SUMMARY"
exit 1
fi

- name: Validate deployment URL
Expand All @@ -73,6 +81,24 @@ jobs:
exit 1
fi

- name: Verify immutable deployment release API
if: >-
steps.main.outputs.matches == 'true' &&
steps.credential.outputs.configured == 'true'
shell: bash
run: |
set -euo pipefail
release_api="${DEPLOYMENT_URL%/}/api/v1/releases/current"
for attempt in 1 2 3 4 5 6; do
if curl --fail --silent --show-error "${release_api}" \
| jq -e '.data.id and .data.recordCounts.programs' >/dev/null; then
exit 0
fi
sleep 10
done
echo 'The immutable Vercel deployment did not pass the release API smoke test; the stable alias was not changed.' >&2
exit 1

- name: Use Node.js 24
if: >-
steps.main.outputs.matches == 'true' &&
Expand All @@ -86,6 +112,8 @@ jobs:
steps.main.outputs.matches == 'true' &&
steps.credential.outputs.configured == 'true'
shell: bash
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
run: |
set -euo pipefail
npx --yes vercel@58.0.0 alias set \
Expand All @@ -94,7 +122,7 @@ jobs:
--scope henry-yangs-projects-c9706eac \
--token "${VERCEL_TOKEN}"

- name: Verify public release API
- name: Verify stable alias release API
if: >-
steps.main.outputs.matches == 'true' &&
steps.credential.outputs.configured == 'true'
Expand Down
30 changes: 17 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,12 @@ The product is built around three promises:

<div align="center">

| **266** universities | **1,211** programs | **355** scholarships | **62** cities |
| **266** universities | **1,234** programs | **356** scholarships | **62** cities |
|:---:|:---:|:---:|:---:|

</div>

The public catalogue also contains **345 published admission-cycle records** and is backed by **2,037 registered official source records**. Snapshot evaluated for **2026-08-08** with `npm run quality:platform-scorecard`.
The public catalogue also contains **256 published admission-cycle records** and is backed by **2,070 registered official source records**. Snapshot evaluated for **2026-08-10** with `npm run quality:platform-scorecard`.

<details>
<summary><strong>Open the honest data-depth scorecard</strong></summary>
Expand All @@ -49,18 +49,20 @@ Record count is not the same as record completeness. These are the current depth

| Quality indicator | Current baseline | Next gate |
|---|---:|---:|
| Programs with a current public cycle | 337 / 1,211 · **27.83%** | ≥ 70% |
| Programs with duration | **61.93%** | ≥ 90% |
| Programs with an official application route | **50.87%** | ≥ 80% |
| Programs with known teaching language | **85.96%** | ≥ 95% |
| Programs with eligibility/language evidence | **6.11%** | ≥ 50% |
| Universities connected to scholarships | 205 / 266 | ≥ 230 |
| Universities below three published programs | **8** | 0 |
| Programs with a current public cycle | 253 / 1,234 · **20.50%** | ≥ 70% |
| Programs with duration | **61.59%** | ≥ 90% |
| Programs with an official application route | **50.89%** | ≥ 80% |
| Programs with known teaching language | **85.09%** | ≥ 95% |
| Programs with eligibility/language evidence | **6.00%** | ≥ 50% |
| Universities connected to scholarships | 207 / 266 | ≥ 230 |
| Cities with reviewed coordinates | 27 / 62 | 62 / 62 |
| Complete Source Manifests | 10 / 266 | 266 / 266 |
| Source Manifests registered | 10 / 266 | 266 / 266 |
| Completed V2 Source Manifests | 0 / 266 | 266 / 266 |
| Complete catalogue reconciliation | 0 / 266 | 266 / 266 |
| Platform quality gates passing | 3 / 14 | 14 / 14 |

The raw compatibility dataset contains 272 universities, 1,232 programs and 381 scholarships. Draft, archived, identity-conflicting or publication-ineligible records are intentionally excluded from the public numbers above.
The raw compatibility dataset contains 272 universities, 1,255 programs and 384 scholarships. Draft, archived, identity-conflicting or publication-ineligible records are intentionally excluded from the public numbers above.

</details>

Expand All @@ -70,6 +72,8 @@ The raw compatibility dataset contains 272 universities, 1,232 programs and 381
- Explore programs through a 17-field, applicant-oriented taxonomy, including Chinese language and international Chinese education.
- Share URL-based filters, sorting and pagination; remove active filters individually and preserve browser history.
- Move from application state and deadline to fees, language and duration through decision-first cards and detail-page application snapshots.
- Narrow programs to records with an explicit university or program scholarship relationship without claiming applicant-specific eligibility.
- Explore cities through a geographic constellation or an accessible searchable directory, then use flagship guides with official sources, FAQs and stable section links.
- Keep identity-only records available for official discovery while excluding thin pages from search-engine indexing until they meet deterministic completeness gates.
- Save records locally, compare up to four programs and print a compact comparison sheet.
- Inspect field-level source links, last-check dates and uncertainty states.
Expand Down Expand Up @@ -278,10 +282,10 @@ flowchart LR

Near-term work is measured by:

- raising the remaining 17 sparse universities to 3–5 verified international-student programs or a documented `limited` reconciliation;
- increasing current-cycle coverage from 27.83% to at least 70%;
- raising the remaining 8 sparse universities to 3–5 verified international-student programs or a documented `limited` reconciliation;
- increasing current-cycle coverage from 20.50% to at least 70%;
- reaching 90% duration, 80% official application-route and 95% teaching-language coverage;
- expanding scholarship-connected institutions from 205 to at least 230;
- expanding scholarship-connected institutions from 207 to at least 230;
- completing 266 Source Manifests and 266 catalogue reconciliations;
- completing three matching shadow releases over at least 72 hours before Production switches to D1;
- passing two full monthly update cycles before expansion to 500, then 1,000+ institutions.
Expand Down
Loading
Loading