-
-
Notifications
You must be signed in to change notification settings - Fork 158
fix(publish): remove scan-skip footgun, pin republish checkout, harden CI guardrails #8444
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3a5465d
1431f6a
3147a9d
3382a8d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,6 +22,12 @@ name: npm stage publish | |
| # as a trusted publisher on npmjs.com (same one-time setup npm/README.md | ||
| # describes for release-packages.yml). The auth-posture gate in | ||
| # scripts/publish/auth-posture.mts refuses any long-lived token present here. | ||
| # | ||
| # Guardrails: dispatches for the same dist-tag are serialized (concurrency: | ||
| # below); a caller-supplied build-run-id is verified (workflow identity, | ||
| # success, exact commit) before its artifacts are trusted; and the stage | ||
| # step is idempotent — it skips packages a prior partial run already staged | ||
| # instead of re-attempting all 9. | ||
|
|
||
| on: | ||
| workflow_dispatch: | ||
|
|
@@ -42,6 +48,14 @@ on: | |
| permissions: | ||
| contents: read | ||
|
|
||
| # Serialize staged publishes for the same dist-tag — two concurrent dispatches | ||
| # could otherwise both pass resolve-build's run-matching and both attempt | ||
| # `npm stage publish` for the same version. `cancel-in-progress: false` so a | ||
| # real staged publish already in flight is never cancelled out from under it. | ||
| concurrency: | ||
| group: npm-stage-publish-${{ inputs.dist-tag }} | ||
| cancel-in-progress: false | ||
|
|
||
| jobs: | ||
| # Resolve the build run to stage from: either a caller-supplied build-run-id | ||
| # (re-use), or dispatch release-packages.yml in stage mode and capture its | ||
|
|
@@ -64,34 +78,69 @@ jobs: | |
| EXISTING: ${{ inputs.build-run-id }} | ||
| run: | | ||
| set -euo pipefail | ||
| # The commit THIS workflow run is dispatching from — every downstream | ||
| # check (reused-run headSha match, or the fresh dispatch's --ref and | ||
| # run-selection below) pins to this, never to "whatever the default | ||
| # branch happens to be at the moment we call gh". | ||
| CUR_SHA=$(git rev-parse HEAD) | ||
| if [ -n "$EXISTING" ]; then | ||
| RUN_ID="$EXISTING" | ||
| echo "Reusing build run $RUN_ID." | ||
| # Validate the reused run before trusting its artifacts: it must | ||
| # actually be a release-packages.yml run, it must have succeeded, | ||
| # and it must have built the commit we're dispatching from — a | ||
| # stale or wrong-workflow run-id would otherwise stage old/wrong | ||
| # binaries under today's version and dist-tag with no error. | ||
| RUN_JSON=$(gh run view "$RUN_ID" -R "$REPO" --json workflowName,conclusion,status,headSha) | ||
| RUN_WORKFLOW=$(echo "$RUN_JSON" | jq -r '.workflowName') | ||
| RUN_STATUS=$(echo "$RUN_JSON" | jq -r '.status') | ||
| RUN_CONCLUSION=$(echo "$RUN_JSON" | jq -r '.conclusion') | ||
| RUN_SHA=$(echo "$RUN_JSON" | jq -r '.headSha') | ||
| if [ "$RUN_WORKFLOW" != "Release Packages" ]; then | ||
| echo "::error::build-run-id $RUN_ID is a '$RUN_WORKFLOW' run, not Release Packages — refusing to stage its artifacts." >&2 | ||
| exit 1 | ||
| fi | ||
| if [ "$RUN_STATUS" != "completed" ] || [ "$RUN_CONCLUSION" != "success" ]; then | ||
| echo "::error::build-run-id $RUN_ID is status=$RUN_STATUS conclusion=$RUN_CONCLUSION, not a successful completed run." >&2 | ||
| exit 1 | ||
| fi | ||
| if [ "$RUN_SHA" != "$CUR_SHA" ]; then | ||
| echo "::error::build-run-id $RUN_ID built $RUN_SHA, but this dispatch is at $CUR_SHA — refusing to stage a build of a different commit." >&2 | ||
| exit 1 | ||
| fi | ||
| echo "Reusing build run $RUN_ID (verified: Release Packages, success, sha $RUN_SHA)." | ||
| else | ||
| # Dispatch release-packages.yml in stage mode. Stage mode is the | ||
| # Dispatch release-packages.yml in stage mode, pinned to OUR exact | ||
| # commit via --ref. Without this, `gh workflow run` dispatches | ||
| # against the repo's default branch tip at call time — which can | ||
| # differ from the commit this npm-stage-publish.yml run itself | ||
| # checked out (a different ref triggered it, or main advanced in | ||
| # the gap between the two dispatches) — and would build/stage the | ||
| # wrong commit's binaries under today's version. Stage mode is the | ||
| # DEFAULT workflow_dispatch (no inputs = build-only smoke run; the | ||
| # preflight job's else-branch sets MODE=stage), so no input is needed. | ||
| # preflight job's else-branch sets MODE=stage), so no other input | ||
| # is needed. | ||
| DISPATCHED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ) | ||
| gh workflow run release-packages.yml -R "$REPO" | ||
| gh workflow run release-packages.yml -R "$REPO" --ref "$CUR_SHA" | ||
|
Comment on lines
+112
to
+123
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Confirm the CLI contract for workflow dispatch refs.
gh workflow run --help | rg -n -C 2 -- '--ref|Branch or tag'
# Inspect whether the release workflow has an input and validation path for an expected SHA.
rg -n -C 4 'workflow_dispatch|inputs:|expected.*sha|checkout-ref|actions/checkout' \
.github/workflows/release-packages.ymlRepository: PerryTS/perry Length of output: 6688 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- npm-stage-publish.yml ---'
sed -n '70,155p' .github/workflows/npm-stage-publish.yml
printf '%s\n' '--- release workflow dispatch and preflight ---'
sed -n '1,145p' .github/workflows/release-packages.ymlRepository: PerryTS/perry Length of output: 12538 🌐 Web query:
💡 Result: When using the GitHub REST API to create a workflow dispatch event (POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches), the Citations:
🌐 Web query:
💡 Result: When triggering a workflow using Citations:
Dispatch with a supported workflow ref.
🤖 Prompt for AI Agents |
||
| # Poll for the workflow_dispatch run we just created. Match by | ||
| # event + createdAt after the dispatch timestamp — NOT just the | ||
| # newest run, which could be a concurrent or unrelated dispatch and | ||
| # would stage the wrong artifacts. | ||
| # event + createdAt AFTER the dispatch timestamp AND headSha == | ||
| # our commit — createdAt alone could still match a concurrent | ||
| # dispatch of the SAME workflow from a different ref that happened | ||
| # to land in the same window, which would stage the wrong commit. | ||
| sleep 5 | ||
| RUN_ID="" | ||
| for i in $(seq 1 20); do | ||
| RUN_ID=$(gh run list --workflow release-packages.yml -R "$REPO" \ | ||
| --event workflow_dispatch --limit 5 \ | ||
| --json databaseId,createdAt \ | ||
| --jq "[.[] | select(.createdAt >= \"$DISPATCHED_AT\")][0].databaseId" 2>/dev/null || true) | ||
| --json databaseId,createdAt,headSha \ | ||
| --jq "[.[] | select(.createdAt >= \"$DISPATCHED_AT\" and .headSha == \"$CUR_SHA\")][0].databaseId" 2>/dev/null || true) | ||
| if [ -n "$RUN_ID" ]; then break; fi | ||
| sleep 3 | ||
| done | ||
| if [ -z "$RUN_ID" ]; then | ||
| echo "::error::could not resolve a release-packages.yml run id after dispatch." >&2 | ||
| echo "::error::could not resolve a release-packages.yml run id (dispatched at $CUR_SHA) after dispatch." >&2 | ||
| exit 1 | ||
| fi | ||
| echo "Dispatched release-packages.yml stage build: run $RUN_ID." | ||
| echo "Dispatched release-packages.yml stage build: run $RUN_ID (sha $CUR_SHA)." | ||
| fi | ||
| echo "build-run-id=$RUN_ID" >> "$GITHUB_OUTPUT" | ||
| - name: Await the stage-mode build | ||
|
|
@@ -123,6 +172,7 @@ jobs: | |
| DIST_TAG: ${{ inputs.dist-tag }} | ||
| BUILD_RUN_ID: ${{ needs.resolve-build.outputs.build-run-id }} | ||
| PUBLISH: ${{ inputs.publish }} | ||
| SOCKET_API_TOKEN: ${{ secrets.SOCKET_API_TOKEN }} | ||
| steps: | ||
| - uses: actions/checkout@v7 | ||
|
|
||
|
|
@@ -191,10 +241,30 @@ jobs: | |
| exit 1 | ||
| fi | ||
| done | ||
| # Snapshot what's already staged ONCE so a re-run after a partial | ||
| # failure skips the packages that made it through last time instead | ||
| # of re-attempting all 9 (npm rejects a re-stage of an already-staged | ||
| # version, which used to surface as "N failures" for a single | ||
| # genuine failure). | ||
| already_staged() { | ||
| node -e ' | ||
| const raw = require("fs").readFileSync(0, "utf8") | ||
| let list | ||
| try { list = JSON.parse(raw) } catch { list = [] } | ||
| const arr = Array.isArray(list) ? list : Object.values(list || {}) | ||
| const [n, v] = process.argv.slice(1) | ||
| process.exit(arr.some(e => (e.name || e.packageName) === n && e.version === v) ? 0 : 1) | ||
| ' "$1" "$2" <<<"$STAGED_JSON" | ||
| } | ||
| STAGED_JSON=$(npm stage list --json 2>/dev/null || echo '[]') | ||
| failed="" | ||
| for pkg in ./npm/perry-*; do | ||
| name=$(node -p "require('$pkg/package.json').name") | ||
| ver=$(node -p "require('$pkg/package.json').version") | ||
| if already_staged "$name" "$ver"; then | ||
| echo "=== $name@$ver already staged — skipping ===" | ||
| continue | ||
| fi | ||
| echo "=== staging $name@$ver ===" | ||
| if ! npm stage publish "$pkg" --access public --tag "$DIST_TAG" --ignore-scripts --provenance; then | ||
| echo "::error::npm stage publish failed for $name@$ver — continuing" >&2 | ||
|
|
@@ -204,13 +274,27 @@ jobs: | |
| # Wrapper LAST (optionalDependencies must be staged first). | ||
| name=$(node -p "require('./npm/perry/package.json').name") | ||
| ver=$(node -p "require('./npm/perry/package.json').version") | ||
| echo "=== staging $name@$ver (wrapper, last) ===" | ||
| if ! npm stage publish ./npm/perry --access public --tag "$DIST_TAG" --ignore-scripts --provenance; then | ||
| echo "::error::npm stage publish failed for $name@$ver" >&2 | ||
| failed="$failed $name@$ver" | ||
| if already_staged "$name" "$ver"; then | ||
| echo "=== $name@$ver (wrapper) already staged — skipping ===" | ||
| else | ||
| echo "=== staging $name@$ver (wrapper, last) ===" | ||
| if ! npm stage publish ./npm/perry --access public --tag "$DIST_TAG" --ignore-scripts --provenance; then | ||
| echo "::error::npm stage publish failed for $name@$ver" >&2 | ||
| failed="$failed $name@$ver" | ||
| fi | ||
| fi | ||
| if [ -n "$failed" ]; then | ||
| echo "::error::staging failures:$failed" >&2 | ||
| exit 1 | ||
| fi | ||
| echo "All 9 packages staged (not public). Next: npm run publish:approve locally." | ||
|
|
||
| - name: Socket scan the staged tarballs | ||
| # Mandatory, same as the local pipeline — CI scans what it just staged | ||
| # immediately, rather than leaving the ONLY scan gate to whichever | ||
| # human later remembers to run `publish:approve` correctly. Reuses | ||
| # pipeline.mts's --scan-only path (list staged entries, sha1-verify, | ||
| # Socket full scan) instead of duplicating that logic here — same | ||
| # code, same gate, whether it runs in CI or locally. | ||
| if: env.PUBLISH == 'true' | ||
| run: node scripts/publish/pipeline.mts --scan-only | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| Hardened the npm staged-publish pipeline after an end-to-end adversarial review: the Socket scan gate can no longer be skipped (`--no-scan` removed, missing/invalid `SOCKET_API_TOKEN` now fails closed and reports `BLOCKED` instead of looking like a clean scan); `scripts/publish/pipeline.mts` no longer silently dispatches a real staged publish when run with no arguments (a mode flag is now required, unknown flags are rejected); `release-packages.yml`'s `republish` mode now pins every checkout to the tag being republished and cross-checks the tag's `Cargo.toml` before proceeding, closing a version-drift path where a `main` that advanced past the tag could silently publish a different version under the old tag's label; `npm-stage-publish.yml`'s `build-run-id` reuse is now verified (workflow identity, success, exact commit) before its artifacts are trusted; both workflows gained a `concurrency:` group keyed on tag/dist-tag; and the stage-upload step is now actually idempotent, skipping packages a prior partial run already staged instead of re-attempting all 9. |
Uh oh!
There was an error while loading. Please reload this page.