Automated CI/CD for Block Explorer. Dev, prod and previews all serve one static bundle from CloudFront.
PR merged to main
→ codebase-versioning.yml tags v.X.Y.Z (increment:* label picks the bump; one run at a time)
→ publish-bundle.yml builds the static bundle, or finds one it already has
→ deploy.yml copies it into dev's bucket and points dev at it
→ prepare-release.yml leaves exactly one draft release
Draft published
→ deploy.yml deploys prod
Every merge to main gets a version tag, but most releases change nothing the build reads —
terraform, workflows, tests and docs are all outside it. So a bundle's identity is a content SHA
(app-<sha12>) from scripts/app-content-sha.sh, which is both
the artifact's name and its prefix in the bucket. Run it locally with pnpm app:sha.
Two things fall out of that. publish-bundle.yml probes for a live artifact of that name and skips
the build when it finds one. And deploy.yml's publish job skips its upload when the prefix is
already in the bucket, because the same SHA is the same bytes. Terraform still applies every release;
terraform changes are exactly what these releases carry.
The footer therefore shows the release that last changed the app, not the one being cut — that is
what is running. APP_VERSION reaches the browser in <env>/runtime-config.json rather than being
baked in, and activate pins it to the bundle, so a no-op release rewrites nothing.
Previews share that identity: nothing PR-specific is in a bundle, so a release can skip a build a PR
already did, and a preview usually finds its bundle already in the bucket. Both unit-test.yml and
deploy-preview.yml resolve the SHA with the default branch's copy of the script, so the name one
publishes is the name the other waits for — and a pull request cannot publish under a release's name
by editing that script.
One workflow for both environments, with the target derived from the trigger. Dev and prod deploy the same bundle, so parity is structural rather than maintained by hand.
| Trigger | Target |
|---|---|
Completion of publish-bundle.yml |
dev — the bundle is guaranteed to exist rather than polled for |
| A GitHub Release being published | prod — the single draft prepare-release.yml left |
| Manual dispatch | either, chosen by the environment input (break-glass, e.g. to roll back) |
Jobs:
guard- Resolves the target, the tag and its content SHA, refuses anything not reachable frommain, and derives the stack list below, and skips an automatic dev deploy of a tag older than the one dev already servesterraform- Applies each stack serially in dependency order, planning immediately before each applypublish- Copies the bundle artifact into that environment's bucket, unless the prefix is already thereactivate- Writes<env>/runtime-config.json, points the environment's hosts at the new bundle, invalidates the config, then checks the CDN by its own namedraft-release- Callsprepare-release.yml(dev only, and not on dispatch)
publish runs after terraform because the bucket is terraform's to create, and activate after
publish because the routing store must never name a prefix that is not there yet. That ordering is
what makes a deploy seamless: the hosts keep answering from the previous bundle until the last step.
Domains: https://dev.block-explorer.vechain.org, https://block-explorer.vechain.org
vars.AWS_OIDC_ROLE_ARN resolves per GitHub Environment and prod approval is a protection rule on the
same object, so credentials need no conditional. TF_VAR_prod_deploy_role_arn and TF_VAR_dns_role_arn
are each set on one Environment only and pass unconditionally — the other resolves to "", which is
the off value the stacks already read as "not cross-account". Everything else is derived: the config
is terraform/environments/<env>/<env>.yaml, and the workspace and backend config follow the same
pattern. That leaves one guard output that
differs: stacks, which is the three both share plus dns in dev, because the zone role dns/
hands prod lives in that account.
The ref a run starts on is part of the security model, because the credentialed jobs apply whatever
Terraform they check out. Dev takes main's tree with the tag's bundle; prod takes the tag's own
tree, which is why a branch is refused there outright.
The terraform job never takes a ref: from event data — that would be an untrusted checkout with
execution, which CodeQL rates critical and main's code_scanning rule blocks. Only the content SHA
travels, and that is a string.
Stacks are applied serially, each planned immediately before its own apply. The design doc's parallel plan matrix is deliberately not used: on a first deploy the downstream stacks cannot plan at all until the upstream state they read exists. The plan file never leaves the job, because a binary plan can carry state.
The activate job checks the distribution by its own CloudFront name rather than by the public one.
Going through DNS would let a cached or weighted answer pass the check on the deployed stack's
behalf.
Called by deploy.yml after a successful dev deploy. Deletes any stale v.* draft and cuts a fresh
one for the just-deployed tag, so cutting prod is "open the single draft and click Publish".
Notes start at the last published release, not GitHub's default baseline — every merge to main
produces a tag, so the default picks the previous dev-only cut.
It is a workflow_call rather than a workflow_run chained off the deploy, because the deploy's dev
path is itself workflow_run-triggered: its own head_branch is main, so the deployed tag is not
recoverable from the event payload. Passing it as an input is exact.
Skipped on workflow_dispatch deploys — a break-glass run may be redeploying an older tag, and
drafting a release for it would offer prod that rollback as the next cut.
Runs the org's reusable checkov.yaml and action-lint.yaml on every PR and on main, behind a
single Static checks aggregator job so branch protection needs one entry rather than a context per
reusable workflow.
Checkov is soft_fail: true for now: it reports a few dozen findings across the Terraform, most of
them accepted design decisions (the wildcard preview cert, no DNSSEC, AWS-managed secret encryption). A follow-up records those in a .checkov.yaml and flips the
flag in the same change. actionlint hard-fails.
No paths: filter, deliberately. A filtered workflow never reports on a PR that misses the filter,
which would leave that PR waiting forever on a required status.
.github/actionlint-matcher.json is vendored from actionlint v1.7.8 because the reusable workflow
registers it with ::add-matcher:: unconditionally, and the runner fails the step when the file is
absent. It only annotates findings onto the diff.
Triggers:
- PR opened/synchronized/reopened/labeled on
mainbranch, gated on thecreate-previewlabel
Concurrency: Shared with destroy-preview.yml per PR, never cancelling
Jobs:
gate- Requires thecreate-previewlabel; alabeledevent for any other label is a no-opannounce- Posts the sticky "deploying" commentdeploy- Waits for the PR's bundle, copies it into dev's bucket if it is not already there, writespr-{number}/runtime-config.json, adds the host's key to the routing store, then checks the URLcomment- Updates the sticky comment with the URL or a link to the logs
Bundle: built by unit-test.yml once the tests pass, and named by content, so a preview usually
finds a bundle dev or another PR already published. That build runs on pull_request rather than in a
privileged workflow, so a pull request's own code never executes anywhere the release path can reach —
which is also why the build steps are a composite action (.github/actions/build-bundle) rather than a
reusable workflow: a composite inherits its caller's trust context, a reusable workflow is analysed
under every caller's at once.
Domain: https://pr-{number}.block-explorer-preview.vechain.org, served by dev's distribution —
previews own no infrastructure of their own, only a key and a config file.
Triggers:
- PR closed/merged
Security: Only runs for same-repo PRs (not forks)
Jobs:
destroy- Deletes the host's key from the routing store and thepr-{number}/prefix, then updates the sticky comment
The bundle stays: it is content-addressed and shared with dev and with any other PR on the same content, so deleting it would break them.
Removing the create-preview label does not tear a preview down; preview-reconcile.yml reaps it
within six hours.
Triggers:
- Every six hours, and on demand
Event-driven teardown misses cleanups — a queued destroy can be evicted from the shared concurrency
group by a newer run, a destroy can fail on a throttle or a lock, and a long-lived labelled PR sits
forever. This sweep lists the pr-* keys in the routing store (the store is the source of truth, because a
key is the only thing that makes a preview host answer) and deletes the ones whose PR is closed or no
longer labelled.
It is deliberately conservative: it skips on any failed PR query, re-confirms immediately before deleting, treats an already-gone key as reaped, and isolates each reap so one stuck preview cannot starve the rest.
Triggers:
- PR to
mainbranch
Process:
- Setup Node.js 20 + pnpm 9.15.4
- Install dependencies
- Run
pnpm test - Upload coverage report
| Store | Key | Purpose |
|---|---|---|
| Workflow artifact | app-{sha12} |
The built bundle, retained 90 days for a release and 14 for a PR |
| S3 | app-{sha12}/… |
What the CDN serves. One prefix per distinct build, shared by dev, prod and every preview |
| S3 | {env}/runtime-config.json |
Per environment: the version, the dev-mode flag and the solo overrides |
| KeyValueStore | host → {bundle, config} |
Which of the above a host answers from |
app-{sha12} comes from scripts/app-content-sha.sh, a hash of every input the build reads, so a
squash merge or rebase that leaves the app unchanged keeps the same name — one bundle per distinct
build, however many releases ship it.
Version tags (v.X.Y.Z) never name a bundle. They are what activate writes into
runtime-config.json, and a release whose bundle is already serving keeps the version that shipped it.
OIDC throughout — no long-lived keys.
vars.AWS_OIDC_ROLE_ARN is set per GitHub Environment, one variable name resolving to a different
role in dev and in prod, which is what keeps every if env == prod conditional out of the
workflows. Prod approval is an Environment protection rule on the same object. The preview workflows
still use secrets.AWS_ACC_ROLE, the shared dev-account role.
Neither role ARN is committed: they carry account ids, and this repo is public.
permissions:
id-token: write # OIDC token
contents: read # Checkout code
pull-requests: write # Update PR commentsOne bucket per account, one key per stack, workspace-namespaced. Bucket names are in
terraform/environments/<env>/backend.config; the layout is in
terraform/README.md.
Workspaces:
dev/prod- one per environment, in different accountspr-{number}- one per preview, in the dev account
`create-preview` label added, or a push to an already-labelled PR:
→ Post "deploying" comment
→ Wait for the PR's bundle, copy it into the bucket if it is not already there
→ Write pr-{number}/runtime-config.json and the host's routing key
→ Update comment to "ready" (with the URL)
Label removed:
→ Nothing immediately; the reconcile sweep reaps within six hours
PR Closed/Merged:
→ Delete the routing key and the pr-{number}/ prefix
→ Update comment to "torn down"
Every preview job runs against the single preview GitHub Environment, with environment.url set per
deployment so the PR timeline still links to the right preview. The per-PR name lives only in the
routing store, which destroy-preview.yml and preview-reconcile.yml reap.
Do not name a GitHub Environment after the PR. Declaring environment: preview-pr-N creates that
environment implicitly and nothing can remove it: GITHUB_TOKEN has no environments or
administration permission, so the delete API is out of reach from a workflow. 113 of them
accumulated before this was noticed. If a per-PR environment is ever genuinely needed, the teardown
step needs a PAT or GitHub App token with Environments write, and it has to be added in the same
change that introduces the environment.
Configuration:
concurrency:
group: preview-pr-${{ github.event.pull_request.number }}
cancel-in-progress: falseBehavior:
- Prevents race conditions with Terraform
- Each PR has isolated concurrency group
- Different PRs can deploy simultaneously
cancel-in-progress is false because deploy and destroy share the group: GitHub evaluates it on
the incoming run, so true would let a deploy cancel a teardown mid-destroy and strand the state
lock. The cost is that a queued teardown can be evicted by a newer run, which is what
preview-reconcile.yml exists to catch.
- Compute: none. A preview is a prefix in dev's bucket plus one routing key
- Bundle: shared with dev and with any PR on the same content, so most previews upload nothing
- Auto-cleanup: the key and the config go when the PR closes, and are swept every six hours regardless
- Old prefixes are not reaped. They are a few MB each and one of them may still be what an environment serves, so age is not a safe signal for deleting one
- Check the
Build Static Bundlejob inpublish-bundle.yml, orBuild Preview Bundleinunit-test.yml - Verify
pnpm-lock.yamlis compatible with pnpm 9.15.4
- Check the Terraform plan in the job that failed — each stack is planned immediately before its apply
publishfailing on a missing artifact means it expired; re-runpublish-bundle.ymlfor that tag- Ensure the environment config file is valid YAML
- Verify
pull-requests: writepermission is set - Check that PR is from same repository (not fork)
- Look for errors in
update-commentjob logs
- Concurrency control should prevent this
- If multiple deploys run, check concurrency group configuration
- Old builds should be cancelled automatically
- Check the ACM certificate is issued and its Route53 validation records exist. CloudFront reads its
certificate from us-east-1 only, so
cdn/holds a second one for the same names - Both public zones are in the dev account; prod writes into them through an assumed role
- The distribution's own name works meanwhile, and is what the deploy verifies against