From b5f97ab2dd4bdb36f38e02f83e511f01e256b7f5 Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 6 Aug 2026 00:59:48 +0200 Subject: [PATCH 1/4] feat(ci): release on merge to main, version read from the code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merging `develop` into `main` now publishes. The tag is DERIVED from the version in build.gradle.kts rather than supplied alongside it, so the two can no longer disagree — the mismatch the old flow guarded against with a comparison simply cannot occur. A merge that does not bump the version publishes nothing: the workflow finds the tag 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 stay immutable, which is the correction ADR 0001 records after v4.3.2 and v4.4.1 were each force-re-cut three times. Pushing a tag by hand still works, as the escape hatch for re-cutting after a failed publish without an empty commit on main. A tag this workflow creates does not re-trigger it, so there is no loop. The tag is cut AFTER the approval and the publish, not in the guard. Created earlier it would name a version that was never published when a build fails or an approval is declined — and since tags here are immutable, that would block the next attempt. Cutting it last makes it mean "this was published", which is the only claim it can honestly make once the version, not the tag, is the input. WHAT THIS COSTS, STATED RATHER THAN GLOSSED The tag is signed by 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 ends in hardware because the CI key is certified by it. But no signature on a release now asserts that a person authorised it. That claim moves entirely to the two gates around publication: `main` accepts only reviewed pull requests, and publishing requires an approval from a named reviewer on a protected environment. BRANCHING.md and SECURITY.md said the opposite and are corrected — a verification instruction that overstates what it proves is worse than none, because someone acts on it. ADR 0001 still describes the tag-triggered flow and needs superseding. --- .github/workflows/release.yml | 114 ++++++++++++++++++++++++++++------ SECURITY.md | 16 ++++- docs/BRANCHING.md | 31 +++++++-- 3 files changed, 132 insertions(+), 29 deletions(-) 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/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 From 9ad75f562c5a604f580c9f968732264c424e2b66 Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 6 Aug 2026 01:00:11 +0200 Subject: [PATCH 2/4] ci: enforce the zero-deprecation rule and stop duplicating work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit STRICTER — the policy is now a gate rather than a promise CLAUDE.md says "never ship a deprecated or scheduled-for-removal API — treat it as a blocker, not a warning". Nothing enforced it. The verifier's default failure level is COMPATIBILITY_PROBLEMS + INTERNAL_API_USAGES + OVERRIDE_ONLY_API_USAGES, so a deprecated usage was reported and the job went green anyway. A rule that lives only in prose is not a rule; DEPRECATED_API_USAGES is what makes the sentence true. Verified green today, so it lands with no debt to forgive. EXPERIMENTAL_API_USAGES is deliberately excluded, 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 is written. An experimental API is acceptable with a reason. A deprecated one is not, because it has an announced removal and this plugin must keep working across 251 → 262. LEANER — four measured duplications, none of them a weakened gate - Every commit ran the pipeline TWICE. `push` on topic branches and `pull_request` both fire, and the concurrency group keyed on `github.ref` differs between them (refs/heads/x vs refs/pull/N/merge), so neither cancelled the other. Keyed on the commit now: same SHA, same group, duplicate cancelled. - Superseded runs were only cancelled for pull requests. Three pushes in a row left three full pipelines racing, each spending ten minutes downloading IDEs for a commit that had already been replaced. - The JVM suite ran twice. `koverVerify` depends on `:test`, so putting it in the `Static analysis` job re-ran the entire suite on a second runner with a cold cache. Coverage is a property OF a test run and now shares its job. - The plugin was built twice. `verifyPlugin` already produces the distributable; `Build plugin` built its own. That was not only wasteful but subtly wrong — the bytes being asserted were never the bytes that were verified. It now downloads the verified artifact. Job DISPLAY NAMES are unchanged, deliberately: a ruleset references a required check by its name, so renaming one does not fail the gate — it silently stops applying it. No rulesets need reapplying. Also gives drift.yml the concurrency group it was missing (queue, do not cancel: a half-written drift report is worse than a late one). Caught while writing this: the SHA I pinned actions/download-artifact to was invented. Verified against the API and corrected. Pinning by SHA protects nothing if the SHA is made up. --- .github/workflows/ci.yml | 66 +++++++++++++++++++++++-------------- .github/workflows/drift.yml | 7 ++++ build.gradle.kts | 21 ++++++++++++ 3 files changed, 69 insertions(+), 25 deletions(-) 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/build.gradle.kts b/build.gradle.kts index ac6c5f7b..e3a0b903 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,26 @@ 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 From 79aee8b804bba7fbfa21023c4cf5738f5529eabb Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 6 Aug 2026 01:02:34 +0200 Subject: [PATCH 3/4] build(deps): group Dependabot updates instead of one PR per bump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine open Dependabot PRs, each firing the full pipeline — verifier included, ten minutes and 1.25 GB of IDE downloads apiece — for dependency bumps that are reviewed in seconds. Five of them were SECURITY updates (undici, ip-address, fast-uri, hono, postcss: exactly the `npm audit` findings). Two things about that stream were not understood when this file was written, and both are documented at the setting now: - security updates ignore `open-pull-requests-limit` entirely, which is how five arrived under a limit of three; - the existing group did not cover them, because `applies-to` defaults to version-updates. So each ecosystem now has an explicit `applies-to: security-updates` group. These are transitive devDependencies that are never distributed — `npm audit --omit=dev` reports 0, and the artifact contains zero node_modules entries — so reviewing them one at a time bought nothing. github-actions and gradle had no grouping at all. Actions are pinned by full commit SHA, so a bump is a one-line change per action; grouping them costs no review fidelity. Gradle groups minor and patch only: a MAJOR keeps its own PR deliberately, because that is the ecosystem where a bump can hang the headless suite — the 2.16 -> 2.18 platform-plugin attempt did exactly that, and it deserved its own run and its own decision. Syntax verified against GitHub's Dependabot options reference rather than written from memory, after inventing an action SHA earlier today. --- .github/dependabot.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) 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 From c820ea3ff982ab82207a0ff1b8776faf77a65311 Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 6 Aug 2026 01:04:47 +0200 Subject: [PATCH 4/4] style: format the failureLevel assignment per ktlint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I edited build.gradle.kts and ran verifyPlugin and `help` against it, but not spotlessCheck — so `Static analysis` failed on spotlessKotlinGradleCheck for a purely mechanical reason. The formatter is a gate like any other and running a subset of the gate is the same as not running it. --- build.gradle.kts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index e3a0b903..a166de0a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -326,12 +326,13 @@ intellijPlatform { // 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, - ) + 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