Skip to content

feat(claude-code): bake happy CLI into default and sandbox images #99

feat(claude-code): bake happy CLI into default and sandbox images

feat(claude-code): bake happy CLI into default and sandbox images #99

Workflow file for this run

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."