Skip to content

Migrate pipeline from CircleCI to GitHub Actions - #212

Merged
phelma merged 1 commit into
mainfrom
gha-migration
Jul 22, 2026
Merged

phelma merged 1 commit into
mainfrom
gha-migration

Conversation

@phelma

@phelma phelma commented Jul 22, 2026 •

Copy link
Copy Markdown
Contributor

Part of PP-709.

Cutover to GitHub Actions per the Variant A family plan (gem pilot).
Includes decommission — merging this PR completes the repo's migration.

  • main + pr workflows: check/test, prerelease, release environment gate
  • PR CI publishes a namespaced pre-release to RubyGems (see below)
  • git-crypt unlock on the runner; encrypted CI GPG key moved to .github/
  • Slack notifications via rake_slack; dependabot auto-merge job
  • Rakefile provisioning swapped to rake_github secrets/environments; rake_circle_ci dropped
  • CircleCI pipeline removed: .circleci/, scripts/ci/, the CI SSH deploy
    key pair and its keys:deploy/deploy_keys provisioning, and the stored
    CircleCI/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 release publishes to RubyGems before the version-bump commit is
    pushed — pre-existing ordering inside the untouched release logic.
  • Prerelease publishes on every push to main with no approval gate; only
    full releases are gated (environment: release).
  • 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 next
    human-triggered release.
  • The release job pulls main at approval time, so a delayed approval
    publishes main as it stands then, not the SHA this run tested — parity with
    the old release.sh (which also pulled; prerelease.sh did not, so the
    prerelease job has no pull).
  • asdf_install@v1 is our own action (infrablocks/github-actions); we are
    happy tracking its major version tag.
  • 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 and
    supplies secrets/context.
  • Gemfile.lock carries transitive major bumps — the unavoidable resolution
    of the targeted bundle lock --update, not scope creep.
  • Small library hunks may appear where the refreshed toolchain's rubocop
    autocorrects existing code (e.g. Style/ArgumentsForwarding) — required
    by the library:check verification gate, not drive-by refactoring.
  • Provisioning (pipeline:prepare) authenticates with the operator's ambient
    gh login (GITHUB_TOKEN fallback) instead of a stored PAT — a deliberate
    parity deviation; the stored token in config/secrets/github/config.yaml
    is deleted with the rest of the CircleCI-era credentials.

PR-CI prerelease publish (deliberate, permanent)

pr.yaml has a prerelease job that publishes a namespaced pre-release of
this 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 new prerelease:publish
Rakefile task), so it can never collide with main's version: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 release is 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-request does not
depend 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 CircleCI deploy key 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 Ruby gem's CI from CircleCI to GitHub Actions.

Pipeline Task pack Family Run
migrate migrate-gem gem 2026-07-22T15-58-21-187Z

atomic-foundry-pr · foundry-pipeline: migrate · foundry-task: migrate-gem · foundry-run: 2026-07-22T15-58-21-187Z

@phelma phelma left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: #212 - Migrate pipeline from CircleCI to GitHub Actions

Verdict: COMMENT

This CircleCI → GitHub Actions cutover is a high-quality, faithful implementation of the Variant A family plan. Across correctness, security, safety, standards and code-quality lenses no critical or major in-scope defects were found; the diff tracks the plan's authoritative templates closely and its notable safety/security properties (env-passed facts, guarded prerelease job, --match-head-commit, namespaced prerelease versions, git-crypt ciphertext guard) are correct. The single genuine in-scope finding is a cosmetic divergence from the prescribed Rakefile shape; the remaining observations all challenge documented-deliberate plan decisions and are recorded as plan concerns for a human to revisit.

Strengths

  • ✅ All user-controllable values (PR title/URL/SHA, PR/run facts) are passed via env vars, never interpolated into run: — closing the ${{ }} injection vector.
  • ✅ Least-privilege permissions: contents: read default with per-job escalation.
  • ✅ The secret-bearing prerelease job is guarded to same-repo, non-Dependabot PRs.
  • ✅ Dependabot auto-merge keys off the immutable user.login and uses --match-head-commit.
  • ✅ prerelease:publish restores version.rb and removes the built gem in an ensure block on every path.
  • ✅ Provisioning fails fast on a missing/locked passphrase, detecting the \x00GITCRYPT magic rather than uploading ciphertext as a live secret.
  • ✅ Alphabetical require/dependency ordering preserved; comments self-contained.

General Findings

  • 🔵 Standards: resolve_github_token was extracted to a top-level method rather than the plan's prescribed inline block (§4.4 step 3 says "do not restructure anything else"). Functionally equivalent; the guiding comment now sits above a method that no longer contains the raise it references.

Plan Concerns (documented-deliberate — not blocking)

These challenge decisions the family plan / PR description document as deliberate. Recorded so a human can revisit the plan, but they do not block a plan-conformant diff.

  • 🔵 Security: The prerelease PR-CI job exposes RubyGems push credentials and the git-crypt passphrase to same-repo PR-authored code — a wider pre-merge secret surface than CircleCI. Documented D8 trade-off (prove the publish path pre-merge).
  • 🔵 Security: SLACK_BOT_TOKEN reaches PR-authored slack:notify code on same-repo PRs — the standard on: pull_request same-repo trust boundary; the notify steps are plan-prescribed.
  • 🔵 Code Quality: rescue Errno::ENOENT around `gh auth token` is unlikely to fire (backticks run via /bin/sh, so a missing gh yields exit 127 + empty stdout, not Errno::ENOENT). Handled incidentally by the empty-token fallthrough. This is the plan-prescribed code (§4.4 step 3).
  • 🔵 Code Quality: The %i[pr_number run_number run_attempt] list is duplicated between the task signature and the validation loop. Verbatim from the plan template (§4.4 step 8).
  • 🔵 Correctness: The prerelease version compounds an already-.pre base (2.16.0.pre.2.pr<PR>.<run>.<attempt>). Valid Gem::Version, uniquely namespaced, cannot collide — consistent with the documented D8 format.

Review generated by /accelerator:review-pr

Comment thread Rakefile
# otherwise surface later as an opaque Octokit 401. An empty or
# whitespace-only GITHUB_TOKEN is treated as absent so an authenticated
# operator falls through to `gh auth token` rather than hitting the raise.
def resolve_github_token

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Standards (severity: minor, confidence: medium)

The GitHub-token resolution is implemented as a top-level def resolve_github_token method, whereas the family plan (§4.4 step 3) prescribes the logic inline within the RakeGithub.define_repository_tasks block and explicitly instructs "Do not restructure anything else in the Rakefile." The extraction also leaves the guiding comment (which mentions "rather than hitting the raise") above a method that no longer contains that raise. Functionally equivalent — inline for cross-repo fleet consistency, or confirm this as an accepted variation.

Comment thread Rakefile
token = ENV['GITHUB_TOKEN'].to_s.strip
token = `gh auth token`.strip if token.empty?
token
rescue Errno::ENOENT

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Code Quality (severity: minor, confidence: medium)

Ruby backticks execute via /bin/sh -c, so a missing gh binary produces exit status 127 and empty stdout rather than raising Errno::ENOENT. This rescue reads as protection that never triggers; the "absent gh" case is actually handled by the empty-stdout fallthrough to the token-empty raise. Note this matches the plan-prescribed code, so treat as a plan concern. Consider checking $?.success? if explicit handling of the not-installed / unauthenticated cases is wanted.

Comment thread Rakefile
desc 'Build and push a namespaced pre-release to RubyGems ' \
'(PR CI only; no bump, no tag, no commit, no push)'
task :publish, %i[pr_number run_number run_attempt] do |_, args|
%i[pr_number run_number run_attempt].each do |name|

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Code Quality (severity: suggestion, confidence: high)

%i[pr_number run_number run_attempt] is repeated verbatim as the task signature (line 214) and in this validation loop, and must be kept in sync by hand. Bind it to a local (e.g. arg_names = %i[...]) and reference it in both places. This is verbatim from the plan template — a plan concern.

Comment thread .github/workflows/pr.yaml
run: ./scripts/ci/common/configure-rubygems.sh
- name: Publish prerelease
# Facts via env, never interpolated
run: ./go "prerelease:publish[$PR_NUMBER,$RUN_NUMBER,$RUN_ATTEMPT]"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Security (severity: minor, confidence: medium)

The prerelease job checks out the PR branch, unlocks git-crypt with ENCRYPTION_PASSPHRASE, configures RubyGems push credentials, then runs PR-authored ./go prerelease:publish. A collaborator with push access could modify the task to exfiltrate the RubyGems API key and git-crypt passphrase pre-merge — a wider secret surface than CircleCI, which published nothing pre-merge. Documented as a deliberate, accepted D8 trade-off; no change required for this migration. If the collaborator trust assumption weakens, consider a scoped/short-lived RubyGems token.

@phelma
phelma marked this pull request as ready for review July 22, 2026 17:09
@phelma
phelma merged commit 4528b9c into main Jul 22, 2026
5 of 6 checks passed
@phelma
phelma deleted the gha-migration branch July 22, 2026 17:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant