-
Notifications
You must be signed in to change notification settings - Fork 0
433 lines (415 loc) · 23.3 KB
/
release.yml
File metadata and controls
433 lines (415 loc) · 23.3 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
name: Release
# Manual GitHub Release workflow.
#
# Composes `npm run check:release-readiness` (Layer 1 — release metadata),
# `npm pack` to materialise a candidate archive, `npm run
# check:release-package-contents` (Layer 2 — fresh-surface contract per
# ADR-0021 / SPEC-V05-010), `actions/attest-build-provenance` for the
# workflow-built tarball, `gh release create` for the canonical `vX.Y.Z` tag
# on `main` per ADR-0020, `npm publish --provenance` to npmjs.com via
# Trusted Publishing (ADR-0044 restoring ADR-0040 after the ADR-0041
# NPM_TOKEN fallback used for v0.7.0 / v0.7.1), and `gh release upload` to
# attach the package tarball as a release asset.
#
# Inputs and the confirm gate satisfy SPEC-V05-002 (explicit publish
# authorisation), SPEC-V05-003 (GitHub Release publication), SPEC-V05-004
# (package contract and publication), and SPEC-V05-009 (release candidate
# dry run).
on:
workflow_dispatch:
inputs:
version:
description: 'Semantic version X.Y.Z to release. Must equal `package.json#version` and the existing `v<version>` tag on main.'
type: string
required: true
dry_run:
description: 'Dry run — execute readiness + lifecycle steps without creating a GitHub Release (REQ-V05-011 / SPEC-V05-009).'
type: boolean
required: false
default: true
prerelease:
description: 'Mark the published Release as a pre-release.'
type: boolean
required: false
default: false
draft:
description: 'Create the Release in draft state (operator finalises before publishing).'
type: boolean
required: false
default: false
confirm:
description: 'Type the literal version (X.Y.Z) to authorise non-dry-run publish (SPEC-V05-002).'
type: string
required: false
default: ''
publish_package:
description: 'Publish the package to npmjs.com on this run (SPEC-V05-004). Defaults to false so candidate runs — `dry_run`, `draft`, `prerelease`, or a release intended for review only — do not push the npm package. The package publish step is gated on `! dry_run && publish_package`; the GitHub Release create + asset upload run on `! dry_run` regardless.'
type: boolean
required: false
default: false
# Least-privilege workflow permissions per ADR-0020 / ADR-0040 / ADR-0044 /
# SPEC-V05-002 / NFR-V05-001. `REQUIRED_WORKFLOW_PERMISSIONS`
# (scripts/lib/release-readiness.ts) enforces the top-level block as exactly
# { contents: write, attestations: write, id-token: write }; job-level
# overrides may only narrow, never widen. `contents: write` for
# `gh release create` + upload, `attestations: write` to persist GitHub
# Release tarball attestations, `id-token: write` is now load-bearing for
# both attestation paths: it mints the OIDC token consumed by `npm publish
# --provenance` (ADR-0044) and the OIDC token consumed by
# `actions/attest-build-provenance` (Release tarball asset).
# zizmor: suppressed inline.
permissions: # zizmor: ignore[excessive-permissions,undocumented-permissions]
contents: write # zizmor: ignore[excessive-permissions]
attestations: write # zizmor: ignore[excessive-permissions]
id-token: write # zizmor: ignore[excessive-permissions]
concurrency:
group: release-${{ inputs.version }}
cancel-in-progress: false
jobs:
smoke:
name: Smoke test (release gate)
uses: ./.github/workflows/smoke-test.yml
# No job-level `permissions:` block — `scripts/lib/release-readiness.ts`
# `diagnosticsForPermissions` enforces strict equality between job-level
# and top-level permission values (line ~852: "is `<actual>` but must be
# `<expected>`"). A `contents: read` override here failed Layer 1
# readiness on the v0.8.0-rc.1 dispatch (run 25639883562). The smoke job
# therefore inherits the top-level `{ contents: write, attestations:
# write, id-token: write }` block. The reusable smoke-test workflow is
# read-only in practice (npm pack + install + CLI smoke); the inherited
# write scopes are unused.
release:
name: Manual GitHub Release
needs: smoke
runs-on: ubuntu-latest
# Deployment environment matches the npmjs.com Trusted Publisher
# configuration (workflow=`release.yml`, environment=`release`) so the
# OIDC token minted by GitHub for this job authenticates the npm
# publish step. The environment also lets the operator add
# required-reviewer / wait-timer protection rules without changing
# the workflow. URL points at the just-published GitHub Release page.
environment:
name: release
url: ${{ github.server_url }}/${{ github.repository }}/releases/tag/v${{ inputs.version }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
# `registry-url` writes `~/.npmrc` so `npm publish` resolves the
# registry to npmjs.com. The `scope` field is unnecessary because the
# package is unscoped (`specorator`), per ADR-0040. No
# `NODE_AUTH_TOKEN` is set — Trusted Publishing (ADR-0044) supplies
# the auth token via OIDC at the publish step.
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: npm
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
run: npm ci
# Step 3b — build the Claude Code plugin bundle. Per ADR-0043 the bundle
# is gitignored on develop/main and published to the orphan branch
# `dist/claude-plugin` by `.github/workflows/publish-claude-plugin.yml`.
# The release pipeline still ships the bundle inside the npm tarball
# (Layer 1 readiness validates `claude-plugin/specorator/.claude-plugin/
# plugin.json#version`; `npm run build:release-archive` stages the bundle
# tree into `.release-staging/`), so we rebuild it here from canonical
# `.claude/{agents,skills,commands}/` + `.mcp.json` sources before the
# readiness gate runs.
- name: Build claude-plugin bundle
run: npm run build:claude-plugin
# Step 4 — Layer 1 readiness (release metadata only). The CLI reads
# version + quality signals from the env mapping below, so no
# `${{ inputs.* }}` value lands inside the `run:` shell string (zizmor
# template-injection guard).
- name: Readiness — Layer 1 (release metadata)
env:
RELEASE_VERSION: ${{ inputs.version }}
RELEASE_CI_STATUS: ${{ vars.RELEASE_CI_STATUS }}
RELEASE_VALIDATION_STATUS: ${{ vars.RELEASE_VALIDATION_STATUS }}
RELEASE_QUALITY_WAIVER: ${{ vars.RELEASE_QUALITY_WAIVER }}
# `gh api` requires an explicit token via GH_TOKEN; without it
# the immutable-releases probe (#233 prevention E) silently
# fails through its catch block and the warning never fires
# in production dispatches (Codex P1 round 3 on PR #242).
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npm run check:release-readiness -- --json
# Step 5 — build the candidate archive. The release pipeline runs the
# build-time transform first (T-V05-013) so the tarball reflects the
# released *form* (numbered ADRs filtered, every shipping `docs/**/*.md`
# page replaced with the stub shape from `release-stubify.ts`) rather
# than the codebase form. `npm pack` then runs against the staged
# directory so `package.json#files` is still the single allowlist for
# *what* ships, and Layer 2 readiness in step 6 asserts the contract
# against the extracted candidate.
#
# `package-lock.json` is intentionally stripped by `npm pack` even when
# listed in `files`; `npm-shrinkwrap.json` is the npm-canonical
# publication lockfile, listed in `package.json#files` so it ships in
# the tarball. `package-contract.md` §3 promises a shipped lockfile so
# consumers have a deterministic `npm ci` baseline after copying the
# template (Codex P2 on PR #160). Stage the lockfile under the canonical
# name on the runner before the build script runs `npm pack --dry-run`;
# the codebase form keeps `package-lock.json` for day-to-day development
# and the runner is ephemeral so no codebase cleanup is needed.
- name: Build candidate archive
id: pack
run: |
cp package-lock.json npm-shrinkwrap.json
npm run build:release-archive -- --out .release-staging
archive_root="$(mktemp -d)"
# `npm pack <folder>` packs that folder; the staged tree already
# has numbered ADRs filtered and shipping docs stubified so the
# tarball is the released form (T-V05-013).
tarball_name="$(npm pack --silent --pack-destination "$archive_root" ./.release-staging)"
tarball_path="${archive_root}/${tarball_name}"
extract_dir="${archive_root}/extracted"
mkdir -p "$extract_dir"
# `npm pack` wraps contents in a top-level `package/` directory;
# `--strip-components=1` lifts them to `extract_dir` directly so the
# archive layout matches the running repo (what
# `checkReleasePackageContents` walks).
tar -xzf "$tarball_path" -C "$extract_dir" --strip-components=1
echo "tarball=${tarball_path}" >> "$GITHUB_OUTPUT"
echo "archive_dir=${extract_dir}" >> "$GITHUB_OUTPUT"
# Step 6 — Layer 2 readiness. Asserts the three fresh-surface contract
# rules from ADR-0021 / SPEC-V05-010 against the extracted archive: no
# numbered ADRs ship, intake folders are empty, docs are stubs. Fails
# closed when any assertion fails — release is blocked before publish.
- name: Readiness — Layer 2 (fresh-surface)
env:
RELEASE_PACKAGE_ARCHIVE: ${{ steps.pack.outputs.archive_dir }}
run: npm run check:release-package-contents -- --json
# Step 8 — confirm gate. SPEC-V05-002 requires the operator to type the
# literal version to authorise a non-dry-run publish. Step 7 (tag at
# main) is already covered by Layer 1 readiness above.
- name: Confirm gate
if: ${{ ! inputs.dry_run }}
env:
INPUT_CONFIRM: ${{ inputs.confirm }}
INPUT_VERSION: ${{ inputs.version }}
run: |
if [ "${INPUT_CONFIRM}" != "${INPUT_VERSION}" ]; then
echo "::error::confirm input does not match version — refusing to publish (SPEC-V05-002)" >&2
exit 1
fi
# Step 8b — generate GitHub artifact attestation for the workflow-built
# tarball after both readiness layers pass and after the explicit
# non-dry-run confirm gate, but before the Release is created or the
# package is published. This attests only the GitHub Release tarball
# asset; it does not change the npmjs.com publish path and
# does not opt into npmjs.com trusted publishing (#387).
- name: Generate tarball provenance attestation
if: ${{ ! inputs.dry_run }}
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-path: ${{ steps.pack.outputs.tarball }}
# Step 9a — non-dry-run: create the GitHub Release and attach the
# candidate tarball in one call, OR promote-in-place if a Release for
# this tag already exists. Generated notes use the
# `.github/release.yml` categories from PR #156 (T-V05-003).
#
# Two changes from the original step (#233 prevention B + C):
#
# B — combine create + asset upload. The tarball is passed as a
# positional argument to `gh release create`, so the Release page
# and its asset land in one transaction. The original two-step
# shape (create, then a separate `gh release upload` in step 11)
# left a window where `gh release create` had succeeded but the
# asset was missing — exactly the failure mode that turned the
# v0.5.0 incident from "transient asset upload glitch" into
# "operator deletes the Release, GitHub burns the tag" (#233).
#
# C — promote-draft-if-exists. The two-step CLAR-V05-003 dispatch
# path (step 1: draft + prerelease; step 2: stable + publish)
# used to call `gh release create` twice for one tag, which
# creates two separate Releases — orphan draft from step 1,
# stable from step 2. Now step 2 detects the draft via
# `gh release view`, flips its flags via `gh release edit`, and
# replaces the asset with `--clobber`. Result: one Release per
# tag, regardless of dispatch shape.
#
# `--verify-tag` on the create branch makes `gh release create` fail
# if `vX.Y.Z` does not already exist (Codex P2 on PR #159). Without
# it, `gh release create` auto-creates the tag from `--target main`
# when it is missing, which would silently bypass the ADR-0020
# requirement that the canonical tag is cut on main *before* the
# workflow runs. Layer 1 readiness already enforces this, but
# `--verify-tag` is a cheap defence-in-depth against a tag-race or
# readiness-gap path.
- name: Create or promote GitHub Release
if: ${{ ! inputs.dry_run }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INPUT_VERSION: ${{ inputs.version }}
INPUT_PRERELEASE: ${{ inputs.prerelease }}
INPUT_DRAFT: ${{ inputs.draft }}
TARBALL: ${{ steps.pack.outputs.tarball }}
run: |
# Detect existing Release for this tag. `gh release view` exits
# non-zero (and writes "release not found" to stderr) when no
# Release exists. We capture the JSON shape so we can inspect
# the existing Release's draft / prerelease / asset state and
# branch safely — the v0.5.1 cycle showed that "blindly apply
# inputs" creates two destructive failure modes (Codex P1
# round 2 on PR #241).
existing_json="$(gh release view "v${INPUT_VERSION}" --json isDraft,isPrerelease,assets 2>/dev/null || true)"
if [ -n "${existing_json}" ]; then
existing_draft="$(echo "${existing_json}" | jq -r '.isDraft')"
existing_prerelease="$(echo "${existing_json}" | jq -r '.isPrerelease')"
existing_has_asset="$(echo "${existing_json}" | jq -r '[.assets[]? | select(.name == "'"$(basename "${TARBALL}")"'")] | length > 0')"
# Refuse-to-demote guard (Codex P1 round 2 #1): if the
# existing Release is already a published stable (draft=false
# AND prerelease=false), do NOT let an old draft+prerelease
# dispatch flip it back. Such a flip would unpublish a
# consumer-visible release; the operator presumably did not
# intend that. Stable→stable rerun is a no-op and harmless.
if [ "${existing_draft}" = "false" ] && [ "${existing_prerelease}" = "false" ] \
&& { [ "${INPUT_DRAFT}" = "true" ] || [ "${INPUT_PRERELEASE}" = "true" ]; }; then
echo "::error::Release for v${INPUT_VERSION} is already published as stable; refusing to demote it to draft=${INPUT_DRAFT} prerelease=${INPUT_PRERELEASE}. Cut a new vX.Y.(Z+1) instead." >&2
exit 1
fi
echo "::notice::Release for v${INPUT_VERSION} already exists — promoting in place (#233 prevention C)"
# Asset upload BEFORE flag flip (Codex P1 round 3 on PR #241):
# if the existing Release is a draft with no asset (e.g. a
# prior dispatch failed mid-create), and we publish first
# then upload, post-publish asset upload can fail when the
# repo's Immutable Releases setting is on (assets cannot be
# added to published immutable Releases — see operator-guide
# §7.7). Uploading while the Release is still in its current
# draft state preserves the recovery path. We still skip the
# upload entirely when the asset is already attached, so the
# two-step CLAR-V05-003 path with a step-1 asset present
# remains a no-op on the upload (Codex P1 round 2 #2).
if [ "${existing_has_asset}" = "true" ]; then
echo "::notice::Asset $(basename "${TARBALL}") already attached to v${INPUT_VERSION} — skipping upload to avoid clobber-then-fail data loss"
else
gh release upload "v${INPUT_VERSION}" "${TARBALL}"
fi
# `gh release edit` preserves generated notes from the prior
# create call. Pass `--draft=<bool>` and `--prerelease=<bool>`
# explicitly so flipping from draft+prerelease (step 1) to
# stable (step 2) takes effect deterministically. Runs AFTER
# the asset upload so we never publish-then-fail-to-attach.
gh release edit "v${INPUT_VERSION}" \
--draft="${INPUT_DRAFT}" \
--prerelease="${INPUT_PRERELEASE}"
else
echo "::notice::Creating fresh Release for v${INPUT_VERSION} with asset attached (#233 prevention B)"
flags=()
if [ "${INPUT_PRERELEASE}" = "true" ]; then flags+=(--prerelease); fi
if [ "${INPUT_DRAFT}" = "true" ]; then flags+=(--draft); fi
gh release create "v${INPUT_VERSION}" \
--target main \
--title "v${INPUT_VERSION}" \
--verify-tag \
--generate-notes \
"${flags[@]}" \
"${TARBALL}"
fi
# Step 9b — dry run: log a generated-notes preview without creating any
# public artifact (SPEC-V05-009). All readiness diagnostics from step 4
# have already gated the run.
- name: Log dry-run candidate (no Release created)
if: ${{ inputs.dry_run }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INPUT_VERSION: ${{ inputs.version }}
INPUT_PRERELEASE: ${{ inputs.prerelease }}
INPUT_DRAFT: ${{ inputs.draft }}
run: |
echo 'Dry run — no GitHub Release created.'
echo "Candidate tag: v${INPUT_VERSION}"
echo "Prerelease flag: ${INPUT_PRERELEASE}"
echo "Draft flag: ${INPUT_DRAFT}"
echo '--- Generated release notes preview ---'
gh api -X POST "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \
-f "tag_name=v${INPUT_VERSION}" \
-f target_commitish=main \
--jq '.body' \
|| echo '(generate-notes preview unavailable; readiness diagnostics above remain authoritative)'
# Step 10 — publish the package to npmjs.com via npmjs.com Trusted
# Publishing (ADR-0044, restoring ADR-0040 after the ADR-0041
# NPM_TOKEN fallback used for v0.7.0 / v0.7.1). The publisher is
# pre-registered on npmjs.com against this workflow file
# (`release.yml`) on the `release` deployment environment; the
# OIDC token minted via `id-token: write` authenticates the
# publish. `actions/setup-node` wrote `~/.npmrc` pointing at
# `https://registry.npmjs.org`; no `NODE_AUTH_TOKEN` is read.
# `npm publish --provenance` mints a sigstore provenance
# statement that ships with the tarball and is visible on the
# npmjs.com package page under `Provenance`. Tracking issue
# #411 closed when Trusted Publishing was activated on
# 2026-05-10.
#
# The pre-flight check short-circuits before `npm publish` if
# `package.json` drifted from `INPUT_VERSION` between Layer 1
# readiness and now (defence-in-depth against tag-race / late-merge).
#
# The step is gated on `! dry_run && publish_package` so candidate
# runs (draft / pre-release / non-package release) do not publish a
# stable package version. The operator opts in by setting
# `publish_package: true` alongside `dry_run: false` and a matching
# `confirm` input.
#
# Idempotency: when a previous run published successfully but failed
# in step 11 (asset upload), rerunning the workflow with the same
# inputs would otherwise die here with `EPUBLISHCONFLICT` — `npm
# publish` is not repeatable for the same `name@version`. Query the
# registry first and branch on the result:
# - exit 0 + version present → already published, skip npm publish
# so step 11 gets a chance to recover the asset upload.
# - npm E404 → version genuinely not published, proceed.
# - any other failure (transient registry / auth / DNS) → fail
# closed.
- name: Publish to npmjs.com
if: ${{ ! inputs.dry_run && inputs.publish_package }}
env:
INPUT_VERSION: ${{ inputs.version }}
INPUT_PRERELEASE: ${{ inputs.prerelease }}
TARBALL: ${{ steps.pack.outputs.tarball }}
run: |
actual="$(node -p "require('./package.json').name + '@' + require('./package.json').version")"
expected="specorator@${INPUT_VERSION}"
if [ "$actual" != "$expected" ]; then
echo "::error::package.json identity (${actual}) does not match expected (${expected}) — refusing to publish (ADR-0040)" >&2
exit 1
fi
# Pre-release versions must publish under a non-`latest` dist-tag.
# `npm publish` refuses to default a prerelease to `latest` and
# exits with "You must specify a tag using --tag when publishing
# a prerelease version." `inputs.prerelease == true` → publish
# under `next`; stable releases → default `latest` (no `--tag`).
publish_args=("--provenance")
if [ "${INPUT_PRERELEASE}" = "true" ]; then
publish_args+=("--tag" "next")
fi
set +e
view_output="$(npm view "specorator@${INPUT_VERSION}" version --json 2>&1)"
view_exit=$?
set -e
if [ "$view_exit" -eq 0 ] && echo "$view_output" | grep -q "\"${INPUT_VERSION}\""; then
echo "::notice::version ${INPUT_VERSION} already published to npmjs.com — skipping npm publish (idempotent rerun, NFR-V05-005)"
elif echo "$view_output" | grep -qE "\"code\": *\"E404\"|E404|code E404|404 Not Found"; then
# Publish the byte-identical tarball produced in step 5 so the
# published archive equals the GitHub Release asset uploaded in
# step 11 (T-V05-013). `--provenance` mints a sigstore provenance
# statement via the OIDC token (ADR-0044, restoring ADR-0040).
npm publish "${publish_args[@]}" "${TARBALL}"
else
echo "::error::npm view failed with a non-404 error — refusing to publish so EPUBLISHCONFLICT cannot mask a real failure" >&2
echo "$view_output" >&2
exit 1
fi
# Step 11 was the standalone "Attach release asset" step. As of #233
# prevention B + C it has been folded into step 9a: a fresh Release
# gets the tarball as a positional argument to `gh release create`
# (one transaction); a promoted-in-place Release uses
# `gh release upload --clobber` immediately after `gh release edit`.
# Removing the standalone step closes the v0.5.0 failure window
# where the Release page existed but the asset upload had not yet
# run — that gap is what the operator was investigating when the
# Release got deleted and the tag got burned.