Migrate pipeline from CircleCI to GitHub Actions - #214
Conversation
nokogiri 1.18.8 was yanked from rubygems, so bundle install failed in CI. Targeted 'bundle lock --update=nokogiri' bumps it to 1.19.4 (linux platform entry becomes x86_64-linux-gnu). No other gems changed.
phelma
left a comment
There was a problem hiding this comment.
Code Review: #214 - Migrate pipeline from CircleCI to GitHub Actions
Verdict: COMMENT
Reviewed through five lenses (correctness, security, safety, standards, code quality) against the Variant B family plan. The diff is a faithful, near-verbatim implementation of the plan: both workflows reproduce §4.1/§4.2 exactly (job/step names and order, timeout-minutes, fetch-depth: 0 where tags matter, per-job permissions, the gate/slot concurrency split), the three retained/added scripts match their prescribed bodies, the Rakefile blocks and prerelease:publish task carry the correct node-exporter-aws substitutions, and decommission is complete (git grep -i circleci clean, config/secrets/ci/ holds only encryption.passphrase + the GPG pair, no prerelease gems in the lockfile, Ruby bumped to 3.3.11). Only two small in-scope deviations were found; the remaining findings all challenge decisions the plan or PR description documents as deliberate and are recorded below for a human to revisit at plan level.
Cross-Cutting Themes
queue: maxconcurrency key (flagged by: correctness, safety) — three lenses independently doubted this key exists in the Actions schema. It is prescribed verbatim by plan §4.2, which cites the 2026-05-07 GA changelog for larger concurrency queues. Plan concern, not a defect of this diff — but if the key is in fact invalid,main.yamlfails to parse fleet-wide, so it is worth one host-side confirmation before the family rolls out.- docker-compose pinned to
v5.3.1(flagged by: correctness, security, safety, code quality) — all four lenses read v5.x as implausible for docker/compose. Plan §4.7 pins exactly this version and checksum and records it as verified on 2026-07-20 against the release'schecksums.txt, explicitly asking reviewers not to re-raise it. Plan concern. Same note as above: a wrong pin fails everytestjob. - Supply-chain pinning by mutable tag (flagged by: security) —
asdf_install@v1/actions/checkout@v4. Plan §1 explicitly accepts tracking thev1major tag for our own action. Plan concern.
Tradeoff Analysis
- Security vs. plan parity (PR prerelease publish): the security lens notes the
prereleasejob decryptsENCRYPTION_PASSPHRASEand Docker Hub credentials while running branch-controlled code (Rakefile,go,scripts/ci/*), so push access is effectively promoted to secret-read access with no reviewer in the loop. This is D9 / §4.4 step 7 — deliberate and in scope for the plan. Worth revisiting at plan level (anenvironment:with reviewers, or a PR-namespace-scoped registry token) rather than blocking this PR. - Safety vs. parity (release checks out
ref: mainand pulls): an approver may approve run for commit A and ship commit C. Plan §4.2 prescribes this as parity with the oldrelease.sh. Plan concern.
Strengths
- ✅ Untrusted PR-controlled values (title, URL, number) are passed via
env:rather than${{ }}-interpolated intorun:— the classic GHA script-injection sink is closed throughout. - ✅
gh pr merge --match-head-commit "$HEAD_SHA"closes the checks-pass-then-push race on dependabot auto-merge. - ✅ The
\0GITCRYPTheader guards (credentialsdynamicblock, provisioning passphrase,prerelease:publish) convert silent late failures into immediate, actionable ones — and the header check is correctly preferred over rescuingPsych::SyntaxError. - ✅
assert-release-environment-protected.shturns GitHub's fail-open auto-created environment into a fail-closed gate. - ✅ Explicit
repo_tag:onimage.pushcorrectly works around docker-api resolving the push repository from cachedRepoTags, and the comment explains why. - ✅
git pull --ff-only,cancel-in-progress: falseon all tag/publish jobs, andtimeout-minuteson every job. - ✅ Decommission is complete and surgical; the GPG key move is a pure rename.
- ✅ Comments are self-contained and never leak plan/D-number/migration-process context, per the plan's comment rule.
General Findings
- 🔵 Standards: Two small deviations from the plan's prescribed shapes — see inline comments on
GemfileandRakefile. - 🔵 Security: Docker Hub credentials are read inside Ruby from git-crypted YAML rather than passed as an Actions secret, so they are never registered with
::add-mask::. Any exception or push-error chunk echoing the auth hash would appear verbatim in public logs. The plan accepts credentials-in-git-crypted-YAML; a defensive mask after unlock would be cheap insurance. (Not plan-specified — genuinely open.)
Plan Concerns (deliberate decisions challenged by lenses — not blocking)
Recorded so a human can revisit the plan, per the parity principle in §1:
- 🔴→PC
queue: maxon theprereleaseandreleasejobs (main.yaml) — plan §4.2 verbatim. - 🔴→PC docker-compose
v5.3.1+ sha256 pin — plan §4.7 verbatim, recorded as verified 2026-07-20. - 🟡→PC
release-gatetoken scope:gh api .../environments/releasemay need more than the inheritedcontents: readGITHUB_TOKEN; if it 403s,[ "" -eq 0 ]is a confusing bash error rather than a clear message. Plan §4.7 prescribes the script body verbatim. - 🟡→PC
release-gateassertion runs after approval, so it reports rather than prevents the unprotected-environment case; andenvironment: releasesits on the gate, not on the privilegedreleasejob. Both are the plan's deliberate gate/slot split (§4.2). - 🟡→PC
prerelease:publisharguments unvalidated — an empty arg yields…-rc.1.pr.., rejected only at push time after a full build. Plan §4.4 step 7 gives the exact task body. - 🟡→PC PR prerelease job exposes secrets to any branch pusher — D9, documented.
- 🟡→PC
releasepublishes main at approval time, not the approved SHA — plan §4.2 parity withrelease.sh. - 🟡→PC Actions pinned by mutable tag (
asdf_install@v1) — plan §1 accepts. - 🔵→PC Tag-then-publish ordering leaves a version tag with no image on a failed publish; no recovery runbook documented. Plan §1 parity hazard.
- 🔵→PC Deleted CircleCI secret files are not revoked at their issuers — plan §1 and "Out of scope"; tracked on the end-of-migration sweep.
- 🔵→PC README's
openssl … -md sha1legacy KDF — pre-existing, untouched by this diff. - 🔵→PC Rakefile duplication / task length: the
\0GITCRYPTsniff appears three times,node-exporter-awsis repeated four times inprerelease:publish, and the locked-tree credential skip is silent. All are the plan's verbatim templates; extracting helpers would be a family-wide plan amendment, not per-repo drift. - 🔵→PC
latest_tag.rc!bang-method mutation inprerelease:publish— plan verbatim; benign given the current task ordering.
Review generated by /accelerator:review-pr
| gem 'rake_git' | ||
| gem 'rake_git_crypt' | ||
| gem 'rake_factory', '~> 0.33.0' | ||
| gem 'rake_git', '~> 0.2.0' |
There was a problem hiding this comment.
🔵 Standards — severity: minor | confidence: medium
Plan §4.5 prescribes adding gem 'rake_slack' and gem 'rake_factory', and says version constraints should be added only where needed to hold a gem to its stable line (the no-prerelease-gems rule). Four pins appear here — rake_factory '~> 0.33.0', rake_git '~> 0.2.0', rake_git_crypt '~> 0.4.0', ruby_git_crypt '~> 0.1.0' — while every other gem stays unconstrained.
Impact: rake_git and ruby_git_crypt were previously unpinned and are now held at older lines; more pins than necessary is per-repo drift from the family-uniform Gemfile shape, which bites when the fleet upgrades.
Suggestion: Keep only the pins genuinely load-bearing for the anti-prerelease rule (likely rake_factory), drop the rest — or note in the PR description why each is required. Note plan §4.5 also requires rake_git_crypt >= 0.4.0 and rake_github >= 0.17.0 in the lockfile, which ~> 0.4.0 satisfies but also caps.
| end | ||
| end | ||
|
|
||
| # rubocop:disable Metrics/BlockLength |
There was a problem hiding this comment.
🔵 Standards / Code Quality — severity: minor | confidence: high
A # rubocop:disable Metrics/BlockLength / # rubocop:enable pair has been added around the RakeGithub.define_repository_tasks block. Plan §4.4 step 3's template does not include it, and the plan's comment rule says the templates are "a ceiling, not a floor — reproduce them, do not add more".
Impact: Cosmetic drift from the family-uniform Rakefile. If the block genuinely exceeds the limit here it will exceed it in every sibling repo too, so the fix belongs in the plan rather than in one repo.
Suggestion: Confirm the disable is actually needed under this repo's rubocop config; if it is, raise it as a plan amendment so the whole family stays diffable against one template. (Extracting github_token / ci_passphrase helpers above the block would shrink it below the limit without a suppression — also a plan-level change.)
| concurrency: | ||
| group: main-tags | ||
| cancel-in-progress: false | ||
| queue: max |
There was a problem hiding this comment.
🔵 Plan concern (correctness + safety lenses) — not a defect of this diff
Both lenses independently flagged queue: max as absent from the Actions concurrency schema (which they read as accepting only group and cancel-in-progress). This line is prescribed verbatim by family plan §4.2, which cites the 2026-05-07 changelog announcing larger concurrency queues, so it is deliberate and out of scope for this PR.
Raising it only because the blast radius is fleet-wide: if the key is in fact unsupported, main.yaml fails to parse in every migrated repo, and if it is silently ignored the documented "keep every queued publish" property does not hold (Actions retains only the newest pending run). Worth one host-side confirmation on the pilot's first main run before the family rolls out. Same applies to the release job's identical block.
| # fleet-wide change, not per-repo drift. | ||
| if ! command -v docker-compose >/dev/null 2>&1; then | ||
| curl -fsSL \ | ||
| "https://github.com/docker/compose/releases/download/v5.3.1/docker-compose-linux-x86_64" \ |
There was a problem hiding this comment.
🔵 Plan concern (correctness, security, safety, code-quality lenses) — not a defect of this diff
All four lenses read v5.3.1 as implausible for docker/compose (they expect the v2.x line) and therefore could not corroborate the sha256. This pin and checksum are prescribed verbatim by plan §4.7, which records them as verified on 2026-07-20 against that release's checksums.txt and explicitly asks reviewers not to re-raise it — so this is deliberate.
Flagged only for blast radius: if the pin were wrong, curl -fsSL 404s under set -euo pipefail and every test job on both workflows fails at setup, fleet-wide. The PR's own test check is the authoritative proof — confirm it went green on a runner before merging.
The script's structure is otherwise sound: verify-before-install in a scratch path, sudo install only after sha256sum -c passes.
| # step. | ||
| count=$(gh api \ | ||
| "repos/${GITHUB_REPOSITORY}/environments/release" \ | ||
| --jq '.protection_rules | length') |
There was a problem hiding this comment.
🔵 Plan concern (correctness + safety lenses) — not a defect of this diff
This script body is prescribed verbatim by plan §4.7, so the shape is deliberate. Two lens observations worth carrying back to the plan rather than fixing here:
- Token scope:
gh api repos/.../environments/releasetypically needs more than the workflow-levelcontents: readtherelease-gatejob inherits. If it returns 403/404,--jqyields an empty string and[ "" -eq 0 ]fails with an opaque bash error — indistinguishable from "genuinely unprotected". Acount=$(... || echo "")plus an explicit non-numeric check would separate "cannot check" from "not protected". - Ordering: because
environment: releasesuspends the job before any step runs, this assertion executes only after a human has already approved — so it reports the unprotected case rather than preventing it. (It still fails the release closed, which is the substantive guarantee.)
Both are family-wide, so they belong in the plan, not this repo.
| # Docker tags forbid '+', so we reuse the semver '-'/'.'-separated | ||
| # form and no build metadata. No git tag is created, pushed, or | ||
| # committed — unlike version:bump this only ever tags the built image. | ||
| pr_tag = "#{latest_tag.rc!}.pr#{args.pr_number}" \ |
There was a problem hiding this comment.
🔵 Plan concern (correctness lens) — not a defect of this diff
args.pr_number / args.run_number / args.run_attempt are interpolated straight into the image tag with no presence check. A misinvocation (or an empty env var in the workflow) yields e.g. 0.2.0-rc.1.pr.., which Docker rejects only at push time — after a full image build has already run.
The task body is prescribed verbatim by plan §4.4 step 7, so this is out of scope here. If the plan is revisited, a fail-fast guard at the top of the task would turn a late, opaque registry error into an immediate one:
%i[pr_number run_number run_attempt].each do |k|
raise ArgumentError, "#{k} required" if args[k].to_s.empty?
end| if: >- | ||
| github.event.pull_request.head.repo.full_name == github.repository && | ||
| github.event.pull_request.user.login != 'dependabot[bot]' | ||
| concurrency: |
There was a problem hiding this comment.
🔵 Plan concern (security lens) — not a defect of this diff
The guard correctly excludes forks and dependabot, and keys on the immutable user.login rather than github.actor — both right. The lens notes the residual: anyone with push access can open a same-repo PR whose branch modifies Rakefile, go, or scripts/ci/common/*, and that modified code then runs in the job that decrypts ENCRYPTION_PASSPHRASE and the Docker Hub credentials — no reviewer in the loop.
This is D9 / plan §4.4 step 7, explicitly in scope and deliberate, so it does not block. Carrying it back to the plan: if PR-time publish proof is worth keeping (it is), binding this job to an environment: with reviewers, or issuing a push-only Docker Hub token scoped to the PR tag namespace, would close the gap without losing the feature.
| - uses: actions/checkout@v4 | ||
| with: | ||
| ref: main | ||
| fetch-depth: 0 |
There was a problem hiding this comment.
🔵 Plan concern (safety lens) — not a defect of this diff
ref: main plus git pull --ff-only means the release publishes main as of approval time, not the SHA whose checks the approver saw. Approve for commit A, ship commit C.
Plan §4.2 prescribes exactly this as parity with the old release.sh (which also pulled), and the PR description documents it — deliberate. --ff-only correctly makes a non-fast-forward fail loudly rather than creating a CI merge commit. Noted only so the tradeoff is visible to a human revisiting the plan; pinning to github.sha would be the alternative.
There was a problem hiding this comment.
Pull request overview
This PR completes the repository’s CI/CD migration from CircleCI to GitHub Actions, updating the build/release pipeline, secrets handling, and related tooling to align with the “Variant B family plan” for Docker image publishing.
Changes:
- Replaces CircleCI config and step scripts with GitHub Actions workflows for PR and main (including prerelease + gated release).
- Updates Rake tasks to provision GitHub secrets/environments, adds Slack notification tasks, and adjusts Docker Hub credential loading for locked git-crypt trees.
- Refreshes the Ruby/tooling stack (Ruby 3.3.11 and dependency lock updates) and relocates the encrypted CI GPG key into
.github/.
Reviewed changes
Copilot reviewed 21 out of 27 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| scripts/ci/steps/test.sh | Removes CircleCI test step script (superseded by GHA jobs). |
| scripts/ci/steps/release.sh | Removes CircleCI release step script (superseded by GHA release job). |
| scripts/ci/steps/prerelease.sh | Removes CircleCI prerelease step script (superseded by GHA prerelease job). |
| scripts/ci/steps/merge-pull-request.sh | Removes CircleCI dependabot merge script (replaced by GHA merge job). |
| scripts/ci/common/install-slack-deps.sh | Removes CircleCI-specific Slack deps installer. |
| scripts/ci/common/install-gpg-key.sh | Removes CircleCI-specific GPG key install script (key artifact moved). |
| scripts/ci/common/install-git-crypt.sh | Updates git-crypt install script for GHA runners (sudo, stricter bash flags). |
| scripts/ci/common/install-docker.sh | Removes CircleCI docker installer (runner daemon used in GHA). |
| scripts/ci/common/install-docker-compose.sh | Adds pinned, checksum-verified docker-compose installer for integration tests. |
| scripts/ci/common/install-asdf.sh | Removes CircleCI asdf install script (replaced by shared asdf action). |
| scripts/ci/common/install-asdf-dependencies.sh | Removes CircleCI asdf dependency installation step. |
| scripts/ci/common/configure-git.sh | Removes CircleCI git author config script (replaced by rake task). |
| scripts/ci/common/configure-asdf.sh | Removes CircleCI asdf plugin configuration script. |
| scripts/ci/common/assert-release-environment-protected.sh | Adds fail-closed guard ensuring release environment has protection rules. |
| README.md | Updates CI key management docs for new encrypted key location under .github/. |
| Rakefile | Swaps CircleCI provisioning for GitHub Actions provisioning; adds Slack + PR prerelease publish task; adds Docker Hub credential locking guard. |
| Gemfile.lock | Updates lockfile for new/updated pipeline dependencies and Ruby/tooling changes. |
| Gemfile | Removes CircleCI gems; pins/selects updated rake-related gems (GitHub/Slack/git-crypt). |
| .tool-versions | Bumps Ruby version to 3.3.11. |
| .github/workflows/pr.yaml | Adds PR workflow: check/test + PR prerelease publish + Dependabot merge job. |
| .github/workflows/main.yaml | Adds main workflow: check/test + prerelease publish + protected release gate + release publish. |
| .github/gpg.private.enc | Adds encrypted CI GPG private key artifact under .github/. |
| .circleci/config.yml | Removes CircleCI pipeline configuration. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| on: | ||
| pull_request: | ||
|
|
| check: | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 30 | ||
| steps: |
| test: | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 30 | ||
| steps: |
| if: >- | ||
| github.event.pull_request.head.repo.full_name == github.repository && | ||
| github.event.pull_request.user.login != 'dependabot[bot]' |
| needs: [check, test] | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 10 | ||
| if: github.event.pull_request.user.login == 'dependabot[bot]' |
|
Closing to re-run this migration fresh through the updated pipeline. |
Cutover to GitHub Actions per the Variant B family plan (docker image).
Includes decommission — merging this PR completes the repo's migration.
releaseenvironment gatebefore merge — see the deliberate-decisions note below)
.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], and the merge subject here carries the same literalmarker (belt-and-braces against a future non-
GITHUB_TOKENtoken).Updates ship with the next human-triggered release.
The old pipeline ran CI only on
mainanddependabot/*branches;pr.yamlrunning on every pull request is a deliberate family-uniformimprovement.
PR prerelease publish (permanent feature, not migration scaffolding).
On every push to a same-repo human PR, the
prereleasejob builds theimage and pushes it to Docker Hub under a single PR-namespaced tag
(
<next-version>.pr<PR>.<run>.<attempt>) — proving the publish path beforemerge instead of discovering a broken push only on
main. It publishesthat tag only, never
latest, and pushes no git tag or commit(
contents: read). Skipped for forks and dependabot (no secrets). Thenamespaced tags can never collide with main's rc sequence; they accumulate
on Docker Hub, the same accepted cost as main's ungated rc publishes.
The
releasejob pullsmain(--ff-only, after the CI git author isset) at approval time, so a delayed approval releases main as it stands
then, not the SHA this run tested — parity with the old
release.sh(which also pulled;
prerelease.shdid not, so the prerelease job has nopull). A non-fast-forward pull fails loudly rather than merging.
The
release-gatejob asserts thereleaseenvironment has protectionrules before approving — GitHub silently auto-creates an unprotected
environment if provisioning never ran, which would release ungated.
Version tags live in git only — there is no version-bump commit, so no
push-back commit to guard against.
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.
Where the repo's Ruby was older than 3.3.11,
.tool-versionsis bumped to3.3.11per the org-wide Ruby policy (plan §4.5) — older Rubies cannothold a stable lockfile on CI.
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.
🏭 This PR was opened by Foundry, Atomic's AI software development
factory. Implementation, review, and fixes are performed by AI agents;
merges happen automatically once the review and checks gates pass.
This task migrates a Docker image repo's CI from CircleCI to GitHub Actions.
migratemigrate-docker2026-07-20T22-00-29-255Zatomic-foundry-pr · foundry-pipeline: migrate · foundry-task: migrate-docker · foundry-run: 2026-07-20T22-00-29-255Z