Migrate pipeline from CircleCI to GitHub Actions - #119
Conversation
phelma
left a comment
There was a problem hiding this comment.
Code Review: #119 - Migrate pipeline from CircleCI to GitHub Actions
Verdict: COMMENT
This is a clean, plan-conformant Variant A cutover. Across six lenses the diff
holds up strongly: untrusted PR metadata is passed via env vars (never
interpolated into shells), secret-bearing jobs are correctly gated to same-repo
non-Dependabot PRs, permissions are least-privilege, the release-loop guard
([ci skip] bump commits) is preserved, and the Rakefile helpers fail fast with
actionable errors. Every substantive lens finding lands on a decision the family
plan or PR description documents as deliberate, so none block; they are retained
below as plan concerns for a human to revisit fleet-wide.
Cross-Cutting Themes
queue: maxconcurrency key (flagged by: correctness, safety) — Both
lenses readqueue: maxas an unsupported concurrency key that would invalidate
main.yaml. This is a false positive against the plan: family plan §4.2
documentsqueue: maxas a GA GitHub Actions feature (2026-05-07,
github.blog/changelog/2026-05-07-github-actions-concurrency-groups-now-allow-larger-queues)
and includes it in the authoritative YAML. The reviewing models' training
predates that GA. Recorded as a plan concern, not a defect — worth a human
confirming the feature is live before merge, but it does not block a
plan-conformant diff.- Divergence from the
rake_slackreference (flagged by: standards,
architecture) — notify-step idiom (!cancelled()+continue-on-errorvs
always()), job name (merge-pull-requestvsdependabot-auto-merge), omitted
skip-ci-checkjob, and per-file check/test duplication. Each is a documented
Variant A decision (§3 "the §4 YAML is authoritative where they differ", §4.1
job-name note, D4 loop-guard rationale, §1 flat-scaffolding parity item).
Strengths
- ✅ Untrusted PR metadata (number, title, URL, head SHA) passed via env vars,
explicitly "never interpolated" — no Actions expression injection into shells. - ✅ Prerelease (secret-unlocking) job double-gated on same-repo + non-Dependabot;
fork/Dependabot PRs cannot reachENCRYPTION_PASSPHRASEor RubyGems creds. - ✅ Least-privilege: top-level
contents: read,writescoped only where needed. - ✅ Release-loop guard preserved:
[ci skip]bump commits +GITHUB_TOKENpush
semantics stop main pushes re-triggering the workflow. - ✅
--match-head-commitTOCTOU guard and--ff-onlypull on the release job. - ✅
read_ci_passphrasegit-crypt magic-byte guard prevents uploading ciphertext
as a secret;resolve_github_tokenfails fast instead of an opaque Octokit 401. - ✅
prerelease:publishwraps theversion.rbrewrite inensureso the tree is
always restored and the built gem removed, even on push failure. - ✅ Secret-file deletions scoped correctly: only CircleCI-era credentials removed;
RubyGems creds, GPG key, and encryption passphrase retained.
General Findings
- 🔵 Code Quality:
resolve_github_token/read_ci_passphraseare bare
top-leveldefs (private methods on Object) — a conventional Rakefile shortcut;
a mild namespace smell only if helper count grows. (In-scope suggestion.) - 🔵 Correctness:
main.yamlprereleasegit pushruns on a checkout with no
explicitref:;actions/checkout@v4resolves a branch for push events so this
is fine in practice — flagged low-confidence, no change required. (In-scope suggestion.)
Plan Concerns (documented-deliberate — do not block)
- 🔵
queue: maxreported as invalid — plan §4.2 documents it as a GA feature. - 🔵
asdf_install@v1mutable tag in secret-bearing jobs — §1: our own action,
tracking the major tag is fine. - 🔵 Dependabot auto-merge accepts any update type — D3: no update-type filter, parity.
- 🔵 Pending release approval blocks later prereleases — §4.2 documents this as the
intended behaviour of the shared job-levelmaingroup. - 🔵 Notify idiom (
!cancelled()+continue-on-error) diverges fromrake_slack's
always()— §3: the §4 YAML is authoritative. - 🔵 Job named
merge-pull-requestvs canonicaldependabot-auto-merge— §4.1
Variant A decision. - 🔵
skip-ci-checkjob omitted — D4: no custom loop-guard needed. - 🔵 Regex/backreference
version.rbrewrite is fragile — plan §4.4 step 8 specifies
this exact implementation. - 🔵 Secret/tooling setup split between rake tasks and surviving shell scripts —
§4.7 mandates retaining and callinginstall-git-crypt.sh/configure-rubygems.sh. - 🔵 check/test duplicated across
pr.yaml/main.yaml— §1 flat-scaffolding parity. - 🔵
buildverified on PR but not main — plan §4.2 specifies main's job set
(check/test/prerelease/release).
Plan-Conformance (independent §4 check)
Verified both directions. All required §4 changes are present and correct:
workflows (§4.1/4.2), the byte-identical GPG key move (§4.3), all Rakefile edits
(§4.4 — requires, RakeCircleCI/keys:deploy removal, token/passphrase/secrets/
environments, slack, set_ci_author, library:build, pipeline:prepare,
prerelease:publish), Gemfile/lockfile (§4.5), README (§4.6), and the full CircleCI
decommission (§4.7). The release job correctly uses version:bump[minor] and omits
documentation:update (both mirror this repo's old release.sh). rake_git is
pinned ~> 0.2.0 to avoid the 0.3.0.pre.2 prerelease — exactly per §4.5/§5
check 10. .circleci grep hits remaining are only library source/spec (an example
string and tests), which §4.8 leaves alone. Nothing beyond the plan changed.
Per-Lens Results
Correctness
Summary: Task names, arguments, and interpolation are almost entirely correct;
slack:notify arity, git_crypt:unlock, version:bump, prerelease:publish, and routing
rules all resolve. Only flagged queue: max (false positive — see above).
Security
Summary: Written with clear security awareness — env-var facts, double-gated
prerelease, least-privilege permissions, pull_request (not _target),
--match-head-commit. Residual: mutable-tag action and no update-type filter (both
plan-documented).
Safety
Summary: Release-loop guard preserved, --ff-only/--match-head-commit,
ensure-restored version rewrite, correctly-scoped secret deletions. queue: max
and approval-blocks-prerelease flagged (both plan-documented).
Standards
Summary: Closely follows the rake_slack canonical shape (alphabetical deps,
routing block, task shapes). Notify idiom, merge-job name, and omitted skip-ci-check
diverge — all plan-documented.
Code Quality
Summary: High-quality Rakefile — small well-named helpers, strong fail-fast
error handling. Minor: fragile regex version rewrite (plan-specified) and top-level
helper namespace pollution.
Architecture
Summary: Clean job graph, sound dependency direction (CI depends on rake),
well-reasoned job-level concurrency and independent dependabot path. Notes: rake/shell
logic split, cross-file check/test duplication, build-on-PR-not-main — all plan-scoped.
Review generated by /accelerator:review-pr
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.
🏭 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 Ruby gem's CI from CircleCI to GitHub Actions.
migratemigrate-gem2026-07-22T17-48-59-192Zatomic-foundry-pr · foundry-pipeline: migrate · foundry-task: migrate-gem · foundry-run: 2026-07-22T17-48-59-192Z