Skip to content

feat: replace Claude Code with OpenCode #1

feat: replace Claude Code with OpenCode

feat: replace Claude Code with OpenCode #1

Workflow file for this run

# yamllint disable rule:line-length
---
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
workflow_dispatch: {}
concurrency:
group: ci-${{ github.ref }}
# Cancel superseded PR builds, but never cancel a run on main —
# main runs validate merged code and must complete.
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
jobs:
validate:
name: Validate
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: ShellCheck shell scripts
uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # 2.0.0
with:
severity: warning
scandir: .
version: v0.11.0
- name: Unit tests — update_pins.py helpers
# stdlib unittest on the runner's preinstalled python3 — no install,
# no third-party action (the suite imports only the standard library).
run: python3 -m unittest discover -s tests -p 'test_*.py' -v
- name: Hadolint Dockerfile
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0
with:
dockerfile: Dockerfile
config: .hadolint.yaml
failure-threshold: warning
- name: npm-pinned tools supply-chain audit
# For EVERY npm-pinned tool enforce two policies: (1) tarball signed by
# npm's keyring over dist.integrity (`npm audit signatures`), (2) soak
# since publish (default 7 days) so loud supply-chain attacks get yanked
# before we ingest. The soak gate is enforced by update_pins.py --audit,
# which is the single source of truth for soak logic (including the window
# default).
# The tool list is provided by update_pins.py --list-npm-tools so CI never
# re-implements generator logic.
run: |
set -euo pipefail
python3 update_pins.py --audit
# Capture the tool list into a variable BEFORE looping: a process
# substitution `done < <(cmd)` does NOT propagate cmd's non-zero exit
# under `set -e`, so a fail-closed --list-npm-tools (e.g. a missing pin)
# would silently yield an empty loop and skip every signature check
# (fail-open). `$(...)` assignment, by contrast, DOES abort the step
# under `set -e`, so the here-string consumer below is fail-closed.
tools=$(python3 update_pins.py --list-npm-tools)
while IFS=$'\t' read -r name pkg env var ver; do
[ -n "$ver" ] || { echo "::error::no pinned version for $name"; exit 1; }
scratch=$(mktemp -d)
( cd "$scratch" && npm init -y >/dev/null \
&& npm install --ignore-scripts --no-audit --no-fund --silent "${pkg}@${ver}" \
&& npm audit signatures )
done <<< "$tools"
- name: Markdown lint (advisory)
uses: DavidAnson/markdownlint-cli2-action@6bf21b07787794f89a243495939cd651942aeabe # v24.1.0
continue-on-error: true
with:
globs: "**/*.md"
- name: Broken relative links (advisory, offline)
uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2.9.0
continue-on-error: true
with:
args: --offline --include-fragments --no-progress .
docker-build:
name: Docker build (validate, no push)
needs: [validate]
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Build (amd64, no push)
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
# Load into the local daemon so the smoke test below can run the
# image. Build is amd64-only (single-arch load is fine); arm64
# coverage relies on the maintainers' local builds for now.
load: true
tags: opencode:ci
platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=min
- name: Smoke test — opencode --version matches Dockerfile pin
# Catches the failure mode where the JS launcher is installed
# but the native-binary postinstall isn't run, leaving `opencode` as a
# stub that errors at exec time. Asserts the actual reported version
# matches the OPENCODE_VERSION pin in pins/opencode.env.
run: |
set -euo pipefail
EXPECTED=$(python3 update_pins.py --list-npm-tools | awk -F'\t' '$1=="opencode"{print $5}')
ACTUAL=$(docker run --rm opencode:ci opencode --version)
echo "Expected: ${EXPECTED} Actual: ${ACTUAL}"
[[ "$ACTUAL" == "${EXPECTED}" ]]
- name: Cross-platform smoke (Linux)
# Drives smoke.sh across a matrix of cells, all reusing the already-loaded
# opencode:ci image from the build step above. Do NOT move this to a
# separate job — a separate job gets a blank local daemon and cannot reuse
# the image loaded by the build step.
#
# Cell inventory:
# 1. runner-uid/cold/no-optins — baseline privilege-drop to runner UID
# 2. runner-uid/cold/aws — AWS credential opt-in
# 3. runner-uid/cold/glab — GitLab credential opt-in
# 4. runner-uid/cold/aws+glab+tfe — all three opt-ins combined
# 5. runner-uid/warm — warm volume (HOST_UID-owned /root traversal)
# 6. uid-501/cold/no-optins — macOS-style UID (<1000, below Ubuntu floor)
# 7. uid-0/cold/no-optins — root-legacy path (HOST_UID=0 → exec directly)
# 8. runner-uid/ephemeral — --ephemeral mode (no named volumes)
# 9. runner-uid/ro/no-optins — read-only workspace (EROFS robustness)
run: |
set -euo pipefail
export IMAGE=opencode:ci
RUNNER_UID=$(id -u)
run_cell() {
echo "--- Cell: $* ---"
IMAGE=opencode:ci bash smoke/smoke.sh "$@"
}
# 1. Baseline: runner UID, cold volume, no opt-ins.
run_cell --uid="${RUNNER_UID}" --volstate=cold --ephemeral=0
# 2. AWS opt-in.
run_cell --uid="${RUNNER_UID}" --optins=aws --volstate=cold --ephemeral=0
# 3. GitLab opt-in.
run_cell --uid="${RUNNER_UID}" --optins=glab --volstate=cold --ephemeral=0
# 4. All three opt-ins combined.
run_cell --uid="${RUNNER_UID}" --optins=aws,glab,tfe --volstate=cold --ephemeral=0
# 5. Warm volume: first run populates the named volume, second run
# traverses the now HOST_UID-owned 0700 /root (exercises CAP_DAC_READ_SEARCH).
run_cell --uid="${RUNNER_UID}" --volstate=warm --ephemeral=0
# 6. macOS-style UID 501 (below Ubuntu's default UID_MIN=1000).
run_cell --uid=501 --volstate=cold --ephemeral=0
# 7. Root-legacy path: HOST_UID=0 → entrypoint skips useradd+runuser and
# exec's claude directly as container root.
run_cell --uid=0 --volstate=cold --ephemeral=0
# 8. Ephemeral mode: no named volumes.
run_cell --uid="${RUNNER_UID}" --ephemeral=1
# 9. Read-only workspace: EROFS robustness — chown on :ro mounts must not
# abort the entrypoint; assert no spurious WARN on stderr.
run_cell --uid="${RUNNER_UID}" --volstate=cold --ro=1 --ephemeral=0
# 10. Non-runner GID: exercises the entrypoint's `useradd -g $HOST_GID`
# path with a GID that differs from the runner's (asserts the
# dropped process lands in exactly that primary group).
run_cell --uid=501 --gid=501 --volstate=cold --ephemeral=0