Repository navigation
feat(claude-code): bake happy CLI into default and sandbox images #99
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| pull_request: | |
| branches: [master] | |
| # Deliberately unfiltered: "CI complete" below is the required status check | |
| # for master, and a required check that never runs blocks a PR forever -- a | |
| # docs-only PR would deadlock. Image builds are still skipped per-image via | |
| # detect-changes; the always-on jobs are lint-only and take seconds. | |
| concurrency: | |
| group: ci-${{ github.event.pull_request.number || github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| # ── Lint Dockerfiles with hadolint ────────────────────────────────── | |
| lint-dockerfiles: | |
| name: Hadolint · ${{ matrix.dockerfile }} | |
| runs-on: ubuntu-latest | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| dockerfile: | |
| - bun/.devcontainer/Dockerfile | |
| - claude-bun/.devcontainer/Dockerfile | |
| - claude-code/.devcontainer/Dockerfile | |
| - hugo-bun/.devcontainer/Dockerfile | |
| - hugo-bun-node/.devcontainer/Dockerfile | |
| - ralphex-fe/Dockerfile | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v6 | |
| - name: Run hadolint | |
| uses: hadolint/hadolint-action@v3.0.0 | |
| with: | |
| dockerfile: ${{ matrix.dockerfile }} | |
| # ── Lint GitHub Actions workflows with actionlint ─────────────────── | |
| lint-workflows: | |
| name: Actionlint | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v6 | |
| - name: Run actionlint | |
| uses: raven-actions/actionlint@v2 | |
| # ── Detect which images have changed files ────────────────────────── | |
| detect-changes: | |
| name: Detect changes | |
| runs-on: ubuntu-latest | |
| outputs: | |
| matrix: ${{ steps.set-matrix.outputs.matrix }} | |
| has-changes: ${{ steps.set-matrix.outputs.has-changes }} | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v6 | |
| - name: Check changed paths | |
| uses: dorny/paths-filter@v4 | |
| id: filter | |
| with: | |
| # Each image also watches .github/verify-commands.json: editing a | |
| # verify command should re-run it against a real image, not sit | |
| # unexercised until the next Dockerfile change. A manifest edit | |
| # therefore rebuilds every image — rare enough to be worth it. | |
| filters: | | |
| bun: | |
| - 'bun/**' | |
| - '.github/verify-commands.json' | |
| claude-bun: | |
| - 'claude-bun/**' | |
| - '.github/verify-commands.json' | |
| claude-code: | |
| - 'claude-code/**' | |
| - '.github/verify-commands.json' | |
| hugo-bun: | |
| - 'hugo-bun/**' | |
| - '.github/verify-commands.json' | |
| hugo-bun-node: | |
| - 'hugo-bun-node/**' | |
| - '.github/verify-commands.json' | |
| ralphex-fe: | |
| - 'ralphex-fe/**' | |
| - '.github/verify-commands.json' | |
| - name: Build matrix from changes | |
| id: set-matrix | |
| env: | |
| CHANGED_BUN: ${{ steps.filter.outputs.bun }} | |
| CHANGED_CLAUDE_BUN: ${{ steps.filter.outputs.claude-bun }} | |
| CHANGED_CLAUDE_CODE: ${{ steps.filter.outputs.claude-code }} | |
| CHANGED_HUGO_BUN: ${{ steps.filter.outputs.hugo-bun }} | |
| CHANGED_HUGO_BUN_NODE: ${{ steps.filter.outputs.hugo-bun-node }} | |
| CHANGED_RALPHEX: ${{ steps.filter.outputs.ralphex-fe }} | |
| run: | | |
| INCLUDES="[]" | |
| # Verify commands live in .github/verify-commands.json so that this | |
| # workflow and build-claude-code.yml cannot drift apart. Looking the | |
| # command up here (rather than passing it in) means an image added | |
| # without an entry fails the job instead of silently verifying nothing. | |
| add_image() { | |
| local image="$1" context="$2" dockerfile="$3" target="${4:-}" | |
| local verify | |
| verify=$(jq -er --arg k "$image" '.[$k]' .github/verify-commands.json) || { | |
| echo "::error::no verify command for '$image' in .github/verify-commands.json" | |
| exit 1 | |
| } | |
| INCLUDES=$(echo "$INCLUDES" | jq -c \ | |
| --arg img "$image" \ | |
| --arg ctx "$context" \ | |
| --arg df "$dockerfile" \ | |
| --arg v "$verify" \ | |
| --arg tgt "$target" \ | |
| '. + [{"image":$img,"context":$ctx,"dockerfile":$df,"verify":$v,"target":$tgt}]') | |
| } | |
| if [ "$CHANGED_BUN" = "true" ]; then | |
| add_image "bun" \ | |
| "bun/.devcontainer" \ | |
| "bun/.devcontainer/Dockerfile" | |
| fi | |
| if [ "$CHANGED_CLAUDE_BUN" = "true" ]; then | |
| add_image "claude-bun" \ | |
| "claude-bun/.devcontainer" \ | |
| "claude-bun/.devcontainer/Dockerfile" | |
| fi | |
| if [ "$CHANGED_CLAUDE_CODE" = "true" ]; then | |
| add_image "claude-code" \ | |
| "claude-code/.devcontainer" \ | |
| "claude-code/.devcontainer/Dockerfile" \ | |
| "default" | |
| add_image "claude-code-sandbox" \ | |
| "claude-code/.devcontainer" \ | |
| "claude-code/.devcontainer/Dockerfile" \ | |
| "sandbox" | |
| add_image "claude-code-happy" \ | |
| "claude-code/.devcontainer" \ | |
| "claude-code/.devcontainer/Dockerfile" \ | |
| "happy" | |
| fi | |
| if [ "$CHANGED_HUGO_BUN" = "true" ]; then | |
| add_image "hugo-bun" \ | |
| "hugo-bun/.devcontainer" \ | |
| "hugo-bun/.devcontainer/Dockerfile" | |
| fi | |
| if [ "$CHANGED_HUGO_BUN_NODE" = "true" ]; then | |
| add_image "hugo-bun-node" \ | |
| "hugo-bun-node/.devcontainer" \ | |
| "hugo-bun-node/.devcontainer/Dockerfile" | |
| fi | |
| if [ "$CHANGED_RALPHEX" = "true" ]; then | |
| add_image "ralphex-fe" \ | |
| "ralphex-fe" \ | |
| "ralphex-fe/Dockerfile" | |
| fi | |
| if [ "$INCLUDES" = "[]" ]; then | |
| { | |
| echo "has-changes=false" | |
| echo "matrix={\"include\":[]}" | |
| } >> "$GITHUB_OUTPUT" | |
| else | |
| { | |
| echo "has-changes=true" | |
| echo "matrix=$(echo "$INCLUDES" | jq -c '{include:.}')" | |
| } >> "$GITHUB_OUTPUT" | |
| fi | |
| # ── Build changed images (amd64 only) and verify installed tools ──── | |
| # arm64 is tested on native runners at merge time — no QEMU emulation in CI. | |
| build-and-verify: | |
| name: Build · ${{ matrix.image }} | |
| needs: detect-changes | |
| if: needs.detect-changes.outputs.has-changes == 'true' | |
| runs-on: ubuntu-latest | |
| strategy: | |
| fail-fast: false | |
| matrix: ${{ fromJSON(needs.detect-changes.outputs.matrix) }} | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v6 | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v4 | |
| - name: Build amd64 image | |
| uses: docker/build-push-action@v7 | |
| with: | |
| context: ${{ matrix.context }} | |
| file: ${{ matrix.dockerfile }} | |
| target: ${{ matrix.target }} | |
| platforms: linux/amd64 | |
| load: true | |
| tags: ${{ matrix.image }}:test | |
| cache-from: type=gha,scope=${{ matrix.image }}-amd64 | |
| cache-to: type=gha,mode=max,scope=${{ matrix.image }}-amd64 | |
| - name: Verify tools (amd64) | |
| env: | |
| IMAGE_TAG: ${{ matrix.image }}:test | |
| VERIFY_CMD: ${{ matrix.verify }} | |
| run: docker run --rm --entrypoint sh "$IMAGE_TAG" -c "$VERIFY_CMD" | |
| # -- Single required status check for branch protection ------------------ | |
| # Aggregates every job above so master needs exactly one required check. | |
| # Passes when each job succeeded or was legitimately skipped (path-filtered | |
| # builds), and fails if any job failed or was cancelled. | |
| # | |
| # Any job added to this workflow must also be added to `needs` below: this is | |
| # the only required check on master and Renovate auto-merges on it, so an | |
| # unwatched job would go red while the gate stayed green. The first step | |
| # enforces that rather than trusting anyone to remember. | |
| ci-complete: | |
| name: CI complete | |
| # always(), not !cancelled(): GitHub counts a *skipped* required check as | |
| # passing, so !cancelled() would let a cancelled run report a green gate. | |
| if: always() | |
| needs: [lint-dockerfiles, lint-workflows, detect-changes, build-and-verify] | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v6 | |
| - name: Verify every job is watched | |
| env: | |
| WATCHED: ${{ toJSON(needs) }} | |
| run: | | |
| missing="" | |
| for job in $(yq '.jobs | keys | .[]' .github/workflows/ci.yml); do | |
| [ "$job" = "ci-complete" ] && continue | |
| jq -e --arg j "$job" 'has($j)' <<< "$WATCHED" > /dev/null || missing="$missing $job" | |
| done | |
| if [ -n "$missing" ]; then | |
| echo "::error::Jobs missing from ci-complete.needs:$missing" | |
| exit 1 | |
| fi | |
| - name: Check results of all CI jobs | |
| env: | |
| WATCHED: ${{ toJSON(needs) }} | |
| run: | | |
| jq -r 'to_entries[] | "\(.key): \(.value.result)"' <<< "$WATCHED" | |
| bad=$(jq -r ' | |
| to_entries[] | |
| | select(.value.result != "success" and .value.result != "skipped") | |
| | "\(.key) (\(.value.result))"' <<< "$WATCHED") | |
| if [ -n "$bad" ]; then | |
| echo "::error::CI jobs did not pass: $(echo "$bad" | tr '\n' ' ')" | |
| exit 1 | |
| fi | |
| echo "All CI jobs passed or were skipped." |