From 57a945620ed5b565967bfd0edbe5690941f449d3 Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 6 Aug 2026 01:54:36 +0200 Subject: [PATCH 1/4] ci: stop re-running every PR on each merge, and cache on topic branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes, all aimed at the same thing: the pipeline was spending its time on work nobody needed. DEVELOP NO LONGER REQUIRES BRANCHES TO BE UP TO DATE `strict_required_status_checks_policy` meant every merge into develop invalidated every other open PR, forcing each to update and re-run the entire suite. With two grouped Dependabot PRs that is annoying; with five it makes a release impractical, and the cost is paid on exactly the changes that least deserve scrutiny. What the setting guards against is real — two PRs that are green apart can break together — so it is not simply dropped. It is dropped where a second net exists: CI runs on every push to develop, so a semantic conflict is caught there, on develop, before anything reaches main. `main` KEEPS the strict policy: one merge per release, and it is the merge that publishes. THE VERIFIER RUNS WHERE THE ANSWER MATTERS ~10 minutes and 1.25 GB of IDE downloads, previously on every push to every topic branch. Now on pull requests and on the protected branches. It remains a required check, so nothing merges without it. The loss is early detection mid-branch, which is a genuine cost rather than free savings. TOPIC BRANCHES WRITE THEIR OWN CACHE Read-only was blanket-applied to everything but develop and main, so a topic branch restored the shared cache and saved nothing — every push re-downloaded what the previous one had already fetched. Read-only now applies to pull requests from forks only. Verified against GitHub's cache documentation rather than assumed: "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 and "can only be restored by re-runs of the pull request". A topic branch cannot reach what develop reads back; the isolation is the platform's, not ours. NB the ruleset change needs ./scripts/apply-rulesets.sh to take effect. --- .github/rulesets/develop.json | 2 +- .github/workflows/ci.yml | 26 +++++++++++++++++++++----- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/.github/rulesets/develop.json b/.github/rulesets/develop.json index 5a71a8cf..19cc549a 100644 --- a/.github/rulesets/develop.json +++ b/.github/rulesets/develop.json @@ -32,7 +32,7 @@ { "type": "required_status_checks", "parameters": { - "strict_required_status_checks_policy": true, + "strict_required_status_checks_policy": false, "do_not_enforce_on_create": false, "required_status_checks": [ { "context": "JVM tests" }, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71b08c6c..7e78dfc5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,9 +67,16 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: - # Only the trunk writes the shared cache. A PR from a fork must never be able to poison what - # the next build on develop reads back. - cache-read-only: ${{ github.ref != 'refs/heads/develop' && github.ref != 'refs/heads/main' }} + # 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 }} # 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 @@ -110,7 +117,7 @@ jobs: - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: - cache-read-only: ${{ github.ref != 'refs/heads/develop' && github.ref != 'refs/heads/main' }} + cache-read-only: ${{ github.event.pull_request.head.repo.fork == true }} - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -219,6 +226,15 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 60 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 + # was re-verifying a commit nobody was about to merge. The gate is unchanged: it is still a required check + # on develop and main, and a PR cannot merge without it. What is lost is early detection mid-branch, which + # is a real cost and the reason it ran everywhere until now. + if: >- + github.event_name == 'pull_request' || + github.ref == 'refs/heads/develop' || + github.ref == 'refs/heads/main' steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -231,7 +247,7 @@ jobs: - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 with: - cache-read-only: ${{ github.ref != 'refs/heads/develop' && github.ref != 'refs/heads/main' }} + 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. From 9398de2fb41bfa6299a87e448a1cedfb86eba6bf Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 6 Aug 2026 02:03:11 +0200 Subject: [PATCH 2/4] ci: run the verifier only on develop and main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ~10 minutes and 1.25 GB of IDE downloads per run, previously paid on every iteration of a branch nobody was about to merge. It now runs only on pushes to develop and main — not on topic branches, not on pull requests. This REQUIRED dropping "Plugin verifier" and "Build plugin" from the required checks on both rulesets, and that is not a detail: a required check whose job never runs is never reported, so the pull request would wait forever. The two changes have to move together or the branch becomes unmergeable. What still covers a release: release.yml re-runs the FULL gate — verifier included — on the exact tree being published, behind the environment approval, so nothing reaches the Marketplace unverified. What is genuinely lost is catching a binary incompatibility at pull-request time rather than after the merge into develop. A real regression in feedback latency, accepted deliberately. Dependabot also moves to monthly, and majors are no longer bot-proposed in any ecosystem: they are where a bump actually breaks something and where CI is not enough on its own — the platform-plugin 2.16 -> 2.18 attempt passed CI and hung the headless suite locally, inside the platform's own test fixture. Security updates are unaffected by either change. --- .github/dependabot.yml | 36 ++++++++++++++++++++----- .github/rulesets/develop.json | 41 +++++++++++++++++++--------- .github/rulesets/main.json | 50 ++++++++++++++++++++++++----------- .github/workflows/ci.yml | 5 ++-- 4 files changed, 96 insertions(+), 36 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d8e86ac2..e8dee000 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,8 +10,11 @@ updates: - package-ecosystem: github-actions directory: / schedule: - interval: weekly - day: monday + # Monthly, not weekly. Every proposed bump costs a full pipeline and a review, and weekly produced + # more of both than the changes justified — build tooling that moves a patch version does not need + # attention four times a month. SECURITY updates are unaffected: they arrive on their own schedule + # regardless of this interval, which is the point of separating them. + interval: monthly open-pull-requests-limit: 5 commit-message: prefix: build # Conventional Commits — the commit-msg hook and the changelog both depend on it @@ -25,6 +28,11 @@ updates: security: applies-to: security-updates patterns: ['*'] + ignore: + # A major of an action can change its inputs or its runtime, and every one here is pinned by commit + # SHA — so the diff is opaque by design and the changelog is the only way to know what moved. + - dependency-name: '*' + update-types: [version-update:semver-major] # 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 @@ -32,8 +40,11 @@ updates: - package-ecosystem: npm directory: / schedule: - interval: weekly - day: monday + # Monthly, not weekly. Every proposed bump costs a full pipeline and a review, and weekly produced + # more of both than the changes justified — build tooling that moves a patch version does not need + # attention four times a month. SECURITY updates are unaffected: they arrive on their own schedule + # regardless of this interval, which is the point of separating them. + interval: monthly open-pull-requests-limit: 3 commit-message: prefix: build @@ -52,6 +63,11 @@ updates: applies-to: security-updates patterns: ['*'] ignore: + # Majors are proposed by a human, not by a bot. They are where a bump actually breaks something, they + # need reading the changelog and running the suite, and a PR that sits open for weeks re-running CI on + # every merge into develop is worse than no PR. Patch and minor keep arriving grouped. + - dependency-name: '*' + update-types: [version-update:semver-major] # 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 # gets silently blessed. @@ -60,8 +76,11 @@ updates: - package-ecosystem: gradle directory: / schedule: - interval: weekly - day: monday + # Monthly, not weekly. Every proposed bump costs a full pipeline and a review, and weekly produced + # more of both than the changes justified — build tooling that moves a patch version does not need + # attention four times a month. SECURITY updates are unaffected: they arrive on their own schedule + # regardless of this interval, which is the point of separating them. + interval: monthly open-pull-requests-limit: 3 commit-message: prefix: build @@ -78,6 +97,11 @@ updates: applies-to: security-updates patterns: ['*'] ignore: + # Majors by hand — and this ecosystem is the cautionary tale: the IntelliJ Platform Gradle Plugin + # 2.16 -> 2.18 bump passed CI and hung the headless suite locally, forever, inside the platform's own + # test fixture. A bot cannot make that call and CI would not have caught it either. + - dependency-name: '*' + update-types: [version-update:semver-major] # 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 # NoSuchMethodError on a user's IDE, not an upgrade. diff --git a/.github/rulesets/develop.json b/.github/rulesets/develop.json index 19cc549a..4c8d903f 100644 --- a/.github/rulesets/develop.json +++ b/.github/rulesets/develop.json @@ -4,20 +4,28 @@ "enforcement": "active", "conditions": { "ref_name": { - "include": ["refs/heads/develop"], + "include": [ + "refs/heads/develop" + ], "exclude": [] } }, "bypass_actors": [], "rules": [ - { "type": "deletion" }, - { "type": "non_fast_forward" }, - { "type": "required_signatures" }, + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "required_signatures" + }, { "type": "pull_request", "parameters": { "_comment": [ - "0, not 1 — see the long note in main.json. GitHub does not let an author approve their own", + "0, not 1 \u2014 see the long note in main.json. GitHub does not let an author approve their own", "pull request, so on a single-maintainer repository requiring an approval makes the branch", "unmergeable rather than well-guarded. Raise it to 1 when a second maintainer exists." ], @@ -26,7 +34,10 @@ "require_code_owner_review": false, "require_last_push_approval": false, "required_review_thread_resolution": false, - "allowed_merge_methods": ["squash", "merge"] + "allowed_merge_methods": [ + "squash", + "merge" + ] } }, { @@ -35,12 +46,18 @@ "strict_required_status_checks_policy": false, "do_not_enforce_on_create": false, "required_status_checks": [ - { "context": "JVM tests" }, - { "context": "Static analysis" }, - { "context": "Frontend tests" }, - { "context": "Dependency audit" }, - { "context": "Plugin verifier" }, - { "context": "Build plugin" } + { + "context": "JVM tests" + }, + { + "context": "Static analysis" + }, + { + "context": "Frontend tests" + }, + { + "context": "Dependency audit" + } ] } } diff --git a/.github/rulesets/main.json b/.github/rulesets/main.json index da0279c7..18b45420 100644 --- a/.github/rulesets/main.json +++ b/.github/rulesets/main.json @@ -4,20 +4,28 @@ "enforcement": "active", "conditions": { "ref_name": { - "include": ["refs/heads/main"], + "include": [ + "refs/heads/main" + ], "exclude": [] } }, "bypass_actors": [], "rules": [ - { "type": "deletion" }, - { "type": "non_fast_forward" }, - { "type": "required_signatures" }, + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "required_signatures" + }, { "type": "pull_request", "parameters": { "_comment": [ - "required_approving_review_count is 0 ON PURPOSE, and it is not a weakened gate — it is the", + "required_approving_review_count is 0 ON PURPOSE, and it is not a weakened gate \u2014 it is the", "only value that is not a deadlock. GitHub does not let an author approve their own pull", "request, so on a single-maintainer repository 'require 1 approval' with no bypass actors means", "NOTHING can ever be merged: no direct push, no approval available, no way around it.", @@ -27,7 +35,7 @@ "talked out of. A human approval is a real control when there IS a second human; requiring one", "that cannot exist is theatre that locks the door from the inside.", "", - "RAISE THIS TO 1 the moment a second maintainer has write access — and re-enable", + "RAISE THIS TO 1 the moment a second maintainer has write access \u2014 and re-enable", "require_code_owner_review and require_last_push_approval at the same time, both of which are", "off for the same reason." ], @@ -36,7 +44,9 @@ "require_code_owner_review": false, "require_last_push_approval": false, "required_review_thread_resolution": true, - "allowed_merge_methods": ["merge"] + "allowed_merge_methods": [ + "merge" + ] } }, { @@ -45,14 +55,24 @@ "strict_required_status_checks_policy": true, "do_not_enforce_on_create": false, "required_status_checks": [ - { "context": "JVM tests" }, - { "context": "Static analysis" }, - { "context": "Frontend tests" }, - { "context": "Dependency audit" }, - { "context": "Plugin verifier" }, - { "context": "Build plugin" }, - { "context": "CodeQL (java-kotlin)" }, - { "context": "CodeQL (javascript-typescript)" } + { + "context": "JVM tests" + }, + { + "context": "Static analysis" + }, + { + "context": "Frontend tests" + }, + { + "context": "Dependency audit" + }, + { + "context": "CodeQL (java-kotlin)" + }, + { + "context": "CodeQL (javascript-typescript)" + } ] } } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e78dfc5..44223306 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -232,9 +232,8 @@ jobs: # on develop and main, and a PR cannot merge without it. What is lost is early detection mid-branch, which # is a real cost and the reason it ran everywhere until now. if: >- - github.event_name == 'pull_request' || - github.ref == 'refs/heads/develop' || - github.ref == 'refs/heads/main' + github.event_name == 'push' && + (github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main') steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: From 20184bd0620978f8eb7f806918934dd3e4203669 Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 6 Aug 2026 02:07:02 +0200 Subject: [PATCH 3/4] ci: put the exhaustive gate on the develop -> main door MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrects the previous commit, which moved the verifier in the wrong direction. It ran it only on PUSHES to develop and main and dropped it from main's required checks — so a pull request from develop into main, the merge that publishes, would not have run it at all. That is precisely the door that has to be guarded. The policy now matches the intent: topic branches and pull requests into develop run the fast checks (compile, our own JVM and frontend suites, static analysis, dependency audit) and iterate quickly. Any pull request targeting main also runs the plugin verifier and the artifact assertions, both required again on main alongside the two CodeQL analyses. The verifier is the only thing that catches a BINARY incompatibility across the declared 251 -> 262 range — compiling against 252 proves nothing about 262, which is exactly how the 4.4.1 /login regression shipped. Skipping it on a branch is a latency trade; skipping it on the way to a release would not be. --- .github/rulesets/main.json | 6 ++++++ .github/workflows/ci.yml | 11 +++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/rulesets/main.json b/.github/rulesets/main.json index 18b45420..0e7f67c0 100644 --- a/.github/rulesets/main.json +++ b/.github/rulesets/main.json @@ -72,6 +72,12 @@ }, { "context": "CodeQL (javascript-typescript)" + }, + { + "context": "Plugin verifier" + }, + { + "context": "Build plugin" } ] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44223306..d67a40bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -231,9 +231,16 @@ jobs: # was re-verifying a commit nobody was about to merge. The gate is unchanged: it is still a required check # on develop and main, and a PR cannot merge without it. What is lost is early detection mid-branch, which # is a real cost and the reason it ran everywhere until now. + # Where the exhaustive check belongs: the develop -> main door, plus the protected branches themselves. + # + # NOT on topic branches and NOT on pull requests into develop — those iterate constantly and this job is + # ~10 minutes and 1.25 GB of IDE downloads. It DOES run on any pull request targeting main, because that + # is the merge that publishes, and it is the only gate that catches a BINARY incompatibility across the + # 251 -> 262 range (compiling against 252 proves nothing about 262 — see the 4.4.1 /login regression). if: >- - github.event_name == 'push' && - (github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main') + (github.event_name == 'pull_request' && github.base_ref == 'main') || + (github.event_name == 'push' && + (github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main')) steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: From c66700b7c1c87023db9379e04ecf8614f50adf0e Mon Sep 17 00:00:00 2001 From: Lain Date: Thu, 6 Aug 2026 02:08:52 +0200 Subject: [PATCH 4/4] ci: only the two test suites gate a branch; everything gates main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branches now run JVM tests and frontend tests and nothing else. Static analysis and the dependency audit join the plugin verifier behind the develop -> main door, where the exhaustive gate belongs. Required checks match, because they have to: a check whose job does not run is never reported and would block the pull request forever. develop requires the two suites; main requires all eight. The costs, named rather than discovered later: - A formatting, detekt, ESLint or coverage failure now lands ON develop and is fixed by a follow-up commit, instead of being caught in the pull request. That happened today with spotlessKotlinGradleCheck, and the PR is what caught it. - The dependency audit no longer runs on the Dependabot PR that proposes a bump. It runs once that bump is on develop, and again before it can reach main, so nothing ships un-audited — the finding just arrives one merge later. What this buys is the thing that was actually hurting: a branch iterates in about three minutes instead of thirteen, and nothing is promoted to main without the full gate, plus release.yml re-running all of it on the exact published tree. --- .github/rulesets/develop.json | 6 ------ .github/workflows/ci.yml | 15 +++++++++++++++ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/.github/rulesets/develop.json b/.github/rulesets/develop.json index 4c8d903f..c808a0df 100644 --- a/.github/rulesets/develop.json +++ b/.github/rulesets/develop.json @@ -49,14 +49,8 @@ { "context": "JVM tests" }, - { - "context": "Static analysis" - }, { "context": "Frontend tests" - }, - { - "context": "Dependency audit" } ] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d67a40bd..bdd2c632 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,6 +105,14 @@ jobs: name: Static analysis runs-on: ubuntu-latest timeout-minutes: 20 + # 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 + # failure now lands ON develop and is fixed by a follow-up commit, instead of being caught in the PR. + if: >- + (github.event_name == 'pull_request' && github.base_ref == 'main') || + (github.event_name == 'push' && + (github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main')) steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -200,6 +208,13 @@ jobs: name: Dependency audit runs-on: ubuntu-latest timeout-minutes: 10 + # 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. + if: >- + (github.event_name == 'pull_request' && github.base_ref == 'main') || + (github.event_name == 'push' && + (github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main')) steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: