From 30a80b8156b46855ac66666d1bf30ac937703167 Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 6 Aug 2026 02:27:58 +0200 Subject: [PATCH 1/5] ci: trigger on pull requests only, never on push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A branch with an open pull request already fires `pull_request` on every push to it (the `synchronize` event), so keeping a `push` trigger meant two complete pipelines per commit for identical information. This removes the duplication at its source instead of relying on the concurrency group to cancel one in time. The result is the intended shape: a PR into develop runs the two required suites once, a PR from develop into main runs all eight. Two things this gives up, recorded because each removes something the current setup was leaning on: - There is no longer a CI run on the push 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 — later, but still before anything is published. - A branch with no open pull request gets no checks at all, and a pull request from a fork is the only path that would ever exercise them for an outside contributor. release.yml is untouched: it carries its own `push: branches: [main]` trigger and still fires on the merge that publishes. --- .github/workflows/ci.yml | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bdd2c632..13d93437 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: From a0e8a9a59f7a2334e662be6bb14bd938ec9c64ce Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 6 Aug 2026 02:48:48 +0200 Subject: [PATCH 2/5] ci: run the Gradle and Node jobs in the prebuilt image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Points the five toolchain jobs at ghcr.io/serialexperimentslainnnn/cc-ci and drops the setup-java and setup-node steps, which provisioned inside a container that already has both. `Build plugin` keeps no container: it only unzips an artifact. GRADLE_USER_HOME is set per job and MUST match the value in the Dockerfile. If the two diverge nothing fails — the run simply re-downloads everything the image already holds, and the image appears to have bought nothing. That silence is the reason it is stated at the setting rather than assumed. NOT VERIFIED against a real image: at the time of writing it has not been built or pushed. Two things have to be true before this can merge, and both fail in ways that look like something else: - the package must be public (or linked to this repo), or every job dies on a 401 that reads like a wrong image name; - the warmed caches must actually be in the image — `docker run --rm IMAGE sh -c 'ls /opt/gradle-home/caches'` answers it in seconds. Also still open: whether gradle/actions/setup-gradle should stay. It restores its own cache over GRADLE_USER_HOME, so it now layers on top of the baked one. That may be a useful increment or redundant work; it needs measuring, not guessing. --- .github/ci-image/Dockerfile | 108 ++++++++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 53 +++++++++--------- 2 files changed, 133 insertions(+), 28 deletions(-) create mode 100644 .github/ci-image/Dockerfile diff --git a/.github/ci-image/Dockerfile b/.github/ci-image/Dockerfile new file mode 100644 index 00000000..e0a6f913 --- /dev/null +++ b/.github/ci-image/Dockerfile @@ -0,0 +1,108 @@ +# 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. +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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13d93437..9e15545f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,18 +59,16 @@ jobs: name: JVM tests runs-on: ubuntu-latest timeout-minutes: 30 + container: ghcr.io/serialexperimentslainnnn/cc-ci:latest + 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 @@ -113,6 +111,11 @@ jobs: name: Static analysis runs-on: ubuntu-latest timeout-minutes: 20 + container: ghcr.io/serialexperimentslainnnn/cc-ci:latest + 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 @@ -126,19 +129,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 }} - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: '22' - cache: npm - run: npm ci @@ -180,16 +175,16 @@ jobs: name: Frontend tests runs-on: ubuntu-latest timeout-minutes: 10 + container: ghcr.io/serialexperimentslainnnn/cc-ci:latest + 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. @@ -216,6 +211,11 @@ jobs: name: Dependency audit runs-on: ubuntu-latest timeout-minutes: 10 + container: ghcr.io/serialexperimentslainnnn/cc-ci:latest + 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. @@ -228,10 +228,6 @@ jobs: with: persist-credentials: false - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: '22' - cache: npm - run: npm ci @@ -248,6 +244,11 @@ jobs: name: Plugin verifier runs-on: ubuntu-latest timeout-minutes: 60 + container: ghcr.io/serialexperimentslainnnn/cc-ci:latest + 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 @@ -269,10 +270,6 @@ 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: From fbfb64d0a60804ae0380a2ef80b704bbb0a83f66 Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 6 Aug 2026 02:50:26 +0200 Subject: [PATCH 3/5] ci: pull the private image with the run's own token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package stays private. Each container job authenticates with the GITHUB_TOKEN the run already has, so there is no new secret to create, store or rotate, and the credential expires with the job. `packages: read` is granted per job rather than at the top level, keeping the default token read-only on everything else. Without it the pull fails with a 401 that reads like a wrong image name rather than a permission problem — which is exactly the kind of error that gets debugged in the wrong place. One prerequisite this does NOT remove: the package must be linked to this repository, or the token has no grant on it. That is done once, from the package settings, and it is what makes "same account" mean "same permissions" here. --- .github/workflows/ci.yml | 60 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 55 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e15545f..6492e2d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,7 +59,17 @@ jobs: name: JVM tests runs-on: ubuntu-latest timeout-minutes: 30 - container: ghcr.io/serialexperimentslainnnn/cc-ci:latest + 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. @@ -111,7 +121,17 @@ jobs: name: Static analysis runs-on: ubuntu-latest timeout-minutes: 20 - container: ghcr.io/serialexperimentslainnnn/cc-ci:latest + 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. @@ -175,7 +195,17 @@ jobs: name: Frontend tests runs-on: ubuntu-latest timeout-minutes: 10 - container: ghcr.io/serialexperimentslainnnn/cc-ci:latest + 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. @@ -211,7 +241,17 @@ jobs: name: Dependency audit runs-on: ubuntu-latest timeout-minutes: 10 - container: ghcr.io/serialexperimentslainnnn/cc-ci:latest + 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. @@ -244,7 +284,17 @@ jobs: name: Plugin verifier runs-on: ubuntu-latest timeout-minutes: 60 - container: ghcr.io/serialexperimentslainnnn/cc-ci:latest + 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. From 1249fbb1a816f6890c34d6d101de3802293c740e Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 6 Aug 2026 02:59:16 +0200 Subject: [PATCH 4/5] fix(ci-image): bake the npm cache, not node_modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cleanup step wipes /warmup, and `npm ci` had installed node_modules inside it — so the image built the frontend dependencies and deleted them moments later. The warm-up looked like it worked and bought nothing: CI would re-download the whole tree on every run, silently, because nothing fails when a cache is missing. Setting npm_config_cache moves the reusable part to /opt/npm-cache, which the cleanup does not touch. node_modules stays disposable, and that is correct independently of this bug: it must match the package-lock.json of the commit CI checks out, not the one that happened to be current when the image was cut. Found by a question about what that `rm -rf` actually deletes, which is a better review than reading the line I had just written myself. --- .github/ci-image/Dockerfile | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/ci-image/Dockerfile b/.github/ci-image/Dockerfile index e0a6f913..c68e0ebc 100644 --- a/.github/ci-image/Dockerfile +++ b/.github/ci-image/Dockerfile @@ -89,6 +89,14 @@ 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, From 2fad4bc107e79b162c1ab42d11055f24ba696bc0 Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 6 Aug 2026 03:35:30 +0200 Subject: [PATCH 5/5] ci: run every job in the CI image and drop the caching action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The image was already pulled by most jobs; the remaining ones provisioned their own JDK, Node and Gradle cache and so ran on a toolchain nothing else had used. Now `Build plugin`, the protocol-drift check and the release gate use it too, which is the point of having built it. `gradle/actions/setup-gradle` is removed everywhere rather than set to read-only, because it was not doing the job it appeared to be doing. The warm GRADLE_USER_HOME measures 31 GB — 23 GB of extracted IDE transforms under caches/9.5.1 and 7.7 GB of downloaded IDE artifacts under modules-2 — and an Actions cache entry is capped at 10 GB per repository. It could only ever have stored a fraction, evicted it, and re-downloaded the rest next run. The image has no such ceiling. The trade is explicit and worth stating: refreshing what CI has cached is now a deliberate rebuild-and-push, not something that drifts between runs. Also removes the verifier's `Free disk space` step. Inside a container those paths are the IMAGE's, not the runner's, so it had been freeing nothing while looking like this job's safety margin. The margin now comes from the IDEs being baked: nothing is downloaded or extracted at verify time. Recorded because it is the failure everyone hits once: the private package must be granted Read access to this repository in its own settings. The `packages: read` permission widens what the token may ASK for; it does not authorise it against a package the repo was never linked to, and without the link the pull fails with a bare `denied` that reads like a wrong image name. Not containerised, deliberately: `publish`, which holds the Marketplace token and the signing key and is not a test, and CodeQL, which is weekly and is where a container breaks quietly. Not verified: that the image builds with no network at all. The one attempt failed on uid mapping, which says nothing about CI, where the container runs as root. --- .github/ci-image/Dockerfile | 1 - .github/workflows/ci.yml | 61 ++++++++++++++++++----------------- .github/workflows/drift.yml | 22 ++++++------- .github/workflows/release.yml | 31 ++++++++---------- 4 files changed, 54 insertions(+), 61 deletions(-) diff --git a/.github/ci-image/Dockerfile b/.github/ci-image/Dockerfile index c68e0ebc..4556a54f 100644 --- a/.github/ci-image/Dockerfile +++ b/.github/ci-image/Dockerfile @@ -112,5 +112,4 @@ 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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6492e2d7..1974b807 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,11 +59,22 @@ 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. `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. + # 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 }} @@ -80,19 +91,9 @@ jobs: persist-credentials: false - - 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 @@ -150,11 +151,9 @@ jobs: persist-credentials: false - - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 - with: - cache-read-only: ${{ github.event.pull_request.head.repo.fork == true }} - - + # `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 @@ -321,16 +320,10 @@ jobs: persist-credentials: false - - 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 @@ -366,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 diff --git a/.github/workflows/drift.yml b/.github/workflows/drift.yml index 1c45b941..6cdd54b5 100644 --- a/.github/workflows/drift.yml +++ b/.github/workflows/drift.yml @@ -32,27 +32,23 @@ jobs: name: Check protocol drift runs-on: ubuntu-latest timeout-minutes: 30 + container: + image: ghcr.io/serialexperimentslainnnn/cc-ci:latest + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} permissions: contents: read + packages: read issues: write # to file the drift report + env: + # MUST match GRADLE_USER_HOME in .github/ci-image/Dockerfile. + GRADLE_USER_HOME: /opt/gradle-home steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 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: true - - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: '22' - # Deliberately NOT `npm ci`: checkDrift's whole job is to compare the pinned baseline against the # LATEST published SDK, so it needs the tree to be updatable. It runs `npm update` itself. - name: Install dependencies diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2706eb23..c8c1942f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -103,33 +103,30 @@ jobs: timeout-minutes: 60 needs: [guard] if: needs.guard.outputs.release == 'true' + # The same image ci.yml uses, for the same reason and with one extra: this gate must run the toolchain + # the branch was green on. Provisioning the JDK and Node here from separate actions meant the release + # gate could pass or fail on a toolchain the pull request never saw. + container: + image: ghcr.io/serialexperimentslainnnn/cc-ci:latest + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + permissions: + contents: read + packages: read + env: + # MUST match GRADLE_USER_HOME in .github/ci-image/Dockerfile. + GRADLE_USER_HOME: /opt/gradle-home steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 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: true - - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: '22' - cache: npm - - run: npm ci - run: npm test env: CI: 'true' - - name: Free disk space - run: sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc - - run: ./gradlew --no-daemon --stacktrace test verifyPlugin