Migrate pipeline from CircleCI to GitHub Actions#80
Conversation
phelma
left a comment
There was a problem hiding this comment.
Code Review: #80 - Migrate pipeline from CircleCI to GitHub Actions
Verdict: COMMENT
Reviewed through five lenses (correctness, security, safety, standards, code quality) against the Variant A family plan. The workflow files reproduce the plan's §4.1/§4.2 templates faithfully, decommission is complete and verifiable, and the Rakefile guards (git-crypt ciphertext detection, fail-fast token resolution) are well done. Findings below separate genuine in-scope defects from concerns about decisions the plan documents as deliberate — the latter are recorded for a future plan revision, not as blockers for this diff.
Cross-Cutting Themes
concurrency: queue: max(flagged by: correctness, safety) — both lenses believequeueis not a recognisedconcurrencykey. It is prescribed verbatim by plan §4.2, which cites the 2026-05-07 Actions changelog introducing it. Recorded as a plan concern: worth a one-off confirmation against a live run that the key is honoured rather than silently ignored, since if it is ignored the stated "no queued run is dropped" guarantee does not hold fleet-wide.- No
buildjob onmain.yaml(flagged by: correctness, safety) —library:buildgates PRs but not main. Plan-conformant (§4.2 specifies check/test/prerelease/release only), so a plan concern rather than a defect, but it means a packaging regression on a direct main push is first discovered mid-release.
Tradeoff Analysis
- Fleet consistency vs local readability: the Rakefile extracts the plan's inline token/passphrase snippets into named helper methods. Genuinely more readable, but it diverges from the shape ~103 sibling repos will land, which is what makes fleet-wide grep/diff auditing possible. Recommendation: either inline per the template, or promote the extracted form into the plan so every repo gets it.
Strengths
- ✅
pr.yamlandmain.yamlmatch the §4.1/§4.2 authoritative templates — job names,needs,timeout-minutes,permissions, and the template comments verbatim with nothing added. - ✅
merge-pull-requestcorrectly omitsprereleasefromneeds, avoiding the skipped-dependency freeze of the Dependabot auto-merge. - ✅ Untrusted PR facts (title, number, head SHA) are passed via
env:and referenced as$VAR, avoiding the classic${{ }}shell-injection sink. - ✅
--match-head-commit "$HEAD_SHA"closes the window between checks passing and the merge landing. - ✅
read_encryption_passphrasefails loudly on a locked clone rather than uploading git-crypt ciphertext as a repository secret — a failure that would otherwise surface as an opaque GPG error much later. - ✅
prerelease:publishrestoresversion.rband removes the built gem in anensureblock. - ✅ Decommission is thorough and verified:
.circleci/gone,scripts/ci/retains only the two portable scripts,config/secrets/ci/has nossh.*,circle_ci/andgithub/secret dirs gone,git grep -i circleciclean, GPG key move is a pure rename,rubyplatform correctly re-added to the lockfile.
General Findings
In-scope defects
- 🟡 Standards / plan conformance —
Gemfile.lock:BUNDLED WITHjumped2.3.26→4.0.16. Sibling migrated repos (rake_slack,rake_github,lino) all sit on2.5.15. Bundler 4 is a major release with changed defaults, and the runner installs the bundler the lockfile pins. This is an operator-environment artefact riding along with the migration, not a resolution outcome sanctioned by §4.5. Recommend restoring a bundler version consistent with the fleet. - 🔵 Standards —
Rakefile:141-175: the §4.4 step 3 snippets were restructured into top-levelgh_auth_token/resolve_github_token/read_encryption_passphrasemethods, with reworded raise messages ("expected an unlocked clone"vs the plan's"expected a git-crypt-unlocked clone with the CI secrets present") and the combined comment split across two methods. See the tradeoff above. - 🔵 Standards —
Rakefile:133:documentation:update's commit message was refactored from"...#{skip_ci ? ' [ci skip]' : ''}"to"...#{' [ci skip]' if skip_ci}". Behaviour-preserving but out of scope — §4.4 closes with "Do not restructure anything else in the Rakefile." - 🔵 Security — the CircleCI API token, the GitHub PAT and the SSH deploy key are removed from the tree but remain live and present in git history. Revoking them (alongside the plan's host-side deploy-key deletion) belongs in the decommission checklist.
- 🔵 Code quality —
gh_auth_tokenrescues onlyErrno::ENOENT; the installed-but-unauthenticated case falls through to the same raise, so behaviour is correct, but therescuereads as if it were the sole failure mode. A one-line comment would help the next reader.
Plan concerns (challenges to documented deliberate decisions — for a future plan revision, not blockers)
- 🔵
concurrency: queue: maxonprerelease/release(§4.2 verbatim) — see cross-cutting themes. - 🔵 No
buildjob onmain.yaml(§4.2) — see cross-cutting themes. - 🔵 Publish-then-push ordering in
./go release, and nogit pullin the prerelease job before the irreversible RubyGems push — both explicitly listed as inherited hazards in plan §1 and D5. - 🔵 The PR
prereleasejob exposesENCRYPTION_PASSPHRASE(and thus the RubyGems credential) to any same-repo pushable branch, with noenvironment:protection — D8 accepts this, gated to same-repo non-Dependabot PRs. Worth revisiting fleet-wide: a scoped push-only RubyGems key would materially shrink the blast radius. - 🔵 The
prereleasejob carries no job-levelpermissions:— plan §4.1 states this explicitly (top-levelcontents: readis all it needs). - 🔵
.github/gpg.private.encuses openssl's legacy-md sha1EVP_BytesToKey KDF — pre-existing; §4.6 says leave the openssl commands untouched. A-pbkdf2 -iter 600000 -md sha256re-encryption is good post-migration hardening. - 🔵
git pull --ff-onlyin the release job relies on the implicit upstream fromcheckout ref: main(§4.2 verbatim);git pull --ff-only origin mainwould be more robust to a futureref:change. - 🔵
prerelease:publishhard-codes the gem name in three places, infers the built.gemfilename by convention rather thangem build -o, and its VERSION regex matches only single-quoted literals. All three are verbatim from the §4.4 step 8 template; if they are worth fixing, fix them in the plan. - 🔵
prerelease:publish'sensuredoes not run on a hard kill, so an interrupted local invocation leaves a mutatedversion.rb. Harmless on a fresh CI checkout; a footgun locally.
Verified, not a defect
grep -E '\((\S+\.)?(pre|rc|alpha|beta)[.0-9]*\)' Gemfile.lockmatchesenruby (0.1.0.pre.3)— that is the gem's own committed version, not a dependency resolved to a prerelease. §5 check 10 passes.arm64-darwin-25inPLATFORMSis pre-existing onmain, not introduced here; the diff correctly adds the genericrubyplatform per §4.5.rake_git_crypt (0.4.0),rake_github (0.17.0),rake_slack (0.3.0)all meet the plan's floors; norake_circle_ci/rake_sshremain.
Review generated by /accelerator:review-pr
phelma
left a comment
There was a problem hiding this comment.
Code Review: #80 - Migrate pipeline from CircleCI to GitHub Actions
Verdict: COMMENT
Reviewed through five lenses (correctness, security, safety, standards, code quality) against the Variant A family plan. The workflow YAML reproduces the plan's authoritative §4.1/§4.2 shape essentially verbatim, the decommission is complete, and the new Rakefile provisioning guards are genuinely well-built. I found no critical or major in-scope defects — two minor conformance nits, and a set of plan concerns that are documented-deliberate and are recorded here for the plan owner rather than as blockers.
Verification performed
I ran the plan's §5 checks locally rather than taking them on trust:
- ✅
bundle exec rspec— 14 examples, 0 failures - ✅
bundle exec rubocop Rakefile— no offences - ✅ Both workflow files parse as YAML
- ✅
bundle exec rake -Tlists all eight required tasks (git_crypt:unlock_with_encrypted_gpg_key,slack:notify,github:secrets:*,github:environments:*,repository:set_ci_author,pipeline:prepare,prerelease:publish,keys:gpg:generate) and nocircle_ci:*orkeys:deploy:* - ✅
git grep -i circleci -- ':!docs' ':!Gemfile.lock'— no matches - ✅
scripts/ci/retains onlyconfigure-rubygems.sh+install-git-crypt.sh, both trimmed as §4.7 specifies - ✅
config/secrets/ci/= passphrase + gpg.private + gpg.public, nossh.*;circle_ci/andgithub/gone - ✅
.github/gpg.private.encis a byte-identical rename (git show --statreportsBinwith no delta;git log --followtraces through) - ✅ Lockfile:
rake_git_crypt 0.4.0,rake_github 0.17.0,rake_slack 0.3.0present;rake_circle_ci/rake_sshgone
False positives — raised by a lens, disproven by evidence
- "The
documentation:updateternary change is an out-of-scope edit" (standards). Disproven. I reverted the line to its original form and re-ran rubocop: it fails withStyle/EmptyStringInsideInterpolation— a cop from the refreshed toolchain. The change is required by thelibrary:checkgate, exactly as the PR description predicted. - "
GITHUB_TOKENis forced read-only for Dependabotpull_requestruns, sogh pr mergewill 403" (security). Not correct: GitHub explicitly supports raising a Dependabot run's token scope via the job-levelpermissions:key, which is precisely what this job does. This is also the shape D3 prescribes. - "
library:buildleaves an untracked.gembehind" (code quality).*.gemis on line 2 of.gitignore.
General findings — in-scope (2 minor)
- 🔵 Standards / Code Quality: the §4.4 provisioning code was extracted into three top-level Rakefile methods rather than the plan's inline form, and the raise messages were shortened. Functionally equivalent (and arguably more readable), but it is per-repo divergence in a fleet idiom being replicated ~103 times — see inline.
- 🔵 Standards:
Gemfile.lockPLATFORMSstill lists operator-localarm64-darwin-*/x86_64-darwin-*entries, which §4.5 says the section should not carry — see inline.
Plan concerns — documented-deliberate, NOT defects of this diff
The lenses surfaced 21 findings that challenge decisions the plan or PR description records as intentional. They are kept here so the plan owner can revisit them fleet-wide; none should block this PR.
Inherited hazards, explicitly listed in plan §1:
- 🟡 Publish-before-push:
./go releaserunsgem release --tag --pushbeforegit push/git push --tags, so a failed push leaves a published gem with no commit or tag, wedging the next release (correctness + safety — the strongest of the plan concerns, and the one most worth a post-migration fix;git push --follow-tagswould collapse the two failure points into one) - 🟡 The
releasejob'scheckout ref: main+git pull --ff-onlypublishesmainas of approval time, not the SHA the approver saw (correctness + safety; parity with the oldrelease.sh, per PR description) - 🔵 Prerelease publishes to RubyGems on every
mainpush with no approval gate (safety) - 🔵 Dependabot auto-merge accepts any update type that passes checks (security — D3, parity, no update-type filter by design)
- 🔵
asdf_install@v1andactions/checkout@v4are mutable tags rather than pinned SHAs (security — D6, our own action) - 🔵 Job scaffolding repeated flat across seven jobs (code quality — §1 explicit design choice; any factoring belongs in
infrablocks/github-actions)
The plan's exact prescribed code (§4.4 step 8 / step 3) — all verbatim as specified:
- 🔵
sh "gem push #{gem_file}"uses the shell-string form where the surrounding file uses multi-argsh(...) - 🔵
version_patternonly matches single-quotedVERSIONliterals; a quote-style change breaks the publish (fails loudly, so blast radius is limited) - 🔵
%i[pr_number run_number run_attempt]is written twice on adjacent lines - 🔵 The
ensureblock'sFile.write/rm_fcould mask the original publish failure - 🔵
gh_auth_tokenrescues onlyErrno::ENOENT, not a non-zeroghexit - 🔵
prerelease:publishbundles parsing, composition, mutation and shelling out with no test seam - 🔵 A SIGKILL mid-task skips the
ensure, leaving a.prNNversion in a local tree
Other documented-deliberate design:
- 🔵
prerelease/releasesharingconcurrency: group: mainmeans cross-run ordering is by queue arrival (§4.2 explicit design; worth confirmingqueue: maxbehaves as documented on the first live cutover) - 🔵
ENCRYPTION_PASSPHRASEis a repo-level Actions secret readable by any future workflow, not scoped to thereleaseenvironment (§4.4 step 3) - 🔵
.github/gpg.private.encuses the legacyopenssl -md sha1KDF with no-pbkdf2(§4.6: leave the openssl commands untouched — a good fleet-wide follow-up) - 🔵
actions/checkoutpersists its token alongside a git-crypt-unlocked tree in the publish jobs (inherent to auto-publish) - 🔵 Unpinned, unretried
apt-get installon the gated release critical path (§4.7 prescribes exactly this) - 🔵 Combined cutover + decommission means rollback needs host-side steps (plan's explicit choice)
- 🔵 Dependabot secret store not provisioned, so dependabot runs' unlock/notify steps will fail (§7, deferred to issue 19)
- 🔵 PR prerelease versions accumulate permanently on RubyGems and sort by run number (D8, accepted)
Strengths
- ✅ Both workflow files reproduce the plan's authoritative YAML faithfully — job names, step names, ordering, timeouts, concurrency, permissions and comment wording all match
- ✅
merge-pull-requestcorrectlyneeds: [check, test, build]and does not listprerelease, avoiding the skipped-dependency freeze the plan warns about - ✅ Untrusted PR data (title, number, head SHA) is passed via
env:and referenced as shell variables — no${{ }}script injection anywhere - ✅
pull_requestrather thanpull_request_target; prerelease gated onhead.repo.full_nameand the immutableuser.login, keeping secrets away from forks and Dependabot - ✅ Least-privilege default
permissions: contents: readwith narrow per-job escalation;releasebehind an environment with team reviewers - ✅
gh pr merge --match-head-commit "$HEAD_SHA"closes the TOCTOU window between checks passing and the merge landing - ✅
read_encryption_passphrase's\0GITCRYPTguard turns a silent garbage-secret upload into a fast, explicit failure — genuinely good defensive design - ✅
resolve_github_tokennormalises whitespace-only tokens and rescues a missinggh, converging all three absence paths on one actionable raise - ✅
prerelease:publishrestoresversion.rband removes the built gem in anensure, so the task is safe to run locally - ✅ Every job carries
timeout-minutes; Slack notifies arecontinue-on-errorso an outage cannot block the auto-merge - ✅ Decommission is thorough and the GPG key move is a genuine byte-identical rename
- ✅ The lockfile change correctly restores the missing generic
rubyplatform
Review generated by /accelerator:review-pr
| private_key: File.read('config/secrets/ci/ssh.private') | ||
| } | ||
| ] | ||
| def gh_auth_token |
There was a problem hiding this comment.
🔵 Standards / Code Quality (minor)
The plan's §4.4 step 3 specifies the token resolution and the passphrase ciphertext guard as inline code inside the RakeGithub.define_repository_tasks block. This implementation instead defines three top-level methods — gh_auth_token, resolve_github_token, read_encryption_passphrase — and calls them from the block. The raise messages were also shortened (e.g. "expected an unlocked clone" vs the plan's "expected a git-crypt-unlocked clone with the CI secrets present").
To be clear: the extracted form is arguably better code — each method has one responsibility and one clear failure mode, and it reads well. The concern is purely fleet consistency: this idiom is being replicated across ~103 repos, and per-repo divergence makes future scripted or agentic fleet-wide edits harder to apply uniformly. Top-level defs in a Rakefile also land on Object, unlike the plan's block-scoped locals.
Suggestion: either inline the plan's exact form here, or — if the extraction is genuinely preferred — raise it as a plan amendment so every sibling repo gets the same shape. Behaviour is equivalent either way, so this need not block the merge.
| arm64-darwin-22 | ||
| arm64-darwin-23 | ||
| arm64-darwin-25 | ||
| ruby |
There was a problem hiding this comment.
🔵 Standards (minor)
Good catch adding the generic ruby platform back — that was missing before and would have bitten a fresh GHA runner.
However, plan §4.5 asks that the PLATFORMS section "retains the generic ruby and x86_64-linux platforms and contains no operator-local platforms (e.g. arm64-darwin-*)". The section still carries arm64-darwin-21, arm64-darwin-22, arm64-darwin-23, arm64-darwin-25, x86_64-darwin-19/20/21 and aarch64-linux.
These are all pre-existing (this PR didn't add them), and they're harmless on a x86_64-linux runner — hence minor. But since §4.5 explicitly calls for the cleanup, bundle lock --remove-platform on the darwin entries would bring the section into full conformance and stop an operator's local resolve from silently re-adding more.
There was a problem hiding this comment.
Pull request overview
This PR migrates the repository’s CI/CD from CircleCI to GitHub Actions, re-plumbing pipeline provisioning/secrets and introducing a PR prerelease publish path while removing CircleCI-era scripts and config.
Changes:
- Add GitHub Actions workflows for PR and
main(check/test/build, prerelease publish, gated release, Dependabot auto-merge, Slack notifications). - Update Rake tasks and dependencies to use GitHub Actions secrets/environments and add
prerelease:publish+library:build. - Remove CircleCI configuration and CI shell scripts; update docs and relocate the encrypted CI GPG key into
.github/.
Reviewed changes
Copilot reviewed 19 out of 25 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
.github/workflows/pr.yaml |
New PR workflow: check/test/build + optional prerelease publish + Dependabot auto-merge. |
.github/workflows/main.yaml |
New main workflow: check/test + prerelease + gated release via environment: release. |
Rakefile |
Drops CircleCI provisioning, adds GitHub secrets/environments + Slack tasks + prerelease publish/build tasks. |
Gemfile.lock |
Updates dependency graph (removes CircleCI gems, adds rake_slack, Bundler/platform updates). |
enruby.gemspec |
Removes CircleCI/SSH dev deps; adds rake_slack. |
README.md |
Updates key management docs from CircleCI paths to GitHub Actions paths. |
.github/gpg.private.enc |
Adds the encrypted CI GPG key payload under .github/. |
scripts/ci/common/install-git-crypt.sh |
Updates git-crypt install for GitHub Actions runner (sudo + gnupg). |
scripts/ci/common/configure-rubygems.sh |
Stops attempting a git-crypt unlock inside RubyGems credential setup. |
scripts/ci/common/install-slack-deps.sh |
Removed (no longer used after migration). |
scripts/ci/common/install-gpg-key.sh |
Removed (replaced by GitHub Actions + .github/ encrypted key flow). |
scripts/ci/common/install-asdf.sh |
Removed (replaced by asdf_install@v1 action). |
scripts/ci/common/install-asdf-dependencies.sh |
Removed (replaced by asdf_install@v1 action). |
scripts/ci/common/configure-asdf.sh |
Removed (replaced by asdf_install@v1 action). |
scripts/ci/common/configure-git.sh |
Removed (replaced by repository:set_ci_author task). |
scripts/ci/steps/build.sh |
Removed (job now runs ./go library:check/build directly). |
scripts/ci/steps/test.sh |
Removed (job now runs ./go test:unit directly). |
scripts/ci/steps/prerelease.sh |
Removed (job now runs GitHub Actions prerelease flow). |
scripts/ci/steps/release.sh |
Removed (job now runs GitHub Actions release flow). |
scripts/ci/steps/merge-pull-request.sh |
Removed (Dependabot merge now uses gh pr merge in Actions). |
.circleci/config.yml |
Removed (CircleCI pipeline decommissioned in-repo). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # --match-head-commit fails the merge if a commit landed after checks passed | ||
| # [skip ci] stops the merge commit triggering a release build | ||
| # PR title via env, never interpolated | ||
| run: gh pr merge --merge --match-head-commit "$HEAD_SHA" "$PR_URL" --subject "$PR_TITLE [skip ci]" |
| %i[pr_number run_number run_attempt].each do |name| | ||
| raise "Missing task argument: #{name}" if args[name].to_s.empty? | ||
| end |
| sh 'gem build enruby.gemspec' | ||
| sh "gem push #{gem_file}" |
Cutover to GitHub Actions per the Variant A family plan (gem pilot).
Includes decommission — merging this PR completes the repo's migration.
releaseenvironment gate.github/rake_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:
./go releasepublishes to RubyGems before the version-bump commit ispushed — pre-existing ordering inside the untouched release logic.
mainwith no approval gate; onlyfull releases are gated (
environment: release).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.
releasejob pullsmainat approval time, so a delayed approvalpublishes 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).
asdf_install@v1is our own action (infrablocks/github-actions); we arehappy tracking its major version tag.
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.autocorrects existing code (e.g.
Style/ArgumentsForwarding) — requiredby the
library:checkverification gate, not drive-by refactoring.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.
PR-CI prerelease publish (deliberate, permanent)
pr.yamlhas aprereleasejob that publishes a namespaced pre-release ofthis gem to RubyGems from the PR branch — a permanent CI feature, not
migration-only. This is a deliberate deviation from CircleCI (which published
nothing pre-merge): it proves the publish path before merge instead of
discovering it broken on
main. The version is<committed-version>.pr<PR>.<run>.<attempt>(via the newprerelease:publishRakefile task), so it can never collide with
main'sversion:bump[pre]sequence; the task builds the gem and pushes it straight to RubyGems, then
restores
version.rb, so nothing is committed, tagged, or pushed(
gem releaseis not used — it aborts on the uncommitted version rewrite).The job is skipped for fork
and Dependabot PRs (they hold no secrets), and
merge-pull-requestdoes notdepend on it. PR pre-release versions accumulate permanently on RubyGems —
accepted.
Do not merge manually — the pipeline merges once checks are green.
Disabling the CircleCI project and deleting the
CircleCIdeploy key aredeferred to the end-of-migration sweep.