-
Notifications
You must be signed in to change notification settings - Fork 58
260 lines (236 loc) · 11.6 KB
/
Copy pathpublish.yml
File metadata and controls
260 lines (236 loc) · 11.6 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
# Publish `@orcarouter/code-review` to npm when its version is not on the registry yet.
#
# The trigger is every push to main, but the decision is "is this exact version
# already published?" — not "did package.json change in this commit?". Diffing
# against the previous commit looks equivalent and is not: a squash merge, a
# force push, or a revert all make "the previous commit" the wrong thing to
# compare against, and the failure is silent in both directions (a missed
# publish, or a publish attempt that dies on E409). Asking the registry is one
# HTTP call and has no such states.
#
# So: bump `version` in package.json when you want a release. Every other
# commit no-ops with a green check.
#
# Publishing is irreversible in practice — npm's unpublish window is 72 hours
# and a withdrawn version number can never be reused. Everything that can be
# checked before the point of no return is checked below, and the job fails
# closed if any of it is off.
#
# NOTE: this workflow deliberately creates no git tags. The npm package version
# and the action's own `v1.x` release tags are SEPARATE version lines (the
# action is on v1.4.x while the CLI starts at 1.0.0). Tagging `v1.0.0` here
# would collide with the action's tag series and could move consumers'
# `uses: ...@v1` onto the wrong commit. Action tags stay manual — see RELEASE.md.
name: Publish
concurrency:
group: publish-npm
cancel-in-progress: false # never abort a run that may be mid-publish
on:
push:
branches: [main]
permissions:
contents: read
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # provenance attestation; not used for auth
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # the v1 tag check below needs the tag objects
- uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
# ---- Is there anything to do? ------------------------------------
#
# `npm view <pkg>@<version>` prints the version when it exists, exits
# non-zero (E404) when the package or version does not. Both are normal
# answers here, so the failure is swallowed and only the output is read.
- id: check
name: Check whether this version is already published
run: |
set -uo pipefail
NAME=$(node -p "require('./package.json').name")
VERSION=$(node -p "require('./package.json').version")
echo "name=$NAME" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
PUBLISHED=$(npm view "$NAME@$VERSION" version 2>/dev/null || true)
if [ -n "$PUBLISHED" ]; then
echo "needed=false" >> "$GITHUB_OUTPUT"
echo "::notice::$NAME@$VERSION is already on npm — nothing to publish."
else
echo "needed=true" >> "$GITHUB_OUTPUT"
echo "::notice::$NAME@$VERSION is not on npm — will publish."
fi
# ---- Everything below runs only for a real release ---------------
- name: Verify the three versions are in sync
if: steps.check.outputs.needed == 'true'
run: |
set -euo pipefail
PKG=$(node -p "require('./package.json').version")
PLUGIN=$(node -p "require('./.claude-plugin/plugin.json').version")
MARKET=$(node -p "require('./.claude-plugin/marketplace.json').metadata.version")
echo "package.json=$PKG plugin.json=$PLUGIN marketplace.json=$MARKET"
if [ "$PKG" != "$PLUGIN" ] || [ "$PKG" != "$MARKET" ]; then
echo "::error::Version mismatch. package.json, .claude-plugin/plugin.json and" \
".claude-plugin/marketplace.json must move together (see RELEASE.md)."
exit 1
fi
# The installers generate a workflow pinned to `@v1`. If that tag predates
# scripts/settings.mjs and scripts/report.mjs, every new user gets working
# reviews with a silently inert Settings tab and empty Analytics — no
# error, just two dead console tabs. Refuse to ship an installer into that.
- name: Verify the v1 tag can serve what the installer generates
if: steps.check.outputs.needed == 'true'
run: |
set -euo pipefail
if ! git rev-parse -q --verify refs/tags/v1 >/dev/null; then
echo "::error::No v1 tag. The generated workflow pins @v1 — see RELEASE.md."
exit 1
fi
for f in scripts/settings.mjs scripts/report.mjs; do
if ! git cat-file -e "v1:$f" 2>/dev/null; then
echo "::error::v1 is missing $f. Move the v1 tag before publishing (RELEASE.md)."
exit 1
fi
done
echo "v1 = $(git rev-parse --short v1), ships settings.mjs and report.mjs"
- name: Test
if: steps.check.outputs.needed == 'true'
run: node --test scripts/*.test.mjs
# A tarball that omits bin/ or skills/ still publishes cleanly and then
# fails on the user's first `npx`. Assert the payload before shipping it.
- name: Verify the tarball carries the CLI and the skill
if: steps.check.outputs.needed == 'true'
run: |
set -euo pipefail
npm pack --dry-run --json > /tmp/pack.json
node -e '
const files = require("/tmp/pack.json")[0].files.map(f => f.path);
const required = [
"bin/orcacode-review.mjs",
"bin/platforms.mjs",
"bin/i18n.mjs",
"bin/harness.mjs",
"bin/review.mjs",
"bin/selection.mjs",
"bin/localconfig.mjs",
"scripts/postfilter.mjs",
"scripts/severity.mjs",
"rules/severity-instruction.md",
"rules/output-shape.md",
"vendor/open-code-review/LICENSE",
"vendor/open-code-review/system_rules.json",
"vendor/open-code-review/rule_docs/default.md",
"skills/orca-review-action/SKILL.md",
"skills/orca-review/SKILL.md",
"skills/orca-review/references/contract.md",
];
const missing = required.filter(f => !files.includes(f));
if (missing.length) {
console.error("::error::tarball is missing: " + missing.join(", "));
process.exit(1);
}
console.log("tarball ok — " + files.length + " files");
'
# Install the real tarball and run it through npm's real `.bin` shim.
#
# This exists because 1.0.0 shipped a CLI that did nothing. npm installs a
# `bin` as a symlink, so argv[1] is the link while import.meta.url is its
# target; the entry-point guard compared them without realpath and was
# false for every npx and every global install. The process exited 0
# having printed nothing, and every check above passed. `node bin/...`
# never takes that path, so only an actual install can catch its kind.
- name: Smoke-test the packed tarball
if: steps.check.outputs.needed == 'true'
run: |
set -euo pipefail
TARBALL="$PWD/$(npm pack --json | node -pe 'JSON.parse(require("fs").readFileSync(0,"utf8"))[0].filename')"
SMOKE=$(mktemp -d)
cd "$SMOKE"
npm init -y >/dev/null 2>&1
npm install --no-audit --no-fund --silent "$TARBALL"
SHIM=./node_modules/.bin/orcacode-review
test -L "$SHIM" || echo "::warning::$SHIM is not a symlink — this platform cannot exercise that path"
PRINTED=$("$SHIM" --version)
echo "shim printed: '$PRINTED'"
if [ "$PRINTED" != "${{ steps.check.outputs.version }}" ]; then
echo "::error::the installed bin printed '$PRINTED', expected '${{ steps.check.outputs.version }}'." \
"An empty value means the entry-point guard rejected npm's symlink."
exit 1
fi
# A real subcommand, so the check covers more than argument parsing.
"$SHIM" skill list --lang en | grep -q "claude" || {
echo "::error::\`skill list\` did not list the platform catalog"; exit 1; }
echo "tarball smoke test passed"
- name: Publish
if: steps.check.outputs.needed == 'true'
run: npm publish --access public --provenance
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# `npm publish` exiting 0 does not mean anyone can install it.
#
# A scoped package defaults to RESTRICTED. When 1.0.2 first went out the
# log read `+ @orcarouter/code-review@1.0.2` and the job was green, while
# the registry 404'd and the package page 403'd for everyone outside the
# org — despite both `--access public` and `publishConfig.access`.
#
# So: assert it, then prove it. `npm access set` is idempotent and fixes
# the state; the anonymous fetch is what actually decides, because it asks
# the question a user asks — can a stranger install this?
- name: Force public access
if: steps.check.outputs.needed == 'true'
continue-on-error: true # the verification below is the real gate
run: npm access set status=public "${{ steps.check.outputs.name }}"
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Verify the published version is installable by a stranger
if: steps.check.outputs.needed == 'true'
run: |
set -uo pipefail
NAME='${{ steps.check.outputs.name }}'
VERSION='${{ steps.check.outputs.version }}'
ENCODED=$(node -pe 'encodeURIComponent(process.argv[1])' "$NAME")
# Fetch the PACKUMENT, not the per-version document.
#
# `npm install` reads the packument and picks a version out of it, so
# that is the document whose freshness actually decides whether a
# stranger can install. The /<name>/<version> route is a colder path:
# it 404'd for more than three minutes after two correct publishes,
# failing builds that had shipped fine. Checking the wrong document
# was the bug, not the timeout.
#
# No auth header on purpose — an authenticated read succeeds against a
# restricted package and would prove nothing.
for attempt in $(seq 1 20); do
BODY=$(curl -sS -H 'Cache-Control: no-cache' "https://registry.npmjs.org/$ENCODED" || echo '')
FOUND=$(printf '%s' "$BODY" | node -pe '
try {
const d = JSON.parse(require("fs").readFileSync(0, "utf8"));
d.versions?.[process.argv[1]] ? "yes" : "no";
} catch { "no" }
' "$VERSION" 2>/dev/null || echo no)
if [ "$FOUND" = "yes" ]; then
echo "$NAME@$VERSION is in the public packument — installable"
exit 0
fi
echo "attempt $attempt/20: not in the public packument yet"
sleep 15
done
echo "::error::$NAME@$VERSION published but is absent from the public packument after 5 minutes."
echo "::error::Either it is restricted — fix with: npm access set status=public $NAME"
echo "::error::or the CDN is unusually slow. Check https://registry.npmjs.org/$ENCODED by hand."
exit 1
- name: Summary
if: steps.check.outputs.needed == 'true'
run: |
{
echo "### Published \`${{ steps.check.outputs.name }}@${{ steps.check.outputs.version }}\`"
echo
echo '```'
echo "npx ${{ steps.check.outputs.name }}@${{ steps.check.outputs.version }} skill list"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"