The build-image.yml workflow now exists in
internal-portal/.github/workflows/, but hasn't run green
end-to-end yet (it needs the terraform from
Terraform bring-up applied
first). Follow it and report mismatches. Status in
.claude/features/deploy-pipeline/tasks.md § P1.
build-image.yml is the only sanctioned path to a production image —
once it's green, just publish from a laptop is retired. It's also the
org template: every future app repo copies this workflow nearly verbatim.
Prerequisites
Done once, from Terraform bring-up:
-
The
internal/portalECR repo and thegha-internal-portalIAM role exist (terraformapplyinlive/prod). -
On
ShyftSolutions/internal-portal, set a repository variable (not a secret — it's not sensitive) namedAWS_GHA_ROLE_ARNto theportal_gha_role_arnterraform output:Settings → Secrets and variables → Actions → Variables → New variable AWS_GHA_ROLE_ARN = arn:aws:iam::973309142234:role/gha-internal-portal
No AWS access keys are stored anywhere — auth is OIDC (below).
What the workflow does
Two jobs. The gate runs the standardized just
recipes that every app shares; build-and-push does the CI-specific
image plumbing.
push to main / v* tag
│
▼
gate: just install → just typecheck → just lint
│ (smoke is deferred — needs a Postgres service; see below)
▼
build-and-push:
strip dev-bypass route (Containerfile refuses to build with it present)
OIDC → assume AWS_GHA_ROLE_ARN
ECR login
compute tags (metadata-action)
buildx build + push → linux/amd64 + linux/arm64
OIDC, not keys
permissions: id-token: write lets the job mint a short-lived OIDC
token; aws-actions/configure-aws-credentials trades it for temporary
STS credentials by assuming AWS_GHA_ROLE_ARN. The role's trust policy
(in the gha-oidc-role module) only accepts that exchange from
ShyftSolutions/internal-portal on main or a v* tag — a PR from a
fork can't assume it.
Why buildx here, not just image
just image / just publish are laptop recipes: single-arch, docker
daemon, SSO profile. CI needs multi-arch (amd64 + arm64) and OIDC push,
so the workflow uses docker/build-push-action directly. The
app-logic gate still goes through just so it's identical to what you
run locally.
Tag strategy
Computed by docker/metadata-action. Which tags land depends on the
trigger (this is decision D2):
| Tag | When | Mutable? | Purpose |
|---|---|---|---|
sha-<short> | every build | no (by discipline) | Immutable provenance — exact commit |
v<semver> | push of a v* tag | no | The release |
stable | push of a v* tag | yes | "Current release" pointer |
main | push to main | yes | "Latest main" pointer |
ECR tag-immutability is repo-wide, and we need :stable to
move — so the repo is MUTABLE and immutability of
:v*/:sha- is enforced by CI never re-pushing
them. The ecr-repo module header notes the
IMMUTABLE_WITH_EXCLUSION upgrade path if we want it in metal.
Prod pins an immutable tag (:v<semver> or :sha-<short>); never
:stable/:main for a real rollout.
Testing it
# 1. Trigger a main build — push any no-op commit to main (or use the
# workflow_dispatch button in the Actions tab).
# 2. Watch: Actions tab → build-image → both jobs green.
# 3. Confirm the tags landed:
aws ecr list-images --repository-name internal/portal \
--region us-east-2 --profile internal \
--query 'imageIds[].imageTag' --output table
# expect: sha-<short>, main
# 4. Cut a release tag and confirm the release tags:
git tag v0.3.0 && git push origin v0.3.0
# expect additionally: v0.3.0, stableFailure modes
| Symptom | Likely cause |
|---|---|
Not authorized to perform sts:AssumeRoleWithWebIdentity | AWS_GHA_ROLE_ARN unset/wrong, or the push ref isn't main/v* (trust policy rejects it) |
Error: failed to solve: ... dev-bypass | The dev-bypass strip step didn't run / route re-added — Containerfile refuses prod builds with it present |
ECR push denied | Role's push policy doesn't cover this repo ARN, or the ECR repo doesn't exist yet (run terraform) |
| arm64 build slow/OOM | QEMU emulation of arm64 on an amd64 runner is slow; acceptable for now, native arm64 runners are a later optimization |
Deferred
The gate job runs typecheck + lint but not just smoke — Playwright
needs a Postgres service container and a browser install, which would
make the first pipeline flaky. It's a follow-up: add a services: postgres job once the happy path is proven.