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
304 changes: 304 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,304 @@
name: Release

on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: 'Existing published release tag to publish (for example, v0.13.1)'
required: true
type: string

concurrency:
group: release-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false

env:
NODE_VERSION: 24
PNPM_VERSION: 10.33.2

jobs:
release:
# DO NOT change this job to a self-hosted runner.
# npm trusted publishing + provenance for GitHub Actions releases
# requires a GitHub-hosted runner, and publishing fails on self-hosted
# environments with:
# "Unsupported GitHub Actions runner environment: self-hosted".
# Before the first run, configure mcporter's npmjs.com trusted publisher
# for openclaw/mcporter and .github/workflows/release.yml. No NPM_TOKEN is used.
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- name: Resolve real published release
id: release
shell: bash
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }}
run: |
set -euo pipefail
[[ "$RELEASE_TAG" =~ ^v[0-9]+[.][0-9]+[.][0-9]+$ ]] || {
echo "::error::Release tags must match vX.Y.Z; received ${RELEASE_TAG:-<missing>}."
exit 1
}

if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then
expected_ref="refs/heads/$DEFAULT_BRANCH"
expected_workflow_ref="$GITHUB_REPOSITORY/.github/workflows/release.yml@$expected_ref"
[[ "$GITHUB_REF" == "$expected_ref" ]] || {
echo "::error::Manual release publication must be dispatched from $expected_ref"
exit 1
}
[[ "$GITHUB_WORKFLOW_REF" == "$expected_workflow_ref" ]] || {
echo "::error::Release workflow must come from $expected_workflow_ref"
exit 1
}
fi

release="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG")"
[[ "$(jq -r '.tag_name' <<<"$release")" == "$RELEASE_TAG" ]]
[[ "$(jq -r '.draft' <<<"$release")" == false ]] || {
echo "::error::GitHub Release $RELEASE_TAG is still a draft."
exit 1
}
[[ "$(jq -r '.prerelease' <<<"$release")" == false ]] || {
echo "::error::GitHub Release $RELEASE_TAG is a prerelease."
exit 1
}
[[ "$(jq -r '.published_at // empty' <<<"$release")" != "" ]] || {
echo "::error::GitHub Release $RELEASE_TAG has not been published."
exit 1
}

echo "tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT"
echo "default_branch=$DEFAULT_BRANCH" >> "$GITHUB_OUTPUT"

- uses: actions/checkout@v7
with:
ref: ${{ steps.release.outputs.tag }}
fetch-depth: 0
persist-credentials: false

- uses: pnpm/action-setup@v6.0.8
with:
version: ${{ env.PNPM_VERSION }}

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

- run: pnpm install --frozen-lockfile

- name: Validate package metadata for trusted publishing
run: node scripts/validate-release-metadata.mjs

- name: Validate release tag
id: tag
shell: bash
env:
DEFAULT_BRANCH: ${{ steps.release.outputs.default_branch }}
RELEASE_TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
git fetch --no-tags origin "$DEFAULT_BRANCH" --depth=1
release_sha="$(git rev-parse "$RELEASE_TAG^{commit}")"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Re-verify the release tag signature before publishing

If the tag is deleted/recreated after the native verifier succeeds but before this Release job runs, this step only resolves $RELEASE_TAG^{commit} and later compares that commit to the proof; a lightweight or otherwise unsigned tag pointing at the same commit still passes the branch/proof checks because the proof artifact is not bound to the tag object. That lets npm be published for a release whose current public tag no longer satisfies the signed-tag trust boundary enforced by package-release.sh/verify-release.sh; configure .github/release-allowed-signers here and run git tag -v "$RELEASE_TAG" after checkout before accepting the tag.

Useful? React with 👍 / 👎.

package_version="$(node -p "require('./package.json').version")"
expected_tag="v$package_version"

[[ "$RELEASE_TAG" == "$expected_tag" ]] || {
echo "::error::Release tag $RELEASE_TAG does not match package.json version $package_version; expected $expected_tag."
exit 1
}
git merge-base --is-ancestor "$release_sha" "origin/$DEFAULT_BRANCH" || {
echo "::error::Tagged commit $release_sha is not contained in origin/$DEFAULT_BRANCH."
exit 1
}

echo "sha=$release_sha" >> "$GITHUB_OUTPUT"
echo "version=$package_version" >> "$GITHUB_OUTPUT"

- name: Ensure version is not already published
shell: bash
env:
PACKAGE_VERSION: ${{ steps.tag.outputs.version }}
run: |
set -euo pipefail
error_log="$(mktemp)"
trap 'rm -f "$error_log"' EXIT

if npm view "mcporter@$PACKAGE_VERSION" version >/dev/null 2>"$error_log"; then
echo "::error::mcporter@$PACKAGE_VERSION is already published on npm."
exit 1
fi
if ! grep -Eq "E404|404 Not Found" "$error_log"; then
cat "$error_log" >&2
exit 1
fi

echo "Publishing mcporter@$PACKAGE_VERSION"

- name: Verify protected native proof and published assets
id: proof
shell: bash
env:
DEFAULT_BRANCH: ${{ steps.release.outputs.default_branch }}
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
RELEASE_SHA: ${{ steps.tag.outputs.sha }}
RELEASE_TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
[[ -n "$GH_TOKEN" ]] || {
echo "::error::HOMEBREW_TAP_TOKEN is required to read protected verifier artifacts and dispatch Homebrew."
exit 1
}

proof_runs="$RUNNER_TEMP/native-proof-runs.json"
gh run list \
--repo "$GITHUB_REPOSITORY" \
--workflow release-assets.yml \
--event workflow_dispatch \
--branch "$DEFAULT_BRANCH" \
--status success \
--limit 100 \
--json conclusion,databaseId,displayTitle,event,headBranch,headSha,workflowName \
> "$proof_runs"
run_id="$(jq -r \
--arg title "Verify release assets $RELEASE_TAG" \
--arg sha "$RELEASE_SHA" \
--arg branch "$DEFAULT_BRANCH" \
'[.[] | select(
.conclusion == "success" and
.event == "workflow_dispatch" and
.displayTitle == $title and
.headBranch == $branch and
.headSha == $sha and
.workflowName == "Verify Release Assets"
)][0].databaseId // empty' "$proof_runs")"
[[ "$run_id" =~ ^[0-9]+$ ]] || {
echo "::error::No successful protected native verifier run matches $RELEASE_TAG at $RELEASE_SHA."
exit 1
}

proof_dir="$RUNNER_TEMP/native-proof"
asset_dir="$RUNNER_TEMP/release-assets"
mkdir -p "$proof_dir/arm64" "$proof_dir/x86_64" "$asset_dir"
gh run download "$run_id" \
--repo "$GITHUB_REPOSITORY" \
--name verified-assets-arm64 \
--dir "$proof_dir/arm64"
gh run download "$run_id" \
--repo "$GITHUB_REPOSITORY" \
--name verified-assets-x86_64 \
--dir "$proof_dir/x86_64"

gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" > "$RUNNER_TEMP/release.json"
while IFS=$'\t' read -r asset_id asset_name; do
gh api \
--header 'Accept: application/octet-stream' \
"repos/$GITHUB_REPOSITORY/releases/assets/$asset_id" > "$asset_dir/$asset_name"
done < <(jq -r '.assets[] | [.id, .name] | @tsv' "$RUNNER_TEMP/release.json")

node scripts/verify-published-release-proof.mjs \
"$RUNNER_TEMP/release.json" \
"$proof_dir/arm64/verified-assets.json" \
"$proof_dir/x86_64/verified-assets.json" \
"$asset_dir" \
"$RELEASE_TAG" \
"$RELEASE_SHA"

echo "native_verifier_run_id=$run_id" >> "$GITHUB_OUTPUT"
echo "npm_archive=$asset_dir/mcporter-${RELEASE_TAG#v}.tgz" >> "$GITHUB_OUTPUT"

- name: Run source gates
run: |
pnpm check
pnpm test

- name: Verify npm trusted publishing OIDC
shell: bash
run: |
set -euo pipefail
[[ -n "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" && -n "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ]] || {
echo "::error::GitHub OIDC is unavailable. Keep id-token: write and use a GitHub-hosted runner."
exit 1
}

oidc_response="$RUNNER_TEMP/npm-oidc.json"
if ! curl --fail --silent --show-error \
--header "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=npm:registry.npmjs.org" \
--output "$oidc_response"; then
echo "::error::GitHub could not issue the OIDC token required by npm trusted publishing."
exit 1
fi
jq -e '.value | strings | length > 0' "$oidc_response" >/dev/null || {
echo "::error::GitHub returned an invalid OIDC response for npm trusted publishing."
exit 1
}

- name: Publish verified npm archive with provenance
shell: bash
env:
NPM_ARCHIVE: ${{ steps.proof.outputs.npm_archive }}
NPM_CONFIG_IGNORE_SCRIPTS: 'true'
run: |
set -euo pipefail
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

- 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
}

- name: Dispatch protected Homebrew update
shell: bash
env:
DEFAULT_BRANCH: ${{ steps.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 }}
run: |
set -euo pipefail
[[ -n "$GH_TOKEN" ]]
gh workflow run update-homebrew-tap.yml \
--repo "$GITHUB_REPOSITORY" \
--ref "$DEFAULT_BRANCH" \
-f tag="$RELEASE_TAG" \
-f native_verifier_run_id="$NATIVE_VERIFIER_RUN_ID"
30 changes: 15 additions & 15 deletions docs/RELEASE.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
summary: 'Serialized release checklist for exact-tag native proof, GitHub, npm, and Homebrew publication.'
summary: 'Serialized release checklist for exact-tag native proof and automated npm and Homebrew publication.'
read_when:
- 'Cutting a release or updating release automation'
---
Expand All @@ -22,6 +22,9 @@ v0.12.3's standalone arm64 binary is not a continuity baseline: it was ad-hoc `a
- `.github/workflows/release-assets.yml` and `.github/workflows/update-homebrew-tap.yml` must be dispatched from the repository's current default branch. Both reject a mismatched workflow ref.
- Release automation accepts stable `vMAJOR.MINOR.PATCH` tags only; prereleases require a separate dist-tag-aware contract before they can enter this pipeline.

> [!IMPORTANT]
> Before the first automated publication, configure npm trusted publishing for the `mcporter` package on npmjs.com with repository `openclaw/mcporter` and workflow `.github/workflows/release.yml`. The workflow deliberately has no `NPM_TOKEN`: it requires GitHub OIDC on a GitHub-hosted runner and fails with an actionable error when that path is unavailable. This npmjs.com setting cannot be verified from the repository.

## 1. Credential-free preparation

1. Update `package.json` and `CHANGELOG.md`; contributor work must keep its changelog thanks and commit `Co-authored-by` trailer.
Expand Down Expand Up @@ -71,23 +74,20 @@ Do not publish GitHub, npm, or Homebrew before both native jobs succeed.

## 4. Serialized publication

1. Publish the already-verified GitHub draft without changing its tag or asset inventory.
2. Publish npm only through the proof-gated phase:
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.

```bash
NATIVE_VERIFIER_RUN_ID=<run-id> ./scripts/release.sh publish-npm
```
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**.

This repeats local native verification, checks the exact successful protected workflow run, downloads and cross-checks both architecture proof artifacts, re-downloads every published asset by REST ID, and requires every digest to match the protected draft proof. It publishes the exact verified npm tarball, tolerates registry propagation after a successful publish, and verifies immutable registry integrity before continuing.
Verify registry metadata after automation completes:

3. Verify registry metadata:

```bash
npm view mcporter@<version> version dist-tags.latest dist.tarball dist.integrity time
./scripts/release.sh smoke
```

4. Dispatch **Update Homebrew Tap** from the current default branch with `tag=v<version>` and the same `native_verifier_run_id`. The workflow rechecks npm, the exact native proof SHA/title/workflow, both architecture manifests, and every published GitHub byte against the preserved proof. It computes SHA-512 integrity from the verified GitHub npm tarball and requires npm `dist.integrity` plus `latest` to match before dispatching. Its token is scoped only to those dispatch/wait steps.
```bash
npm view mcporter@<version> version dist-tags.latest dist.tarball dist.integrity time
./scripts/release.sh smoke
```

## 5. Downstream verification and closeout

Expand Down
17 changes: 16 additions & 1 deletion scripts/test-release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -376,8 +376,9 @@ assert_fails env -u GH_TOKEN -u GITHUB_TOKEN \
"$ROOT/scripts/verify-release.sh" "$TAG" "$package_only_out"

# Workflow and orchestration boundaries: protected current default branch,
# exact REST draft inventory, narrow token scope, and no one-shot publish path.
# exact REST inventories, narrow token scope, and serialized publication.
release_workflow="$ROOT/.github/workflows/release-assets.yml"
publish_workflow="$ROOT/.github/workflows/release.yml"
homebrew_workflow="$ROOT/.github/workflows/update-homebrew-tap.yml"
assert_fails "$ROOT/scripts/package-release.sh" v1.2.3-rc.1
assert_fails "$ROOT/scripts/verify-release.sh" v1.2.3-rc.1 "$WORK/missing-prerelease-assets"
Expand All @@ -403,6 +404,20 @@ grep -Eq 'arch: process.env.RELEASE_ARCH' "$release_workflow"
! grep -Eq 'secrets\.RELEASE_ASSET_TOKEN' "$release_workflow" || fail 'release verifier uses a persistent secret'
! grep -Eq 'gh release download' "$release_workflow" || fail 'release download bypasses exact REST lookup'

grep -Eq '^ release:$' "$publish_workflow"
grep -Eq '^ types: \[published\]$' "$publish_workflow"
grep -Eq '^ workflow_dispatch:$' "$publish_workflow"
grep -Fq 'npm publish --access public --provenance "$NPM_ARCHIVE"' "$publish_workflow"
grep -Fq 'pnpm check' "$publish_workflow"
grep -Fq 'pnpm test' "$publish_workflow"
grep -Fq 'id-token: write' "$publish_workflow"
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 'validate-release-metadata.mjs' "$publish_workflow"
! 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" || \
fail 'standalone CLI verification must not require raw-binary spctl success'
grep -Eq -- '--requirements "=designated => \$REQUIREMENT"' "$ROOT/scripts/codesign-native.sh"
Expand Down
Loading