diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 43188167..d8e86ac2 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -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 @@ -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 @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc51c5c3..71b08c6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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() @@ -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 @@ -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 @@ -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. diff --git a/.github/workflows/drift.yml b/.github/workflows/drift.yml index 3aeea02b..1c45b941 100644 --- a/.github/workflows/drift.yml +++ b/.github/workflows/drift.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7ae6d61b..2706eb23 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: @@ -34,16 +40,23 @@ 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 @@ -51,17 +64,36 @@ jobs: 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. @@ -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: @@ -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 @@ -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)" @@ -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 ------------------------------------------------------------------------- @@ -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 # a person authorised this release + git verify-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 diff --git a/SECURITY.md b/SECURITY.md index 48884b19..4ab4f97a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -268,17 +268,27 @@ gpg --check-sigs "$(gpg --show-keys --with-colons docs/ci-signing-key.asc | awk ``` **Verify both signatures.** They are complementary, not redundant — the artifact -signature alone cannot tell you a human intended the release, and the tag -signature alone says nothing about the bytes you downloaded: +signature covers the bytes you downloaded, and the tag ties those bytes to a +commit on `main`: ```sh gpg --import docs/ci-signing-key.asc gpg --verify claude-code-native-X.Y.Z.zip.asc # bytes came from the workflow -git verify-tag vX.Y.Z # a person authorised the release +git verify-tag vX.Y.Z # cut from main by that workflow gh attestation verify claude-code-native-X.Y.Z.zip \ --repo serialexperimentslainnnn/claude-code-for-jetbrains # build provenance ``` +**What no signature here claims.** Releases are cut automatically when `develop` +is merged into `main`, and both the tag and the artifact are signed by the CI +key — which is certified by the maintainer's hardware key, so the chain still +ends in hardware, but which signs without a human present. **Nothing in a +release attests that a person authorised it.** That rests on the two gates +around publication: `main` accepts only reviewed pull requests, and publishing +requires an approval from a named reviewer on a protected environment. Read +`git verify-tag` as *"this workflow cut this from main"*, and treat the human +judgement as living in the pull request, not in the signature. + The attestation is worth having and worth not overtrusting: it proves *where* a build ran, not that the result is benign. A compromised runner can produce a valid attestation for a malicious artifact. What actually reduces that risk is diff --git a/build.gradle.kts b/build.gradle.kts index ac6c5f7b..a166de0a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,6 +2,7 @@ import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType import org.jetbrains.intellij.platform.gradle.TestFrameworkType import org.jetbrains.intellij.platform.gradle.extensions.intellijPlatform import org.jetbrains.intellij.platform.gradle.models.ProductRelease +import org.jetbrains.intellij.platform.gradle.tasks.VerifyPluginTask plugins { kotlin("jvm") version "2.1.20" @@ -311,6 +312,27 @@ intellijPlatform { // 'JetBrains' in the plugin name is a Marketplace naming lint, not an API problem; muting it lets // the verifier proceed to the actual binary-compatibility / internal-API checks we care about. freeArgs = listOf("-mute", "TemplateWordInPluginName") + + // The "zero deprecations" rule, ENFORCED rather than merely written down. + // + // The plugin's default failure level is COMPATIBILITY_PROBLEMS + INTERNAL_API_USAGES + + // OVERRIDE_ONLY_API_USAGES — deprecated usages are only REPORTED. So this repo's stated policy + // ("never ship a deprecated or scheduled-for-removal API — treat it as a blocker, not a warning") + // was a promise a human had to keep by reading logs, and a rule that lives only in prose is not a + // rule. Adding DEPRECATED_API_USAGES is what makes the sentence true. + // + // EXPERIMENTAL_API_USAGES is deliberately NOT here, and that is a decision rather than an oversight: + // `DiffTabCleanup` uses `ProjectCloseListener.projectClosingBeforeSave` knowingly, because it is the + // only hook that runs BEFORE the workspace state is written — which is the whole point of it. An + // experimental API is acceptable with a reason; a deprecated one is not acceptable at all, because + // it has an announced removal date and the plugin has to keep working across the IDE range. + failureLevel = + listOf( + VerifyPluginTask.FailureLevel.COMPATIBILITY_PROBLEMS, + VerifyPluginTask.FailureLevel.INTERNAL_API_USAGES, + VerifyPluginTask.FailureLevel.OVERRIDE_ONLY_API_USAGES, + VerifyPluginTask.FailureLevel.DEPRECATED_API_USAGES, + ) ides { // No hardcoded path in the repo: a developer can point the verifier at local IDE installs to skip the // downloads, via -PlocalIdePath=[,…] or the LOCAL_IDE_PATH env var (comma-separated). This is diff --git a/docs/BRANCHING.md b/docs/BRANCHING.md index 874d8edd..5c64a7d8 100644 --- a/docs/BRANCHING.md +++ b/docs/BRANCHING.md @@ -32,13 +32,32 @@ Naming: `feature/`, e.g. `feature/hunk-selection`, `bugfix/ 1. Land everything for the version on `develop`; bump `version` in `build.gradle.kts` and add the section to `RELEASE_NOTES.md` / `CHANGELOG.md`. 2. Merge `develop` → `main` via PR. `main` is protected: the CI checks must be green and the PR approved. -3. Tag the merge commit `vX.Y.Z` and push the tag. `release.yml` then verifies the tag came from `main`, - re-runs the full gate on the tagged tree, builds and attests, and waits on the `marketplace` environment - approval before `signPlugin publishPlugin`. +3. **That is the whole procedure.** The merge triggers `release.yml`, which reads the version from + `build.gradle.kts`, re-runs the full gate on the merged tree, builds and attests, waits on the + `marketplace` environment approval, publishes, and only then cuts and signs the `vX.Y.Z` tag. -> A tag pushed from anywhere other than `main` is rejected by the workflow's first job, before any -> credential is in scope. That is the mechanism that makes "release only via PR into main" true rather -> than merely intended. +**`build.gradle.kts` is the single source of truth for the version.** The tag is derived from it rather than +supplied alongside it, so the two can no longer disagree — the failure mode the old flow guarded against with +a comparison simply cannot occur now. + +**A merge to `main` that does not bump the version publishes nothing.** The workflow finds the tag already +present, logs a notice and stops. It does not fail: `main` legitimately receives merges that are not releases, +and a red run on each of those is an alarm people learn to ignore. Published tags remain immutable. + +> Pushing a `vX.Y.Z` tag by hand still works and is kept as the escape hatch — re-cutting after a failed +> publish, without pushing an empty commit to `main`. On that path the tag must match the declared version, +> and its commit must be reachable from `main`, both checked before any credential is in scope. + +### What the signatures claim, now that the tag is automatic + +The tag is cut by the workflow and signed with the **CI key**, not the maintainer's YubiKey — which cannot +sign inside a runner, and whose non-exportability is precisely what makes it worth trusting. The chain still +terminates in hardware, because the CI key is certified by the YubiKey. + +The cost is stated rather than glossed: **no signature on a release asserts that a person authorised it.** That +claim now rests entirely on the two gates around the publish — `main` accepts only reviewed pull requests, and +publication requires an approval on the `marketplace` environment by a named reviewer. Anyone verifying a +release should read `git verify-tag` as *"this workflow cut this from main"*, not as *"a human signed off"*. ## Cleaning up obsolete branches