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
127 changes: 111 additions & 16 deletions .github/workflows/pages-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -28,10 +30,21 @@ concurrency:
cancel-in-progress: false

jobs:
build:
deploy:
Comment thread
duncanleo marked this conversation as resolved.
Resolved
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:
Expand All @@ -41,8 +54,69 @@ 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
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
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

Expand All @@ -51,22 +125,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 _ 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 }}
Expand All @@ -82,9 +167,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"
94 changes: 87 additions & 7 deletions docs/gtfs-timetable-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/__tests__/combinedPagesFixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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') {
Expand Down
Loading
Loading