Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 37 additions & 33 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ jobs:
permissions:
contents: read
id-token: write
outputs:
default_branch: ${{ steps.release.outputs.default_branch }}
npm_integrity: ${{ steps.publish.outputs.integrity }}
native_verifier_run_id: ${{ steps.proof.outputs.native_verifier_run_id }}
tag: ${{ steps.release.outputs.tag }}
version: ${{ steps.tag.outputs.version }}
steps:
- name: Resolve real published release
id: release
Expand Down Expand Up @@ -247,57 +253,55 @@ jobs:
}

- name: Publish verified npm archive with provenance
id: publish
shell: bash
env:
NPM_ARCHIVE: ${{ steps.proof.outputs.npm_archive }}
NPM_CONFIG_IGNORE_SCRIPTS: 'true'
run: |
set -euo pipefail
expected_integrity="sha512-$(openssl dgst -sha512 -binary "$NPM_ARCHIVE" | openssl base64 -A)"
echo "integrity=$expected_integrity" >> "$GITHUB_OUTPUT"
if ! npm publish --access public --provenance "$NPM_ARCHIVE"; then
echo "::error::npm trusted publishing failed. Configure the mcporter package on npmjs.com with openclaw/mcporter and .github/workflows/release.yml; this workflow intentionally has no NPM_TOKEN."
exit 1
fi

verify-npm:
needs: release
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.release.outputs.tag }}
persist-credentials: false

- uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
check-latest: true

- name: Verify immutable npm publication
shell: bash
env:
NPM_ARCHIVE: ${{ steps.proof.outputs.npm_archive }}
PACKAGE_VERSION: ${{ steps.tag.outputs.version }}
run: |
set -euo pipefail
expected_integrity="sha512-$(openssl dgst -sha512 -binary "$NPM_ARCHIVE" | openssl base64 -A)"
registry_ready=0

for _ in {1..20}; do
registry_version="$(npm view "mcporter@$PACKAGE_VERSION" version 2>/dev/null || true)"
registry_integrity="$(npm view "mcporter@$PACKAGE_VERSION" dist.integrity 2>/dev/null || true)"
if [[ "$registry_version" == "$PACKAGE_VERSION" && "$registry_integrity" == "$expected_integrity" ]]; then
registry_ready=1
break
fi
if [[ "$registry_version" == "$PACKAGE_VERSION" && -n "$registry_integrity" && "$registry_integrity" != "$expected_integrity" ]]; then
echo "::error::npm registry integrity does not match the protected GitHub Release tarball."
exit 1
fi
sleep 3
done

[[ "$registry_ready" == 1 ]] || {
echo "::error::npm did not expose the verified release artifact before timeout."
exit 1
}
[[ "$(npm view mcporter dist-tags.latest)" == "$PACKAGE_VERSION" ]] || {
echo "::error::npm latest does not point to $PACKAGE_VERSION."
exit 1
}
EXPECTED_INTEGRITY: ${{ needs.release.outputs.npm_integrity }}
PACKAGE_VERSION: ${{ needs.release.outputs.version }}
run: node scripts/verify-npm-publication.mjs mcporter "$PACKAGE_VERSION" "$EXPECTED_INTEGRITY"

dispatch-homebrew:
needs: release
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Dispatch protected Homebrew update
shell: bash
env:
DEFAULT_BRANCH: ${{ steps.release.outputs.default_branch }}
DEFAULT_BRANCH: ${{ needs.release.outputs.default_branch }}
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
NATIVE_VERIFIER_RUN_ID: ${{ steps.proof.outputs.native_verifier_run_id }}
RELEASE_TAG: ${{ steps.release.outputs.tag }}
NATIVE_VERIFIER_RUN_ID: ${{ needs.release.outputs.native_verifier_run_id }}
RELEASE_TAG: ${{ needs.release.outputs.tag }}
run: |
set -euo pipefail
[[ -n "$GH_TOKEN" ]]
Expand Down
23 changes: 9 additions & 14 deletions .github/workflows/update-homebrew-tap.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,14 @@ jobs:
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "request_id=mcporter-${tag}-${GITHUB_RUN_ID}" >> "$GITHUB_OUTPUT"

- name: Verify npm publication
shell: bash
env:
RELEASE_TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
version="${RELEASE_TAG#v}"
[[ "$(npm view "mcporter@$version" version)" == "$version" ]]
- uses: actions/checkout@v7
with:
persist-credentials: false

- uses: actions/setup-node@v6
with:
node-version: 24
check-latest: true

- name: Dispatch tap update
env:
Expand Down Expand Up @@ -176,12 +176,7 @@ jobs:
process.stdout.write(`sha512-${digest}`);
NODE
)
registry_npm_integrity=$(npm view "mcporter@$version" dist.integrity)
[[ "$registry_npm_integrity" == "$verified_npm_integrity" ]] || {
echo "npm registry integrity does not match the verified GitHub tarball" >&2
exit 1
}
[[ "$(npm view mcporter dist-tags.latest)" == "$version" ]]
node scripts/verify-npm-publication.mjs mcporter "$version" "$verified_npm_integrity"

gh workflow run update-formula.yml \
--repo steipete/homebrew-tap \
Expand Down
6 changes: 3 additions & 3 deletions docs/RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,10 @@ Do not publish GitHub, npm, or Homebrew before both native jobs succeed.
1. Publish the already-verified GitHub draft without changing its tag or asset inventory. Publishing a real, non-prerelease GitHub Release triggers **Release**; pushing the tag alone does not publish npm.
2. **Release** checks out the exact tag on a GitHub-hosted Ubuntu runner, validates the package author and normalized repository URL, requires `vMAJOR.MINOR.PATCH` to equal `v${package.json.version}`, and proves the tagged commit is contained in `origin/main`. It rejects draft and prerelease releases and any version already present on npm.
3. Before npm publication, the workflow finds the successful **Verify Release Assets** run for the exact tag commit, downloads both architecture proof artifacts and every published release asset, and requires their IDs, sizes, and SHA-256 digests to match. It then runs `pnpm check` and `pnpm test` without weakening either gate.
4. With those proofs complete, the workflow confirms that GitHub OIDC is available and uses npm trusted publishing plus provenance to publish the exact verified `mcporter-<version>.tgz` from the GitHub Release. It waits for npm to expose that immutable version, requires registry integrity to match the verified tarball, and requires `latest` to point to it. No Developer ID, notarization, or npm token enters Actions.
5. Only after npm verification succeeds, **Release** dispatches **Update Homebrew Tap** from the current default branch with the tag and resolved native verifier run ID. That existing workflow rechecks npm, the exact native proof SHA/title/workflow, both architecture manifests, and every published GitHub byte before dispatching the tap. It computes SHA-512 integrity from the verified GitHub npm tarball and requires npm `dist.integrity` plus `latest` to match; `HOMEBREW_TAP_TOKEN` is scoped only to proof access and dispatch/wait steps.
4. With those proofs complete, the workflow confirms that GitHub OIDC is available and uses npm trusted publishing plus provenance to publish the exact verified `mcporter-<version>.tgz` from the GitHub Release. A sibling verification job gives npm up to five minutes to expose the version, integrity, and `latest` metadata, using capped exponential backoff for metadata that is still absent. A visible wrong version, integrity, or `latest` value is not retried: it fails immediately as an immutable-publication mismatch. No Developer ID, notarization, or npm token enters Actions.
5. **Release** dispatches **Update Homebrew Tap** from a separate job that depends on successful publication, not on the sibling npm propagation check. This prevents ordinary registry latency from suppressing Homebrew recovery while preserving a red release run for a genuine verification failure. The Homebrew workflow repeats the same patient npm check after revalidating the exact native proof SHA/title/workflow, both architecture manifests, and every published GitHub byte; only matching SHA-512 integrity and `latest` metadata can reach the tap dispatch. `HOMEBREW_TAP_TOKEN` is scoped only to proof access and dispatch/wait steps.

A manual **Release** dispatch is a recovery fallback, not a way around the native gate. Dispatch it from the current default branch with `tag=v<version>`; it accepts only an existing published, non-prerelease GitHub Release and repeats the same tag, `main`, native-proof, source-gate, npm, and Homebrew checks. Because npm versions are immutable, if npm succeeded but the downstream Homebrew dispatch failed, rerun **Update Homebrew Tap** directly with the same tag and recorded native verifier run ID instead of rerunning **Release**.
A manual **Release** dispatch is a recovery fallback, not a way around the native gate. Dispatch it from the current default branch with `tag=v<version>`; it accepts only an existing published, non-prerelease GitHub Release and repeats the same tag, `main`, native-proof, source-gate, npm, and Homebrew checks. Because npm versions are immutable, do not rerun **Release** after npm accepted the version. If the separate Homebrew job or **Update Homebrew Tap** fails, rerun **Update Homebrew Tap** directly with the same tag and recorded native verifier run ID.

Verify registry metadata after automation completes:

Expand Down
25 changes: 2 additions & 23 deletions scripts/release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ phase_publish_npm() {
banner "Publish npm after native proof"
phase_verify_local
verify_public_native_proof
local npm_archive local_integrity existing_version registry_version registry_integrity registry_ready
local npm_archive local_integrity existing_version
npm_archive=$VERIFIED_PUBLIC_NPM_ARCHIVE
[[ -f "$npm_archive" ]] || {
echo "missing verified npm artifact: $npm_archive" >&2
Expand All @@ -213,28 +213,7 @@ NODE
echo "npm publish returned non-zero; checking whether the immutable version was accepted before retrying." >&2
fi

registry_ready=0
for _ in {1..20}; do
registry_version=$(npm view "mcporter@$VERSION" version 2>/dev/null || true)
registry_integrity=$(npm view "mcporter@$VERSION" dist.integrity 2>/dev/null || true)
if [[ "$registry_version" == "$VERSION" && "$registry_integrity" == "$local_integrity" ]]; then
registry_ready=1
break
fi
if [[ "$registry_version" == "$VERSION" && -n "$registry_integrity" && "$registry_integrity" != "$local_integrity" ]]; then
echo "npm registry integrity does not match the verified release tarball" >&2
exit 1
fi
sleep 3
done
[[ "$registry_ready" == 1 ]] || {
echo "npm registry did not expose the verified release artifact before timeout" >&2
exit 1
}
[[ "$(npm view mcporter dist-tags.latest)" == "$VERSION" ]] || {
echo "npm latest dist-tag does not point to $VERSION" >&2
exit 1
}
run node "$ROOT/scripts/verify-npm-publication.mjs" mcporter "$VERSION" "$local_integrity"
run npm view "mcporter@$VERSION" dist.tarball dist.integrity time
}

Expand Down
61 changes: 52 additions & 9 deletions scripts/test-release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ for script in \
"$ROOT/scripts/verify-release.sh"; do
bash -n "$script"
done
node --check "$ROOT/scripts/verify-npm-publication.mjs"
plutil -lint "$ROOT/scripts/macos-release.entitlements" >/dev/null

WORK=$(mktemp -d "${TMPDIR:-/tmp}/mcporter-release-contract.XXXXXX")
Expand Down Expand Up @@ -161,13 +162,33 @@ MOCK
cat >"$MOCK_BIN/npm" <<'MOCK'
#!/usr/bin/env bash
set -euo pipefail
[[ "${1:-}" == install ]]
archive=${!#}
stage=$(mktemp -d "${TMPDIR:-/tmp}/mcporter-mock-install.XXXXXX")
trap 'rm -rf "$stage"' EXIT
tar -xzf "$archive" -C "$stage"
mkdir -p node_modules/mcporter
cp -R "$stage/package/." node_modules/mcporter/
case "${1:-}" in
install)
archive=${!#}
stage=$(mktemp -d "${TMPDIR:-/tmp}/mcporter-mock-install.XXXXXX")
trap 'rm -rf "$stage"' EXIT
tar -xzf "$archive" -C "$stage"
mkdir -p node_modules/mcporter
cp -R "$stage/package/." node_modules/mcporter/
;;
view)
case "${3:-}" in
version) value=${MOCK_NPM_VERSION:-} ;;
dist.integrity) value=${MOCK_NPM_INTEGRITY:-} ;;
dist-tags.latest) value=${MOCK_NPM_LATEST:-} ;;
*) echo "unexpected mock npm view field: ${3:-<missing>}" >&2; exit 64 ;;
esac
if [[ -z "$value" ]]; then
echo 'npm error code E404' >&2
exit 1
fi
node -e 'process.stdout.write(JSON.stringify(process.argv[1]))' "$value"
;;
*)
echo "unexpected mock npm arguments: $*" >&2
exit 64
;;
esac
MOCK

cat >"$MOCK_BIN/codesign" <<'MOCK'
Expand Down Expand Up @@ -249,6 +270,23 @@ MOCK
chmod 755 "$MOCK_BIN"/*
export PATH="$MOCK_BIN:$PATH"

# Immutable npm metadata mismatches are hard failures, not propagation retries.
mock_integrity=sha512-protected-release
MOCK_NPM_VERSION="$VERSION" \
MOCK_NPM_INTEGRITY="$mock_integrity" \
MOCK_NPM_LATEST="$VERSION" \
node "$ROOT/scripts/verify-npm-publication.mjs" mcporter "$VERSION" "$mock_integrity" >/dev/null
assert_fails env \
MOCK_NPM_VERSION="$VERSION" \
MOCK_NPM_INTEGRITY="$mock_integrity" \
MOCK_NPM_LATEST="$VERSION" \
node "$ROOT/scripts/verify-npm-publication.mjs" mcporter "$VERSION" sha512-mutated
assert_fails env \
MOCK_NPM_VERSION="$VERSION" \
MOCK_NPM_INTEGRITY="$mock_integrity" \
MOCK_NPM_LATEST=0.0.0 \
node "$ROOT/scripts/verify-npm-publication.mjs" mcporter "$VERSION" "$mock_integrity"

# Ordinary builds never enter signing or notarization code.
MCPORTER_OFFICIAL_RELEASE=0 "$ROOT/scripts/codesign-native.sh" "$WORK/missing"
[[ ! -s "$MOCK_LOG" ]] || fail 'ordinary build invoked release tools'
Expand Down Expand Up @@ -416,7 +454,12 @@ grep -Fq 'ACTIONS_ID_TOKEN_REQUEST_URL' "$publish_workflow"
grep -Fq 'secrets.HOMEBREW_TAP_TOKEN' "$publish_workflow"
grep -Fq 'workflow run update-homebrew-tap.yml' "$publish_workflow"
grep -Fq 'verify-published-release-proof.mjs' "$publish_workflow"
grep -Fq 'verify-npm-publication.mjs' "$publish_workflow"
grep -Fq 'validate-release-metadata.mjs' "$publish_workflow"
grep -Eq '^ verify-npm:$' "$publish_workflow"
grep -Eq '^ dispatch-homebrew:$' "$publish_workflow"
[[ "$(grep -Ec '^ needs: release$' "$publish_workflow")" == 2 ]] || fail 'npm verification and Homebrew dispatch no longer depend independently on publication'
! grep -Eq 'needs: verify-npm' "$publish_workflow" || fail 'Homebrew dispatch became downstream of npm propagation verification'
! grep -Eq 'secrets\.(NPM_TOKEN|NODE_AUTH_TOKEN)' "$publish_workflow" || fail 'automated release regained a persistent npm token'

! grep -Eq '\bspctl\b' "$ROOT/scripts/codesign-native.sh" "$ROOT/scripts/verify-release.sh" || \
Expand All @@ -438,7 +481,7 @@ grep -Eq 'verified-assets-arm64' "$homebrew_workflow"
grep -Eq 'verified-assets-x86_64' "$homebrew_workflow"
grep -Eq 'native proof artifacts disagree on the verified asset set' "$homebrew_workflow"
grep -Eq 'published asset digest changed after native verification' "$homebrew_workflow"
grep -Eq 'npm registry integrity does not match the verified GitHub tarball' "$homebrew_workflow"
grep -Fq 'verify-npm-publication.mjs' "$homebrew_workflow"
grep -Eq 'codesign-run --' "$ROOT/scripts/release.sh"
grep -Eq 'command -v mac-release' "$ROOT/scripts/release.sh"
grep -Eq 'MAC_RELEASE_HELPER' "$ROOT/scripts/release.sh"
Expand All @@ -447,7 +490,7 @@ grep -Eq 'verified-assets-arm64' "$ROOT/scripts/release.sh"
grep -Eq 'verified-assets-x86_64' "$ROOT/scripts/release.sh"
grep -Eq 'native proof artifacts disagree on the verified asset set' "$ROOT/scripts/release.sh"
grep -Eq 'immutable registry metadata' "$ROOT/scripts/release.sh"
grep -Eq 'registry did not expose the verified release artifact before timeout' "$ROOT/scripts/release.sh"
grep -Fq 'verify-npm-publication.mjs' "$ROOT/scripts/release.sh"
! grep -Eq '^ all|git push|git tag ' "$ROOT/scripts/release.sh" || fail 'release helper regained combined/tag/push path'
grep -Eq 'pnpm clean' "$ROOT/scripts/package-release.sh"
grep -Eq 'pnpm build' "$ROOT/scripts/package-release.sh"
Expand Down
Loading