Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions .github/ci-image/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# CI image for claude-code-native — Fedora 44.
#
# WHY THIS EXISTS
# The expensive part of this pipeline is not compute, it is downloads: `verifyPlugin` pulls ~1.25 GB of
# IntelliJ IDEs on every cold run, and a GitHub runner starts cold every time a branch cannot write its own
# cache. Baking those into an image turns a 10-minute job into a pull plus a couple of minutes.
#
# WHERE TO PUBLISH IT
# ghcr.io, NOT Docker Hub. It sits on the same network as the runners (much faster pulls) and has no
# anonymous pull-rate limit — that limit is a classic cause of a pipeline failing for reasons nobody
# changed.
#
# HOW IT GOES STALE, WHICH IS THE REAL CAVEAT
# `verifyPlugin` resolves IDEs from the EAP/RC channels, so the set it wants MOVES. The day JetBrains
# publishes a new build, the baked copies stop matching and Gradle downloads the new one anyway — the image
# degrades to "no worse than before" rather than breaking. Rebuild it weekly (a scheduled workflow) or
# accept that the saving decays between builds.
#
# docker build -f .github/ci-image/Dockerfile -t ghcr.io/OWNER/cc-ci:latest .
# docker push ghcr.io/OWNER/cc-ci:latest
#
# Used from a workflow as:
# jobs:
# test:
# runs-on: ubuntu-latest
# container: ghcr.io/OWNER/cc-ci:latest
FROM fedora:44

# Parallel downloads: dnf defaults to 3, and this image installs a JDK plus a Node toolchain over a link
# that is not the bottleneck. Set before the first transaction so every one of them benefits.
RUN echo "max_parallel_downloads=20" >> /etc/dnf/dnf.conf \
&& echo "fastestmirror=True" >> /etc/dnf/dnf.conf

# Temurin, not Fedora's OpenJDK.
#
# Fedora 44 no longer packages java-21-openjdk — it has moved on to a newer LTS — and the JDK version is not
# ours to float: build.gradle.kts pins the toolchain to 21 because the IDE runs on JBR 21, which is the
# ceiling. Building on 25 would produce class files no target IDE can load.
#
# Adoptium's repository is the same source the `setup-java` action uses on the GitHub runners, so the image
# and the hosted pipeline compile against the same JDK rather than two different builds of "21".
RUN dnf -y --setopt=install_weak_deps=False install dnf-plugins-core \
&& curl -fsSL https://packages.adoptium.net/artifactory/api/gpg/key/public \
-o /etc/pki/rpm-gpg/RPM-GPG-KEY-Adoptium \
&& rpm --import /etc/pki/rpm-gpg/RPM-GPG-KEY-Adoptium \
&& printf '%s\n' \
'[Adoptium]' \
'name=Adoptium' \
'baseurl=https://packages.adoptium.net/artifactory/rpm/fedora/$releasever/$basearch' \
'enabled=1' \
'gpgcheck=1' \
'gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-Adoptium' \
> /etc/yum.repos.d/adoptium.repo

# `git` is required by actions/checkout; `which`/`findutils`/`procps-ng` are assumed present by various
# actions and by Gradle's own probing, and Fedora's base image is minimal enough not to ship them.
# `--setopt=install_weak_deps=False` keeps the image from pulling in recommended-but-unused packages.
RUN dnf -y --setopt=install_weak_deps=False install \
temurin-21-jdk \
nodejs npm \
git unzip zip tar which findutils procps-ng ca-certificates \
&& dnf clean all \
&& rm -rf /var/cache/dnf

# JAVA_HOME is resolved rather than hardcoded: the exact path carries the package's build number and would
# silently break on the next base-image bump.
RUN JH="$(dirname "$(dirname "$(readlink -f "$(command -v javac)")")")" \
&& echo "JAVA_HOME=$JH" >> /etc/environment \
&& ln -sfn "$JH" /opt/java-21 \
&& "$JH/bin/java" -version
# A stable symlink, so JAVA_HOME does not carry Temurin's build number and break on the next image rebuild.
ENV JAVA_HOME=/opt/java-21
ENV PATH="${JAVA_HOME}/bin:${PATH}"

# Gradle writes here, and the path must match what the job will use, or the warm caches below are invisible
# to it. Set GRADLE_USER_HOME to the same value in the workflow.
ENV GRADLE_USER_HOME=/opt/gradle-home

WORKDIR /warmup

# Only the build definition, on purpose: this layer is invalidated by a dependency change, not by every edit
# to the Kotlin sources. The whole source tree is copied later, in a layer that costs nothing to rebuild.
COPY gradle/ gradle/
COPY gradlew settings.gradle.kts build.gradle.kts gradle.properties* ./
COPY package.json package-lock.json ./

# Downloads the Gradle distribution itself and resolves the plugin/dependency graph.
RUN ./gradlew --no-daemon --version \
&& ./gradlew --no-daemon dependencies --configuration compileClasspath > /dev/null 2>&1 || true

# npm dependencies for the frontend tests. `npm ci` needs package-lock.json, which is why it is copied above.
#
# What is baked is the npm CACHE, not `node_modules`, and the distinction is the whole point: the cleanup
# step below wipes /warmup, so a baked node_modules would be deleted moments after being built — the warm-up
# would look like it worked and buy nothing. `node_modules` also MUST match the package-lock.json of whatever
# commit CI checks out, not the one that happened to be current when the image was cut, so keeping it would
# be wrong even if it survived. The cache is version-addressed and therefore safe to reuse: `npm ci` in CI
# rebuilds node_modules from it without touching the network.
ENV npm_config_cache=/opt/npm-cache
RUN npm ci --no-audit --no-fund

# The big one. `verifyPlugin` is what pulls the IDEs, and there is no way to fetch them without running it,
# so the full source is needed here. This step is SLOW (~10 minutes) by design — it is paying once, at image
# build time, for what every CI run was paying.
#
# `|| true`: a verification FAILURE must not fail the image build. We are here for the side effect (the
# downloaded IDEs now sitting in GRADLE_USER_HOME), not for the verdict — the verdict is CI's job, on the
# real commit, not on whatever happened to be checked out when the image was cut.
COPY . .
RUN ./gradlew --no-daemon verifyPlugin > /dev/null 2>&1 || true

# The sources were only ever scaffolding for the warm-up; keeping them would ship a stale copy of the
# repository inside the image, which someone would eventually mistake for the real one.
RUN rm -rf /warmup/* /warmup/.git /warmup/.[!.]* 2>/dev/null || true
WORKDIR /workspace
186 changes: 121 additions & 65 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,23 @@
name: CI

on:
push:
# Every working branch gets the same gate. A quality bar that only applies once you open the PR is a
# bar you discover late, when the change is already big and the rework is expensive.
branches:
- develop
- main
- 'feature/**'
- 'bugfix/**'
- 'hotfix/**'
# Pull requests ONLY — there is deliberately no `push` trigger.
#
# A branch with an open PR fires `pull_request` on every push to it (the `synchronize` event), so the
# iteration loop is fully covered, and covered ONCE. Having both triggers meant two complete pipelines per
# commit for identical information; this removes that at the root instead of relying on the concurrency
# group to cancel one of them in time.
#
# Two consequences, recorded because each removes something we were leaning on:
#
# - No CI on the push that a merge into `develop` creates. That run was the stated justification for
# dropping the up-to-date requirement on develop — two pull requests that are green apart can break
# together, and develop's own run was what would have caught it. It is now caught at the pull request
# into `main`, where the full gate runs, rather than immediately after the merge.
# - A branch with no open pull request gets no checks at all. That is the intent: no PR, no promotion.
#
# `release.yml` is unaffected — it carries its own `push: branches: [main]` trigger and still fires on the
# merge that publishes.
pull_request:
branches: [develop, main]
workflow_dispatch:
Expand Down Expand Up @@ -51,32 +59,41 @@ jobs:
name: JVM tests
runs-on: ubuntu-latest
timeout-minutes: 30
# EVERY job in this file runs in this image, and the image is the ONLY caching mechanism.
#
# `gradle/actions/setup-gradle` used to sit in the heavy jobs and was quietly useless here: the warm
# GRADLE_USER_HOME measures 31 GB (23 GB of extracted IDE transforms under caches/9.5.1, 7.7 GB of the
# downloaded IDE artifacts under modules-2), and a GitHub Actions cache entry is capped at 10 GB per
# repository. It could never have stored what it appeared to be storing — it was saving a partial
# cache, evicting it, and re-downloading the rest on the next run. The image has no such ceiling, and
# the trade is explicit: refreshing what CI has cached now means rebuilding and pushing the image,
# which is a deliberate act rather than something that drifts between runs.
container:
image: ghcr.io/serialexperimentslainnnn/cc-ci:latest
# The package stays PRIVATE and is pulled with the run's own GITHUB_TOKEN — no new secret, nothing to
# rotate, and access dies with the job. This requires the package to have been granted Read access to
# THIS repository (package settings -> Manage Actions access): `packages: read` widens what the token
# may ask for, it does not authorise it against a package the repo was never linked to. Without that
# link the pull fails with a bare `denied`, which reads like a wrong image name.
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
permissions:
contents: read
packages: read
env:
# MUST match GRADLE_USER_HOME in .github/ci-image/Dockerfile. If these diverge, the warmed caches
# baked into the image are invisible and every run silently re-downloads what the image already has.
GRADLE_USER_HOME: /opt/gradle-home
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Set up JDK 21
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
# Temurin, not the JetBrains Runtime: the JBR matters for *running* an IDE, not for compiling
# against the platform. The toolchain is pinned to 21 in build.gradle.kts either way.
distribution: temurin
java-version: '21'

- name: Set up Gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
# Read-only ONLY for pull requests from forks. Every other branch writes its own cache, which is
# what stops a second push from re-downloading 1.25 GB of IDEs it already had.
#
# This is safe without our help, and the previous blanket read-only was more conservative than the
# platform requires. GitHub scopes caches per branch: "Workflow runs cannot restore caches created
# for child branches or sibling branches", and a cache created on a pull request is written to the
# merge ref, so it "can only be restored by re-runs of the pull request". A topic branch therefore
# cannot reach — let alone overwrite — what develop reads back. Forks stay read-only anyway: there
# is no reason to let untrusted code populate anything this repository will later restore.
cache-read-only: ${{ github.event.pull_request.head.repo.fork == true }}
# NB there is deliberately no `setup-gradle` step, in this job or any other. See the note at the
# `container:` block above: the image IS the cache, and the action's cache was never doing the job
# it looked like it was doing.

# Coverage is verified HERE, in the same job and the same Gradle invocation as the tests.
# `koverVerify` depends on `:test`, so running it in the separate `Static analysis` job re-ran the whole
Expand Down Expand Up @@ -105,6 +122,21 @@ jobs:
name: Static analysis
runs-on: ubuntu-latest
timeout-minutes: 20
container:
image: ghcr.io/serialexperimentslainnnn/cc-ci:latest
# The package stays PRIVATE and is pulled with the run's own GITHUB_TOKEN — no new secret, nothing to
# rotate, and access dies with the job. `packages: read` is granted per job below; without it the pull
# fails with a 401 that reads like a wrong image name rather than a permission problem.
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
permissions:
contents: read
packages: read
env:
# MUST match GRADLE_USER_HOME in .github/ci-image/Dockerfile. If these diverge, the warmed caches
# baked into the image are invisible and every run silently re-downloads what the image already has.
GRADLE_USER_HOME: /opt/gradle-home
# Same door as the verifier: pull requests into main, and the protected branches themselves.
# A branch iterating towards develop runs only the two test suites; formatting, lint and coverage are
# settled before anything is promoted. The cost is real and worth naming — a formatting or detekt
Expand All @@ -118,20 +150,10 @@ jobs:
with:
persist-credentials: false

- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: temurin
java-version: '21'

- uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.event.pull_request.head.repo.fork == true }}

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
cache: npm

# `npm ci` is fast rather than free here: node_modules is NOT baked into the image (it must match the
# lockfile of the commit under test, not the one current when the image was cut), but the npm cache is,
# so this resolves from /opt/npm-cache without touching the network.
- run: npm ci

# detekt: rule config in config/detekt/detekt.yml, each non-default setting carrying its reasoning
Expand Down Expand Up @@ -172,16 +194,26 @@ jobs:
name: Frontend tests
runs-on: ubuntu-latest
timeout-minutes: 10
container:
image: ghcr.io/serialexperimentslainnnn/cc-ci:latest
# The package stays PRIVATE and is pulled with the run's own GITHUB_TOKEN — no new secret, nothing to
# rotate, and access dies with the job. `packages: read` is granted per job below; without it the pull
# fails with a 401 that reads like a wrong image name rather than a permission problem.
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
permissions:
contents: read
packages: read
env:
# MUST match GRADLE_USER_HOME in .github/ci-image/Dockerfile. If these diverge, the warmed caches
# baked into the image are invisible and every run silently re-downloads what the image already has.
GRADLE_USER_HOME: /opt/gradle-home
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
cache: npm

# `npm ci` (not `install`): it installs exactly the committed lockfile and fails if package.json
# and the lockfile disagree, which is the only way CI tests the dependency tree that was reviewed.
Expand All @@ -208,6 +240,21 @@ jobs:
name: Dependency audit
runs-on: ubuntu-latest
timeout-minutes: 10
container:
image: ghcr.io/serialexperimentslainnnn/cc-ci:latest
# The package stays PRIVATE and is pulled with the run's own GITHUB_TOKEN — no new secret, nothing to
# rotate, and access dies with the job. `packages: read` is granted per job below; without it the pull
# fails with a 401 that reads like a wrong image name rather than a permission problem.
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
permissions:
contents: read
packages: read
env:
# MUST match GRADLE_USER_HOME in .github/ci-image/Dockerfile. If these diverge, the warmed caches
# baked into the image are invisible and every run silently re-downloads what the image already has.
GRADLE_USER_HOME: /opt/gradle-home
# Same door. NB this is the check that judges exactly what a Dependabot pull request changes, so it no
# longer runs on the PR that proposes the bump — only once that bump is on develop, and again before it
# can reach main. Nothing ships un-audited; the finding simply arrives one merge later.
Expand All @@ -220,10 +267,6 @@ jobs:
with:
persist-credentials: false

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
cache: npm

- run: npm ci

Expand All @@ -240,6 +283,21 @@ jobs:
name: Plugin verifier
runs-on: ubuntu-latest
timeout-minutes: 60
container:
image: ghcr.io/serialexperimentslainnnn/cc-ci:latest
# The package stays PRIVATE and is pulled with the run's own GITHUB_TOKEN — no new secret, nothing to
# rotate, and access dies with the job. `packages: read` is granted per job below; without it the pull
# fails with a 401 that reads like a wrong image name rather than a permission problem.
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
permissions:
contents: read
packages: read
env:
# MUST match GRADLE_USER_HOME in .github/ci-image/Dockerfile. If these diverge, the warmed caches
# baked into the image are invisible and every run silently re-downloads what the image already has.
GRADLE_USER_HOME: /opt/gradle-home
needs: [test, frontend-test]
# The expensive one: ~10 minutes and 1.25 GB of IDE downloads. It runs where the answer is load-bearing —
# on every pull request, and on the protected branches — and NOT on each push to a topic branch, where it
Expand All @@ -261,21 +319,11 @@ jobs:
with:
persist-credentials: false

- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: temurin
java-version: '21'

- uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.event.pull_request.head.repo.fork == true }}

# The runner ships with a few GB of preinstalled toolchains we will never use, and the verifier
# needs room for multiple extracted IDEs. Reclaiming it is cheaper than debugging a disk-full run.
- name: Free disk space
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc
df -h /
# The old `Free disk space` step is gone. It deleted /usr/share/dotnet and friends, and inside a
# container those paths are the IMAGE's, not the runner's — it was freeing nothing while looking
# like the safety margin for this job. The margin now comes from the IDEs being baked: this job no
# longer downloads or extracts 1.25 GB, it reads what is already on disk.

- name: Verify plugin
run: ./gradlew --no-daemon --stacktrace verifyPlugin
Expand Down Expand Up @@ -311,6 +359,14 @@ jobs:
name: Build plugin
runs-on: ubuntu-latest
timeout-minutes: 10
container:
image: ghcr.io/serialexperimentslainnnn/cc-ci:latest
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
permissions:
contents: read
packages: read
needs: [verify]
steps:
- name: Fetch the verified distributable
Expand Down
Loading
Loading