Everything in this repo runs for free on GitHub Actions plus GitHub Pages, and
that is how the pilot runs today. This file is for the optional AWS pieces that
the rollout roadmap (docs/roadmap.md) needs as the registry grows: the
feed-health email digest, the self-serve forms, and a CDN in front of the
published JSON.
All the code and Terraform for these is written and tested. None of it is required to keep the site live.
Standing up your own branded instance rather than deploying the maintainer's?
Start at docs/fork-quickstart.md; it sequences the
branding config (instance.yaml) and the deploy steps below in order.
Current deployment status (maintainer's account). The artifacts CDN (§1) and the feed-health digest (§2) are applied and live:
gtfsscorecard.orgis verified in SES and out of the sandbox, and the daily workflow mirrors artifacts to S3 and sends the digest. The self-serve submission form (§3) is also applied and live. The fan-out compute (§4) and instant scoring (§5) are written but not yet applied. The steps below are the from-scratch runbook, so they still read as operator work to do — follow them for a fork or a clean rebuild, and skip the stacks that are already up.
One operator with:
- An AWS account and credentials configured locally (
aws configureor SSO). terraform>= 1.5 and theawsCLI installed.- Admin (or close) on the
ChelseaKR/gtfs-scorecardrepo, to set Actions variables and secrets.
The five stacks use local Terraform state by design, except artifacts,
which keeps state in S3. They are independent; apply only the ones you want.
The daily workflow (.github/workflows/scorecard.yml) already has the deploy
steps. They are gated on repository variables, so they stay off until you
set them, and forks keep working with nothing set:
| Feature | Set this Actions variable | Also needs |
|---|---|---|
| Mirror artifacts to the CDN bucket | ARTIFACTS_BUCKET |
AWS_ROLE_ARN secret, infra/artifacts applied |
| Send the feed-health email digest | SES_FROM |
a verified SES sender, infra/alerts applied |
| AWS region (optional) | AWS_REGION |
defaults to us-west-2 |
Set variables and secrets under Settings → Secrets and variables → Actions.
infra/artifacts/backend.tf keeps Terraform state in an S3 bucket named
gtfs-scorecard-tfstate-ckr. Create it once before the first apply (skip if it
exists):
aws s3api create-bucket --bucket gtfs-scorecard-tfstate-ckr \
--region us-west-2 --create-bucket-configuration LocationConstraint=us-west-2
aws s3api put-bucket-versioning --bucket gtfs-scorecard-tfstate-ckr \
--versioning-configuration Status=EnabledUse a different name if that one is taken, and update backend.tf to match.
Serves the published JSON from S3 + CloudFront instead of from Pages. Optional; Pages carries the pilot fine.
cd infra/artifacts
cp terraform.tfvars.example terraform.tfvars # edit bucket_name to be globally unique
terraform init
terraform applyThen:
- Read the outputs:
bucket_nameandcdn_domain. - Set the
ARTIFACTS_BUCKETActions variable tobucket_nameand theAWS_ROLE_ARNsecret to the OIDC role this stack creates. The next daily run mirrorsdata/artifactsto the bucket. - To point the web app at the CDN, set
window.SCORECARD_DATA_BASEinweb/src/config.jstohttps://<cdn_domain>/data/artifacts. The JSON contract is unchanged, so this is the only frontend change. Leaving it unset keeps the app reading from Pages. - Forks: set the
ARTIFACTS_CDNActions variable to yourcdn_domain. WhenARTIFACTS_BUCKETis set butARTIFACTS_CDNis not, the daily workflow's CDN privacy canary falls back to the maintainer's CloudFront domain, which a fork does not want to inherit.
Deployments created before the validator cache moved to the private
cache/validator/ prefix may still contain
data/artifacts/<agency>/validator-cache.json objects. Upgrade in this order:
-
Apply
infra/artifactsso the CloudFront viewer function and S3 origin policy deny the legacy key shape. -
Deploy the current Pages workflow. It publishes an explicit file allowlist. Raw validator caches, structural fingerprints, finding-clearance state, and generated feed copies remain private; the renderer still turns reconciled receipts into public HTML.
-
Remove the old public-path objects from S3. The daily, hourly, and targeted publishers do this idempotently; an operator can perform the cleanup immediately with:
aws s3 rm "s3://${ARTIFACTS_BUCKET}/data/artifacts" --recursive \ --exclude "*" --include "*/validator-cache.json" \ --include "*/structure.json" --include "*/fixlog.json" \ --include "*/corrected.zip"
-
Verify a current
latest.jsonreturns HTTP 200 and the four former internal paths return HTTP 403 or 404 through the CDN. The daily workflow runs this public/private canary after each publication.
This is the highest-value piece: it emails an agency before its feed silently
expires. The subscribe API (double opt-in) and the send path are already built;
the send is off until you verify a sender and set SES_FROM.
- Verify a sender in SES. Verify the domain
gtfsscorecard.org(DKIM) or a single address likealerts@gtfsscorecard.org. A new SES account starts in the sandbox, which only sends to verified addresses; request production access before sending to real agencies. - Apply the subscribe API (if not already live):
The
cd infra/alerts terraform init terraform apply -var ses_from=alerts@gtfsscorecard.orgsubscribe_urloutput is the endpoint the web form posts to; it is already wired intoweb/src/config.js. - Turn on sending. Set the
SES_FROMActions variable to your verified sender. The dailycollectjob then runsscorecard notify --send --from "$SES_FROM"for confirmed subscribers only. - Dry run first. Locally,
scorecard notify(no--send) reports how many emails would go out and to whom, without sending. Always check this before enabling the variable.
Lets an agency add itself from web/submit.html without manually opening a pull request.
The maintainer's endpoint is applied and wired into web/src/config.js; the
commands below are for a fork or clean rebuild. The service opens the pull
request on the submitter's behalf so a person still reviews every addition.
cd infra/submit
terraform init
terraform apply -var github_repo=ChelseaKR/gtfs-scorecard -var github_token=<PAT>The submit_url output is the endpoint; wire it into the form's config the same
way the subscribe URL is wired. The github_token is a fine-grained PAT that can
open pull requests on this repo.
Only needed when the daily run outgrows the Actions matrix. EventBridge + SQS +
a container-image Lambda built from pipeline/. See
docs/decisions/0003-fan-out-compute.md; apply it the same way when the time
comes.
Scores any GTFS URL on demand for web/try.html: a container-image Lambda
(the same JVM base as compute/) behind API Gateway, with a DynamoDB jobs
table, per-IP rate limiting, explicit country context passed to the validator,
and a reserved concurrency cap. The public form requires a country; only
omitted legacy HTTP requests default to US. This is the one
deliberate exception to the cost ceiling (roughly $20-60/month at demo-era
volume); the funnel case and the guardrails are in
docs/decisions/0029-instant-score-funnel.md. Build and push the image, then:
cd infra/instant-score
terraform init
terraform apply -var image_uri=<ECR image> -var artifacts_bucket=<bucket>The instant_score_url output is the endpoint web/try.html posts to; wire
it into web/src/config.js like the subscribe URL. Until it is applied, the
page's inline form stays disabled and falls back to the GitHub Issue Form
path (onboard.yml).
The runbook above covers the AWS stacks. Day-to-day operations run entirely in Actions; this is the inventory an operator should know exists:
| Workflow | Cadence | What it does |
|---|---|---|
scorecard.yml |
daily | The full sharded re-score, commit, deploy, optional S3 mirror and SES digest. |
refresh.yml |
hourly | Cheap intraday tier: change/down detection by conditional GET, no validator (ADR 0010). |
targeted-score.yml |
manual | Activates up to 25 reviewed registry agencies against the authoritative S3 corpus, then deploys. |
rt-monitor.yml |
every 3 h | Short realtime sampling burst across agencies into data/rt-health (ADR 0012). |
rt-archive.yml |
manual | Bounded high-resolution realtime polling session for one agency (ADR 0012). |
watchdog.yml |
every 6 h | Independent uptime and freshness check, no AWS dependency. |
discover.yml |
weekly | Checks expired feeds against the Mobility Database; opens PRs for moved URLs. |
equity.yml |
weekly | Refreshes the US equity overlay from Census ACS (ADR 0015). |
canada-equity.yml |
monthly | Refreshes the Canada overlay from StatCan CIMD (ADR 0027). |
otp-qa.yml |
weekly | Routing QA against containerized OpenTripPlanner (ADR 0014). |
dataset-release.yml |
monthly | Tags the citable dataset-YYYY-MM release with the flat exports. |
onboard.yml |
on issue | Scores a feed from a "score-request" issue and comments the scorecard. |
validator-canary.yml |
manual | Shadow-scores a candidate validator version for governed upgrades. |
tiles.yml |
manual | Rebuilds the national PMTiles route archive (needs tippecanoe). |
mutation.yml |
weekly | Advisory mutation testing of the scoring math. |
ci.yml, a11y.yml, e2e.yml, security.yml, pages.yml |
push/PR | The merge and deploy gates. |
Use Actions → Targeted agency activation → Run workflow when a reviewed agency is already present on the default branch but should be scored and made visible before the next daily run. Enter one to 25 exact registry ids; commas, spaces, and newlines are accepted. The dispatch rejects an empty list, unknown or malformed ids, normalized duplicates, and more than 25 targets.
This path requires ARTIFACTS_BUCKET and the same AWS_ROLE_ARN OIDC role as
the daily publisher. It is intentionally unavailable to Pages-only forks:
without the authoritative bucket, a partial checkout cannot safely rebuild the
worldwide directory.
The workflow serializes with the daily collect and hourly refresh writer jobs.
Their shared concurrency group uses the 100-run FIFO queue, so a new hourly
refresh cannot replace a waiting activation or daily collect. The activation
captures the authoritative index.json bytes and ETag in one request, then uses
that compact manifest to fetch every registered latest.json, its indexed
current dated object, and optional fix receipt by exact key with bounded
concurrency. This avoids recursively listing the lifecycle-managed dated
archive. When a current dated object has expired, and only on a not-found
response, the hydrator copies the byte-identical latest payload to the local
dated path without recreating the remote object. A retained dated object must
match latest exactly. The selected agencies' complete retained directories and
the small rollups/, changes/, and run/ namespaces are still hydrated in
full. Downloaded objects retain their S3 LastModified time so the bounded
publish sync skips retained files that were not changed locally.
S3 connections use a five-second connect timeout and a 30-second per-read timeout. If a transient transport error interrupts a response body, hydration closes and discards that attempt, then retries the complete object up to three times with short deterministic backoff. Permanent S3 responses and local path, write, or artifact-validation errors fail immediately; re-run the activation after correcting those errors.
Before publication, the workflow checks the captured ETag again and uses the
same ETag as an If-Match condition on the final index commit. An unexpected
change aborts the commit and the operator can re-run against the new state.
Publication is additive and path-bounded: only selected agency directories,
directory.json, scoring.json, rollups/, changes/, and a changed
index.json are uploaded. There is no whole-tree sync and no remote delete.
The selected dated snapshots receive the same lifecycle tag as daily scores.
The targeted run reads but never writes run/, so /status/ continues to
describe the daily pipeline rather than presenting a manual activation as a
full-corpus run. A successful publish calls the normal Pages workflow with the
data-refresh performance gate in advisory mode; accessibility still blocks.
Each stack is terraform destroy from its directory. Unset the matching Actions
variable first so the daily run stops trying to use it.
At a few thousand small JSON files refreshed daily, S3 and CloudFront sit in or
near the free tier, and SES is fractions of a cent per email. The
single-digit-dollars-a-month budget in CLAUDE.md holds well into Year 2; see
the roadmap's per-tier cost notes.