-
Notifications
You must be signed in to change notification settings - Fork 0
354 lines (314 loc) · 14 KB
/
publish.yml
File metadata and controls
354 lines (314 loc) · 14 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
name: publish
run-name: "${{ format('{0} {1}', inputs.channel || 'latest', inputs.version || inputs.bump || 'auto') }}"
on:
# Releases are manual only — trigger via workflow_dispatch.
# Both "latest" and "dev" channels are dispatched by hand. There is
# no auto-publish on push.
workflow_dispatch:
inputs:
channel:
description: 'npm dist-tag channel — "latest" (public stable) or "dev" (internal)'
required: true
type: choice
default: latest
options:
- latest
- dev
bump:
description: "Bump major/minor/patch — for latest, bumps stable; for dev, resets dev cycle"
required: false
type: choice
options:
- ""
- patch
- minor
- major
version:
description: "Override version (X.Y.Z for latest, X.Y.Z-dev.N for dev). Wins over bump."
required: false
type: string
concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.channel }}-${{ inputs.version || inputs.bump }}
# id-token:write is required for npm provenance (SLSA attestation).
# This workflow must run on GitHub-hosted runners (not Blacksmith) for
# provenance to work — GitHub's OIDC token is only issued on their infra.
#
# contents: read is sufficient. Post-publish pushes to `main` are
# authenticated by the kilo-maintainer App token (minted by the
# setup-git-committer composite action), not by GITHUB_TOKEN. If a
# future edit accidentally introduces a git/gh call that falls back
# to GITHUB_TOKEN for a write, we want it to fail loudly here rather
# than silently succeed with broader privilege.
permissions:
id-token: write
contents: read
jobs:
publish:
name: Publish to npm
runs-on: ubuntu-24.04
if: github.repository == 'Kilo-Org/shell-security'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- uses: actions/setup-node@v4
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Typecheck
run: bun run typecheck
- name: Test
run: bun test
- name: Format check
run: bun run format:check
- name: Resolve version
id: version
run: bun script/version.ts
env:
KILO_CHANNEL: ${{ inputs.channel }}
KILO_BUMP: ${{ inputs.bump }}
KILO_VERSION: ${{ inputs.version }}
GH_REPO: ${{ github.repository }}
GH_TOKEN: ${{ github.token }}
# ============================================================
# POINT OF NO RETURN
# ============================================================
# `npm publish` is irreversible. Once it succeeds, every step
# below this point MUST eventually succeed (with retries) or the
# workflow exits via the recovery handler with explicit manual
# recovery instructions.
#
# Atomicity story:
# - All pre-publish validation runs above (token, version, no
# existing tag/release per version.ts).
# - Publish runs once. If it fails, no git/GH side effects.
# - Verification of publish-landed is INFORMATIONAL ONLY. It uses
# the registry HTTP endpoint (faster than `npm view`) and never
# fails the workflow regardless of outcome.
# - Tag + release operations are bundled into a single step with
# internal retries. If anything fails after retries, the
# recovery handler prints the exact manual recovery commands.
# ============================================================
# Authentication for npm publish uses OIDC trusted publishing —
# no NODE_AUTH_TOKEN needed. npm CLI auto-detects the OIDC
# environment when id-token: write is set and no token is present.
# Configured on npmjs.com under package settings → Trusted Publishers.
# Requires npm CLI v11.5.1+ and Node 22.14.0+.
- name: Publish to npm
id: publish
run: bun script/publish.ts
env:
NPM_CONFIG_PROVENANCE: "true"
KILO_CHANNEL: ${{ steps.version.outputs.channel }}
# Informational verification of the publish landing on the npm
# registry. Uses curl against the registry HTTP endpoint (not
# `npm view`, which has 30-90s propagation lag). Polls 6 times at
# 10s intervals. ALWAYS exits 0 — this step never fails the
# workflow. The publish step itself is the source of truth for
# whether the publish actually happened.
- name: Verify publish landed (informational)
if: steps.publish.outcome == 'success'
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
echo "Probing registry for @kilocode/shell-security@$VERSION..."
for i in 1 2 3 4 5 6; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
"https://registry.npmjs.org/@kilocode/shell-security/$VERSION")
if [ "$STATUS" = "200" ]; then
echo "::notice::Verified $VERSION is live on the registry"
exit 0
fi
echo " Attempt $i/6: registry returned HTTP $STATUS, retrying in 10s..."
sleep 10
done
echo "::warning::Could not verify $VERSION on the registry after 60s of polling. The publish step itself reported success; verification is informational only and the workflow will continue to the tag/release steps."
exit 0
# Swap from the default `github-actions[bot]` identity to the
# `kilo-maintainer` GitHub App. Its installation token is what
# authorizes the post-publish push to `main` (the app is the sole
# principal on main's branch-ruleset bypass list). Using a
# purpose-built app keeps the bypass narrowly scoped to the
# publish flow instead of every workflow in this repo.
- name: Setup git committer (kilo-maintainer App)
id: committer
if: steps.publish.outcome == 'success'
uses: ./.github/actions/setup-git-committer
with:
kilo-maintainer-app-id: ${{ secrets.KILO_MAINTAINER_APP_ID }}
kilo-maintainer-app-secret: ${{ secrets.KILO_MAINTAINER_APP_SECRET }}
# Atomic git/GH operations bundled into ONE step:
# 1. Build the local commit (on main for stable, on detached HEAD for dev)
# 2. Build the local tag
# 3. Push refs in a SINGLE git push transaction
# - stable: `git push origin HEAD --follow-tags` (commit + tag in one call)
# - dev: `git push origin <tag>` (the orphan commit travels with the tag)
# 4. Create the GH release
#
# All network operations have internal 3x retries with 5s backoff.
# If anything fails after retries, the next step prints recovery
# instructions.
# Explicit guard on BOTH publish AND committer succeeding. Without
# the committer half, a skipped committer step (e.g. someone edits
# its `if:` in the future) would leave `steps.committer.outputs.token`
# as an empty string — gh/git calls here would then fail with an
# opaque 401 instead of a clear "committer was skipped/failed"
# signal. Guard is cheap; diagnostic value is high.
- name: Tag and release (post-publish)
id: tag_and_release
if: steps.publish.outcome == 'success' && steps.committer.outcome == 'success'
env:
TAG: ${{ steps.version.outputs.tag }}
VERSION: ${{ steps.version.outputs.version }}
CHANNEL: ${{ steps.version.outputs.channel }}
PREVIEW: ${{ steps.version.outputs.preview }}
# Use the kilo-maintainer App token for both `git push` (via the
# origin remote URL rewritten by the committer step above) and
# `gh release create` (this env var). The default GITHUB_TOKEN
# would be refused by main's branch ruleset.
GH_TOKEN: ${{ steps.committer.outputs.token }}
run: |
set -euo pipefail
# Build local commit + tag.
# For stable: commit on main (will be pushed below).
# For dev: detach HEAD first so main stays clean — the orphan
# commit gets pushed via the tag itself.
#
# If package.json already matches $VERSION (e.g. a prep PR
# landed the bump on main before dispatch), `git add` stages
# nothing and `git commit` would fail under `set -e`. In that
# case skip the commit and tag HEAD directly — the tree
# already represents the release.
if [ "$CHANNEL" != "latest" ]; then
git checkout --detach
fi
git add package.json
if git diff --cached --quiet; then
echo "::notice::package.json already at $VERSION; tagging HEAD without a release commit"
else
git commit -m "release: $TAG"
fi
git tag "$TAG" -m "Release $TAG"
echo "::notice::Built local commit + tag $TAG ($(git rev-parse HEAD))"
# Push refs with retries. Single git push command per branch
# to keep the operation as atomic as git allows.
push_with_retry() {
local max=3
for i in $(seq 1 $max); do
if "$@"; then
return 0
fi
if [ "$i" -lt "$max" ]; then
echo " push attempt $i/$max failed, retrying in 5s..."
sleep 5
fi
done
return 1
}
if [ "$CHANNEL" = "latest" ]; then
push_with_retry git push origin HEAD --follow-tags
else
push_with_retry git push origin "$TAG"
fi
echo "::notice::Pushed $TAG to origin"
# Create the GH release (last step). Retried 3x with backoff.
PRERELEASE_FLAG=""
if [ "$PREVIEW" = "true" ]; then
PRERELEASE_FLAG="--prerelease"
fi
gh_release_create() {
gh release create "$TAG" \
--title "$TAG" \
--generate-notes \
$PRERELEASE_FLAG
}
for i in 1 2 3; do
if gh_release_create; then
echo "::notice::Created GitHub release $TAG"
exit 0
fi
if [ "$i" -lt 3 ]; then
echo " gh release create attempt $i/3 failed, retrying in 5s..."
sleep 5
fi
done
echo "::error::Failed to create GH release $TAG after 3 attempts"
exit 1
# Recovery handler: runs ONLY when npm publish succeeded but the
# post-publish tag/release operations failed (or were skipped due
# to a failure between them). Prints exact manual recovery
# commands so the operator can complete the release by hand.
- name: Print recovery instructions on partial failure
if: failure() && steps.publish.outcome == 'success' && steps.tag_and_release.outcome != 'success'
env:
TAG: ${{ steps.version.outputs.tag }}
VERSION: ${{ steps.version.outputs.version }}
CHANNEL: ${{ steps.version.outputs.channel }}
PREVIEW: ${{ steps.version.outputs.preview }}
run: |
cat >&2 <<MSG
============================================================
PARTIAL PUBLISH STATE
============================================================
npm publish for @kilocode/shell-security@$VERSION
SUCCEEDED, but the post-publish git/GitHub-release operations
FAILED.
State right now:
- npm: $VERSION is live (cannot be unpublished)
- git: tag $TAG MAY OR MAY NOT exist on origin (check below)
- GH: release $TAG MAY OR MAY NOT exist (check below)
To complete the release manually, run from your local checkout:
cd /path/to/shell-security
git fetch origin --tags
# First check what already exists:
git ls-remote --tags origin "$TAG"
gh release view "$TAG" --repo Kilo-Org/shell-security
MSG
if [ "$CHANNEL" = "latest" ]; then
cat >&2 <<'MSG'
# === STABLE channel recovery ===
# If the tag is missing, build the commit on main and push with the tag:
MSG
cat >&2 <<MSG
git checkout main
git pull
node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json'));p.version='$VERSION';delete p.private;fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n');"
git add package.json
git commit -m "release: $TAG"
git tag "$TAG" -m "Release $TAG"
git push origin main --follow-tags
MSG
else
cat >&2 <<'MSG'
# === DEV channel recovery ===
# If the tag is missing, build an orphan commit (does NOT touch main):
MSG
cat >&2 <<MSG
git checkout main
git pull
git checkout --detach
node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json'));p.version='$VERSION';delete p.private;fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n');"
git add package.json
git commit -m "release: $TAG"
git tag "$TAG" -m "Release $TAG"
git push origin "$TAG"
git checkout main # CRITICAL: get back to main from detached HEAD
MSG
fi
PRERELEASE_FLAG=""
if [ "$PREVIEW" = "true" ]; then
PRERELEASE_FLAG=" --prerelease"
fi
cat >&2 <<MSG
# If the GH release is missing, create it:
gh release create "$TAG" \\
--repo Kilo-Org/shell-security \\
--title "$TAG" \\
--generate-notes${PRERELEASE_FLAG}
============================================================
MSG
exit 1