Skip to content
Merged
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
26 changes: 26 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ updates:
prefix: build # Conventional Commits — the commit-msg hook and the changelog both depend on it
include: scope
labels: [dependencies, ci]
groups:
# Every action here is pinned by full commit SHA, so a bump is a one-line SHA change per action and
# reviewing them one PR at a time buys nothing but pipeline runs.
actions:
patterns: ['*']
security:
applies-to: security-updates
patterns: ['*']

# Build tooling only: vitest/jsdom for the frontend tests, commitlint for the commit gate, and the
# Agent SDK as protocol reference. None of it ships (see SECURITY.md), which is exactly why the
Expand All @@ -35,6 +43,14 @@ updates:
dev-tooling:
patterns: ['*']
update-types: [minor, patch]
# Security updates are a SEPARATE stream: they ignore `open-pull-requests-limit` entirely, and the
# group above does not cover them because `applies-to` defaults to version-updates. That is how five
# landed at once despite a limit of three — each firing a full pipeline, verifier included, for
# transitive devDependencies that are never distributed (`npm audit --omit=dev` reports 0).
# One PR for the lot: same review, one CI run.
security:
applies-to: security-updates
patterns: ['*']
ignore:
# The SDK baseline is not Dependabot's to move. `checkDrift` bumps it as part of a *reconciled*
# protocol review — taking the new version without reading the surface diff is how a protocol gap
Expand All @@ -51,6 +67,16 @@ updates:
prefix: build
include: scope
labels: [dependencies]
groups:
# Patch and minor bumps of build tooling reviewed together. A MAJOR stays on its own PR on purpose:
# this ecosystem is where a bump can hang the headless suite (the 2.16 -> 2.18 platform-plugin
# attempt did exactly that), so a major deserves its own run and its own decision.
build-tooling:
patterns: ['*']
update-types: [minor, patch]
security:
applies-to: security-updates
patterns: ['*']
ignore:
# kotlinx-serialization is provided by the IntelliJ Platform at runtime; the declared version has
# to match what the targeted IDEs ship, not the newest release. Bumping it blindly is a
Expand Down
66 changes: 41 additions & 25 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,14 @@ permissions:
# One run per ref. Superseded PR runs are cancelled — but pushes to develop/main are NOT, so the history
# of what passed on the trunk stays complete.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
# Keyed on the COMMIT, not the ref. A branch with an open PR fires both `push` and `pull_request` for the
# same commit, and `github.ref` differs between them (refs/heads/x vs refs/pull/N/merge) — so the old group
# let both run to completion, doubling every job on every push for no extra information. Same SHA now means
# same group, so the duplicate is cancelled and whichever run survives reports the checks.
group: ci-${{ github.event.pull_request.head.sha || github.sha }}
# Cancel superseded runs on EVERY event, not just PRs. Pushing three times in a row previously left three
# full pipelines racing, each spending ten minutes downloading IDEs for a commit already replaced.
cancel-in-progress: true

env:
# No daemon: a fresh JVM per job is the honest measurement on ephemeral runners, and a leaked daemon
Expand Down Expand Up @@ -65,8 +71,12 @@ jobs:
# the next build on develop reads back.
cache-read-only: ${{ github.ref != 'refs/heads/develop' && github.ref != 'refs/heads/main' }}

- name: Run tests
run: ./gradlew --no-daemon --stacktrace test
# 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
# JVM suite on a second runner with a cold cache — the single most expensive duplicate in this pipeline,
# and invisible because both jobs were green. Coverage is a property OF a test run; it belongs with it.
- name: Run tests and verify coverage gates
run: ./gradlew --no-daemon --stacktrace test koverVerify

- name: Upload test reports
if: always()
Expand Down Expand Up @@ -118,10 +128,9 @@ jobs:
- name: Formatting (Spotless / ktlint)
run: ./gradlew --no-daemon --stacktrace spotlessCheck

# Per-package coverage gates. See docs/RELEASE_CHECKLIST.md §Coverage policy for the thresholds,
# what is excluded and why, and the Kover limitation behind the current floor+aggregate shape.
- name: Coverage gates
run: ./gradlew --no-daemon --stacktrace koverVerify
# NB the per-package coverage gates (`koverVerify`) are NOT run here. They live in the `JVM tests` job,
# because Kover derives coverage from an actual test run: invoking it here re-executed the entire JVM
# suite on this runner. See docs/RELEASE_CHECKLIST.md §Coverage policy for the thresholds themselves.

# The shipped JCEF JavaScript. no-eval / no-implied-eval / no-new-func are errors here because the
# page runs under a hash-pinned CSP with no 'unsafe-eval': without this gate, code the browser will
Expand Down Expand Up @@ -234,6 +243,18 @@ jobs:
- name: Verify plugin
run: ./gradlew --no-daemon --stacktrace verifyPlugin

# `verifyPlugin` depends on `buildPlugin`, so the distributable already exists here. Hand it to the
# `Build plugin` job instead of letting it build a second time on a fresh runner: that job asserts
# properties OF the artifact, and asserting them on a DIFFERENT build than the one just verified was
# both wasteful and subtly wrong — the bytes checked were never the bytes verified.
- name: Hand the built distributable to the assertions job
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: verified-distribution
path: build/distributions/*.zip
retention-days: 1
if-no-files-found: error

- name: Upload verifier report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
Expand All @@ -242,29 +263,24 @@ jobs:
path: build/reports/pluginVerifier/
retention-days: 30

# The distributable. Unsigned and unpublished here by design: signing and publishing happen only from
# a tag, in release.yml, behind a human approval. This job proves the artifact BUILDS on every change.
# Properties of the DISTRIBUTABLE, asserted on the exact artifact the verifier just checked.
#
# It no longer builds its own: `verifyPlugin` already produced one, and rebuilding meant these assertions
# ran against bytes that were never verified — a second build on a fresh runner is not guaranteed to be
# the same artifact. Downloading it also drops a full Gradle setup, JDK provision and compile from the
# critical path. Unsigned and unpublished by design: signing and publishing happen only in release.yml,
# behind a human approval.
build:
name: Build plugin
runs-on: ubuntu-latest
timeout-minutes: 30
timeout-minutes: 10
needs: [verify]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Fetch the verified distributable
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
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.ref != 'refs/heads/develop' && github.ref != 'refs/heads/main' }}

- name: Build plugin
run: ./gradlew --no-daemon --stacktrace buildPlugin
name: verified-distribution
path: build/distributions

# A claim SECURITY.md makes to users, enforced here rather than trusted: the published artifact
# contains no npm code. If this ever fails, either the packaging changed or the claim was false.
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/drift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ on:
permissions:
contents: read

# Weekly, so overlap is unlikely — but a manual dispatch during a scheduled run would have two of these
# racing to file the same issue. Queued rather than cancelled: a drift report half-written is worse than
# one that starts a few minutes late.
concurrency:
group: drift
cancel-in-progress: false

jobs:
drift:
name: Check protocol drift
Expand Down
114 changes: 94 additions & 20 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ name: Release

on:
push:
# Primary path: a merge into `main` releases whatever version build.gradle.kts declares. The version in
# the code is the single source of truth — the tag is derived from it, so the two can no longer disagree.
branches: [main]
# Kept as the manual escape hatch: an explicit tag still releases. Useful to re-cut after a failed
# publish without pushing an empty commit to main. A tag created BY this workflow does not re-trigger it
# (GitHub deliberately does not fire workflows for GITHUB_TOKEN-pushed refs), so there is no loop.
tags: ['v[0-9]+.[0-9]+.[0-9]+']

permissions:
Expand All @@ -34,34 +40,60 @@ env:
jobs:
# Gate 2, on its own so it fails in seconds and before any secret is in scope.
guard:
name: Tag must come from main
name: Decide the version and check lineage
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
tag: ${{ steps.resolve.outputs.tag }}
version: ${{ steps.resolve.outputs.version }}
release: ${{ steps.resolve.outputs.release }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0 # need the graph, not just the tagged commit
fetch-depth: 0 # need the graph and the tags, not just this commit
persist-credentials: false

- name: Assert the tagged commit is on main
# Lineage. On a tag push this is the load-bearing gate: without it, anyone who can push a tag can
# publish from any code. On a main push it is trivially true, and checked anyway rather than assumed —
# the cost is one command and the failure mode it guards against is publishing unreviewed code.
- name: Assert this commit is on main
run: |
git fetch --no-tags origin main:refs/remotes/origin/main
if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
echo "::error::${GITHUB_REF_NAME} points at a commit that is not reachable from main."
echo "Releases are cut from main only, and main only accepts reviewed PRs from develop."
exit 1
fi
echo "$GITHUB_REF_NAME is on main — lineage OK."
echo "lineage OK — $GITHUB_SHA is reachable from main."

# The tag says which version this is; build.gradle.kts says which version gets built. If they
# disagree, the artifact would be published under a number nobody chose. Cheap check, real bug.
- name: Assert the tag matches the built version
# build.gradle.kts is the SINGLE SOURCE OF TRUTH for the version. On a main push the tag is derived
# from it; on a tag push the two must agree. Either way a release can never be published under a
# number nobody chose.
- name: Resolve the version and decide whether to release
id: resolve
run: |
declared=$(grep -m1 '^version = ' build.gradle.kts | sed 's/.*"\(.*\)".*/\1/')
expected="${GITHUB_REF_NAME#v}"
[ "$declared" = "$expected" ] || {
echo "::error::tag $GITHUB_REF_NAME does not match build.gradle.kts version $declared"; exit 1; }
echo "version $declared matches the tag."
[ -n "$declared" ] || { echo "::error::could not read version from build.gradle.kts"; exit 1; }
tag="v${declared}"

if [ "$GITHUB_REF_TYPE" = "tag" ]; then
[ "$GITHUB_REF_NAME" = "$tag" ] || {
echo "::error::tag $GITHUB_REF_NAME does not match build.gradle.kts version $declared"; exit 1; }
fi

# An existing tag means this version is already released. Do NOT publish again, and do NOT fail:
# main legitimately receives merges that are not releases (a docs fix, a reverted change), and a
# red run on every one of those is an alarm people learn to ignore. Published tags stay immutable
# — that is the correction ADR 0001 records, after v4.3.2 and v4.4.1 were each force-re-cut.
if git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1; then
echo "release=false" >> "$GITHUB_OUTPUT"
echo "::notice::$tag already exists — nothing to release. Bump the version in build.gradle.kts to cut a new one."
else
echo "release=true" >> "$GITHUB_OUTPUT"
echo "::notice::will release $tag from $GITHUB_SHA"
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "version=$declared" >> "$GITHUB_OUTPUT"

# Full gate again on the exact tagged tree. CI already ran on the branch, but a release must be
# verified against what is actually being shipped, not against what was on develop last week.
Expand All @@ -70,6 +102,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
needs: [guard]
if: needs.guard.outputs.release == 'true'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand Down Expand Up @@ -119,7 +152,8 @@ jobs:
name: Build, sign and publish
runs-on: ubuntu-latest
timeout-minutes: 45
needs: [verify]
needs: [guard, verify]
if: needs.guard.outputs.release == 'true'
environment:
name: marketplace
url: https://plugins.jetbrains.com/plugin/31965-claude-code-native
Expand Down Expand Up @@ -170,7 +204,7 @@ jobs:
exit 1
fi
mkdir -p dist
name="claude-code-native-${GITHUB_REF_NAME#v}.zip"
name="claude-code-native-${{ needs.guard.outputs.version }}.zip"
cp "$signed" "dist/$name"
echo "name=$name" >> "$GITHUB_OUTPUT"
echo "published $name sha256=$(sha256sum "dist/$name" | cut -d' ' -f1)"
Expand Down Expand Up @@ -204,6 +238,43 @@ jobs:
# Check our own output before it leaves the runner: a signature nobody verified is just a file.
for f in "$NAME" "$NAME.sha256"; do gpg --verify "$f.asc" "$f"; done
sha256sum -c "$NAME.sha256"

# --- Cut the tag, signed, AFTER the release was approved and actually published -------------
#
# Deliberately last, and deliberately not in `guard`. Creating it earlier would mean a tag exists for
# a version that was never published (a failed build, a declined approval), and published tags are
# immutable here — so the next attempt would be blocked by a tag naming a release that does not exist.
# Cutting it here makes the tag mean "this was published", which is the only claim it can honestly make
# when the version, not the tag, is the input.
#
# Signed with the CI key, NOT the maintainer's YubiKey — which cannot sign inside a runner, and whose
# non-exportability is exactly what makes it worth trusting. The chain still terminates in hardware
# because the CI key is certified by it. The claims therefore shift, and SECURITY.md says so: the tag
# now attests "this workflow published these bytes", and the human authorisation lives in the two gates
# that remain — the reviewed PR into main, and the required approval on the `marketplace` environment.
- name: Create and sign the release tag
if: github.ref_type != 'tag'
env:
GPG_PASSPHRASE: ${{ secrets.GPG_SIGNING_PASSPHRASE }}
TAG: ${{ needs.guard.outputs.tag }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# git cannot pass gpg the loopback flags it needs in a headless runner, so it gets a wrapper that
# supplies them. The passphrase travels in the environment, never in argv, where `ps` would see it.
printf '#!/bin/sh\nexec gpg --batch --pinentry-mode loopback --passphrase "$GPG_PASSPHRASE" "$@"\n' \
> /tmp/gpg-loopback
chmod +x /tmp/gpg-loopback

# A bot identity, not a person: this tag is not a human's assertion and must not look like one.
# The noreply address is required by git and is not anyone's mailbox.
git config user.name 'github-actions[bot]'
git config user.email 'github-actions[bot]@users.noreply.github.com'
git config gpg.program /tmp/gpg-loopback
git config user.signingkey "$GPG_FPR"

git tag -s "$TAG" -m "Release $TAG — published by the release workflow from $GITHUB_SHA"
git verify-tag "$TAG" # never push a signature we have not checked ourselves
git push "https://x-access-token:${TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "refs/tags/$TAG"
ls -la

# --- GitHub Release -------------------------------------------------------------------------
Expand All @@ -221,18 +292,21 @@ jobs:

---

**Verifying this release.** The `.asc` files are signed by the project's **CI signing key**
(`docs/ci-signing-key.asc`), which is itself certified by the maintainer's hardware key — so the
chain terminates in a key that has never been on a computer. The tag is signed by that hardware
key directly. Check both; they claim different things:
**Verifying this release.** Both the `.asc` files and the tag are signed by the project's **CI
signing key** (`docs/ci-signing-key.asc`), which is itself certified by the maintainer's hardware
key — so the chain terminates in a key that has never been on a computer.

What the signatures do NOT assert is that a human pressed a button: the release is cut
automatically from `main`. That claim rests on the two gates around it — `main` accepts only
reviewed pull requests, and publication requires an approval on a protected environment.

```sh
gpg --import docs/ci-signing-key.asc
gpg --verify claude-code-native-*.zip.asc # these bytes came from this workflow
git verify-tag <tag> # a person authorised this release
git verify-tag <tag> # this workflow cut this release from main
```
EOF
gh release create "$GITHUB_REF_NAME" dist/* \
--title "$GITHUB_REF_NAME" \
gh release create "${{ needs.guard.outputs.tag }}" dist/* \
--title "${{ needs.guard.outputs.tag }}" \
--notes-file /tmp/notes.md \
--verify-tag
Loading