Migrate pipeline from CircleCI to GitHub Actions#213
Conversation
phelma
left a comment
There was a problem hiding this comment.
Code Review: #213 - Migrate pipeline from CircleCI to GitHub Actions
Verdict: APPROVE
Posted as a COMMENT-event review: GitHub rejects a formal approval from the PR's own author (
Can not approve your own pull request). The assessed verdict is APPROVE — zero in-scope defects.
Reviewed through five lenses (correctness, security, safety, standards, code quality) against the Variant B family plan. The diff is fully plan-conformant in both directions — every §4 change is present and correctly shaped, and nothing beyond the plan was touched. I re-ran the plan's §5 self-verification gates independently and all pass. I found no in-scope defects at any severity; every substantive finding below challenges something the plan or PR description documents as deliberate, so they are recorded as plan concerns for a human to revisit at the plan level — they do not block this diff.
Two lens findings were disproven by evidence and are excluded from the counts (see False Positives).
Verification performed
| Check | Result |
|---|---|
rubocop Rakefile |
✅ no offenses |
rake -T required tasks present; no circle_ci:* / keys:deploy:* |
✅ |
| Both workflows parse as YAML | ✅ |
bundle lock under .tool-versions ruby |
✅ no-op |
PLATFORMS = ruby + x86_64-linux, no darwin |
✅ |
Decommission (.circleci/, scripts/ci/, circle_ci/github secrets gone; ci/ keeps passphrase+gpg, no ssh.*; dockerhub/ untouched) |
✅ |
git grep -i circleci -- ':!Gemfile.lock' |
✅ no matches |
| GPG key move is a pure rename (similarity 100%) | ✅ |
docker-compose pin v5.3.1 + checksum vs published .sha256 asset |
✅ matches exactly |
False Positives (excluded from counts)
- 🔴→❌ Security — "docker-compose pin references a non-existent release; checksum provenance unverifiable" (major). Disproven.
docker/composev5.3.1 does exist and is the current latest release; I fetched the release's published.sha256asset and it readsf9ebc6ebdb19d769b793c245a736caaeb198c62587f13b25c660c13b4987f959, matching the workflow byte for byte. The comment's provenance claim ("the checksum is from the release's own.sha256asset") is accurate. The lens reasoned from a stale assumption that Compose is still on the v2.x line. - 🔵→❌ Standards — "double space before trailing channel comments may trip
Layout/ExtraSpacing" (suggestion). Disproven. The lens explicitly asked for verification;bundle exec rubocop Rakefilereports no offenses. The prescribed formatting lints clean.
Cross-Cutting Themes
- Secret blast radius exceeds what each job needs (flagged by: Security ×2) —
ENCRYPTION_PASSPHRASE(the git-crypt master key) and the org-wideSLACK_BOT_TOKENboth live in the repo-level Actions store, reachable from any same-repo PR branch's ownpr.yaml. Both are plan-specified (§4.4, D2). Requires write access to exploit, so it is a defence-in-depth gap, not an open door — but worth a plan-level decision on environment-scoping. - Download-verify-install ordering inverted (flagged by: Security, Safety) — the compose guard writes to its final
sudo-owned path before verifying. The checksum is genuine (see above), so this is about ordering robustness only. Plan-specified verbatim; fix belongs fleet-wide. - git-crypt header knowledge duplicated in two idioms (flagged by: Correctness, Code Quality) — the
\0GITCRYPTcheck appears twice (return-nil for Docker Hub creds, raise for the passphrase), and the passphrase variant resolves for everygithub:*task, not justsecrets:ensure.
Tradeoff Analysis
- Parity vs hardening: several lenses independently reached for improvements (scope the passphrase to an environment, put
environment:on the privilegedreleasejob rather than the slot-free gate, add an update-type filter). Each is a real hardening idea and each is explicitly deferred by the plan's parity principle. Recommendation: keep the diff as-is; route these to the post-migration hardening list. - Fail-loud vs fail-quiet: the Docker Hub guard returns
nilsilently in a locked tree (correct for CI, per plan), while the passphrase guard raises. The asymmetry is deliberate and correct, but costs a local maintainer a confusing Dockerdenied— see the inline note.
Strengths
- ✅ Line-accurate implementation of the plan's §4.1/§4.2 YAML — job names, step names and ordering,
timeout-minutes(5/10/30),fetch-depth: 0on exactly the tag-resolving jobs,permissionselevation only where needed, and job-levelmain-tagsconcurrency withqueue: max. - ✅ The concurrency reasoning holds under scrutiny: job-level grouping blocks the job before its checkout, so a queued prerelease fetches tags after the preceding run's tag push —
latest_tagsees rc.1 and correctly bumps to rc.2 rather than colliding. The gate/slot split genuinely prevents a pending approval freezing all main CI. - ✅ Script-injection mitigations are correct:
github.event.head_commit.messageandpull_request.html_urlare both passed viaenv:and quoted, never interpolated into the shell — the single most common vulnerability class in Actions migrations, avoided. - ✅ The
dynamiccredentials guard is correct on the mechanism, not just intent:rake_factorydefers the configuration block to task-run time, andrake_dockerguardsDocker.authenticate!(t.credentials) if t.credentials, so a locked tree'snilis safe. - ✅ Choosing the explicit
\0GITCRYPTheader check over aPsych::SyntaxErrorrescue is the right call — it keeps genuinely malformed credentials loud in an unlocked release tree instead of silently publishing with nil auth after the tag is pushed. - ✅ Slack routing verified against
rake_slack'sroutecontract: correct channel IDs, correct first-match-wins order, mandatory catch-all present, andif: ${{ !cancelled() }}aligns withsilent_outcomeswithout double-suppression. - ✅ Trust separation: git-crypt unlocks only in
prerelease/release; the jobs that execute PR-authored code never see the passphrase or decrypted Docker Hub credentials. - ✅ The
.tool-versions3.1.1 → 3.2.11 bump is correctly paired with the activesupport 8.1.3 lock, pre-empting the issue-24 silent-re-resolve trap;bundle lockverified a no-op. - ✅ Decommission is complete and the GPG key move is a pure rename.
- ✅ Every non-obvious decision carries an inline comment explaining why — rare and valuable in CI config.
General Findings (plan concerns)
- 🔵 Security — the
releaseenvironment gates ordering, not the privileged job. Themaintainersapproval sits onrelease-gate, which runstrueand holds no credentials; thereleasejob that holdscontents: write, the passphrase and the Docker Hub credentials carries noenvironment:key. This is the documented gate/slot split and correctly gates sequencing, so it is not a defect — but environment protection rules exert no authority over the privileged job. Worth a comment on thereleasejob noting why it has noenvironment:, so a future reader doesn't mistake it for an oversight. - 🔵 Safety — recovery posture for the tag-then-publish window is undocumented. The ordering hazard is sanctioned, but the recovery path (delete the orphan tag locally and on the remote, re-run) is nowhere written down, and the failure notification to
buildsgives no hint a tag may need cleaning. One runbook line covers the whole fleet.
Review generated by /accelerator:review-pr
| permissions: | ||
| contents: write | ||
| pull-requests: write |
There was a problem hiding this comment.
🔵 Security / Correctness — plan concern (D3), not a defect of this diff
This job declares permissions: contents: write / pull-requests: write, but for pull_request events triggered by Dependabot GitHub issues a read-only GITHUB_TOKEN regardless of the workflow's permissions: block — the key cannot elevate above the event's ceiling. secrets.GITHUB_TOKEN here also resolves from the Dependabot secret store, which plan §7 deliberately leaves unprovisioned.
Impact: the job fails closed (a 403 on gh pr merge), so there is no vulnerability — but if this is right, D3 auto-merge does not work as specified, fleet-wide. The declared permissions also create the impression write access was granted on a path that runs on every dependabot PR; someone later "fixing" it by reaching for pull_request_target or a PAT would reintroduce exactly the elevated-privilege-on-untrusted-ref pattern this shape avoids.
Suggestion: no change here — this is D3's specified shape. Worth confirming the actual observed behaviour on the pilot repo (issue 06 watch-item territory): if it 403s, the family plan needs a Dependabot-store GH_TOKEN (or dependabot/fetch-metadata + a workflow_run-based merge), and should explicitly rule out pull_request_target as the workaround.
| t.secrets = [ | ||
| { name: 'ENCRYPTION_PASSPHRASE', value: passphrase.chomp } | ||
| ] |
There was a problem hiding this comment.
🔵 Security — plan concern (§4.4 step 3 prescribes this verbatim)
ENCRYPTION_PASSPHRASE is provisioned as a repository-level Actions secret, so it is available to any workflow in the repo — including pr.yaml as modified on a PR branch (same-repo pull_request runs use the PR's own workflow definition and do receive repo secrets). Only prerelease and release need it.
Impact: anyone with write access could open a same-repo PR whose pr.yaml references ${{ secrets.ENCRYPTION_PASSPHRASE }} and exfiltrate the git-crypt master key, decrypting all of config/secrets/** (including Docker Hub credentials) without holding a git-crypt GPG key. This is a mild widening versus CircleCI, where the equivalent var was only reachable on main/dependabot/* — pr.yaml running on every PR (a deliberate family improvement) is what changes the exposure surface. Requires existing write access, so: defence-in-depth gap, not an open door. Fork PRs are unaffected.
Suggestion: raise at the family-plan level rather than deviating here — e.g. provision the passphrase into a publish environment's secret store and add environment: publish to prerelease/release. A pr.yaml job cannot reference an environment secret without declaring the environment, which is visible in review.
| passphrase = File.binread('config/secrets/ci/encryption.passphrase') | ||
| if passphrase.start_with?("\x00GITCRYPT") | ||
| raise 'encryption.passphrase is git-crypt ciphertext — unlock the ' \ | ||
| 'clone before provisioning' | ||
| end |
There was a problem hiding this comment.
🔵 Correctness / Code Quality — plan concern (§4.4 step 3 prescribes this verbatim)
This guard sits in the RakeGithub.define_repository_tasks configuration block, which rake_factory invokes at task-run time for every task in the set — not just github:secrets:ensure. So it also fires for github:environments:ensure, which only creates the release environment and has no need of the passphrase. On a locked clone, ./go github:environments:ensure fails with "encryption.passphrase is git-crypt ciphertext" despite the requested operation being unrelated to secrets. File.binread also raises a bare Errno::ENOENT (not the friendly message) if config/secrets/ci/ has not been generated yet.
Impact: low — pipeline:prepare runs both tasks together and plan §7 requires an unlocked clone anyway, so the happy path is unaffected. It only bites an operator invoking the environments task in isolation, who then gets a misleading diagnosis.
Suggestion: leave as-is for family uniformity. If tightened later, wrap the value in dynamic { ... } so the read and guard resolve only when the secrets parameter is actually consumed — the same idiom §4.4 step 2a already uses for the Docker Hub credentials.
| if ! command -v docker-compose >/dev/null 2>&1; then | ||
| sudo curl -fsSL \ | ||
| "https://github.com/docker/compose/releases/download/v5.3.1/docker-compose-linux-x86_64" \ | ||
| -o /usr/local/bin/docker-compose | ||
| echo "f9ebc6ebdb19d769b793c245a736caaeb198c62587f13b25c660c13b4987f959 /usr/local/bin/docker-compose" \ | ||
| | sha256sum -c - | ||
| sudo chmod +x /usr/local/bin/docker-compose |
There was a problem hiding this comment.
🔵 Security / Safety — plan concern (§4.1/§4.2 prescribe this verbatim)
To be clear up front: the pin itself checks out. I verified docker/compose v5.3.1 exists and that the release's published .sha256 asset reads exactly f9ebc6ebdb19d769b793c245a736caaeb198c62587f13b25c660c13b4987f959 — the comment's provenance claim is accurate. This note is purely about ordering.
curl writes directly to /usr/local/bin/docker-compose and only then pipes the digest to sha256sum -c -. Verify-after-install inverts the safe idiom: the unverified artefact occupies the final, sudo-owned install location on PATH during the verification window.
Impact: low in practice — chmod +x never runs on a failed verification and runners are ephemeral — but any future reordering, retry, or added PATH consumer between the two commands turns a checksum failure into an executed artefact. This step runs as root on runners that later hold ENCRYPTION_PASSPHRASE.
Suggestion: download → verify → install, fleet-wide in the family plan (the same block is in pr.yaml):
tmp=$(mktemp)
curl -fsSL "$URL" -o "$tmp"
echo "$SHA $tmp" | sha256sum -c -
sudo install -m 0755 "$tmp" /usr/local/bin/docker-compose| - name: Pull latest main | ||
| # Approval can land long after the run starts; release publishes main | ||
| # as of approval time, not the tested SHA — parity with the old | ||
| # release.sh, which also pulled. prerelease.sh never pulled, so the | ||
| # prerelease job deliberately has no pull. | ||
| run: git pull |
There was a problem hiding this comment.
🔵 Correctness — plan concern (§4.2 prescribes this step and its comment verbatim)
This git pull is a guaranteed no-op. The preceding actions/checkout@v4 already uses ref: main with fetch-depth: 0, and it runs after the approval gate and after the job acquires the main-tags slot — so it already fetches main as of approval time. Nothing can land between the checkout and the pull. The behaviour the comment describes ("release publishes main as of approval time, not the tested SHA") is delivered by ref: main, not by the pull; the old release.sh needed the pull only because CircleCI had checked out the triggering SHA.
Impact: no behavioural difference, so nothing is broken. It is dead code adding a small failure surface, and it runs before Set CI git author — in the old pipeline configure-git.sh ran before release.sh's pull, so if a pull ever did need a merge commit it would die with "unable to auto-detect email address" where CircleCI would not. Unreachable today (local main is always equal to or behind origin/main, so the pull fast-forwards at worst).
Suggestion: for the family plan, either drop the step (ref: main already achieves the stated parity) or move Set CI git author above it to match the ordering of the pipeline it reproduces.
| t.credentials = dynamic do | ||
| if File.binread('config/secrets/dockerhub/credentials.yaml') | ||
| .start_with?("\x00GITCRYPT") | ||
| nil # tree locked: skip Docker Hub auth (base image is public) | ||
| else | ||
| YAML.load_file('config/secrets/dockerhub/credentials.yaml') | ||
| end | ||
| end |
There was a problem hiding this comment.
🔵 Code Quality — plan concern (§4.4 step 2a prescribes this snippet verbatim)
When the tree is locked this resolves credentials to nil with no diagnostic output. The # tree locked: skip Docker Hub auth comment explains the intent to a reader of the Rakefile, but a maintainer running ./go image:publish in a locked clone locally gets no signal — they see an opaque Docker denied from the push instead.
To be clear, the silent-nil behaviour is correct for CI (it is exactly what lets pr.yaml's test job build without unlocking) — this is only about the local-maintainer experience.
Impact: a locked-tree publish fails with an error pointing at Docker Hub rather than the real cause (the clone was never unlocked), costing debugging time for a condition the code already knows about.
Suggestion: warn 'Docker Hub credentials are git-crypt locked; skipping authentication' on the locked branch before returning nil — cheap, and turns a mystery into a one-line answer. Fleet-wide in the plan if adopted.
| if File.binread('config/secrets/dockerhub/credentials.yaml') | ||
| .start_with?("\x00GITCRYPT") | ||
| nil # tree locked: skip Docker Hub auth (base image is public) | ||
| else | ||
| YAML.load_file('config/secrets/dockerhub/credentials.yaml') | ||
| end |
There was a problem hiding this comment.
🔵 Code Quality — plan concern (deviates from the plan's verbatim snippets)
The git-crypt ciphertext detection (File.binread(path).start_with?("\x00GITCRYPT")) now appears twice in this Rakefile — here for the Docker Hub credentials, and at the ENCRYPTION_PASSPHRASE guard in the RakeGithub block — in two different shapes (return-nil vs raise). The magic-header constant is duplicated primitive knowledge, and this block additionally reads the same file twice on the unlocked path (binread, then YAML.load_file).
Impact: a future correction (e.g. a different git-crypt header form) must be found in two places, and the duplicated read is an easy inconsistency window.
Suggestion: extract a predicate — def git_crypt_locked?(path) = File.binread(path).start_with?("\x00GITCRYPT") — and use it at both sites. Noting explicitly that this deviates from the plan's prescribed snippets, so it is only worth doing if the family carries the helper fleet-wide; do not fix in this repo alone.
| continue-on-error: true | ||
| run: ./go "slack:notify[${{ job.status }}]" | ||
| env: | ||
| SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} |
There was a problem hiding this comment.
🔵 Security — plan concern (D2), post-migration hardening
This step runs ./go "slack:notify[...]" — the PR branch's own Rakefile — with the org-wide SLACK_BOT_TOKEN in its environment. Any same-repo PR branch can therefore read a credential whose blast radius spans the whole organisation's Slack workspace, not just this repo. D2 provisions it org-wide across ~103 repos.
Impact: low likelihood (requires write access, already substantial trust) but wide blast radius. Fork PRs receive no secrets, so external contributors are not a vector.
Suggestion: no change at cutover. Worth recording as post-migration hardening: constrain the Slack bot's scopes to chat:write on the four build channels only, so a leak cannot escalate into broader workspace access.
There was a problem hiding this comment.
Pull request overview
Migrates the repository’s CI/CD pipeline from CircleCI to GitHub Actions, moving build/test/release orchestration into GA workflows and updating the Ruby/Rake tooling to support the new pipeline (including git-crypt/GPG handling and Slack notifications).
Changes:
- Replace CircleCI pipeline/scripts with GitHub Actions workflows for PR and main (including prerelease/release gating and concurrency controls).
- Update Rake tasks and dependencies to remove CircleCI-era provisioning and add GitHub/Slack-focused automation.
- Move the encrypted CI GPG key into
.github/and update documentation accordingly.
Reviewed changes
Copilot reviewed 20 out of 26 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
.github/workflows/pr.yaml |
Adds PR checks/tests and dependabot auto-merge job. |
.github/workflows/main.yaml |
Adds main-branch CI plus prerelease/release jobs with environment gate and concurrency controls. |
Rakefile |
Switches provisioning from CircleCI to GitHub Actions secrets/environments; adds Slack notification tasks; adds dockerhub-credential guard for locked git-crypt trees. |
Gemfile |
Drops CircleCI/SSH gems; adds rake_factory and rake_slack. |
Gemfile.lock |
Updates dependency set to match Gemfile changes and toolchain refresh. |
.tool-versions |
Updates Ruby version used by the toolchain/CI. |
README.md |
Updates CI key management docs to point to .github/gpg.private.enc. |
.github/gpg.private.enc |
Adds the encrypted CI GPG private key for git-crypt unlock in GA. |
.circleci/config.yml |
Removes CircleCI configuration. |
scripts/ci/steps/test.sh |
Removes CircleCI test step script. |
scripts/ci/steps/prerelease.sh |
Removes CircleCI prerelease step script. |
scripts/ci/steps/release.sh |
Removes CircleCI release step script. |
scripts/ci/steps/merge-pull-request.sh |
Removes CircleCI dependabot merge script. |
scripts/ci/common/install-slack-deps.sh |
Removes CircleCI-specific Slack deps installer. |
scripts/ci/common/install-gpg-key.sh |
Removes CircleCI-specific encrypted GPG key install/decrypt script. |
scripts/ci/common/install-git-crypt.sh |
Removes CircleCI-specific git-crypt installer. |
scripts/ci/common/install-docker.sh |
Removes CircleCI-specific docker installer. |
scripts/ci/common/install-docker-compose.sh |
Removes CircleCI-specific docker-compose installer. |
scripts/ci/common/install-asdf.sh |
Removes CircleCI-specific asdf install script. |
scripts/ci/common/install-asdf-dependencies.sh |
Removes CircleCI-specific asdf dependency install script. |
scripts/ci/common/configure-git.sh |
Removes CircleCI-specific git author configuration script. |
scripts/ci/common/configure-asdf.sh |
Removes CircleCI-specific asdf plugin configuration script. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| - name: Merge pull request | ||
| run: gh pr merge --merge "$PR_URL" | ||
| env: | ||
| PR_URL: ${{ github.event.pull_request.html_url }} | ||
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
Cutover to GitHub Actions per the Variant B family plan (docker image).
Includes decommission — merging this PR completes the repo's migration.
releaseenvironment gate.github/CircleCI's
setup_remote_docker+ install scripts); the integration suitebuilds the image and runs its docker-compose dependencies on the runner
dynamicblockdetecting the git-crypt
\0GITCRYPTheader, nil credentials when locked)so PR checks run without unlocking git-crypt — the one deliberate change
inside the
imagenamespacerake_slack; dependabot auto-merge jobrake_githubsecrets/environments;rake_circle_cidropped.circleci/,scripts/ci/, the CI SSH deploykey pair and its
keys:deploy/deploy_keysprovisioning, and the storedCircleCI/GitHub API credentials (
config/secrets/{circle_ci,github}/)Deliberate decisions (not defects)
This cutover reproduces the CircleCI pipeline's behaviour, warts included;
fixing inherited hazards is post-migration work. In particular:
version:bump[rc]/version:releasetag and push beforeimage:publishruns — a failed publish leaves a version tag with no imagebehind it. Pre-existing ordering inside the untouched release logic.
An rc image is published to Docker Hub on every push to
mainwith noapproval gate; only full releases are gated (
environment: release).version:bump[rc]on a released version increments minor and startsrc.1— every release cycle is a minor bump. Pre-existinglib/version.rblogic.Dependabot auto-merge accepts any update type that passes checks, and the
merge does not trigger a release build — on CircleCI the merge commit
carried
[skip ci], so this matches. Updates ship with the nexthuman-triggered release.
The old pipeline ran CI only on
mainanddependabot/*branches;pr.yamlrunning on every pull request is a deliberate family-uniformimprovement.
The
releasejob pullsmainat approval time, so a delayed approvalreleases main as it stands then, not the SHA this run tested — parity with
the old
release.sh(which also pulled;prerelease.shdid not, so theprerelease job has no pull).
Version tags live in git only — there is no version-bump commit, so no
push-back commit and nothing for the loop-guard to catch;
skip-ci-checkstays as family-uniform belt-and-braces.
asdf_install@v1is our own action (infrablocks/github-actions); we arehappy tracking its major version tag.
Jobs that resolve version tags (
test,prerelease,release) check outwith
fetch-depth: 0— the Rakefile'slatest_tagneeds the full tag set.The
testjobs carry a guard step installing standalonedocker-composeonly if the runner image lacks it (the Rakefile shells out to the
standalone binary); it is a no-op where compose is present or unused.
Job scaffolding is repeated flat per job by design: the logic lives in the
build system (
./go/rake) and CI stays lean — it just triggers tasks andsupplies secrets/context.
Gemfile.lockcarries transitive major bumps — the unavoidable resolutionof the targeted
bundle lock --update, not scope creep.Small hunks may appear where the refreshed toolchain's rubocop
autocorrects existing code — required by the
library:checkverificationgate, not drive-by refactoring.
Provisioning (
pipeline:prepare) authenticates with the operator's ambientghlogin (GITHUB_TOKENfallback) instead of a stored PAT — a deliberateparity deviation; the stored token in
config/secrets/github/config.yamlis deleted with the rest of the CircleCI-era credentials.
Deleting
config/secrets/{circle_ci,github}/removes the CircleCI APItoken and stored PAT from the tree but does not revoke them — both
remain live at their issuers and recoverable (git-crypted) from history.
Revocation is a host-side step on the end-of-migration sweep checklist,
alongside disabling the CircleCI project and deleting the deploy key.
Do not merge manually — the pipeline merges once checks are green.
Disabling the CircleCI project, deleting the
CircleCIdeploy key, andrevoking the CircleCI API token + stored PAT are deferred to the
end-of-migration sweep.