# ADR 016: GitHub Actions

- HTML version: https://robbiepalmer.me/projects/personal-site/adrs/016-github-actions
- Project: Personal Site (https://robbiepalmer.me/projects/personal-site.md)
- Status: Accepted
- Date: 2025-10-19

# Context

I need a Continuous Integration and Continuous Deployment (CI/CD) system to automate testing, linting, and deployment.
For a personal project, the priority is **zero-maintenance**, **zero-cost** (for public repos), and **tight integration**.

I have previous experience with several alternatives:

* **[Semaphore CI](https://semaphoreci.com/)**: Found it functional but often too restrictive and opinionated in its pipeline definition compared to modern flexible workflows
* **[Bitbucket Pipelines](https://bitbucket.org/product/features/pipelines)**: I liked the "Docker-container-per-step" model, which ensured a clean environment for every command. However, it is vendor-locked to Bitbucket, and this project is hosted on GitHub to maximize visibility ("Build in Public")

Other potential alternatives include **[CircleCI](https://circleci.com/)**, which is a robust option but requires setting up a separate platform and managing separate users/billing.

I need a solution that minimizes administrative overhead ([Less Is More](/projects?tab=philosophy#less-is-more)) while maintaining **local reproducibility**.

# Decision

I decided to use **[GitHub Actions](https://github.com/features/actions)**.

This aligns with:

1. **[Less Is More](/projects?tab=philosophy#less-is-more)**: The CI is the platform. Code, issues, PRs, and builds live in a single URL namespace. There is no "go to the CI dashboard" step—the logs are right in the Pull Request
2. **Mise Integration**: Instead of relying on a complex web of third-party Actions (vendor lock-in), I use [Mise](/projects/personal-site/adrs/004-mise) to strictly define the toolchain. The CI workflow becomes a thin wrapper: `mise install && mise run ci`. This ensures that what runs in CI is **exactly** what runs on my laptop
3. **Cost Efficiency**: It is completely free for public repositories
4. **Performance via Native Caching, not Third-Party Runners**: As content has grown and a backend (plus a Neon preview-database branch and AI review) has been added, CI wall-clock time has crept up—mostly in the UI build and dependency installs. The two products advertised as the fix, **[Blacksmith](https://www.blacksmith.sh/)** (faster/cheaper runners) and **[Depot](https://depot.dev/)** (accelerated Docker builds), **don't suit this project**:

   * **I can't beat free**: GitHub's standard runners are free and unlimited for public repos (and got \~40% cheaper for paid tiers in January 2026). Both vendors market "2× faster at half the cost"—but the "half the cost" half is meaningless here, so they could only ever sell me *speed* while *charging* for compute I already get for nothing
   * **The open-source escape doesn't apply**: Blacksmith's free OSS programme selects for projects with real usage and community traction (Celery, Ladybird, …), not a personal site with no community engagement
   * **Depot's strength goes unused**: Depot's flagship is Docker image build acceleration (persistent layer cache + native ARM). This project builds *no* Docker images in CI—the UI is a [static export](/projects/personal-site/adrs/015-ssg) to [Cloudflare Pages](/projects/personal-site/adrs/011-cloudflare-pages) and the Worker deploys via Wrangler—so I'd be paying for generic runners I don't need
   * **A faster CPU can't fix most of the latency anyway**: much of the wall-clock is external-service waits—model inference for AI review, Neon branch creation, Cloudflare/Wrangler deploys, Terraform plans—that no runner upgrade touches

   So the levers I pull instead are free and keep me on the standard runner: **GitHub Actions' native caching** (`actions/cache`)—the [pnpm](/projects/personal-site/adrs/007-pnpm) content-addressable store and Next.js's incremental build cache (`.next/cache`)—plus **cutting redundant work** (the build no longer re-runs the type-check that the `//ui:check` step already performs). These accelerate the parts that genuinely *are* CPU/IO-bound—installs, compilation, tests—at **zero cost and zero lock-in**. The full measurement is in [Build performance](#build-performance-measured) below; the short version is that the free changes save more wall-clock than an 8-core runner upgrade would, for $0
5. **Escape Hatches**: The decision above is not absolute. The workflow syntax still allows swapping the default runners for Blacksmith, or offloading to Depot, without rewriting the pipeline logic—so if this ever moves to a private repo (where compute is billed) or grows a Docker-based step, the option is one `runs-on:` change away
6. **Good Enough**: It is not the most powerful CI in the world, but it is "good enough" for the current scale. The investment in `mise` ensures that if I hit the scaling limits, migrating to CircleCI or others is trivial

# Build performance (measured)

Before reaching for a paid runner I profiled the UI build (Next.js `output: export`, 269 static pages) on a 4-core machine—the same shape as GitHub's free public-repo runner. Per-phase wall-clock of a cache-warm build, *before* the optimisations below:

| Phase                            | Time   | Parallel?                      |
| -------------------------------- | ------ | ------------------------------ |
| Compile (webpack/SWC)            | \~12s  | partly                         |
| Type-check + lint                | \~11s  | no — *now removed (see below)* |
| Collecting page data (reads MDX) | \~7s   | no (I/O-bound)                 |
| Static generation (269 pages)    | \~52s  | yes (\~4 cores)                |
| Export                           | \~0.4s | —                              |

**Caching.** A cold build is \~130s; restoring Next.js's `.next/cache` cuts compilation from \~43s to \~12s, taking the build to \~84s. The pnpm store cache removes package downloads (install \~18s → \~7s). Both are drop-in `actions/cache` steps.

**Confirmed in CI.** A same-commit re-run on GitHub's 4-core runner bears this out: with a cold `.next/cache` the build took \~91s, and with a valid warm cache \~67s—a \~24s (\~26%) saving—while cached `mise` tooling shaved a further \~7s, taking the whole `ci` job from \~172s to \~128s. Two things the real runs exposed. First, editing `next.config.ts` (or any hashed source dir) invalidates Next's *internal* webpack cache, so the warm speedup only appears when those are unchanged—the first warm run showed no gain because the same commit had touched the config. Second, the pnpm store cache is closer to break-even on CI (\~10s warm restore + install vs \~11s cold install): the \~1.5 GB store costs about as much to restore as the dependencies cost to download fresh, so it is kept for install reliability and consistency with the other workflows rather than for speed. The `.next/cache` is the lever that clearly pays off.

**Redundant type-checking removed.** `next build` re-runs `tsc`, but the PR CI job already type-checks (and lints with Biome) in its dedicated `//ui:check` step *before* the build. Setting `typescript.ignoreBuildErrors` skips the in-build pass (\~11s/build). The one subtlety: the deploy and preview workflows build/deploy the UI *without* running the full `//ui:check`, and previously leaned on `next build`'s own `tsc` to catch type errors. To avoid shipping unchecked code, those workflows now run a lighter **`//ui:check:static`** (type-check + lint, no test suite) before building—so nothing reaches production or a preview unchecked. Combined with caching this takes the build to **\~73s**, and the in-build cut alone saves more wall-clock than an 8-core runner would.

**Turbopack was evaluated and rejected for CI.** The dev server already uses Turbopack; the production build uses webpack. `next build --turbopack` builds correctly and is faster *cold* (\~104s vs \~130s), but it emits almost no persistent cache (\~1 MB vs webpack's \~840 MB)—a second, cache-warm Turbopack build was no faster than its cold run (\~104s either way). Webpack, by contrast, has a large cache to restore: cache-warm it lands at \~84s (\~73s with the type-check cut), comfortably beating Turbopack's \~104s. Because CI runs are almost always cache-warm, the production build stays on webpack.

**Why more cores have limited headroom.** Scaling the pre-optimisation build 1→4 cores (167s → 84s) fits a serial floor of \~56s plus \~28s of parallelisable work at 4 cores. Removing the 11s type-check is a serial cut, so it lowers that floor to **\~45s** and shifts the whole curve down without changing how it scales. The \~52s static-generation phase is only *partly* parallel—it carries its own per-page serial overhead—so adding cores pulls the build *toward*, but never below, the floor. That phase also scales linearly with page count, so it is the figure to watch as content grows. Predicted build times from the current \~73s baseline:

| Runner                | Predicted build      | vs free 4-core | \~$/month\* | $ per CI-min saved |
| --------------------- | -------------------- | -------------- | ----------- | ------------------ |
| GitHub 4-core (today) | \~73s                | —              | **$0**      | —                  |
| GitHub 8-core         | \~59s                | −14s           | \~$3.3      | $0.36              |
| GitHub 16-core        | \~52s                | −21s           | \~$6.1      | $0.44              |
| Depot 8 vCPU          | \~59s + faster clock | −14s+          | \~$2.4      | $0.26              |
| Depot 16 vCPU         | \~52s + faster clock | −21s+          | \~$4.7      | $0.33              |

\*Assumes \~40 UI-building job runs/month with the whole job billed; larger runners are paid even on public repos. For comparison, the free type-check removal saves \~7 min/month at **$0**—comparable to an 8-core upgrade. A paid runner only justifies itself once content growth makes the static-generation phase dominate, and even then a faster *clock* (Blacksmith/Depot hardware) helps more than core count, because the build stops scaling past \~4–8 cores.

# Consequences

### Pros

* **Identity Federation**: supports OIDC (OpenID Connect) natively, allowing me to authenticate with AWS/GCP/Cloudflare without managing long-lived static secret keys
* **Community Support**: Every issue I encounter has likely been solved and documented widely due to the massive user base

### Cons

* **Debuggability**: Logic in YAML is brittle. Tools like `act` claim to allow local testing of workflows, but in practice, they often fail to reproduce complex environment or networking issues. By leaning on `mise`, I mitigate this—if a script fails in CI, I can run the *script* locally, but debugging the *workflow YAML* itself remains a slow "commit-push-fail-repeat" cycle
* **Scalability & Analysis**: GitHub Actions lacks deep insights into test flakiness or historical run times compared to dedicated platforms like CircleCI. It creates a "wall of text" logs that are hard to search
* **Vendor Lock-in**: The workflow syntax (`.github/workflows/*.yml`) is proprietary. Migrating to GitLab CI would require a rewrite of the pipeline wrapper (though the underlying `mise` tasks would remain portable)
* **Docker Handling**: While robust, it doesn't default to the "clean container per step" model as aggressively as Bitbucket Pipelines, sometimes leading to state pollution in the runner workspace if not careful
* **Cache Correctness**: Build caching trades a small risk of stale-cache bugs for speed. I mitigate this by scoping cache keys to the lockfile and source files (with `restore-keys` for partial reuse), but a "works on a clean build, fails with a warm cache" failure is a class of bug native runners don't have

---

Markdown index of this site: https://robbiepalmer.me/llms.txt
