-
Notifications
You must be signed in to change notification settings - Fork 2
779 lines (712 loc) · 44.9 KB
/
Copy pathci.yml
File metadata and controls
779 lines (712 loc) · 44.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
# CI — the gate every change passes before it can be merged.
#
# Mirrors exactly what a developer runs locally (see AGENTS.md); if a gate only fails here, that is a
# workstation-provisioning problem, not a pipeline problem, and it gets fixed on the workstation.
#
# Every action is pinned by full commit SHA, not by tag. A tag is mutable: whoever controls the action's
# repository can repoint it at different code, and that code runs with this workflow's token. Dependabot
# (.github/dependabot.yml) proposes SHA bumps monthly — and security updates on their own schedule,
# independently of that interval — so pinning costs nothing in maintenance.
name: CI
on:
# No `push` trigger, deliberately — the pull request is the door. The one addition to that is a nightly
# schedule, for the single suite that must not sit on the pull-request path at all (see `schedule:` below).
#
# A branch with an open PR fires `pull_request` on every push to it (the `synchronize` event), so the
# iteration loop is fully covered, and covered ONCE. Having both triggers meant two complete pipelines per
# commit for identical information; this removes that at the root instead of relying on the concurrency
# group to cancel one of them in time.
#
# Two consequences, recorded because each removes something we were leaning on:
#
# - No CI on the push that a merge into `develop` creates. That run was the stated justification for
# dropping the up-to-date requirement on develop — two pull requests that are green apart can break
# together, and develop's own run was what would have caught it. It is now caught at the pull request
# into `main`, where the full gate runs, rather than immediately after the merge.
# - A branch with no open pull request gets no checks at all. That is the intent: no PR, no promotion.
#
# `release.yml` is unaffected — it carries its own `push: branches: [main]` trigger and still fires on the
# merge that publishes.
#
# LEFTOVER, named so it is not mistaken for live logic: THREE jobs below — `Static analysis`,
# `Dependency audit` and `Plugin verifier` — still carry
# `github.event_name == 'push' && github.ref == 'refs/heads/develop|main'` in their `if:`. Those halves
# can no longer match anything and are kept only so restoring the trigger is a one-line change. They are
# inert, not a second door — nothing runs on a push to a protected branch from this file.
#
# (`No bot PRs pending on develop` has no push half; `Build plugin` has no `if:` at all and is gated
# instead by `needs: [verify]`. So on `workflow_dispatch` and on the schedule below, where no half matches,
# exactly three jobs run: `JVM tests`, `Frontend tests` and `UI end-to-end tests`.)
pull_request:
branches: [develop, main]
# Nightly, on the default branch. This exists for `UI end-to-end tests`, which answers to this trigger and
# to a manual run and to nothing else: that suite boots a real IDE behind a virtual framebuffer, it is
# slower than everything else in this file combined, and it fails for reasons no other job here can (a
# display, a browser, a window that has not finished painting). On the pull-request path it would teach
# people to re-run CI until it went green, which is how a real defect becomes noise.
#
# A trigger belongs to the WORKFLOW and not to one job, so this brings `JVM tests` and `Frontend tests` with
# it — the set is the one the parenthetical above works out, never a second count kept in step by hand.
# That is left as it is rather than gated away: a nightly run of the JVM and frontend suites against the
# default branch answers a question the pull-request path cannot — whether the branch everyone builds on is
# still green on its own, rather than green in the merge commit of somebody's pull request.
schedule:
- cron: '0 3 * * *'
workflow_dispatch:
# Least privilege at the top; a job that needs more elevates it for itself. The default token is
# read/write on everything, which a compromised dependency would happily use.
permissions:
contents: read
# 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:
# 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
# between steps is a class of "works on the second run" bug we do not want to own.
GRADLE_OPTS: -Dorg.gradle.daemon=false -Dorg.gradle.console=plain
jobs:
# Unit + headless (BasePlatformTestCase, in-process IDE fixture) + integration (the bin/fake-claude
# Python stand-in). The whole non-UI pyramid. Deliberately no test count here: it changes every release,
# and a number in a comment is a claim nobody re-checks.
test:
name: JVM tests
runs-on: ubuntu-latest
timeout-minutes: 30
# EVERY job in this file runs in this image, and the image is the ONLY caching mechanism.
#
# `gradle/actions/setup-gradle` used to sit in the heavy jobs and was quietly useless here: the warm
# GRADLE_USER_HOME measures 31 GB (23 GB of extracted IDE transforms under caches/9.5.1, 7.7 GB of the
# downloaded IDE artifacts under modules-2), and a GitHub Actions cache entry is capped at 10 GB per
# repository. It could never have stored what it appeared to be storing — it was saving a partial
# cache, evicting it, and re-downloading the rest on the next run. The image has no such ceiling, and
# the trade is explicit: refreshing what CI has cached now means rebuilding and pushing the image,
# which is a deliberate act rather than something that drifts between runs.
container:
image: ghcr.io/serialexperimentslainnnn/jvm-test:v1.0.0
# The package stays PRIVATE and is pulled with the run's own GITHUB_TOKEN — no new secret, nothing to
# rotate, and access dies with the job. This requires the package to have been granted Read access to
# THIS repository (package settings -> Manage Actions access): `packages: read` widens what the token
# may ask for, it does not authorise it against a package the repo was never linked to. Without that
# link the pull fails with a bare `denied`, which reads like a wrong image name.
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
permissions:
contents: read
packages: read
env:
# MUST match GRADLE_USER_HOME in .github/ci-image/jvm-test.Dockerfile. If these diverge, the warmed caches
# baked into the image are invisible and every run silently re-downloads what the image already has.
GRADLE_USER_HOME: /opt/gradle-home
# The image drops /usr/share/locale to stay small, so the JVM inherits an ASCII `sun.jnu.encoding` and
# CANNOT CREATE A FILE whose name is not ASCII — `DiffPresenterIsWithinRootTest` writes `fïle ñ.txt` and
# died with FileNotFoundException here while passing on any developer machine. `C.UTF-8` is built into
# glibc rather than living under /usr/share/locale, so this needs no image rebuild — which matters,
# because the image is the only cache this pipeline has.
#
# It is not a test concession either: this plugin resolves paths the user chose, and a UTF-8 filesystem
# is the environment it actually ships into. Asserting containment for an accented path is the point.
LANG: C.UTF-8
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# NB there is deliberately no `setup-gradle` step, in this job or any other. See the note at the
# `container:` block above: the image IS the cache, and the action's cache was never doing the job
# it looked like it was doing.
# Coverage is verified HERE, in the same job and the same Gradle invocation as the tests.
# `koverVerify` depends on `:test`, so running it in the separate `Static analysis` job re-ran the whole
# 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()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: jvm-test-reports
path: |
build/reports/tests/
build/test-results/
retention-days: 14
# Static analysis and formatting, for BOTH languages in the repo. Added in 5.0.0 together with the tools
# themselves — before that the entire quality bar rested on review, which is the thing the standards say
# to mechanise ("if formatting is being discussed in a review, a formatter is missing").
#
# Deliberately NOT a dependency of any other job: it is fast, it is independent, and a formatting failure
# should not hide a test failure by short-circuiting the run. Both results land on the PR together.
static-analysis:
name: Static analysis
runs-on: ubuntu-latest
timeout-minutes: 20
container:
image: ghcr.io/serialexperimentslainnnn/jvm-test:v1.0.0
# The package stays PRIVATE and is pulled with the run's own GITHUB_TOKEN — no new secret, nothing to
# rotate, and access dies with the job. `packages: read` is granted per job below; without it the pull
# fails with a 401 that reads like a wrong image name rather than a permission problem.
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
permissions:
contents: read
packages: read
env:
# MUST match GRADLE_USER_HOME in .github/ci-image/jvm-test.Dockerfile. If these diverge, the warmed caches
# baked into the image are invisible and every run silently re-downloads what the image already has.
GRADLE_USER_HOME: /opt/gradle-home
# Same door as the verifier: pull requests into main, and the protected branches themselves.
# A branch iterating towards develop runs only the two test suites; formatting, lint and coverage are
# settled before anything is promoted. The cost is real and worth naming — a formatting or detekt
# 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:
persist-credentials: false
# `npm ci` is fast rather than free here: node_modules is NOT baked into the image (it must match the
# lockfile of the commit under test, not the one current when the image was cut), but the npm cache is,
# so this resolves from /opt/npm-cache without touching the network.
- run: npm ci
# detekt: rule config in config/detekt/detekt.yml, each non-default setting carrying its reasoning
# at the setting itself. config/detekt/baseline.xml holds exactly two accepted findings, both about
# ClaudeSession, both explained in that file — do NOT regenerate it to make a build pass.
- name: detekt
run: ./gradlew --no-daemon --stacktrace detekt
- name: Formatting (Spotless / ktlint)
run: ./gradlew --no-daemon --stacktrace spotlessCheck
# 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
# silently refuse in a user's IDE can still reach main.
- name: ESLint (shipped frontend)
run: npm run lint
- name: Prettier
run: npm run format:check
# NB the PROJECTMAP.md index is deliberately NOT checked here. It is an orientation index for
# AI-assisted sessions — a local convention, excluded from the artifact — so a stale one cannot affect
# anybody who installs the plugin, and gating on it made the only CI failure that is never a defect in
# the product. It also made every contributor's build depend on running a Python script after editing a
# file. Regenerate it when you want it current: `python3 scripts/gen-projectmap.py`.
- name: Upload analysis reports
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: static-analysis-reports
path: |
build/reports/detekt/
build/reports/kover/
retention-days: 14
# The JCEF web app (src/main/resources/jcef/*.js) under vitest + jsdom. Nothing here ships in the
# plugin — vitest and jsdom are devDependencies — but the code under test absolutely does.
frontend-test:
name: Frontend tests
runs-on: ubuntu-latest
timeout-minutes: 10
container:
# `node-test`, not `jvm-test`: this job is `npm ci` and then vitest. On the single combined image it
# pulled a JDK, a Gradle distribution and 3.4 GB of extracted IntelliJ Platform it never opened —
# 1m05s of container init for 8 seconds of work.
image: ghcr.io/serialexperimentslainnnn/node-test:v1.0.0
# The package stays PRIVATE and is pulled with the run's own GITHUB_TOKEN — no new secret, nothing to
# rotate, and access dies with the job. `packages: read` is granted per job below; without it the pull
# fails with a 401 that reads like a wrong image name rather than a permission problem.
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
permissions:
contents: read
packages: read
# No GRADLE_USER_HOME here: there is no Gradle in this image and nothing in this job invokes it.
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# `npm ci` (not `install`): it installs exactly the committed lockfile and fails if package.json
# and the lockfile disagree, which is the only way CI tests the dependency tree that was reviewed.
- name: Install dependencies
run: npm ci
- name: Run frontend tests
run: npm test
env:
CI: 'true' # switches vitest to the JUnit reporter (vitest.config.js)
- name: Upload frontend report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: frontend-test-report
path: build/reports/frontend/
retention-days: 14
# Supply-chain gate on the build tooling. The plugin ships no npm code, so a finding here reaches a
# developer's machine and never a user — hence `--omit=dev` (the distributed scope) is the hard gate,
# and the full tree is reported without breaking the build. See SECURITY.md for the reasoning.
audit:
name: Dependency audit
runs-on: ubuntu-latest
timeout-minutes: 10
container:
# `node-test`: this job is `npm ci` and two `npm audit` invocations. Nothing here touches the JVM.
image: ghcr.io/serialexperimentslainnnn/node-test:v1.0.0
# The package stays PRIVATE and is pulled with the run's own GITHUB_TOKEN — no new secret, nothing to
# rotate, and access dies with the job. `packages: read` is granted per job below; without it the pull
# fails with a 401 that reads like a wrong image name rather than a permission problem.
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
permissions:
contents: read
packages: read
# 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:
persist-credentials: false
- run: npm ci
- name: Audit the distributed scope (blocking)
run: npm audit --omit=dev --audit-level=low
- name: Audit the full tree (informational)
run: npm audit || true
# Release readiness: refuse to promote develop -> main while a bot still has work in flight.
#
# A release is a claim that `develop` is a finished state. An open pull request from Claude or from
# Dependabot is the opposite of that claim: it is a change someone intended to be in this release,
# sitting one click away from being in it. Merging past it does not lose the work, it does something
# worse — it ships a version whose CHANGELOG was written as if that work had landed. For Dependabot
# specifically it also means shipping with a known dependency update sitting unmerged, which is the
# one class of pending change a security advisory can be written about.
#
# This is a status check and NOT a ruleset entry because it cannot be one: a GitHub ruleset can require
# a check, a signature or an approval, and has no vocabulary for "no other pull request exists". The
# gate is therefore this job, and `.github/rulesets/main.json` requires it by DISPLAY name.
#
# THE COST, stated rather than discovered: Dependabot's resting state is "has something open" — this
# repository's history shows long runs of them. So this gate will block releases until that queue is
# drained, and draining it becomes a release step. That is the intended trade (nothing ships alongside
# an un-merged dependency bump), but it is the kind of gate that gets bypassed if the queue is ignored
# for weeks. If it starts being routinely in the way, the fix is to merge Dependabot more often — not
# to widen the filter.
bot-work-in-flight:
name: No bot PRs pending on develop
runs-on: ubuntu-latest
timeout-minutes: 5
# Not in the CI image on purpose: this job needs `gh`, which the image does not install, and needs
# nothing the image does provide. A bare runner ships `gh` and starts instantly.
permissions:
contents: read
pull-requests: read
# Only at the release door. On a pull request into develop this would be self-referential.
if: github.event_name == 'pull_request' && github.base_ref == 'main'
steps:
- name: Fail if Claude or Dependabot has open pull requests into develop
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# The login is matched by PATTERN because the exact one depends on how each integration is
# installed: the REST API renders an app author as `app/<slug>` (`app/dependabot` is what this
# repository's history shows), while a bot user appears as `<name>[bot]`. Anchored rather than
# a bare substring, so a human whose username merely contains "claude" or "dependabot" is not
# caught by a release gate.
pending=$(gh pr list --repo "$GITHUB_REPOSITORY" --base develop --state open \
--json number,title,url,author \
--jq '[.[] | select(.author.login
| ascii_downcase
| test("^app/(claude|dependabot)$|^(claude|dependabot)(\\[bot\\])?$"))]')
count=$(printf '%s' "$pending" | jq 'length')
if [ "$count" -eq 0 ]; then
echo "no bot pull requests open against develop — clear to promote."
exit 0
fi
echo "::error::$count bot pull request(s) still open against develop. Merge or close them before releasing."
printf '%s' "$pending" | jq -r '.[] | " #\(.number) \(.author.login) \(.title)\n \(.url)"'
exit 1
# The IntelliJ Plugin Verifier: the ONLY thing that catches a *binary* incompatibility across the
# declared 253 → 263.* range (the floor moved from 251 to 253 in 5.5.0, when the hard dependency on
# com.intellij.modules.jcef was declared). Compiling against the floor proves nothing about the ceiling —
# that asymmetry is exactly how the 4.4.1 /login regression shipped. It downloads several full IDEs,
# hence the timeout.
#
# It is also NOT the gate that catches a missing plugin dependency: the verifier resolves against the
# whole IDE distribution, not against the plugin's classloader, which is where that failure lives. It
# reported Compatible on 262 all through the 5.1.1 breakage and was right to. JcefDependencyContractTest,
# in the JVM suite, is the gate for that.
verify:
name: Plugin verifier
runs-on: ubuntu-latest
timeout-minutes: 60
container:
# Same image as every other job, and it does NOT carry the IDEs this job downloads — see the note at
# the top of .github/ci-image/jvm-test.Dockerfile. Baking them made the image 38.1 GB, which every job paid for
# on its own runner, to save ten minutes on the one job that runs least often.
image: ghcr.io/serialexperimentslainnnn/jvm-test:v1.0.0
# The package stays PRIVATE and is pulled with the run's own GITHUB_TOKEN — no new secret, nothing to
# rotate, and access dies with the job. `packages: read` is granted per job below; without it the pull
# fails with a 401 that reads like a wrong image name rather than a permission problem.
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
permissions:
contents: read
packages: read
env:
# MUST match GRADLE_USER_HOME in .github/ci-image/jvm-test.Dockerfile. If these diverge, the warmed caches
# baked into the image are invisible and every run silently re-downloads what the image already has.
GRADLE_USER_HOME: /opt/gradle-home
needs: [test, frontend-test]
# The expensive one: ~10 minutes and 1.25 GB of IDE downloads, so it runs at the release door and
# nowhere else — pull requests targeting `main`, which is the merge that publishes.
#
# NOT on pull requests into develop: those iterate constantly, and paying ten minutes per push to
# re-verify a commit nobody is about to promote is the cost this condition removes. What is lost is
# early detection mid-branch, which is real and is the reason it used to run everywhere.
#
# Where the gate actually lives, stated precisely because the two rulesets differ: `Plugin verifier` is
# a required check in .github/rulesets/main.json ONLY. develop.json requires `JVM tests`,
# `Frontend tests` and the two CodeQL jobs, and deliberately not this one. So a binary incompatibility
# can reach develop; it cannot reach main.
#
# The `push` half of the condition below is INERT: this workflow has no `push` trigger (see `on:` at the
# top). It is left in place so that restoring the trigger restores the intended behaviour in one edit
# rather than four. On `workflow_dispatch` neither half matches, so a manual run executes only the two
# ungated test jobs.
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:
persist-credentials: false
# The old `Free disk space` step is gone. It deleted /usr/share/dotnet and friends, and inside a
# container those paths are the IMAGE's, not the runner's — it was freeing nothing while looking
# like the safety margin for this job. The margin now comes from the IDEs being baked: this job no
# longer downloads or extracts 1.25 GB, it reads what is already on disk.
- name: Verify plugin
run: ./gradlew --no-daemon --stacktrace verifyPlugin
# `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
with:
name: plugin-verifier-report
path: build/reports/pluginVerifier/
retention-days: 30
# 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,
# which runs on the merge into `main` and has NO human approval — the `marketplace` environment scopes the
# credentials, it does not gate on a reviewer. See that file's header.
build:
name: Build plugin
runs-on: ubuntu-latest
timeout-minutes: 10
# NO container, deliberately. This job downloads an artifact and runs `unzip`, `grep` and `ls` over it —
# it does not build anything despite the name, and it used to pull GB of JDK, Gradle and extracted
# IntelliJ Platform to do it. `unzip` is on the bare runner, and the job now starts instantly.
permissions:
contents: read
needs: [verify]
steps:
# A sparse checkout of LICENSES/ ONLY, and emphatically not a second build: the attribution assertion
# below compares the artifact against the licence texts this commit actually carries, and that set has
# to come from somewhere other than a list in this file, which would rot the first time a dependency
# is added. Nothing else in this job reads the working tree.
#
# It runs FIRST on purpose: `actions/checkout` cleans the workspace it lands in, so downloading the
# distributable before it would delete build/distributions again.
- name: Fetch the licence texts this commit declares
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: LICENSES
- name: Fetch the verified distributable
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
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.
- name: Assert no npm code is packaged
run: |
zip=$(ls build/distributions/*.zip)
count=$(unzip -l "$zip" | grep -c node_modules || true)
echo "node_modules entries in $zip: $count"
[ "$count" -eq 0 ] || { echo "::error::npm code leaked into the distributed artifact"; exit 1; }
# What the plugin jar may contain, as an ALLOWLIST — because the leak this replaces was not a name
# anyone would have thought to ban.
#
# `src/main/resources/jcef/PROJECTMAP.md` is a map written for this repository, and to Gradle it is a
# resource like any other: it rode into the jar in every published release, 20 KB of internal design
# notes and source paths handed to users. `processResources` excludes it by pattern now, but banning
# that one name would only catch the file we already know about — the next accidental doc, scratch
# fixture or generated report under `resources/` would ship exactly the same way and just as silently.
#
# So the assertion is inverted: four families are runtime content (our classes, the plugin's own
# descriptors and licences, the tool-window icons, and the inlined web app), and anything else is a
# mistake until someone adds it here deliberately. A new legitimate family costs one line; a leak costs
# a red build instead of a release.
- name: Assert the plugin jar carries only what it should
run: |
zip=$(ls build/distributions/*.zip)
unzip -o -q "$zip" -d /tmp/artifact
jar=$(ls /tmp/artifact/*/lib/claude-code-native-*.jar | grep -v searchableOptions)
# -1 lists entry names alone; directory entries end in `/` and carry nothing.
unzip -Z -1 "$jar" | grep -v '/$' \
| grep -vE '^dev/lain/claudejb/.*\.class$' \
| grep -vE '^META-INF/' \
| grep -vE '^icons/[^/]+\.svg$' \
| grep -vE '^jcef/(.+\.(js|html)|css/.+\.css)$' > /tmp/unexpected.txt || true
if [ -s /tmp/unexpected.txt ]; then
echo "::error::unexpected entries in the plugin jar — either they must not ship, or add their family to this allowlist"
cat /tmp/unexpected.txt
exit 1
fi
echo "plugin jar contents are within the declared allowlist"
# Likewise for attribution: the licences of the bundled web libraries must travel INSIDE the jar,
# because that is where the redistribution obligation actually lands.
#
# THREE things are asserted, on the SAME artifact the verifier checked: this repository's own LICENSE,
# the notices file, and — the part that used to be missing — the licence TEXTS every entry in that
# notices file points at (`build.gradle.kts` copies `LICENSES/` to `META-INF/licenses/` in
# `processResources`). Without that third assertion, deleting that one Gradle line left this gate GREEN
# while the plugin shipped a THIRD-PARTY-NOTICES.md whose every "Full text: LICENSES/…" line referred
# to a file that was not in the artifact. MIT, BSD-3-Clause and Apache-2.0 all bind their notice
# obligation on REDISTRIBUTION, so that is a compliance defect, not a cosmetic one.
#
# The expected set is DERIVED FROM THE CHECKOUT, never enumerated here: adding a text under LICENSES/
# extends this gate by itself, which is the only version of this check that survives contact with a
# new dependency.
- name: Assert third-party attribution is packaged
run: |
set -eu
zip=$(ls build/distributions/*.zip)
unzip -o -q "$zip" -d /tmp/dist
jar=$(ls /tmp/dist/*/lib/claude-code-native-*.jar | grep -v searchableOptions | head -1)
# ONE listing, read by every assertion below. `-Z1` prints one entry name per line, so names are
# matched WHOLE (`grep -qxF`) instead of as substrings of `unzip -l`'s formatted table — where
# `META-INF/LICENSE` also matches `META-INF/LICENSES-anything`.
unzip -Z1 "$jar" > /tmp/jar-entries.txt
missing=''
for f in META-INF/LICENSE META-INF/THIRD-PARTY-NOTICES.md; do
grep -qxF "$f" /tmp/jar-entries.txt || missing="$missing $f"
done
# The repository side. Guarded explicitly rather than left to fail obscurely: with no LICENSES/ at
# all there is nothing to compare against, and an assertion with an empty expected set passes
# vacuously — which is the exact failure mode this whole step exists to remove.
[ -d LICENSES ] || { echo "::error::LICENSES/ is absent from the checkout — there is nothing to compare the artifact against"; exit 1; }
find LICENSES -maxdepth 1 -type f -printf '%f\n' | sort > /tmp/expected-licences.txt
[ -s /tmp/expected-licences.txt ] || { echo "::error::LICENSES/ carries no licence texts, but every entry in THIRD-PARTY-NOTICES.md points at one"; exit 1; }
# Every declared text must have a counterpart in the jar. Read from a FILE with `while read`, not
# iterated as a bare $(…): no word splitting on the names, and no subshell to lose `missing` in.
while IFS= read -r name; do
grep -qxF "META-INF/licenses/$name" /tmp/jar-entries.txt || missing="$missing META-INF/licenses/$name"
done < /tmp/expected-licences.txt
# …and the directory itself must exist and be non-empty in the artifact. `grep -c` exits 1 on no
# match while still printing `0`, so the count is the signal and the exit status is not.
texts=$(grep -c '^META-INF/licenses/[^/]\{1,\}$' /tmp/jar-entries.txt || true)
texts=${texts:-0}
if [ -n "$missing" ] || [ "$texts" -eq 0 ]; then
[ "$texts" -ne 0 ] || echo "::error::META-INF/licenses/ is absent or empty in $jar — the LICENSES/ copy in build.gradle.kts (processResources) is what puts it there"
[ -z "$missing" ] || echo "::error::missing from $jar:$missing"
exit 1
fi
echo "attribution present in $jar: LICENSE, THIRD-PARTY-NOTICES.md, and $texts licence text(s) matching LICENSES/"
- name: Upload plugin zip
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: plugin-distribution
path: build/distributions/*.zip
retention-days: 30
# The RemoteRobot end-to-end suite (src/uiTest): a real IDE, booted behind a virtual framebuffer and driven
# over HTTP. It is the only layer that can answer what neither jsdom nor the headless fixture can — that the
# tool window gives a live JCEF web view instead of the "needs JCEF" fallback, that keystrokes from the OS
# keyboard reach the page, that overflow and geometry are what the layout actually produced.
#
# NON-BLOCKING, by omission and by nothing else: no UI context appears in `required_status_checks` in
# .github/rulesets/{main,develop}.json, so a failure here reports RED, is visible on the run, and stops no
# merge. There is deliberately NO `continue-on-error:` anywhere in this job — that reports the job GREEN
# with a failed suite inside it, which is the same lie as a suite that ran nothing, only better dressed.
#
# PROMOTION CRITERION, written down because an informational job with no exit becomes one nobody reads: it
# becomes a required check once it has run thirty consecutive scheduled runs whose every failure was a real
# defect in the plugin — none attributable to the harness, the framebuffer or a timing window. The line to
# add then is `{ "context": "UI end-to-end tests" }` under `required_status_checks` in
# .github/rulesets/main.json, and in develop.json if it should gate both doors. Until that holds, a red run
# is triaged and never re-run until it passes: a retried UI failure converts a defect into noise.
ui-test:
name: UI end-to-end tests
runs-on: ubuntu-latest
# This job's characteristic failure is a HANG, not a red assertion — a background IDE that never finishes
# painting leaves a socket nobody ever answers. The budget is generous because the IDE boots, opens a
# project and indexes before the first assertion runs; the bounded readiness wait below is what turns a
# dead IDE into a fast, explained failure instead of letting it eat the whole budget.
timeout-minutes: 45
container:
image: ghcr.io/serialexperimentslainnnn/jvm-test:v1.0.0
# The package stays PRIVATE and is pulled with the run's own GITHUB_TOKEN — no new secret, nothing to
# rotate, and access dies with the job. `packages: read` is granted per job below; without it the pull
# fails with a 401 that reads like a wrong image name rather than a permission problem.
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
permissions:
contents: read
packages: read
env:
# MUST match GRADLE_USER_HOME in .github/ci-image/jvm-test.Dockerfile, and it carries more weight here
# than in any other job: `runIdeForUiTests` builds its sandbox from the IntelliJ Platform artifact baked
# into that home. Point it elsewhere and this job silently downloads and extracts several GB per run.
GRADLE_USER_HOME: /opt/gradle-home
# Nightly and on demand, never on a pull request — see the `schedule:` comment in `on:` above. That also
# means this job never runs on code a fork author controls. It needs no credential of any kind either: the
# suite drives `bin/fake-claude`, whose `auth status` answers with a synthetic identity, so the sign-in
# card is satisfied without an account existing anywhere in this job.
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# The image running this job carries no X11 whatsoever: it was built for headless Gradle and npm, and
# nothing in it has ever had to draw. This is the one job that does — a real IDE, with a real Chromium
# inside it.
#
# .github/ci-image/jvm-test.Dockerfile ALREADY bakes this exact package set, which is where it belongs:
# the image is this pipeline's only caching mechanism, so a `dnf install` per run is a network
# transaction whose failure mode is this job's worst one. This step survives because every workflow
# pins `jvm-test:v1.0.0`, and that tag names an image built BEFORE the Dockerfile gained the stack. A
# Dockerfile edit changes nothing on its own; the image has to be rebuilt and a new tag cut.
#
# DELETE THIS STEP in the same change that bumps the tag — in every workflow that names it, not just
# this one (ci.yml ×4, release.yml, codeql.yml, drift.yml). Leaving it behind costs one redundant
# install per nightly run; deleting it BEFORE the tag moves costs an IDE that never opens its port.
#
# A missing library here does not announce itself — the IDE simply never opens :8082 — which is exactly
# the silence the bounded wait below turns back into a message.
- name: Install the virtual display and the libraries the IDE draws through
run: |
set -euo pipefail
dnf -y --setopt=install_weak_deps=False --setopt=tsflags=nodocs install \
xorg-x11-server-Xvfb \
gtk3 nss alsa-lib mesa-libgbm libxkbcommon-x11 \
libXtst libXi libXrender libXext libXrandr libXcursor \
liberation-fonts
dnf clean all
# Step one of the two-process dance that build.gradle.kts and docs/UI_TESTING.md both document: the IDE
# comes up under Xvfb and STAYS UP, and the suite is a second Gradle invocation that talks to it over
# :8082. Backgrounded here rather than split into a second job, because the two halves must share a host.
- name: Boot the IDE under test
run: |
set -euo pipefail
xvfb-run -a -s "-screen 0 1920x1080x24" \
./gradlew --no-daemon --stacktrace runIdeForUiTests > ide.log 2>&1 &
echo $! > ide.pid
echo "IDE launched, pid $(cat ide.pid)"
# Bounded, and it watches BOTH ends: the port answering, and the process still being alive. Polling the
# socket alone turns a crashed IDE into three minutes of silence and then a timeout that names the wrong
# thing; `kill -0` makes that case fail in seconds and prints the log that explains why.
- name: Wait for robot-server to answer
run: |
set -euo pipefail
for _ in $(seq 1 90); do
if curl -sf http://127.0.0.1:8082 > /dev/null 2>&1; then
echo "robot-server is answering on :8082"
exit 0
fi
if ! kill -0 "$(cat ide.pid)" 2> /dev/null; then
echo "::error::the IDE exited before robot-server answered — last 100 lines of its log follow"
tail -n 100 ide.log
exit 1
fi
sleep 2
done
echo "::error::robot-server did not answer on :8082 within 180s — last 100 lines of the IDE log follow"
tail -n 100 ide.log
exit 1
# Step two. The step-level timeout is deliberate and independent of the job's: two Gradle invocations
# share this project directory, and the failure mode of that contention is a build WAITING on a lock,
# not one that errors. Bounding the client leaves budget for the teardown and the artifacts, which are
# the only things anyone can diagnose a nightly failure from.
- name: Run the RemoteRobot suite
timeout-minutes: 25
run: ./gradlew --no-daemon --stacktrace uiTest -PuiTest.enabled=true
# THE GATE ON THE GATE, and the reason this job exists in a shape anyone can trust. `uiTest` carries
# `onlyIf { project.findProperty("uiTest.enabled") == "true" }`, and a Gradle `onlyIf` SKIPS SILENTLY:
# without the flag the task never runs, the build reports SUCCESSFUL, and the step above is green having
# executed nothing. Passing `-PuiTest.enabled=true` is necessary and is NOT evidence — a renamed
# property, a renamed task or a filter matching no class all end the same way. So the evidence is
# asserted rather than assumed: the JUnit XML must exist, and the number of tests that actually ran must
# be greater than zero.
- name: Assert the suite executed tests
run: |
set -euo pipefail
results=build/test-results/uiTest
reports=$( { ls "$results"/*.xml 2> /dev/null || true; } | wc -l )
if [ "$reports" -eq 0 ]; then
echo "::error::no JUnit XML under $results — the suite left no evidence that it ran at all."
echo "::error::uiTest is gated by onlyIf { uiTest.enabled } in build.gradle.kts, and a skipped"
echo "::error::Gradle task still reports BUILD SUCCESSFUL."
exit 1
fi
# One <testsuite> element per class carries these as attributes; <testcase> carries none of these
# names, so summing every occurrence across the files cannot pick up anything but the suite totals.
count() {
grep -ho "$1=\"[0-9]\{1,\}\"" "$results"/*.xml | tr -dc '0-9\n' | awk '{ s += $1 } END { print s + 0 }'
}
declared=$(count tests)
skipped=$(count skipped)
executed=$(( declared - skipped ))
echo "$reports report(s): $declared declared, $skipped skipped, $executed executed"
if [ "$executed" -eq 0 ]; then
echo "::error::every test was skipped — a suite that ran nothing is not a passing suite"
exit 1
fi
# `if: always()` so the IDE is stopped after a failed suite too. Note what it does not do: it runs a
# teardown, it does not touch the outcome — the job still fails on whatever the steps above decided.
- name: Stop the IDE
if: always()
run: |
kill "$(cat ide.pid)" 2> /dev/null || true
# `xvfb-run` is only the parent: the Gradle JVM beneath it does not die with it, and a survivor
# holds :8082 against whatever runs next on this host.
pkill -f runIdeForUiTests || true
- name: Upload UI test reports and the IDE log
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ui-test-reports
path: |
build/reports/tests/uiTest/
build/test-results/uiTest/
ide.log
retention-days: 14