-
Notifications
You must be signed in to change notification settings - Fork 2
512 lines (474 loc) · 30.9 KB
/
Copy pathrelease.yml
File metadata and controls
512 lines (474 loc) · 30.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
# Release — sign the plugin and publish it to the JetBrains Marketplace.
#
# This is the only workflow that can reach real users, so it is the most constrained one in the repo.
#
# THE SEQUENCE, which is the part that is easy to get subtly wrong:
#
# PR develop -> main -> tests -> merge -> tag + GitHub Release -> Marketplace, FROM that tag
#
# The tag is cut before the artifact is built, and the build then runs from the tag rather than from
# `main`. That ordering is the point: the tag is the identity of the release (ADR 0001 §3), so the
# artifact is produced from the ref that names it rather than being stamped afterwards.
#
# (An earlier version of this note claimed `main` is a moving ref that a later merge could slip into the
# release. That was wrong: `actions/checkout` defaults to `github.sha`, so every job here was already
# pinned to the triggering commit. Checking the tag out is a provenance statement, not a race fix.)
#
# It is NOT two workflows chained by the tag push, and that is a constraint rather than a preference:
# a tag pushed with the GITHUB_TOKEN does not create a workflow run
# (https://docs.github.com/en/actions/concepts/security/github_token — the recursion guard). Chaining
# would need a PAT, a GitHub App or a deploy key, i.e. a long-lived write credential, to buy nothing:
# the same ordering is achievable inside one run by checking the tag out.
#
# Three independent gates have to line up before anything is published:
#
# 1. VERSION. `build.gradle.kts` is the single source of truth and the tag is derived from it, so the
# two can never disagree. An existing tag means "already released" and the run stops.
# 2. LINEAGE. The commit must be reachable from `main`. Tagging a feature branch, or a develop commit
# that never went through a PR into main, aborts the run. `main` is protected and only
# accepts PRs with every required check green, so "reachable from main" IS "went through
# the pull-request gate". NOT "was reviewed by a second person": main.json sets
# `required_approving_review_count: 0` on purpose — GitHub will not let an author approve
# their own PR, so on a single-maintainer repository any higher value is a deadlock, not a
# control. The mechanical half (PR required, up to date, all checks passing) is what is
# actually enforced; raise the count the moment a second maintainer exists.
# 3. SCOPE. Everything irreversible lives in the `marketplace` GitHub Environment: the credentials
# exist only inside that job, and only refs the environment's branch policy allows can
# deploy at all. Marketplace publication cannot be undone; a version is out the moment
# it is out.
#
# WHAT GATE 3 IS NOT, since this file used to say otherwise in four places: it is **not** a human approval.
# The `marketplace` environment has NO required reviewer — verified against the live repository, and
# deliberate rather than overlooked: the reasoning is written out in scripts/bootstrap-ci.sh §1 (on a
# single-maintainer repository the approval was the same person clicking a second time, moments later, over
# the same decision). So **a merge into `main` that carries a bumped version publishes to the Marketplace
# unattended.** The human act is the merge, not a later button.
#
# That makes gate 2 the load-bearing one, and it is not decoration. Without it, anyone who can push a tag
# can publish from any code, and the pull request into `main` — now the only human step there is —
# becomes optional.
#
# NB the environment's protection rules are a GitHub setting, not a file in this repository: unlike
# .github/rulesets/*.json they are not versioned, so this comment can go stale without any diff. Re-check
# with `gh api repos/OWNER/REPO/environments`, which is what scripts/bootstrap-ci.sh reports at the end of
# every run.
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:
contents: read
concurrency:
# Never cancel a release in flight — a half-published Marketplace upload is not a state anyone wants
# to reason about. Queue instead.
group: release-${{ github.ref }}
cancel-in-progress: false
env:
GRADLE_OPTS: -Dorg.gradle.daemon=false -Dorg.gradle.console=plain
jobs:
# Gate 2, on its own so it fails in seconds and before any secret is in scope.
guard:
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 and the tags, not just this commit
persist-credentials: false
# 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 code that never passed
# the pull-request gate on `main`.
- name: Assert this commit is on main
run: |
git fetch --no-tags origin main:refs/remotes/origin/main
if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
echo "::error::${GITHUB_REF_NAME} points at a commit that is not reachable from main."
echo "Releases are cut from main only, and main only accepts pull requests with every check green."
exit 1
fi
echo "lineage OK — $GITHUB_SHA is reachable from main."
# 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/')
[ -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.
verify:
name: Tests and verifier
runs-on: ubuntu-latest
timeout-minutes: 60
needs: [guard]
if: needs.guard.outputs.release == 'true'
# The same image ci.yml uses, for the same reason and with one extra: this gate must run the toolchain
# the branch was green on. Provisioning the JDK and Node here from separate actions meant the release
# gate could pass or fail on a toolchain the pull request never saw.
container:
# `jvm-test`: this gate runs `npm ci`, `npm test` AND `./gradlew test verifyPlugin` in one job.
image: ghcr.io/serialexperimentslainnnn/jvm-test:v1.0.0
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.
GRADLE_USER_HOME: /opt/gradle-home
# The image drops /usr/share/locale, 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 dies with
# FileNotFoundException. This gate runs the SAME suite as ci.yml, so it needs the SAME locale: a job
# that runs `test` without this is green on the branch and red on the release. `C.UTF-8` is built into
# glibc, so it needs no image rebuild.
LANG: C.UTF-8
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- run: npm ci
- run: npm test
env:
CI: 'true'
- run: ./gradlew --no-daemon --stacktrace test verifyPlugin
# Gate 3, and the ONLY job that produces a distributable.
#
# Build, sign, publish and release all happen here, from ONE `buildPlugin` invocation, because
# "build once, promote the same artifact" is not a slogan: the earlier split built the zip twice —
# once to attach to the GitHub Release, once to sign and send to the Marketplace — and a Gradle zip
# is not byte-reproducible (file timestamps alone see to that). Users would have been offered two
# different artifacts under one version number, and the checksum published next to one of them would
# not have matched the other.
#
# The cost of collapsing it: the provenance attestation is minted in a job that also holds secrets.
# That is the lesser problem. Attestation proves WHERE a build ran, and this is the job where the
# release is genuinely built; two divergent artifacts is a correctness bug users can actually hit.
#
# Everything here is behind the `marketplace` environment, which is what SCOPES the credentials: they
# exist in this job and nowhere else in the repository. It is not an approval gate — that environment has
# no required reviewer (see the header, and scripts/bootstrap-ci.sh §1). Reaching this job means `guard`
# passed: the commit is reachable from `main`, and the version in build.gradle.kts has never been tagged.
publish:
name: Build, sign and publish
runs-on: ubuntu-latest
timeout-minutes: 45
needs: [guard, verify]
if: needs.guard.outputs.release == 'true'
environment:
name: marketplace
url: https://plugins.jetbrains.com/plugin/31965-claude-code-native
permissions:
contents: write # create the GitHub Release
id-token: write # OIDC identity for the attestation
attestations: write # write the provenance record
steps:
# fetch-depth: 0 because this job CREATES a tag and has to push it — a shallow clone has no
# object graph to tag from.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
# --- the tag comes FIRST, and everything below is built FROM it -----------------------------
#
# The order is the point of this job. The tag is the identity of the release (ADR 0001 §3), so it
# is cut before the artifact exists and the artifact is then produced from that exact ref — rather
# than publishing first and stamping a tag on afterwards, which makes the tag a label for something
# already gone out.
#
# What makes it safe to do this early is `guard`, not an approval: this job is only reached once the
# commit is proven reachable from `main` and the declared version proven not already tagged. So a tag
# can only appear for a version somebody merged into `main` on purpose. (An earlier version of this
# note credited a required reviewer on the `marketplace` environment; there is none — see the header.)
# What the tag can still do is outlive a FAILED publish, and published tags are immutable
# here. That is deliberate, and the recovery is to re-run THIS JOB on the existing tag — the tag
# step below detects the ref and skips re-cutting it. Note it has to be a JOB re-run and not a
# workflow re-run: `guard` would see the tag on the remote and correctly report the version as
# already released.
- name: Import the CI signing key
run: |
printf '%s' "${{ secrets.GPG_SIGNING_KEY }}" | gpg --batch --import
fpr=$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr:/ {print $10; exit}')
[ -n "$fpr" ] || { echo "::error::GPG_SIGNING_KEY did not import — is it truncated?"; exit 1; }
echo "GPG_FPR=$fpr" >> "$GITHUB_ENV"
# The tagger address is READ FROM THE KEY, never hardcoded. It is not a preference: GitHub marks
# a tag Verified only when the tagger email, an email on the registered key's uid, and a verified
# account email all agree, so the only address that can possibly work is the one this key carries.
# Deriving it also keeps the address out of the repository — the project deliberately publishes no
# contact email anywhere (CHANGELOG 5.0.0) — and makes rotating the key sufficient on its own.
email=$(gpg --list-keys --with-colons "$fpr" \
| awk -F: '/^uid:/ {print $10; exit}' | sed -n 's/.*<\(.*\)>.*/\1/p')
if [ -z "$email" ]; then
echo "::error::The CI signing key carries no email in its uid, so any tag signed with it will"
echo "::error::show as UNVERIFIED. This is exactly how v5.0.0 shipped. Regenerate the key with"
echo "::error::scripts/gen-ci-signing-key.sh and re-register it: scripts/bootstrap-ci.sh."
exit 1
fi
echo "GPG_EMAIL=$email" >> "$GITHUB_ENV"
# 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
# attests "this workflow released these bytes", and the human authorisation is the MERGE into `main` —
# the only human act in the sequence. There is no approval on this environment (see the header); the
# mechanical guards either side of the merge are the pull-request gate on `main` and `guard`'s lineage
# and already-tagged checks.
- 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
# The tagger email is NOT a stylistic choice — it is what decides whether GitHub will ever show
# this tag as Verified. Marking a signature verified requires three things to agree: the tagger
# email, an email in a uid of the registered key, and a verified email on the account. This used
# to tag as `github-actions[bot]@users.noreply.github.com`, an address that can never appear on
# anyone's key, so the signature was valid and the badge was impossible. v5.0.0 shipped that way.
#
# It therefore uses the address carried by the signing key itself, resolved in the import step
# above. The identity is still unmistakable, because the key's uid names itself as the CI key and
# the signature is made by the CI key, not the hardware one — SECURITY.md keeps that distinction.
git config user.name 'Claude Code Native CI'
git config user.email "$GPG_EMAIL"
git config gpg.program /tmp/gpg-loopback
git config user.signingkey "$GPG_FPR"
# Idempotent, and this is the ONLY thing that makes the documented recovery real.
#
# If the publish fails after the tag is cut, the recovery is to re-run THIS JOB — re-running the
# whole workflow cannot work, because `guard` would now see the tag on the remote and correctly
# decide the version is already released. A job re-run reuses guard's output and lands here with
# the tag already present: `fetch-depth: 0` fetches tags, so a bare `git tag -s` exits non-zero,
# the job dies before `gh release upload --clobber`, and the release is stuck needing a human to
# delete an immutable tag. Which is the exact situation immutability exists to prevent.
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
echo "::notice::$TAG already exists — re-running on an existing tag, not re-cutting it."
git verify-tag "$TAG"
else
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"
fi
# Build from the TAG, not from whatever `main` happens to be. On this workflow's primary path the two
# are the same commit, and checking the tag out anyway is what makes that a fact rather than a race:
# `main` is a moving ref, and a merge landing between the guard job and this one would otherwise be
# silently included in a release named after a different tree.
- name: Check out the tag being released
env:
TAG: ${{ needs.guard.outputs.tag }}
run: |
git checkout --detach "refs/tags/$TAG"
echo "building from $TAG -> $(git rev-parse HEAD)"
# The GitHub Release, created as a DRAFT before anything is published. Draft rather than final
# because it has no assets yet — a release that exists with nothing attached is a broken download
# link for however long the build takes, and it is visible the whole time.
- name: Create the draft GitHub Release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.guard.outputs.tag }}
run: |
# The newest section of CHANGELOG.md — from the first "## [x.y.z]" heading to the next one.
#
# It used to read RELEASE_NOTES.md, which is the Marketplace copy: emoji-led, second person,
# "one more thing". That register belongs on a storefront page where someone is deciding whether
# to install; it is the wrong document to hand a person who arrived at a GitHub Release because
# something broke and they need to know what changed. Those are different readers, so they now
# get different documents: CHANGELOG.md here, RELEASE_NOTES.md on the Marketplace panel that
# build.gradle.kts still feeds.
#
# Sized before switching: the 5.0.0 section is ~27 KB against GitHub's 125 000-character limit.
awk '/^## \[/{if(seen)exit; seen=1} seen' CHANGELOG.md > /tmp/notes.md
[ -s /tmp/notes.md ] || {
echo "::error::No section found in CHANGELOG.md. The heading format is '## [x.y.z] — date';"
echo "::error::if that changed, this extraction changed with it and the release would go out"
echo "::error::with empty notes rather than fail — which is why this check exists."
exit 1
}
# NB the heredoc body stays indented to this block's level: YAML strips the common indentation,
# so the emitted markdown is flush-left. An unindented line here (a bare `---`, say) would end
# the block scalar and be read as a YAML document separator.
cat >> /tmp/notes.md <<'EOF'
---
**Verifying this release.** Both the `.asc` files and the tag are signed by the project's **CI
signing key**, and everything needed to check it is in the one file attached here as
`trust-chain.asc`: that key's public half, plus the two hardware CAs that certify it — so the
chain terminates in keys whose private halves have never been on a computer.
One file rather than three because a chain is imported whole or not at all. A certification you
have no way to follow is indistinguishable from one nobody made, which is exactly the assurance
the two CA keys carry.
What the signatures do NOT assert is that a human pressed a button: the release is cut
automatically from the merge into `main`, with no approval step and no required reviewer. What
stands behind it instead is mechanical, and it is worth being exact about — `main` accepts nothing
but pull requests that are up to date and have all nine required checks green (among them the JVM
and frontend tests, both CodeQL scans, the dependency audit and the plugin verifier), that
protection can be bypassed by nobody including admins, and this workflow refuses to publish a
commit that is not reachable from `main` or a version number that has already been released.
```sh
gpg --import trust-chain.asc
# The CI key is the LAST block in that file — the CAs come first, and the awk below takes the
# last one deliberately. Then: endorsed by both of them, not merely asserted by a file.
gpg --check-sigs "$(gpg --show-keys --with-colons trust-chain.asc \
| awk -F: '$1=="pub"{getline; if ($1=="fpr") f=$10} END{print f}')"
gpg --verify claude-code-native-*.zip.asc # these bytes came from this workflow
git verify-tag <tag> # this workflow cut this release from main
```
**The one check worth doing that this file cannot do for you.** Everything above arrives from
this repository, so it proves the release is internally consistent and nothing more — whoever
could publish a forged artifact could publish a bundle agreeing with it. The CAs are therefore
also on **keys.openpgp.org**, an operator with no relation to GitHub, and fetching one by
fingerprint is what turns the chain into evidence:
```sh
gpg --keyserver hkps://keys.openpgp.org --recv-keys <CA fingerprint from SECURITY.md>
```
Two copies of the same CA from two unrelated publishers either agree, or the disagreement is
the story.
EOF
gh release create "$TAG" --draft --title "$TAG" --notes-file /tmp/notes.md --verify-tag
# The chat page is TypeScript compiled by `tsc` during the Gradle build (`compileWeb`), so the
# publish runner needs node. Pinned by SHA like every other action here; the version comes from
# `.nvmrc`, the one place it is written.
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: temurin
java-version: '21'
# NB no `setup-gradle`, deliberately, and this was the last one left in any workflow.
#
# It sat here with `cache-read-only: true` — and after `ci.yml` dropped its own copies, NOTHING
# writes that cache any more, so this step restored an entry no job produces. It was paying the
# restore for a cache that does not exist. The reason ci.yml removed it applies here too: a warm
# GRADLE_USER_HOME for this project is ~31 GB against a 10 GB per-repository cache ceiling, so the
# action could never store what it appeared to store.
#
# Nothing else is lost: the build runs `./gradlew --no-daemon`, which needs no setup from the
# action, and the wrapper's own integrity is pinned by `gradle/wrapper/gradle-wrapper.properties`
# plus the checked-in validation. What this job does pay, and pays knowingly, is a cold download of
# the IntelliJ platform artifacts on every release — a handful of minutes, a few times a month,
# against a cache that was not working.
# --- build, sign and publish: ONE Gradle invocation --------------------------------------
# The order inside it is not stylistic. Verified against the plugin's own source
# (PublishPluginTask.kt): `publishPlugin` uploads `signPlugin.signedArchiveFile` **only if
# `signPlugin.didWork`**, and falls back to the UNSIGNED archive otherwise. `didWork` is false
# when the task is up-to-date or served from cache — so splitting these across invocations, or
# moving the signed zip before publishing, is how you quietly ship an unsigned plugin. Running
# them together on a fresh checkout makes `didWork` true by construction.
- name: Build, sign and publish to the JetBrains Marketplace
env:
CERTIFICATE_CHAIN: ${{ secrets.CERTIFICATE_CHAIN }}
PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }}
PRIVATE_KEY_PASSWORD: ${{ secrets.PRIVATE_KEY_PASSWORD }}
PUBLISH_TOKEN: ${{ secrets.PUBLISH_TOKEN }}
run: ./gradlew --no-daemon --stacktrace buildPlugin signPlugin publishPlugin
# signPlugin writes `<name>-<version>-signed.zip` next to the unsigned one. The SIGNED file is
# the artifact — it is what the Marketplace now serves and what users install — so it is what
# gets checksummed, GPG-signed, attested and attached. It is republished here under the plain
# versioned name, because "-signed" is a build detail and not a product name.
- name: Collect the published artifact
id: artifact
run: |
signed=$(ls build/distributions/*-signed.zip 2>/dev/null | head -1)
if [ -z "$signed" ]; then
echo "::error::signPlugin produced no *-signed.zip — the plugin may have been published UNSIGNED."
ls -la build/distributions/ || true
exit 1
fi
mkdir -p dist
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)"
- name: Attest build provenance
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-path: dist/${{ steps.artifact.outputs.name }}
# --- GPG-sign the exact bytes that were published -------------------------------------------
# The key is already imported: it was needed above to sign the tag, and it is the same key by
# design — one CI signing key backs both claims, and `docs/trust-chain.asc` is the single file a
# user needs to check either of them.
- name: Sign the artifact
env:
PASSPHRASE: ${{ secrets.GPG_SIGNING_PASSPHRASE }}
NAME: ${{ steps.artifact.outputs.name }}
run: |
cd dist
# Generated from inside dist/ so the checksum file names the artifact, not a build path —
# otherwise `sha256sum -c` fails for anyone who downloads the two files side by side.
sha256sum "$NAME" > "$NAME.sha256"
for f in "$NAME" "$NAME.sha256"; do
gpg --batch --yes --pinentry-mode loopback --passphrase "$PASSPHRASE" \
--local-user "$GPG_FPR" --armor --detach-sign "$f"
done
# 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"
# --- the trust chain travels with the release ------------------------------------------------
#
# A signature is worth what the reader's access to the key is worth, and "clone the repository to
# fetch the key" asks them to trust the same tree the artifact came from. So the chain rides along
# as an asset: one file carrying the CI signing key that made these signatures and the two hardware
# CAs that certify it — without which the certification is present and unfollowable, which is the
# same thing as absent to anyone verifying.
#
# Attaching it per release is also what keeps an OLD release verifiable. Rotating the CI key
# OVERWRITES `docs/trust-chain.asc` in the tree, so the retired key survives beside the CAs that
# vouched for it only on the releases it actually signed — which is where someone verifying one of
# them is already standing. `cp` failing on a missing file is the intended behaviour: a release
# without its chain is worse than no release, because nothing on the page says the chain is missing.
- name: Attach the trust chain
run: cp docs/trust-chain.asc dist/
# --- Attach the artifacts and take the release out of draft ---------------------------------
#
# Last, and only now: the draft became a real release the moment it has the files a user is told to
# verify — the signed zip, its checksum, a detached signature for each, and the trust chain that
# makes those signatures checkable. Undrafting earlier would publish a release whose download
# links 404 for the length of a build.
#
# `--clobber` so re-running this job on an existing tag replaces the assets instead of failing on
# a name collision. That is the documented recovery path when a publish fails after the tag was
# already cut, and it must not require deleting anything by hand.
- name: Attach the artifacts and publish the release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.guard.outputs.tag }}
run: |
gh release upload "$TAG" dist/* --clobber
gh release edit "$TAG" --draft=false
echo "released $TAG with:"
gh release view "$TAG" --json assets --jq '.assets[].name'