-
Notifications
You must be signed in to change notification settings - Fork 0
851 lines (812 loc) · 47.4 KB
/
Copy pathrelease.yml
File metadata and controls
851 lines (812 loc) · 47.4 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
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
name: Daily Release
on:
schedule:
- cron: "23 3 * * *"
workflow_dispatch:
permissions:
contents: write
id-token: write
pull-requests: write
# Lets the merge wait approve a CI run that GitHub parked on `action_required`
# for the release PR. Best-effort: when the token may not approve, the run is
# still reported with its URL so a maintainer can approve it once.
actions: write
concurrency:
group: daily-release
cancel-in-progress: false
jobs:
release:
# Gated at the JOB level, not only by the `Check release ref` step below.
# `npm ci` runs the checked-out package's `prepare` hook, and every step -
# including that one - runs with this job's `id-token: write`. A
# workflow_dispatch from a feature ref would therefore execute
# repository-controlled code with release privileges before any step-level
# refusal could fire. The step check stays as defence in depth.
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
env:
RELEASE_TIMEZONE: Europe/Vienna
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
fetch-tags: true
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
registry-url: "https://registry.npmjs.org"
# node 22 ships npm 10.x, which has no trusted-publishing support at all and
# would fall back to token auth. 11.5.1 is the first npm that can exchange
# the workflow's OIDC id-token for a registry credential.
- name: Use an npm that supports trusted publishing
shell: bash
run: |
set -euo pipefail
npm install -g npm@11.19.0
# Fail closed on the EFFECTIVE version, not on having run the install.
# Installing is not the same as running: a swallowed install error, a
# cached shim, or a later step selecting another npm all leave 10.x
# active. npm 10 has no OIDC support and would fall back to token
# auth, reproducing the exact E404 this migration exists to remove -
# and it would look like trusted publishing itself had failed.
active="$(npm --version)"
required="11.5.1"
if [ "$(printf '%s\n%s\n' "$active" "$required" | sort -V | head -n 1)" != "$required" ]; then
echo "::error::npm $active cannot exchange an OIDC token; $required or newer is required."
exit 1
fi
echo "npm $active can exchange an OIDC token for a registry credential."
- name: Setup Bun
uses: oven-sh/setup-bun@v2.2.0
- name: Install dependencies
run: npm ci
- name: Decide release
id: decide
shell: bash
run: |
set -euo pipefail
git fetch --force --tags
latest_tag="$(git tag --sort=-creatordate | head -n 1 || true)"
if [[ -n "$latest_tag" ]] && git diff --quiet "$latest_tag"..HEAD -- .; then
echo "should_release=false" >> "$GITHUB_OUTPUT"
echo "latest_tag=$latest_tag" >> "$GITHUB_OUTPUT"
echo "No changes since $latest_tag; skipping release." >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
release_date="$(TZ="$RELEASE_TIMEZONE" date +%Y.%m.%d)"
base_tag="v${release_date}"
max_suffix=-1
while IFS= read -r existing_tag; do
if [[ "$existing_tag" == "$base_tag" ]]; then
(( max_suffix < 0 )) && max_suffix=0
continue
fi
if [[ "$existing_tag" == "$base_tag-"* ]]; then
suffix="${existing_tag#"$base_tag-"}"
if [[ "$suffix" =~ ^[0-9]+$ ]] && (( suffix > max_suffix )); then
max_suffix="$suffix"
fi
fi
done < <(git tag -l "${base_tag}*")
if (( max_suffix >= 0 )); then
tag="${base_tag}-$((max_suffix + 1))"
else
tag="$base_tag"
fi
version_core="${tag#v}"
year="${version_core%%.*}"
version_tail="${version_core#*.}"
month="${version_tail%%.*}"
day_and_suffix="${version_tail#*.}"
day="${day_and_suffix%%-*}"
suffix="${day_and_suffix#"$day"}"
npm_version="$((10#$year)).$((10#$month)).$((10#$day))${suffix}"
echo "should_release=true" >> "$GITHUB_OUTPUT"
echo "latest_tag=$latest_tag" >> "$GITHUB_OUTPUT"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "npm_version=$npm_version" >> "$GITHUB_OUTPUT"
echo "base_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
# Refuse non-main release runs BEFORE publishing. `github.ref` is the
# authoritative workflow trigger ref: schedule -> refs/heads/main;
# workflow_dispatch from main -> refs/heads/main; workflow_dispatch from a
# feature branch -> refs/heads/<feature> (refused here). Checking the
# trigger ref (rather than git topology) reliably distinguishes a feature
# branch from main, which commit-topology checks cannot do (a feature
# branch off main is also a descendant of origin/main).
# This runs FIRST, before the OIDC preflight, and that ordering is the
# point: a workflow_dispatch from a feature branch would otherwise mint an
# id-token and exchange it for a short-lived npm PUBLISH CREDENTIAL, and
# only then be refused - requesting a credential the run is not allowed to
# use. Refusing on the ref costs nothing and must come first.
- name: Check release ref
if: steps.decide.outputs.should_release == 'true'
shell: bash
env:
GITHUB_REF: ${{ github.ref }}
run: |
set -euo pipefail
if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then
echo "::error::Refusing to release from non-main ref ('$GITHUB_REF'). Run the release workflow from main."
exit 1
fi
# Publication is the only step that can fail for a reason outside this
# repository, and it runs LAST - after the version bump and the release
# commit have already landed on main. That ordering is what let a dead
# credential hide for ten days: every run advanced main to a new version,
# published nothing, and still reported the bump as progress. Asking the
# registry for a credential BEFORE anything is mutated turns that silent
# drift into an immediate, actionable failure.
- name: Verify npm will accept this workflow's OIDC identity
if: steps.decide.outputs.should_release == 'true'
shell: bash
run: |
set -euo pipefail
pkg_name="$(node -p "require('./package.json').name")"
# A scoped name contains characters that are not path-safe: the URL
# segment for @unbrained/pm-web is %40unbrained%2Fpm-web, and sending
# the raw name instead addresses a different path entirely. Unscoped
# names encode to themselves, so this is not a no-op only for scoped
# packages - it is simply correct for both.
# npm's escapedName contract is NOT encodeURIComponent: the registry
# preserves the leading `@` and encodes only the separator, so
# @unbrained/pm-web addresses @unbrained%2fpm-web. Percent-encoding the
# `@` as well produces a path the registry does not recognise.
pkg_path="$(PKG="${pkg_name}" node -p "process.env.PKG.replace('/', '%2f')")"
# Read defensively: under `set -u` an unset ACTIONS_ID_TOKEN_* aborts the
# step with a raw shell error before the diagnosis below can print, so
# the operator sees "unbound variable" instead of "the job needs
# id-token: write". The whole point of this step is a legible failure.
request_url="${ACTIONS_ID_TOKEN_REQUEST_URL:-}"
request_token="${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}"
if [ -z "${request_url}" ] || [ -z "${request_token}" ]; then
echo "::error::GitHub exposed no OIDC token endpoint to this job. The release job needs 'id-token: write'."
exit 1
fi
# `set -e` would abort on a non-zero curl before anything could be
# classified, so the exit status is captured instead of propagating.
if ! id_token_body="$(curl -sS --max-time 30 -H "Authorization: bearer ${request_token}" \
"${request_url}&audience=npm:registry.npmjs.org")"; then
echo "::error::Could not reach GitHub's OIDC token endpoint. Nothing was bumped, committed or tagged; re-run when it is reachable."
exit 1
fi
id_token="$(printf '%s' "${id_token_body}" | node -p "JSON.parse(require('node:fs').readFileSync(0,'utf8')).value" 2>/dev/null)" || id_token=""
if [ -z "${id_token}" ] || [ "${id_token}" = "undefined" ]; then
echo "::error::GitHub would not mint an OIDC id-token. The release job needs 'id-token: write'."
exit 1
fi
# The response body carries a short-lived PUBLISH CREDENTIAL on success.
# It is never echoed, and it must not outlive this step either: every
# later step in this job runs as the same runner user and could read it
# off disk. mktemp keeps it out of a predictable path and the EXIT trap
# removes it on success, on failure, and on early return alike.
response="$(mktemp)"
trap 'rm -f "${response}"' EXIT
# A timeout, DNS failure or refused connection makes curl exit non-zero,
# and `set -e` would abort here - before the 000 classification below
# could tell a registry outage apart from an identity refusal. Capture
# the status instead, and treat "curl produced nothing" as 000.
if ! status="$(curl -sS --max-time 30 -o "${response}" -w '%{http_code}' \
-X POST "https://registry.npmjs.org/-/npm/v1/oidc/token/exchange/package/${pkg_path}" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${id_token}")"; then
status="000"
fi
[ -n "${status}" ] || status="000"
# npm answers 201 Created on a successful exchange and 200 in some
# paths. Accepting only one of them would fail a release whose trusted
# publisher IS configured - a preflight that blocks correct releases is
# worse than the outage it exists to prevent, so accept any 2xx.
if [ "${status}" -ge 200 ] && [ "${status}" -lt 300 ]; then
echo "npm accepted this workflow's identity for ${pkg_name}."
# The exchange proves the workflow is a recognised trusted publisher.
# It does NOT prove the binding permits publishing: a configuration
# created on or after 2026-05-20 selects allowed actions, and one
# scoped to staging alone exchanges successfully and then fails at
# `npm publish` - after this run has already mutated main. Say so,
# so a failure there is not read as a preflight that lied.
echo "Note: this verifies identity, not the allowed action. The trusted publisher must have 'npm publish' selected."
exit 0
fi
reason="$(RESPONSE_FILE="${response}" node -p "try{JSON.parse(require('node:fs').readFileSync(process.env.RESPONSE_FILE,'utf8')).message||''}catch(e){''}")"
# 000 is curl's "no HTTP response" (timeout, DNS, connection refused)
# and 5xx is the registry failing on its own account. Neither says
# anything about this workflow's identity, and telling a maintainer to
# configure a trusted publisher they already configured would send them
# somewhere useless. Fail either way - a release must not proceed on an
# unverified identity - but say which failure it was.
# 429 belongs here too: the registry is rate-limiting this caller, which
# says nothing about whether a trusted publisher is bound. Sending a
# maintainer to reconfigure a correct publisher is the wrong answer.
if [ "${status}" = "000" ] || [ "${status}" = "429" ] || [ "${status}" -ge 500 ]; then
echo "::error::Could not reach npm to verify this workflow's identity for ${pkg_name} (HTTP ${status}${reason:+: ${reason}})."
echo "::error::This is a registry or network failure, NOT a trusted-publisher problem. Nothing was bumped, committed or tagged; re-run when the registry is reachable."
exit 1
fi
echo "::error::npm refused this workflow's OIDC identity for ${pkg_name} (HTTP ${status}${reason:+: ${reason}})."
echo "::error::Nothing has been bumped, committed or tagged - this run stopped before mutating anything."
echo "::error::Configure a trusted publisher on npmjs.com for ${pkg_name}: Settings -> Trusted Publisher -> GitHub Actions,"
echo "::error::organization 'unbraind', repository '${GITHUB_REPOSITORY#*/}', workflow 'release.yml', no environment."
exit 1
- name: Update release version
if: steps.decide.outputs.should_release == 'true'
env:
NPM_VERSION: ${{ steps.decide.outputs.npm_version }}
shell: bash
run: |
set -euo pipefail
npm version "${NPM_VERSION}" --no-git-tag-version --allow-same-version
node -e "const fs=require('node:fs');const version=JSON.parse(fs.readFileSync('package.json','utf8')).version;for(const file of ['manifest.json']){if(!fs.existsSync(file))continue;const json=JSON.parse(fs.readFileSync(file,'utf8'));json.version=version;fs.writeFileSync(file,JSON.stringify(json,null,2)+'\n');}if(fs.existsSync('index.ts')){const source=fs.readFileSync('index.ts','utf8');const next=source.replace(/version:\s*[\"'][^\"']+[\"']/,'version: \"'+version+'\"');if(next!==source)fs.writeFileSync('index.ts',next);}"
npm run build
- name: Generate changelog and release notes
if: steps.decide.outputs.should_release == 'true'
shell: bash
run: |
set -euo pipefail
npx pm-changelog --pm-root .agents/pm --mode replace --output CHANGELOG.md --all-release-tags --release-version-from-package --date-from-version --item-url-base https://github.com/unbraind/pm-github/blob/main/.agents/pm --respect-item-release --pm-bin ./node_modules/.bin/pm --pm-arg=--output-budget --pm-arg=unbounded --pm-arg=--output-limit --pm-arg=unbounded --github-step-summary
npx pm-changelog --pm-root .agents/pm --mode replace --output CHANGELOG.md --all-release-tags --release-version-from-package --date-from-version --item-url-base https://github.com/unbraind/pm-github/blob/main/.agents/pm --respect-item-release --pm-bin ./node_modules/.bin/pm --pm-arg=--output-budget --pm-arg=unbounded --pm-arg=--output-limit --pm-arg=unbounded --check
npx pm-changelog --pm-root .agents/pm --stdout --since-previous-tag --until-release-tag --release-version-from-package --date-from-version --item-url-base https://github.com/unbraind/pm-github/blob/main/.agents/pm --respect-item-release --pm-bin ./node_modules/.bin/pm --pm-arg=--output-budget --pm-arg=unbounded --pm-arg=--output-limit --pm-arg=unbounded > RELEASE_NOTES.md
- name: Run release checks
if: steps.decide.outputs.should_release == 'true'
run: npm run release:check
- name: Commit release files
if: steps.decide.outputs.should_release == 'true'
env:
REPO_NAME: ${{ github.event.repository.name }}
RELEASE_TAG: ${{ steps.decide.outputs.tag }}
shell: bash
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
paths=(package.json package-lock.json manifest.json CHANGELOG.md)
[[ -f index.ts ]] && paths+=(index.ts)
[[ -d src ]] && paths+=(src)
git add "${paths[@]}"
if git diff --cached --quiet; then
echo "Release files are already current; tagging existing commit."
else
git commit -m "Release ${REPO_NAME} ${RELEASE_TAG}"
fi
- name: Merge release metadata through protected PR
if: steps.decide.outputs.should_release == 'true'
id: release_pr
shell: bash
env:
GH_TOKEN: ${{ github.token }}
RELEASE_BASE_SHA: ${{ steps.decide.outputs.base_sha }}
RELEASE_TAG: ${{ steps.decide.outputs.tag }}
NPM_VERSION: ${{ steps.decide.outputs.npm_version }}
run: |
set -euo pipefail
git fetch origin main --force
current_main_sha="$(git rev-parse origin/main)"
if [[ "$current_main_sha" != "$RELEASE_BASE_SHA" ]]; then
echo "::error::main advanced from ${RELEASE_BASE_SHA} to ${current_main_sha} while preparing the release. Retry from the new main head."
exit 1
fi
release_branch="release/${RELEASE_TAG#v}"
release_commit="$(git rev-parse HEAD)"
if [[ "$release_commit" == "$current_main_sha" ]]; then
echo "Release metadata is already present on main; resuming the publish/tag transaction."
if git ls-remote --exit-code --heads origin "refs/heads/${release_branch}" > /dev/null 2>&1; then
git push origin --delete "$release_branch"
fi
{
echo "merged_sha=$current_main_sha"
echo "pr_number="
} >> "$GITHUB_OUTPUT"
exit 0
fi
remote_branch_sha="$(git ls-remote --heads origin "refs/heads/${release_branch}" | cut -f1)"
if [[ -n "$remote_branch_sha" ]]; then
git push \
--force-with-lease="refs/heads/${release_branch}:${remote_branch_sha}" \
origin "HEAD:refs/heads/${release_branch}"
else
git push origin "HEAD:refs/heads/${release_branch}"
fi
pr_number="$(gh pr list \
--repo "$GITHUB_REPOSITORY" \
--base main \
--head "$release_branch" \
--state open \
--json number \
--jq '.[0].number // empty')"
if [[ -z "$pr_number" ]]; then
pr_url="$(gh pr create \
--repo "$GITHUB_REPOSITORY" \
--base main \
--head "$release_branch" \
--title "Release pm-github ${RELEASE_TAG}" \
--body "Automated daily release metadata for \`${RELEASE_TAG}\`. The release gate passed before this PR was created; npm publication and tagging remain blocked until this protected PR is merged.")"
pr_number="${pr_url##*/}"
fi
owner="${GITHUB_REPOSITORY%/*}"
repo="${GITHUB_REPOSITORY#*/}"
deadline=$(( SECONDS + 1800 ))
awaiting_approval=0
merge_err="$(mktemp)"
pr_view_err="$(mktemp)"
merged_sha=""
while :; do
# Read mergeStateStatus + statusCheckRollup only to detect the
# fatal DIRTY/BEHIND states and to keep the deadline message
# diagnosable. The merge decision is NOT derived from this state
# (see the step-level comment above): the merge API call is the
# authority. `set -e` is active, so an unguarded `gh pr view`
# would abort the whole step on a transient 5xx/rate limit - a
# failed read is not information about the PR, so it is treated as
# an unrecognised state and retried.
pr_state="$(gh pr view "$pr_number" \
--repo "$GITHUB_REPOSITORY" \
--json mergeStateStatus,statusCheckRollup \
--jq '{merge_state: (.mergeStateStatus // "UNKNOWN"),
unsettled: [.statusCheckRollup[]?
| {name: (.name // .context),
state: (.conclusion // .state // .status // "PENDING")}
| select((.state | IN("SUCCESS", "NEUTRAL", "SKIPPED",
"FAILURE", "ERROR", "TIMED_OUT",
"CANCELLED", "ACTION_REQUIRED",
"STARTUP_FAILURE", "STALE")) | not)
| .name],
failing: [.statusCheckRollup[]?
| {name: (.name // .context),
state: (.conclusion // .state // .status // "PENDING")}
| select(.state | IN("FAILURE", "ERROR", "TIMED_OUT",
"CANCELLED", "ACTION_REQUIRED",
"STARTUP_FAILURE", "STALE"))
| .name]}' 2>"$pr_view_err")" || pr_state=""
if [[ -z "$pr_state" ]]; then
echo "Could not read PR #${pr_number} (retrying): $(tr '\n' ' ' < "$pr_view_err")"
merge_state="UNKNOWN"
unsettled="github api unavailable"
failing=""
else
merge_state="$(jq -r '.merge_state' <<< "$pr_state")"
unsettled="$(jq -r '.unsettled | join(", ")' <<< "$pr_state")"
failing="$(jq -r '.failing | join(", ")' <<< "$pr_state")"
fi
case "$merge_state" in
DIRTY | BEHIND)
echo "::error::Release PR #${pr_number} is ${merge_state}; it conflicts with main or its base moved. Retry the release from the new main head."
exit 1
;;
esac
# required_conversation_resolution is enabled, so any unresolved
# review thread blocks the merge forever. Advisory bot reviewers
# (Sourcery, cubic, CodeRabbit) routinely open threads as a
# confidence signal; resolve every unresolved thread on this
# release PR before each attempt so a bot comment cannot dead-end
# the daily release. Only BOT-authored threads on this PR are
# resolved, and only when EVERY comment on the thread is bot-
# authored: a human reply on a bot-opened thread must keep
# blocking. Clearing a human reviewer's thread would remove the
# very protection required_conversation_resolution provides.
unresolved="$(gh api graphql \
-f query='query($o:String!,$r:String!,$n:Int!){repository(owner:$o,name:$r){pullRequest(number:$n){reviewThreads(first:100){nodes{id isResolved comments(first:100){totalCount nodes{author{login __typename}}}}}}}}' \
-F o="$owner" -F r="$repo" -F n="$pr_number" \
--jq '.data.repository.pullRequest.reviewThreads.nodes[]?
| select(.isResolved==false)
| select(.comments.totalCount ==
([.comments.nodes[]?] | length))
| select([.comments.nodes[]?.author.__typename]
| length > 0 and all(. == "Bot"))
| .id' 2>/dev/null || true)"
if [[ -n "$unresolved" ]]; then
while IFS= read -r thread_id; do
[[ -z "$thread_id" ]] && continue
gh api graphql \
-f query='mutation($t:ID!){resolveReviewThread(input:{threadId:$t}){thread{isResolved}}}' \
-F t="$thread_id" >/dev/null 2>&1 || true
done <<< "$unresolved"
fi
# Attempt the merge and let GitHub be the authority. A successful
# PUT is proof the branch-protection rules were satisfied; a
# failure (405 = checks not ready, 409 = SHA moved) is proof they
# were not. No state inference in between.
merge_out="$(gh api --method PUT \
"repos/${GITHUB_REPOSITORY}/pulls/${pr_number}/merge" \
-f merge_method=rebase \
-f sha="$release_commit" \
2>"$merge_err")" && merge_rc=0 || merge_rc=$?
if (( merge_rc == 0 )); then
merged_sha="$(jq -r '.sha // empty' <<< "$merge_out" 2>/dev/null || true)"
if [[ -n "$merged_sha" && "$merged_sha" != "null" ]]; then
echo "Merged release PR #${pr_number} via merge API (state was ${merge_state})."
break
fi
# Merge reported success but no sha - treat as transient, retry.
echo "Merge returned no sha; retrying: $(tr '\n' ' ' < "$merge_err")"
merged_sha=""
else
echo "Merge attempt refused (state: ${merge_state}; settling: ${unsettled:-none}; failing: ${failing:-none}): $(tr '\n' ' ' < "$merge_err")"
# A run parked on `action_required` is awaiting manual approval and
# will never start on its own. It is invisible in statusCheckRollup,
# so without this it is indistinguishable from a required check that
# failed: the loop just logs "settling: none" until the deadline.
# pm-changelog's release PR #133 parked exactly this way on
# 2026-08-10 (attempt 1 conclusion `action_required` at 04:54:24Z,
# policy `first_time_contributors`, and `github-actions[bot]` had no
# merged PR in that repo yet). A human re-ran it at 05:36Z, 12
# minutes after the release had already given up. Try to approve it -
# the token often may not, which is harmless - and always surface the
# run so the wait is diagnosable.
pending_runs="$(gh api \
"repos/${GITHUB_REPOSITORY}/actions/runs?head_sha=${release_commit}&per_page=100" \
--jq '.workflow_runs[]? | select(.conclusion=="action_required" or .status=="waiting") | .id' \
2>/dev/null || true)"
if [[ -n "$pending_runs" ]]; then
while IFS= read -r run_id; do
[[ -z "$run_id" ]] && continue
echo "::warning::CI run ${run_id} for this release PR is awaiting workflow approval: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
gh api --method POST \
"repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/approve" \
>/dev/null 2>&1 \
&& echo "Approved workflow run ${run_id}; its checks can now report." \
|| echo "Could not approve run ${run_id} with this token; a maintainer must approve it once."
done <<< "$pending_runs"
awaiting_approval=1
fi
fi
if (( SECONDS >= deadline )); then
if (( awaiting_approval == 1 )); then
echo "::error::Release PR #${pr_number} did not merge within 30 minutes because its CI run is awaiting workflow approval (last state: ${merge_state}). Approve the run linked above once; this repository requires approval for a contributor that has no merged PR yet. Nothing was published or tagged."
else
echo "::error::Release PR #${pr_number} did not merge within 30 minutes (last state: ${merge_state}; still settling: ${unsettled:-none}; failing: ${failing:-none}). A required check failed or never reported, or a required review is missing; not merging or publishing."
fi
exit 1
fi
sleep 20
done
rm -f "$merge_err" "$pr_view_err"
if [[ -z "$merged_sha" || "$merged_sha" == "null" ]]; then
echo "::error::GitHub did not return the merged main SHA for release PR #${pr_number}."
exit 1
fi
if git ls-remote --exit-code --heads origin "refs/heads/${release_branch}" > /dev/null 2>&1; then
git push origin --delete "$release_branch"
fi
{
echo "merged_sha=$merged_sha"
echo "pr_number=$pr_number"
} >> "$GITHUB_OUTPUT"
echo "Merged release metadata PR #${pr_number} at ${merged_sha}." >> "$GITHUB_STEP_SUMMARY"
# The release PR can only contain the prepared release commit because the
# base SHA is checked immediately before creation. Re-check the exact
# merged main commit anyway so npm always receives byte-for-byte validated
# repository state, and fail safely before publication if main moved.
- name: Verify merged release
if: steps.decide.outputs.should_release == 'true'
shell: bash
env:
MERGED_SHA: ${{ steps.release_pr.outputs.merged_sha }}
NPM_VERSION: ${{ steps.decide.outputs.npm_version }}
run: |
set -euo pipefail
git fetch origin main --force
git checkout --detach origin/main
actual_sha="$(git rev-parse HEAD)"
if [[ "$actual_sha" != "$MERGED_SHA" ]]; then
echo "::error::main advanced from merged release ${MERGED_SHA} to ${actual_sha} before publication. Retry to rebuild release metadata on the new head."
exit 1
fi
actual_version="$(npm pkg get version | tr -d '"')"
if [[ "$actual_version" != "$NPM_VERSION" ]]; then
echo "::error::Merged package version ${actual_version} does not match intended release ${NPM_VERSION}."
exit 1
fi
npm ci
npm run release:check
diff_paths=(package.json package-lock.json manifest.json CHANGELOG.md)
[[ -f index.ts ]] && diff_paths+=(index.ts)
[[ -d src ]] && diff_paths+=(src)
git diff --exit-code -- "${diff_paths[@]}"
# `git diff` above compares dist against itself - nothing
# rebuilds before this point, so it passes unconditionally and
# cannot see untracked or ignored artifacts. Rebuild from clean
# so a stale committed dist cannot ship.
if git ls-files --error-unmatch 'dist' > /dev/null 2>&1; then
rm -rf dist
npm run build
dist_status="$(git status --porcelain=v1 --untracked-files=all --ignored=matching -- 'dist/')"
if [[ -n "$dist_status" ]]; then
echo "::error::Merged dist/ does not match a clean rebuild:"
echo "$dist_status"
exit 1
fi
fi
# Authentication is npm trusted publishing (OIDC), not a long-lived token.
# The registry mints a short-lived credential from this workflow's id-token,
# so no NODE_AUTH_TOKEN is set here on purpose: a stored token is the thing
# that expired and silently stopped every fleet package publishing between
# 2026-08-17 and 2026-08-26 while main kept bumping the version. Trusted
# publishing must be configured for this package on npmjs.com against
# unbraind/pm-github and this workflow filename, or publish fails closed.
- name: Publish npm package
id: publish
if: steps.decide.outputs.should_release == 'true'
shell: bash
env:
NPM_VERSION: ${{ steps.decide.outputs.npm_version }}
run: |
set -euo pipefail
# actions/setup-node's registry-url writes
# `//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}` into the npm
# userconfig. With no token in the environment that expands to an
# EMPTY credential, and npm treats a configured-but-empty token as
# legacy auth - which blocks the OIDC exchange outright and fails with
# the same registry 404 this migration exists to remove. Remove any
# such line before publishing so the only credential path left is OIDC.
# Both files npm reads for a registry credential. `--global` and
# `--location=global` write to the GLOBAL config, which the userconfig
# scrub never touches, so scrubbing only one leaves the other live.
globalconfig="$(npm config get globalconfig 2>/dev/null || true)"
for userconfig in "${NPM_CONFIG_USERCONFIG:-$HOME/.npmrc}" "${globalconfig}"; do
if [ -n "$userconfig" ] && [ -f "$userconfig" ]; then
# Both the registry-scoped forms (//registry/:_auth=...) and the
# GLOBAL forms (_auth=... at the start of a line). npm honours an
# unscoped credential too, so removing only the scoped ones leaves
# legacy authentication configured while every guard still passes.
sed -i'' -e '/_authToken/d' \
-e '/^[[:space:]]*_auth[[:space:]]*=/d' -e '/:_auth[[:space:]]*=/d' \
-e '/^[[:space:]]*username[[:space:]]*=/d' -e '/:username[[:space:]]*=/d' \
-e '/^[[:space:]]*_password[[:space:]]*=/d' -e '/:_password[[:space:]]*=/d' \
-e '/^[[:space:]]*certfile[[:space:]]*=/d' -e '/:certfile[[:space:]]*=/d' \
-e '/^[[:space:]]*keyfile[[:space:]]*=/d' -e '/:keyfile[[:space:]]*=/d' \
-e '/always-auth/d' "$userconfig"
fi
done
# Re-verify HERE, not only at install time. A later step can prepend a
# directory to GITHUB_PATH and change which npm this step resolves, and
# npm 10 cannot exchange an OIDC token - it would fall back to token
# auth and reproduce the exact E404 this migration removes.
active_npm="$(npm --version)"
required_npm="11.5.1"
if [ "$(printf '%s\n%s\n' "$active_npm" "$required_npm" | sort -V | head -n 1)" != "$required_npm" ]; then
echo "::error::npm $active_npm resolved at publish time cannot exchange an OIDC token; $required_npm or newer is required."
exit 1
fi
pkg_name="$(node -p "require('./package.json').name")"
# Idempotence guard: if the version already resolves on the registry,
# treat the publish as already done and exit 0. This is what lets an
# already-published release (e.g. 2026.8.10, which landed on npm while
# main was still at 2026.8.7) reconcile instead of failing with a 403
# when the workflow catches up and re-runs the transaction.
# Reconciliation must check the ARTIFACT, not just the version string.
# `npm view <pkg>@<ver> version` proves only that something is
# published under that coordinate. It cannot distinguish the attested
# package this job just produced from an unattested one published
# earlier, or from another commit -- so a reconcile that accepts mere
# existence would tag and release the current SHA on the strength of
# an artifact nobody verified. That is the very substitution this
# workflow refuses to make on the publish path, so it must not make it
# on the recovery path either.
#
# Every publish this workflow performs carries `--provenance`, so a
# version of ours that landed necessarily has attestations. Their
# presence is therefore the discriminator: attested means "our publish
# got through", absent means "something else is sitting on this
# coordinate" and is refused rather than reconciled.
registry_version_is_attested() {
local attestations
attestations="$(npm view "${pkg_name}@${NPM_VERSION}" dist.attestations --prefer-online --json 2>/dev/null || true)"
[[ -n "${attestations}" && "${attestations}" != "null" && "${attestations}" != "{}" && "${attestations}" != "[]" ]]
}
registry_has_version() {
npm view "${pkg_name}@${NPM_VERSION}" version --prefer-online --json >/dev/null 2>&1
}
# Answers one question and nothing else: is an attested copy of this
# exact version visible right now? It must never terminate the step
# itself -- attestation metadata can appear a moment after the version
# does, and that read can fail transiently, so an `exit` in here would
# turn a lag into a hard failure that skips the tag and leaves npm
# ahead of Git. The retry loop decides when to stop asking; the
# refusal below decides what an exhausted loop means.
reconciled_attested() {
registry_has_version && registry_version_is_attested
}
# Reached only once the loop has stopped asking. An occupied
# coordinate with no attestation is the case worth naming: this
# workflow only ever publishes with --provenance, so that artifact did
# not come from this job, and republishing cannot repair it because npm
# forbids overwriting a published version. It needs a human, and saying
# so is more useful than a green run over an artifact the release notes
# will misdescribe.
# Declared before any function that expands it: bindings are established
# before use so visibility never depends on call-time reasoning.
max_attempts=3
refuse_unattested_or_fail() {
if registry_has_version; then
echo "::error::${pkg_name}@${NPM_VERSION} exists on the registry WITHOUT a visible provenance attestation. This workflow only ever publishes with --provenance, so either that artifact did not come from this job, or its attestation never became visible. Refusing to tag and release around it; investigate before re-running."
else
# Never claim the publish failed when only visibility was not
# confirmed: say exactly what this run knows (pm-cli-website-3y5d).
echo "::error::npm did not confirm ${pkg_name}@${NPM_VERSION} is published after ${max_attempts} publish attempts and a 10-minute visibility window. The registry shows nothing at that coordinate right now: either the publish genuinely failed, or propagation outlasted the window. Refusing to downgrade supply-chain attestations; retry the release transaction."
fi
exit 1
}
# Idempotence guard: if this exact version is already published AND
# attested, treat the publish as done. This is what lets an
# already-published release (e.g. 2026.8.10, which landed on npm while
# main was still at 2026.8.7) reconcile instead of failing with a 403
# when the workflow catches up and re-runs the transaction.
if reconciled_attested; then
echo "::notice::${pkg_name}@${NPM_VERSION} already published and attested; skipping publish step."
exit 0
fi
publish_with_provenance() {
npm publish --access public --provenance --ignore-scripts
}
attempt=0
while (( attempt < max_attempts )); do
attempt=$(( attempt + 1 ))
if publish_with_provenance; then
echo "Published with provenance on attempt ${attempt}."
exit 0
fi
if reconciled_attested; then
echo "::notice::Version landed attested despite the reported error; treating as success."
exit 0
fi
if (( attempt < max_attempts )); then
echo "Publish attempt ${attempt}/${max_attempts} failed; sleeping 30s before retry..."
sleep 30
fi
done
# npm can accept a publish and still report an error, and the registry
# needs a moment to propagate before `npm view` can see it. The retry
# loop gave that grace incidentally, through the sleep between
# attempts; the final attempt has no sleep after it, so a single
# immediate read here would race propagation and fail a release npm
# had already accepted -- skipping the tag and the GitHub release for a
# version that is on the registry, which is the "npm ahead of git"
# split the release ordering exists to prevent. Poll instead.
#
# 20 reads, 30 s apart: a 10-minute visibility window. The old
# 5 x 30 s window closed 2.5 minutes in while npm had already
# ACCEPTED the publish, printed the false "failed after 3 attempts",
# and skipped the tag and the GitHub release for a version the
# registry then served moments later (pm-slack/pm-web 2026-09-18:
# visible 35 s after the window closed). Ten minutes absorbs the
# observed propagation; --prefer-online on the registry reads keeps
# a stale cache answer from faking or hiding visibility. The cost is
# paid only on the path where npm has already reported an error,
# never on a successful publish.
reconcile_attempts=20
for reconcile_attempt in $(seq 1 "${reconcile_attempts}"); do
if reconciled_attested; then
echo "::notice::Version landed attested after the final reported error; treating as success."
exit 0
fi
echo "Not yet visible on the registry; re-reading in 30s (${reconcile_attempt}/${reconcile_attempts})..."
sleep 30
done
# The 20th sleep completes the 10-minute window; read once more so a
# version that became visible during that final 30 s is caught too,
# not failed on an arithmetic edge.
if reconciled_attested; then
echo "::notice::Version landed attested at the end of the 10-minute visibility window; treating as success."
exit 0
fi
refuse_unattested_or_fail
# Tag the exact merged/verified main commit AFTER a successful publish.
# main is already advanced by the protected-PR merge above, so this step
# only creates and pushes the tag - it never pushes HEAD:main again (that
# push was what branch protection rejected with GH006, killing the job
# before the tag push in the old ordering). If publication fails, main
# retains the prepared metadata and the next run resumes the same version
# instead of inventing another release.
- name: Push release tag
id: push_tag
if: steps.decide.outputs.should_release == 'true'
shell: bash
env:
RELEASE_TAG: ${{ steps.decide.outputs.tag }}
run: |
set -euo pipefail
release_tag="$RELEASE_TAG"
# Consult the remote, not just the local tag database. The last tag
# fetch happened back in `Decide release`; if anything created this
# tag on origin since then, the local lookup misses it, `git tag`
# succeeds locally and the push below is rejected as non-fast-forward
# - after a successful publish, which is the npm-ahead-of-git state
# this workflow exists to prevent.
git fetch origin --force --tags
remote_tag_sha="$(git ls-remote --refs --tags origin "refs/tags/${release_tag}" | awk 'NR == 1 { print $1 }')"
current_sha="$(git rev-parse HEAD)"
if [[ -n "$remote_tag_sha" ]]; then
remote_commit="$(git rev-list -n 1 "$remote_tag_sha")"
if [[ "$remote_commit" != "$current_sha" ]]; then
echo "::error::${release_tag} already exists on origin at ${remote_commit}, not verified main ${current_sha}."
exit 1
fi
echo "::notice::${release_tag} is already on origin at the verified commit; nothing to push."
exit 0
fi
if git rev-parse --verify --quiet "refs/tags/${release_tag}" > /dev/null; then
existing_sha="$(git rev-list -n 1 "$release_tag")"
current_sha="$(git rev-parse HEAD)"
if [[ "$existing_sha" != "$current_sha" ]]; then
echo "::error::${release_tag} already points to ${existing_sha}, not verified main ${current_sha}."
exit 1
fi
else
git tag "$release_tag"
fi
git push origin "refs/tags/${release_tag}"
- name: Verify bun install of published package
id: verify_bun
if: steps.decide.outputs.should_release == 'true'
env:
NPM_VERSION: ${{ steps.decide.outputs.npm_version }}
shell: bash
run: |
set -euo pipefail
pkg_name="$(node -p "require('./package.json').name")"
pkg_version="${NPM_VERSION}"
mkdir -p /tmp/bun-verify
cd /tmp/bun-verify
rm -rf node_modules bun.lockb package.json
bun init -y > /dev/null
# Smoke-test that the just-published version installs via bun.
# Retry to absorb npm registry propagation (~60s typical).
# 21 attempts with a 30 s pause BETWEEN them: the same 10-minute
# window the npm reconcile uses. The last attempt runs after the
# final pause, so a version that becomes installable at the very end
# of the window still passes instead of failing on a trailing sleep.
bun_attempts=21
for attempt in $(seq 1 "${bun_attempts}"); do
if bun add "${pkg_name}@${pkg_version}"; then
echo "bun add succeeded on attempt $attempt"
exit 0
fi
if (( attempt < bun_attempts )); then
echo "bun add failed on attempt $attempt, sleeping 30s..."
sleep 30
fi
done
# The GitHub release below is now created whenever the publish and
# the tag push succeeded, regardless of this step, so a bun failure
# must NOT be papered over as success. The old fallback here (treat
# mirror lag as a passing verification once `npm view` confirmed the
# version) still let a total failure skip the Release: pm-linear run
# 35323736826 (2026-09-18) failed this step, the Release was skipped,
# and tag v2026.09.18 has had no Release since. npm acceptance is
# already proven by the publish step above; this step verifies bun
# alone, so a failure here is reported as a failure and the gate
# step below fails the job visibly.
echo "::error::bun could not resolve ${pkg_name}@${pkg_version} after ${bun_attempts} attempts across a 10-minute window. npm accepted the publish and the GitHub release is created regardless of this step; this failure keeps the bun mirror problem visible instead of silent."
exit 1
- name: Create GitHub release
# Created even when bun verification failed: this Release used to be
# skipped behind that step, which is how pm-linear v2026.09.18 ended
# up tagged with no Release. It depends only on the publish and the
# tag push; a bun failure is surfaced by the gate step below so
# nothing goes silent. !cancelled() is required: with the default
# success() condition any earlier failure would skip this step.
if: >-
!cancelled() &&
steps.publish.outcome == 'success' &&
steps.push_tag.outcome == 'success'
env:
REPO_NAME: ${{ github.event.repository.name }}
RELEASE_TAG: ${{ steps.decide.outputs.tag }}
GH_TOKEN: ${{ github.token }}
run: gh release create "${RELEASE_TAG}" --title "${REPO_NAME} ${RELEASE_TAG}" --notes-file RELEASE_NOTES.md --verify-tag
# The visible half of the bun decoupling: the Release above is created
# regardless of bun verification, so without this gate a bun failure
# would end in a green run and mirror lag would be invisible. Only a
# bun FAILURE trips it - a skipped bun step means the publish or the
# tag push already failed the job on its own.
- name: Fail the job on bun verification failure
if: >-
!cancelled() &&
steps.verify_bun.outcome == 'failure'
shell: bash
run: |
set -euo pipefail
echo "::error::bun install verification failed (see the step log above); the npm publish, the tag push and the GitHub release were not affected. Failing the job so the bun mirror problem is not silent."
exit 1