-
Notifications
You must be signed in to change notification settings - Fork 0
270 lines (260 loc) · 13.9 KB
/
Copy pathrelease-cli.yml
File metadata and controls
270 lines (260 loc) · 13.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
# SPDX-License-Identifier: MIT
# Publish @taskless/cli to npm. Adapted from the pattern in
# thecodedrift/firebot-script-music-to-my-ears and hardened for npm publishing.
#
# TWO JOBS IN ONE FILE, ON PURPOSE:
#
# check Decides whether main's version needs publishing — with NO npm
# credential and NO OIDC identity. It runs on every push to main and
# reads packages/cli/package.json (repo source) via `node -p`, so
# nothing untrusted is interpolated into a shell.
#
# publish Exists only when `check` says yes — i.e. right after the Version
# Packages PR merges, when the version in the manifest is one npm has
# never seen. It authenticates with a SHORT-LIVED token minted via
# GitHub OIDC (npm trusted publishing); there is NO stored NPM_TOKEN
# anywhere to exfiltrate.
#
# The gate and the job it gates MUST stay in the same file. The gate's real work
# is not saving a minute of CI — it is keeping an OIDC-capable job from being
# instantiated at all on an ordinary merge. Split across two files, a later edit
# that reads only the publish half would see a job with `id-token: write` and no
# visible reason for the `needs:`, and drop it.
#
# The untrusted half of the release lives in `release-cli-changeset.yml`, which
# holds no credential. By the time this workflow publishes, the changesets have
# already been consumed into the merged CHANGELOG, so this job builds from
# reviewed, merged source only.
#
# Residual perimeter, stated honestly: the publish job builds merged repo code,
# so "what can merge to main" is the real boundary. That is enforced by branch
# protection on main. `--ignore-scripts` keeps dependency lifecycle hooks from
# running while the OIDC identity is available; only our own build runs. No
# `pull_request_target` and no `${{ }}` interpolation of untrusted text into any
# `run:` — the two classic token-exfiltration footguns.
#
# NO CONCURRENCY GROUP HERE, deliberately — do not add one for tidiness. Two
# things stand in for serialization, and the second is the one that matters.
# The credential-free `check` gate makes the ordinary duplicate a no-op: the
# second run sees the version on npm and never instantiates this job. But
# `check` is a SEPARATE JOB, so between its answer and the publish there is a
# window where another run can ship the same version — serialization would
# close that window, and a gate in another job does not.
#
# What closes it is the `npm view` guard immediately before `npm publish`,
# below. Asking again at the moment it matters is idempotent rather than merely
# ordered: it also covers a re-run of this workflow against a version an
# earlier attempt already published, which serialization does nothing about.
# `release-vale.yml` guards each tarball the same way, for the same reason.
#
# Action refs are pinned to commit SHAs (supply-chain hardening); the trailing
# comment records the human-readable tag.
name: Release CLI
on:
push:
branches: [main]
# No workflow-wide grants; each job requests exactly what it needs.
permissions: {}
jobs:
# Publish only a version npm has never seen. On an ordinary feature merge the
# version is unchanged (already on npm) so this is false; it flips true only
# on the merge of the Version Packages PR — so ordinary pushes never
# instantiate an OIDC-capable job or touch the npm-production environment.
check:
name: Check for a new version
runs-on: ubuntu-latest
permissions:
contents: read # checkout + read package.json
outputs:
publish: ${{ steps.check.outputs.publish }}
version: ${{ steps.check.outputs.version }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false # no git writes here; don't leave the token in git config
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 24
- id: check
run: |
name=$(node -p "require('./packages/cli/package.json').name")
version=$(node -p "require('./packages/cli/package.json').version")
echo "version=$version" >> "$GITHUB_OUTPUT"
if npm view "$name@$version" version >/dev/null 2>&1; then
echo "publish=false" >> "$GITHUB_OUTPUT"
echo "$name@$version already published — nothing to do."
else
echo "publish=true" >> "$GITHUB_OUTPUT"
echo "Will publish $name@$version."
fi
publish:
name: Publish to npm
# Gate on the credential-free check: this job — and therefore the OIDC
# identity + npm-production environment — only exists for an actual release.
needs: check
if: needs.check.outputs.publish == 'true'
runs-on: ubuntu-latest
# Environment is the scoping/audit boundary for the release and where the
# npm trusted-publisher for @taskless/cli is bound. npm-production carries a
# REQUIRED REVIEWER and a protected-branches deployment policy, so this job
# waits for a human to approve the deployment before it starts — the merge
# of the Version Packages PR is not by itself enough to ship. That click is
# deliberate and is the gate on what users get by default: a publish here
# moves the `latest` tag that every `npm i @taskless/cli` resolves. Flows
# that reach no user by default (the Vale platform packages, nightlies) use
# npm-autopublish instead and are gated by code review rather than a click.
environment: npm-production
permissions:
contents: read # checkout only
id-token: write # OIDC → short-lived npm auth + build provenance
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false # publish authenticates via OIDC/npm, not git creds
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 24
cache: pnpm
registry-url: https://registry.npmjs.org
- run: pnpm install --frozen-lockfile --ignore-scripts
# OIDC trusted publishing + provenance need npm >= 11.5.1. Pin the version
# (not @latest) so the release is deterministic and a new npm release
# can't change publish behavior unreviewed; bump this intentionally.
# --ignore-scripts: no lifecycle code runs while the OIDC identity exists.
- run: npm install -g npm@12.0.1 --ignore-scripts
- run: pnpm --filter @taskless/cli build
# OIDC handshake happens here (id-token: write + registry-url + a
# registered trusted publisher). No token in env. `--provenance` attaches
# a signed build-provenance attestation.
#
# The `npm view` guard immediately before the publish is what makes the
# missing concurrency group safe, and it has to live HERE rather than in
# the `check` job. `check` runs in a separate job, so between its answer
# and this line there is a window in which another run can publish the
# same version; serializing the workflow would close that window, but so
# does asking again at the moment it matters. Re-asking is the better of
# the two: it is idempotent rather than merely ordered, so it also covers
# a re-run of this workflow on a version an earlier attempt already
# published — which serialization does nothing about.
#
# This mirrors `release-vale.yml`, which guards each tarball the same way
# for the same reason. Without it, the losing run of a race fails with
# npm's "cannot publish over the previously published version", which
# reads as a broken release rather than as a duplicate that was already
# handled.
- name: Publish (skipping a version already on npm)
working-directory: packages/cli
run: |
name=$(node -p "require('./package.json').name")
version=$(node -p "require('./package.json').version")
if npm view "$name@$version" version >/dev/null 2>&1; then
echo "$name@$version is already published — nothing to do."
else
npm publish --provenance --access public
fi
# Tag the released commit, so a changelog can be read as a range
# (`v0.10.2...v0.11.0`) instead of by hunting for the version-bump commit.
#
# A SEPARATE JOB rather than a step in `publish`, and that separation is the
# point. `publish` holds `id-token: write` for OIDC trusted publishing, and
# pushing a ref needs `contents: write`; granting both to one job would mean
# the job that can publish to npm can also move refs in this repository.
# Nothing here needs the npm identity and nothing there needs to write refs,
# so they stay apart. This job takes no `id-token` at all.
#
# No checkout either: the ref is created through the API, so there is no
# working tree and no credential written into `.git/config`.
#
# NIGHTLIES ARE EXCLUDED STRUCTURALLY, not by a condition. They are published
# by `release-cli-nightly.yml`, a different workflow, so nothing here runs for
# one and no `if:` has to stay correct to keep it that way. A nightly is a
# prerelease of a version this workflow will tag when it actually releases.
tag:
name: Tag the release
needs: [check, publish]
if: needs.check.outputs.publish == 'true'
runs-on: ubuntu-latest
permissions:
contents: write # create the tag ref, nothing else
steps:
# Lightweight, and therefore unsigned: a tag created through the API
# points at a commit and carries no signature. That is accepted here —
# these are pointers, and `main`'s own commits are already rewritten and
# unsigned by rebase-and-merge (see CLAUDE.md), so a signed tag over them
# would attest to nothing the commit does not already say.
#
# Idempotent for the same reason the publish step re-checks npm: a re-run
# of this workflow on an already-tagged release is a duplicate to absorb,
# not a failure. `git/refs` returns 422 when the ref exists, so the
# existence check comes first and the create is only attempted when it is
# genuinely absent.
- name: Create the release tag (skipping one that already exists)
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.check.outputs.version }}
SHA: ${{ github.sha }}
run: |
set -euo pipefail
if [ -z "${VERSION:-}" ]; then
echo "::error::No version from the check job; refusing to tag."
exit 1
fi
tag="v${VERSION}"
# An existing tag is only "nothing to do" if it points where this run
# would have put it. A tag at the WRONG commit is the exact state this
# job exists to prevent: the whole value of tagging is that
# `v0.10.2...v0.11.0` can be trusted without hunting for the
# version-bump commit, and a tag pointing elsewhere makes that
# silently false while every check stays green. So compare, and fail
# loudly on a mismatch rather than reporting success.
#
# Deliberately does NOT move the tag. Re-pointing a published tag
# changes history under anyone who already fetched it; a human should
# decide what happened first. Failing here costs nothing that matters,
# since the publish is a separate job that has already succeeded.
# DO NOT go back to `git/ref/tags/<tag>` with `|| true`. That
# endpoint 404s when the tag is absent, and gh prints the error BODY
# to STDOUT, so `2>/dev/null` silences nothing and the substitution
# captures a JSON blob where this comparison assumed an empty string.
# A non-empty blob is not a SHA, so the guard below fired on an
# untagged release and reported `v0.11.0 already exists but points at
# {"message":"Not Found",...}`. It failed loudly, which is the design
# working; it just had nothing true to say.
#
# `matching-refs` has no error path to misread: it returns 200 and an
# empty array when nothing matches, so absence is data rather than a
# failure. It matches by PREFIX (`v0.1.1` would also match `v0.1.10`),
# hence the exact-ref filter. No `|| true` anywhere: with pipefail, a
# genuine API error now stops the job instead of being read as "no
# such tag" and silently creating a second one.
#
# An ANNOTATED tag's `object.sha` is the tag object, not the commit,
# so deref before comparing. This job only ever creates lightweight
# tags, but v0.9.0-v0.10.2 were made by hand with `git tag -a`, and a
# tag this job did not create is precisely where a false "points
# somewhere else" would be hardest to interpret.
matched=$(gh api "repos/${GITHUB_REPOSITORY}/git/matching-refs/tags/${tag}" \
| jq -r --arg ref "refs/tags/${tag}" \
'.[] | select(.ref == $ref) | "\(.object.type) \(.object.sha)"')
existing=""
if [ -n "$matched" ]; then
existing=${matched##* }
if [ "${matched%% *}" = "tag" ]; then
existing=$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${existing}" \
--jq '.object.sha')
fi
fi
if [ -n "$existing" ]; then
if [ "$existing" = "$SHA" ]; then
echo "${tag} already points at ${SHA} — nothing to do."
exit 0
fi
echo "::error::${tag} already exists but points at ${existing}, not ${SHA}. Refusing to move a published tag; resolve this by hand."
exit 1
fi
gh api --method POST "repos/${GITHUB_REPOSITORY}/git/refs" \
-f "ref=refs/tags/${tag}" \
-f "sha=${SHA}" >/dev/null
echo "Tagged ${tag} at ${SHA}."
echo "### Tagged \`${tag}\`" >> "$GITHUB_STEP_SUMMARY"