Skip to content

Replace the CircleCI pipeline with GitHub Actions - #952

Draft
ikuwow wants to merge 4 commits into
masterfrom
migrate-circleci-to-github-actions
Draft

Replace the CircleCI pipeline with GitHub Actions#952
ikuwow wants to merge 4 commits into
masterfrom
migrate-circleci-to-github-actions

Conversation

@ikuwow

@ikuwow ikuwow commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Summary

Moves lint, build and deploy from CircleCI to GitHub Actions, and switches the deploy credentials from long-lived AWS access keys to OIDC. The pipeline had no CircleCI-specific dependencies, so this is a straight port; the Slack notification is dropped rather than migrated.

Changes

.github/workflows/ci.yml replaces .circleci/config.yml with three jobs:

  • lint runs the same six commands as before, each guarded by if: ${{ !cancelled() }} so one failure does not hide the others (the CircleCI when: always equivalent)
  • build runs bundle exec middleman build, re-runs it with --verbose on failure, and uploads build/ as an artifact
  • deploy runs only on push to master, needs both other jobs, assumes an IAM role through OIDC, then syncs to S3 and invalidates CloudFront

Ruby and Node versions come from .tool-versions: ruby/setup-ruby reads it automatically, and actions/setup-node is pointed at it through node-version-file. Dependency installs are bundler-cache: true (deployment mode) and npm ci, replacing the hand-written cache save/restore steps and npm install.

The artifact upload overrides two upload-artifact defaults, because deploy syncs with --delete and anything absent from the artifact is deleted from the live bucket. include-hidden-files: true keeps dotfiles, which the action's search stage otherwise drops without a warning and which CircleCI's persist_to_workspace had no reason to filter. if-no-files-found: error fails the build on empty output instead of warning and leaving the failure to surface two jobs later.

Every AWS value is a repository secret, so README.md and CLAUDE.md no longer carry the bucket name in their manual-deploy examples. The value is recoverable from git history; this only stops adding new copies.

.github/dependabot.yml gains a github-actions entry so the new action pins stay current, and package.json's description sheds the CircleCI badge markdown that had been pasted into it along with a run of = characters.

Setup required outside this PR

AWS

  • Create an IAM identity provider for token.actions.githubusercontent.com with audience sts.amazonaws.com
  • Create an IAM role whose trust policy requires token.actions.githubusercontent.com:aud = sts.amazonaws.com and token.actions.githubusercontent.com:sub = repo:ikuwow/query_ok:ref:refs/heads/master
  • Grant the role s3:ListBucket on the bucket, s3:GetObject / s3:PutObject / s3:DeleteObject on its objects, and cloudfront:CreateInvalidation on the distribution
  • Revoke the AWS access keys currently stored in CircleCI

The sub claim uses the plain form because this repository predates immutable subject claims and has not opted in.

GitHub

  • Add repository secrets AWS_ROLE_ARN, AWS_REGION, S3_BUCKET, CLOUDFRONT_DISTRIBUTION_ID. gh api repos/ikuwow/query_ok/actions/secrets currently reports total_count: 0, so the first master deploy fails at the credential step until these exist
  • Change the required status checks in the Protect default ruleset from ci/circleci: build / ci/circleci: lint to build / lint. The CircleCI config is deleted here, so its checks never report and this PR cannot merge before the change
  • Deactivate the project in CircleCI

Behavior not carried over

The oEmbed cache under .cache/ is not persisted between runs. extensions/oembed_converter.rb fetches from the oEmbed endpoint on a cache miss without rescuing, so every build refetches its embeds and a failed request fails the build. Adding an actions/cache step for .cache/ is the fix if this proves flaky.

CircleCI ran lint and build on every push to every branch. The new triggers are pull_request and pushes to master, so a branch pushed without a PR gets no checks. ./article.sh stops at pushing a branch, so a new article gets its first check when its PR opens.

bundle exec erb_lint source/**/*.erb keeps its existing shell-glob behavior. With globstar off, ** collapses to *, so the 10 .erb files directly under source/ — including index.html.erb and every shared partial — are not linted. That is pre-existing and left for a separate change, along with the copy of the same command in CLAUDE.md.

Follow-ups this review surfaced, deliberately left out of scope

  • A webpack run that exits 0 without emitting bundles yields a green build and a site with dangling <script src> tags: Util#asset_path fabricates a URL for an asset missing from the sitemap rather than raising. A webpack run that fails does fail the build, through external_pipeline.rb's exit(1) reaching the main thread in build mode
  • aws cloudfront create-invalidation returns once the request is accepted, so a green deploy does not mean the cache is cleared. aws cloudfront wait invalidation-completed would close that gap
  • OEmbed::Providers.get(url, query).html returning nil on an HTTP 200 would silently drop the URL from the article and cache the nil

Verification

Run 31311416877:

  • lint passed with all six lint steps reporting success
  • build passed and uploaded the site artifact; the --verbose fallback step was skipped
  • deploy was skipped, as expected on a pull request
  • actionlint exits 0 on the workflow

The cold oEmbed fetch works. source/posts/ holds 106 embeddable status URLs; 3 sit in a published: false draft that is never built and 2 more point at yushakobo, which the converter's regex excludes, leaving 101 expected conversions. The artifact contains exactly 101 twitter-tweet blockquotes across 26 pages.

Enabling include-hidden-files changed nothing in the output: a recursive diff of the artifacts from before and after that commit differs only in sitemap.xml, whose lastmod carries the build timestamp.

Still to confirm after merge: deploy assumes the role, aws s3 sync reports the expected diff, create-invalidation returns an ID, and https://queryok.ikuwow.com/ serves the new build.

ikuwow and others added 4 commits August 9, 2026 20:12
Lint, build and deploy now run in .github/workflows/ci.yml. Deploy
authenticates to AWS through OIDC instead of long-lived access keys, so
the workflow needs no stored credentials beyond the role ARN.

The oEmbed cache under .cache/ is not carried between runs, so every
build refetches the embeds it needs from the oEmbed endpoint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nmxp9cDJY2sQjgssBLLgLe
The manual deploy example takes a placeholder bucket name, matching
CLAUDE.md, so the workflow's S3_BUCKET secret is the only place the real
name lives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nmxp9cDJY2sQjgssBLLgLe
The deploy job syncs with --delete, so any file absent from the artifact
is removed from the live bucket. upload-artifact excludes hidden files
by default and only warns when it matches nothing, so both defaults are
overridden to fail or include instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nmxp9cDJY2sQjgssBLLgLe
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