Files
Jack Chen c3722ab197 fix(release): detect a release by the version field, not the manifest diff (#571)
`publish.yml` decided whether a commit on main was a release by asking
whether packages/core/package.json and packages/create-oma-app/package.json
had changed at all. Those two files carry more than a version: dependency
specs, `exports`, `files`, `engines`, keywords. Any edit to either one read as
a release.

98516ce was the first commit to hit it. It only pinned dependency specs, and
neither version moved, but both manifests changed, so `detect_release_commit`
reported `is_release=true` and the publish job started. It entered the
`npm-release` environment, minted a contents:write GitHub App token, checked
out, installed, built, and then stopped at the publisher's own guard:

    98516ce... is not a release commit: @open-multi-agent/core did not
    increment from its first parent.

Nothing was published, tagged, or released, and the registry versions are
untouched, so the safety net did its job. But the outer gate should not have
opened. A dependency bump is the most ordinary maintenance there is, and it
should not cost a write-scoped token and a red Publish run every time.

The detection now reads the `version` field of both manifests at the release
commit and at its first parent and stops unless both moved, which is the same
fact the publisher re-derives later. A manifest that does not exist at the
release commit is a no-op rather than an error, matching what the old file
comparison concluded for the pre-monorepo tags. A manifest that exists but
declares no version fails the job, because that is a broken tree rather than
an ordinary commit.

It also closes a fail-open path found while building the check harness.
`git diff --quiet` exits 1 for "changed" and 128 for "could not run", and the
old step read both as "changed", so any git failure resolved to
`is_release=true`. Reading the version field fails closed instead. Unreachable
in practice, since checkout and the ancestry verification both run first, but
a publish gate should not lean on that.

Checked against every `v*` tag in the repository. All three tags from after
publish.yml existed — v1.16.0, v1.16.1, v1.17.0 — still detect as releases,
as do v1.8.0 through v1.10.0 and v1.13.0 through v1.15.0. v1.11.0 and v1.12.1
now report false because their tagged commits genuinely did not move both
versions; both predate this workflow, and the publisher would have refused
them for exactly that reason, so the gate now agrees with the publisher
instead of deferring the disagreement to a failed job.
2026-09-04 01:20:00 +08:00

138 lines
5.0 KiB
YAML

name: Publish
on:
workflow_run:
workflows: [CI]
types: [completed]
branches: [main]
workflow_dispatch:
inputs:
sha:
description: Exact merged release commit to recover
required: true
type: string
permissions:
actions: read
contents: read
concurrency:
group: oma-publish
cancel-in-progress: false
jobs:
detect_release_commit:
if: >-
github.repository == 'open-multi-agent/open-multi-agent' &&
(github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success')
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
is_release: ${{ steps.detect.outputs.is_release }}
sha: ${{ steps.detect.outputs.sha }}
env:
RELEASE_SHA: ${{ github.event_name == 'workflow_dispatch' && inputs.sha || github.event.workflow_run.head_sha }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ env.RELEASE_SHA }}
fetch-depth: 0
- name: Verify the candidate belongs to main
run: |
set -euo pipefail
git fetch origin main
git merge-base --is-ancestor "$RELEASE_SHA" origin/main
test "$(git rev-parse HEAD)" = "$RELEASE_SHA"
- name: Verify manual recovery already passed CI
if: github.event_name == 'workflow_dispatch'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh api "repos/$GITHUB_REPOSITORY/actions/runs?head_sha=$RELEASE_SHA&status=completed&per_page=100" \
--jq '.workflow_runs[] | select(.name == "CI" and .conclusion == "success") | .head_sha' \
| grep -Fxq "$RELEASE_SHA"
- name: Detect a release version commit
id: detect
run: |
set -euo pipefail
echo "sha=$RELEASE_SHA" >> "$GITHUB_OUTPUT"
# Read the version field rather than asking whether the manifest
# changed at all, so this agrees with the publisher's own guard:
# both versions must have incremented from the first parent. A
# dependency bump, a metadata fix, or an `exports` edit touches
# these files without being a release, and detecting on the file
# made every one of those start the publish job, mint a
# contents:write App token, and only then fail closed. Verified on
# 98516ce, a dependency-only commit that reached this step as a
# false positive.
manifest_exists() { git cat-file -e "$1:$2" 2>/dev/null; }
version_at() { git show "$1:$2" | jq -r '.version // empty'; }
for manifest in packages/core/package.json packages/create-oma-app/package.json; do
if ! manifest_exists "$RELEASE_SHA" "$manifest"; then
echo "is_release=false" >> "$GITHUB_OUTPUT"
echo "$manifest does not exist at $RELEASE_SHA; publication is a no-op"
exit 0
fi
version=$(version_at "$RELEASE_SHA" "$manifest")
if [ -z "$version" ]; then
echo "::error::$manifest declares no version at $RELEASE_SHA"
exit 1
fi
# A missing parent manifest means this one is new here, which
# counts as a change rather than a reason to skip publication.
parent=''
if manifest_exists "$RELEASE_SHA^" "$manifest"; then
parent=$(version_at "$RELEASE_SHA^" "$manifest")
fi
if [ "$version" = "$parent" ]; then
echo "is_release=false" >> "$GITHUB_OUTPUT"
echo "$manifest still declares $version; publication is a no-op"
exit 0
fi
done
echo "is_release=true" >> "$GITHUB_OUTPUT"
publish:
needs: detect_release_commit
if: needs.detect_release_commit.outputs.is_release == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
environment: npm-release
permissions:
contents: read
id-token: write
env:
RELEASE_SHA: ${{ needs.detect_release_commit.outputs.sha }}
steps:
- name: Create narrowly scoped GitHub App token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.RELEASE_BOT_APP_CLIENT_ID }}
private-key: ${{ secrets.RELEASE_BOT_APP_PRIVATE_KEY }}
permission-contents: write
- uses: actions/checkout@v4
with:
ref: ${{ env.RELEASE_SHA }}
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}
- uses: actions/setup-node@v4
with:
node-version: 24
registry-url: https://registry.npmjs.org
- run: npm ci --ignore-scripts
- run: npm run build
- name: Publish missing packages, tag, and create the GitHub Release
env:
RELEASE_BOT_EXPECTED_SHA: ${{ env.RELEASE_SHA }}
RELEASE_BOT_GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
run: node packages/release-bot/dist/cli.js publish