Skip to content

Publish

Publish #61

Workflow file for this run

name: Publish
on:
release:
types:
- published
workflow_dispatch:
inputs:
version:
description: Version to publish (e.g. 0.3.7). Leave blank for a dry-run.
type: string
default: ""
permissions:
contents: read
id-token: write
concurrency:
group: publish-${{ github.ref }}
cancel-in-progress: false
jobs:
validate:
name: Validate package
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: 24.x
registry-url: https://registry.npmjs.org
cache: npm
- name: Show tool versions
run: |
node --version
npm --version
- name: Install dependencies
run: npm ci
- name: Typecheck
run: npm run typecheck
# Build BEFORE test, matching the main CI. Several tests assert against `dist/` and return early when
# it is absent — the edge-safety check, the version surfaces, the canary — so this order is what
# makes the release gate cover what actually ships.
- name: Build
run: npm run build
- name: Test
run: npm test
# Before publishing anything: does the build about to ship block the exploit the canary is built
# around? `PS_REQUIRE_CANARY` refuses to skip, because a skipped canary reads as a passing one.
- name: Canary — the build about to ship blocks the exploit
run: npx vitest run tests/protect/canary-engine-proof.test.ts
env:
PS_REQUIRE_CANARY: '1'
- name: Verify package contents
run: npm pack --dry-run
- name: Dry-run publish
if: github.event_name == 'workflow_dispatch' && inputs.version == ''
run: npm publish --access public --provenance --dry-run
publish:
name: Publish to npm
if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.version != '')
needs: validate
runs-on: ubuntu-latest
environment:
name: npm
url: ${{ steps.version.outputs.url }}
# Exported so the job that records the version on `main` uses the version this job published, rather
# than resolving the tag a second time and being able to differ from it.
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Resolve version
id: version
run: |
if [ "${{ github.event_name }}" = "release" ]; then
version="${GITHUB_REF_NAME#v}"
else
version="${{ inputs.version }}"
fi
echo "Publishing version $version"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "url=https://www.npmjs.com/package/@patchstack/connect/v/$version" >> "$GITHUB_OUTPUT"
- name: Checkout
uses: actions/checkout@v7
with:
ref: refs/tags/v${{ steps.version.outputs.version }}
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: 24.x
registry-url: https://registry.npmjs.org
cache: npm
- name: Install dependencies
run: npm ci
- name: Set package version
run: npm version "${{ steps.version.outputs.version }}" --no-git-tag-version --allow-same-version
- name: Build
run: npm run build
- name: Publish to npm
run: npm publish --access public --provenance
# A job of its own, separate from `publish`, and the separation carries the invariant.
#
# This runs after the version exists on npm and cannot be unpublished, so its failure is a discovery
# about something already released rather than something preventable. It must therefore not be able to
# stop `record-version`: the state to avoid is a version irreversibly on npm, a tag pointing at it,
# `main` naming a different version, and no pull request proposing the correction.
#
# Publication ends when npm accepts the tarball. Verification and recording are both consequences of
# that, and neither blocks the other. A failure here still turns the workflow red.
verify-published:
name: Verify the published tarball
needs: publish
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
with:
ref: refs/tags/v${{ needs.publish.outputs.version }}
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: 24.x
cache: npm
- name: Install dependencies
run: npm ci
# The proof that matters most, and the only one that covers PACKAGING. Everything above tests a build
# in this checkout; this installs the published tarball from the registry and runs the same canary
# against the engine inside it. A file left out of `files`, a wrong `exports` map, or a bad entry
# point produces an artifact that passes every local check and cannot protect an application — which
# is indistinguishable from protection until an attack is not blocked.
#
# After the publish rather than before, because the tarball does not exist until then. A failure here
# cannot unpublish the version; it is a loud signal to deprecate it, which is the honest position:
# the alternative is not knowing.
- name: Canary — the PUBLISHED tarball blocks the exploit
run: |
set -euo pipefail
version='${{ needs.publish.outputs.version }}'
# npm needs a moment before a fresh version resolves; a failure to install here is not a failure
# of the engine and must not be reported as one.
for attempt in 1 2 3 4 5; do
if npm pack "@patchstack/connect@${version}" --pack-destination /tmp >/dev/null 2>&1; then
break
fi
if [ "$attempt" = "5" ]; then
echo "::error::Could not fetch @patchstack/connect@${version} from the registry to verify it. The published engine is UNVERIFIED — this is not evidence that it works."
exit 1
fi
sleep 15
done
mkdir -p /tmp/published && tar -xzf /tmp/patchstack-connect-"${version}".tgz -C /tmp/published
engine=/tmp/published/package/dist/protect.js
if [ ! -f "$engine" ]; then
echo "::error::The published tarball has no dist/protect.js. Nothing that installs this package can protect anything."
exit 1
fi
PS_CANARY_ENGINE="$engine" PS_REQUIRE_CANARY=1 npx vitest run tests/protect/canary-engine-proof.test.ts
# The tag is the source of truth for what gets published, and this brings the repository's own copy in
# line with it afterwards.
#
# Both halves matter. Writing the version in CI is what lets a release happen with no pre-commit, which
# is what makes it work with branch protection. But only the published tarball gets its version from the
# tag: a git installation, an SBOM built from a checkout, `npm pack` from the repository and
# `--version` all read the committed manifest. For a package whose purpose is to shield known
# vulnerabilities, a manifest that names the wrong version means someone believing they have a fix they
# do not have — so the two must not be allowed to drift.
#
# A pull request rather than a push, because branch protection is the point of branch protection. Never
# auto-merged.
record-version:
name: Record the published version on main
# Depends on both, and gated on the PUBLISH result alone.
#
# No event guard, because the recommended release path runs `gh workflow run publish.yml`, which
# arrives as `workflow_dispatch` and not as `release` — a `release`-only condition would skip this on
# the path that publishes most versions while the workflow still reported success.
#
# `if: always()` with an explicit check on `needs.publish.result` rather than a bare `needs:`, because
# a failed VERIFICATION must not prevent recording. The version is on npm either way; the repository
# naming a different one is a second problem, not a safeguard. `always()` also means a dry-run
# dispatch — where `publish` is skipped, not successful — is correctly excluded by the condition.
if: always() && needs.publish.result == 'success'
needs:
- publish
- verify-published
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
# Required to dispatch CI on the branch below. Job-level `permissions` is a complete replacement
# rather than an addition — every scope not listed here is `none` — so without this the dispatch can
# only ever take its deliberately non-fatal warning path, and the branch would be left with no run at
# all while the workflow reported success.
actions: write
env:
BRANCH: chore/record-published-version
VERSION: ${{ needs.publish.outputs.version }}
steps:
- name: Checkout the default branch
uses: actions/checkout@v7
with:
# Deliberately the default branch, not the tag: the proposal targets `main`, and checking out
# the tag would carry every difference between the tag and `main` into the diff.
ref: ${{ github.event.repository.default_branch }}
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: 24.x
cache: npm
- name: Apply the version to the manifest and the lockfile
id: apply
run: |
set -euo pipefail
test -n "$VERSION" || { echo "::error::No published version was passed to this job."; exit 1; }
before=$(node -p 'require("./package.json").version')
# Read before the tree is touched, and passed to the commit as its expected parent. The commit
# is made through the API rather than from this checkout, so the two have to be tied together
# explicitly or it could be parented on a `main` that moved while this job was running.
echo "main_oid=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
# `npm version` is what writes all three recorded copies (manifest, lockfile top level, lockfile
# root entry). Doing it by hand is how one of the three gets left behind — and the lockfile
# carries the same version string for every dependency that happens to sit at that number, so
# editing the text rewrites a dependency's pin.
npm version "$VERSION" --no-git-tag-version --allow-same-version >/dev/null
echo "before=$before" >> "$GITHUB_OUTPUT"
if git diff --quiet -- package.json package-lock.json; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "main already records $VERSION."
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
# Evidence for the reviewer, gathered here because it may not be available on the pull request
# itself: a pull request opened with GITHUB_TOKEN does not start workflow runs. The consistency
# invariant is the whole content of this change, so it is checked here and the result stated in the
# body rather than leaving a reviewer to approve two edited strings on trust.
#
# `continue-on-error`, because this check must not be able to prevent the pull request. A step that
# exits non-zero stops the job, and a later step whose condition contains no status function carries
# an implicit `success()` — between them enough to skip the recording at exactly the moment it
# matters. `steps.verify.outcome` carries the real result into the body, and the job is failed at the
# end, once the pull request exists.
- name: Check the invariant this change exists to satisfy
if: steps.apply.outputs.changed == 'true'
id: verify
continue-on-error: true
run: |
set -euo pipefail
npm ci
npm run build
npx vitest run tests/package-version.test.ts
# `!cancelled()` rather than a bare condition: a condition without a status function is implicitly
# ANDed with `success()`, which would skip this step whenever the check above failed — precisely when
# the pull request matters most.
# The commit, made through GitHub's API rather than with the git CLI.
#
# `main` requires signed commits and `GITHUB_TOKEN` has no signing key, so a commit pushed from here
# is unsigned and the pull request carrying it cannot merge — it looks ready and is not. Commits
# created by `createCommitOnBranch` are signed by GitHub itself.
#
# The sequence, the checks and the compare-and-swap live in the script, which is tested: what has to
# hold is an ORDER — staged elsewhere, checked, then moved onto this branch in one leased step — and
# an order is not observable from its result.
- name: Commit the recorded version, signed
if: ${{ !cancelled() && steps.apply.outputs.changed == 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
node scripts/record-published-version.mjs \
--version "$VERSION" \
--previous "${{ steps.apply.outputs.before }}" \
--main "${{ steps.apply.outputs.main_oid }}" \
--run-id "$GITHUB_RUN_ID"
- name: Open or update the pull request
if: ${{ !cancelled() && steps.apply.outputs.changed == 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TITLE: "Record published version ${{ env.VERSION }}"
run: |
set -euo pipefail
{
echo "\`${VERSION}\` is published; \`main\` records \`${{ steps.apply.outputs.before }}\`."
echo
echo "The tag is the source of truth for what gets published. This brings the repository's own"
echo "copy in line, so that a git installation, an SBOM built from a checkout, \`npm pack\` and"
echo "\`patchstack-connect --version\` all report the released version."
echo
echo "Prepared by the \`Publish\` workflow. Two version strings and the lockfile entries \`npm"
echo "version\` derives from them; no source changes."
echo
echo "**Version consistency check: ${{ steps.verify.outcome }}** (run inside the workflow)."
echo
echo "**Published tarball verification: ${{ needs['verify-published'].result }}.**"
# Said plainly and near the top of the body, because it changes what the reader should do
# rather than merely how they should feel. The tarball on npm cannot be unpublished; if the
# engine inside it does not block the exploit, the version needs deprecating and this pull
# request is not the urgent part.
if [ "${{ needs['verify-published'].result }}" != "success" ]; then
echo
echo "The published tarball did NOT pass its canary. \`${VERSION}\` is on npm and cannot be"
echo "unpublished — decide whether to deprecate it before treating this pull request as"
echo "routine. It is opened regardless, because a repository naming a version other than the"
echo "one that was released is a second problem rather than a safeguard against the first."
fi
if [ "${{ steps.verify.outcome }}" != "success" ]; then
echo
echo "The version surfaces disagreed even after applying \`${VERSION}\`. Read the workflow run"
echo "before merging: this pull request may not be sufficient on its own."
fi
echo
echo "A pull request opened with \`GITHUB_TOKEN\` does not start workflow runs, so the checks on"
echo "this pull request may be empty. Two things cover that: the invariant above was verified"
echo "inside the workflow, and CI was dispatched on this branch — a dispatched run proves the"
echo "branch is green but does not attach here, so it does not satisfy a required status."
echo "**Close and reopen this pull request** to get the required checks."
} > /tmp/pr-body.md
cat /tmp/pr-body.md >> "$GITHUB_STEP_SUMMARY"
existing=$(gh pr list --head "$BRANCH" --state open --limit 1 --json number --jq '.[0].number // empty')
if [ -n "$existing" ]; then
# Edited in place: one pull request that stays accurate is readable, a new one per release is not.
gh pr edit "$existing" --title "$TITLE" --body-file /tmp/pr-body.md
echo "Updated #$existing."
else
gh pr create --title "$TITLE" --body-file /tmp/pr-body.md --head "$BRANCH"
fi
# Start CI on the branch explicitly. A pull request opened with `GITHUB_TOKEN` does not start
# workflow runs, so without this the branch has no run at all and the only evidence is the
# in-workflow check above.
#
# Stated precisely because the distinction decides whether a human has to do something: a
# dispatched run does NOT attach to the pull request and so does NOT satisfy a required status.
# It proves the branch is green; closing and reopening the pull request is what produces the
# required checks. Never fatal — the version is already published and recorded by this point,
# and a dispatch that fails to start is not a reason to fail the release.
if gh workflow run ci.yml --ref "$BRANCH" 2>/dev/null; then
echo "Dispatched CI on $BRANCH (does not attach to the pull request)." >> "$GITHUB_STEP_SUMMARY"
else
echo "::warning::Could not dispatch CI on $BRANCH. The pull request has no run of its own; close and reopen it to get one."
fi
# Last, so failing is the final act rather than something that prevents the work above: the release
# is loud about a real problem and the correction has still been proposed.
- name: Fail if the invariant did not hold
if: ${{ !cancelled() && steps.verify.outcome == 'failure' }}
run: |
echo "::error::The version surfaces disagree even after applying ${VERSION}. The recording pull request was opened anyway — read it and the run before merging."
exit 1