Skip to content

Commit 706a198

Browse files
daithihearnclaude
andcommitted
refactor: fold the dev and prod deploys into one workflow
deploy-dev.yml and deploy-prod.yml were the same five jobs with a handful of per-environment names between them, which is how prod's health check kept a comment saying DNS did not point at it yet. deploy.yml derives the target from the trigger and reduces the difference to three guard outputs: the ECR tag, the version alias, and the stack list. Both TF_VAR role arns now pass unconditionally — each is set on one Environment only and the other resolves to "", which the stacks already read as off. Dev picks up prod's stronger health check, over the ALB by name rather than DNS. Previews stay separate: they share the ECR promote and nothing else. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 5246ce5 commit 706a198

7 files changed

Lines changed: 249 additions & 501 deletions

File tree

‎.github/workflows/README.md‎

Lines changed: 53 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@ Automated CI/CD for Block Explorer. Dev, prod and previews all run on ECS Fargat
88
PR merged to main
99
→ codebase-versioning.yml tags v.X.Y.Z (increment:* label picks the bump)
1010
→ publish-ghcr-image.yml builds the multi-arch image, or aliases one it already has
11-
→ deploy-dev.yml promotes it to ECR and deploys dev
11+
→ deploy.yml promotes it to ECR and deploys dev
1212
→ prepare-release.yml leaves exactly one draft release
1313
1414
Draft published
15-
→ deploy-prod.yml deploys prod
15+
→ deploy.yml deploys prod
1616
```
1717

1818
### Content-addressed images
@@ -24,9 +24,9 @@ tags are aliases on the same manifest. Run it locally with `pnpm app:sha`.
2424

2525
Two things fall out of that. `publish-ghcr-image.yml` probes GHCR for the content SHA and skips both
2626
arch builds when it is already published. And because the image tag is what Terraform pins, the task
27-
definition of such a release is byte-identical, so `deploy-dev.yml` and `deploy-prod.yml` skip the ECS
28-
roll too — each compares the revision the apply registered against the one the service runs. Terraform
29-
still applies every release; terraform changes are exactly what these releases carry.
27+
definition of such a release is byte-identical, so `deploy.yml` skips the ECS roll too — it compares
28+
the revision the apply registered against the one the service runs. Terraform still applies every
29+
release; terraform changes are exactly what these releases carry.
3030

3131
The footer therefore shows the release that last *changed* the app, not the one being cut — that is
3232
what is running. `APP_VERSION` reaches the container at start rather than being baked in, and the
@@ -45,38 +45,70 @@ fail. The alias resolves to the same manifest, and the ECR destination stays con
4545

4646
## Workflows
4747

48-
### Dev Deployment (`deploy-dev.yml`)
48+
### Deployment (`deploy.yml`)
4949

50-
**Triggers:**
51-
- Completion of `publish-ghcr-image.yml`, so the image is guaranteed to exist rather than polled for
52-
- Manual dispatch with a version tag (break-glass, e.g. to roll back)
50+
One workflow for both environments, with the target derived from the trigger. Dev and prod deploy the
51+
same image, so parity is structural rather than maintained by hand.
52+
53+
| Trigger | Target |
54+
|---|---|
55+
| Completion of `publish-ghcr-image.yml` | dev — the image is guaranteed to exist rather than polled for |
56+
| A GitHub Release being published | prod — the single draft `prepare-release.yml` left |
57+
| Manual dispatch | either, chosen by the `environment` input (break-glass, e.g. to roll back) |
5358

5459
**Jobs:**
55-
1. `guard` - Resolves the tag and its content SHA, and refuses anything not reachable from `main`
56-
2. `promote` - Copies the GHCR manifest list into ECR as `dev-app-<sha>`, keeping both arches. Skipped when that tag is already there
60+
1. `guard` - Resolves the target, the tag and its content SHA, refuses anything not reachable from `main`, and derives the per-environment names below
61+
2. `promote` - Copies the GHCR manifest list into that account's ECR, keeping both arches. Each tag is written only if absent
5762
3. `terraform` - Applies each stack serially in dependency order, planning immediately before each apply
58-
4. `roll` - Moves the service to the new task definition revision unless it already runs it, then checks `/api/health` and that `/api/metrics` is 403
59-
5. `draft-release` - Calls `prepare-release.yml` (not on dispatch)
63+
4. `roll` - Moves the service to the new task definition revision unless it already runs it, then checks `/api/health` and that `/api/metrics` is 403, both over the ALB by name
64+
5. `draft-release` - Calls `prepare-release.yml` (dev only, and not on dispatch)
65+
66+
**Domains:** `https://dev.block-explorer.vechain.org`, `https://block-explorer.vechain.org`
67+
68+
#### What actually differs between the two
69+
70+
`vars.AWS_OIDC_ROLE_ARN` resolves per GitHub Environment and prod approval is a protection rule on the
71+
same object, so credentials need no conditional. `TF_VAR_prod_deploy_role_arn` and `TF_VAR_dns_role_arn`
72+
are each set on one Environment only and pass unconditionally — the other resolves to `""`, which is
73+
the off value the stacks already read as "not cross-account". Everything else is derived: the task
74+
family is `block-explorer-<env>-frontend`, the config is `terraform/environments/<env>/<env>.yaml`,
75+
and the workspace and backend config follow the same pattern. That leaves three `guard` outputs:
6076

61-
**Domain:** `https://dev.block-explorer.vechain.org`
77+
| | dev | prod |
78+
|---|---|---|
79+
| `image_tag` | `dev-app-<sha>` — the registry is shared with previews | `app-<sha>` |
80+
| `alias_tag` | none | `v.X.Y.Z`, aliased onto the same manifest |
81+
| `stacks` | the 8, plus `dns` and `preview-edge` | the 8 |
6282

63-
The `terraform` job takes the default checkout and passes the tag through as a string. It holds AWS
64-
credentials and Terraform executes whatever is in the tree, so checking out a `ref:` taken from event
65-
data would be an untrusted checkout with execution — CodeQL rates it critical and `main`'s
66-
`code_scanning` rule blocks the merge.
83+
The ref a run starts on is part of the security model, because the credentialed jobs apply whatever
84+
Terraform they check out. **Dev takes `main`'s tree with the tag's image; prod takes the tag's own
85+
tree**, which is why a branch is refused there outright.
86+
87+
The `terraform` job never takes a `ref:` from event data — that would be an untrusted checkout with
88+
execution, which CodeQL rates critical and `main`'s `code_scanning` rule blocks. Only the image
89+
travels as a tag, and that is a string.
90+
91+
Stacks are applied serially, each planned immediately before its own apply. The design doc's parallel
92+
plan matrix is deliberately not used: on a first deploy the downstream stacks cannot plan at all until
93+
the upstream state they read exists. The plan file never leaves the job, because a binary plan can
94+
carry state.
95+
96+
The `roll` job checks the ALB by name rather than by DNS. `services-stable` already means the tasks
97+
are healthy in the target group, so what is left to prove is the ALB in front of them — and going
98+
through DNS would let a cached or weighted answer pass the check on the deployed stack's behalf.
6799

68100
---
69101

70102
### Prepare Release Draft (`prepare-release.yml`)
71103

72-
Called by `deploy-dev.yml` after a successful deploy. Deletes any stale `v.*` draft and cuts a fresh
104+
Called by `deploy.yml` after a successful dev deploy. Deletes any stale `v.*` draft and cuts a fresh
73105
one for the just-deployed tag, so cutting prod is "open the single draft and click Publish".
74106

75107
Notes start at the last *published* release, not GitHub's default baseline — every merge to `main`
76108
produces a tag, so the default picks the previous dev-only cut.
77109

78-
It is a `workflow_call` rather than a `workflow_run` chained off the deploy, because "Deploy to Dev"
79-
is itself `workflow_run`-triggered: its own `head_branch` is `main`, so the deployed tag is not
110+
It is a `workflow_call` rather than a `workflow_run` chained off the deploy, because the deploy's dev
111+
path is itself `workflow_run`-triggered: its own `head_branch` is `main`, so the deployed tag is not
80112
recoverable from the event payload. Passing it as an input is exact.
81113

82114
Skipped on `workflow_dispatch` deploys — a break-glass run may be redeploying an older tag, and
@@ -104,27 +136,6 @@ absent. It only annotates findings onto the diff.
104136

105137
---
106138

107-
### Production Deployment (`deploy-prod.yml`)
108-
109-
**Triggers:**
110-
- A GitHub Release being published — the single draft `deploy-dev.yml` left
111-
- Manual dispatch against a tag (break-glass, e.g. to roll back)
112-
113-
**Jobs:**
114-
1. `guard` - Resolves the tag and refuses anything not reachable from `main`
115-
2. `promote` - Copies the GHCR manifest list into the prod account's ECR, keeping both arches
116-
3. `terraform` - Applies each stack serially in dependency order, planning immediately before each apply
117-
4. `roll` - Moves the service to the new revision, then checks `/api/health` and that `/api/metrics` is 403
118-
119-
**Domain:** `https://block-explorer.vechain.org`
120-
121-
Same shape as the dev deploy, in a second account. Both guard the tag identically — it can point
122-
anywhere, so one unreachable from `main` is refused before any credential is handed out. Prod approval
123-
is a GitHub Environment protection rule, and `vars.AWS_OIDC_ROLE_ARN` resolves per Environment, so no
124-
workflow here branches on the target.
125-
126-
---
127-
128139
### Preview Deployment (`deploy-preview.yml`)
129140

130141
**Triggers:**

0 commit comments

Comments
 (0)