From 1eeb25bdf6fb9832d2c5ab18fc0878c91635e278 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:27:45 -0700 Subject: [PATCH] fix(release): tag and pack the resolved release commit, never the dispatch head MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Advances #8525. The reconcile self-heal dispatches publish-*.yml bare (against main head), and the bare-dispatch path tagged-and-packed HEAD -- during today's runner backlog that placed ui-kit-v1.1.2 two commits late, sweeping the would-be 1.1.3 fix into the 1.1.2 tag and published artifact and zombifying the open v1.1.3 release PR (release-please saw 'No commits for path' behind the misplaced tag and could neither regenerate nor prune it; closed by hand as #8506). All four publish workflows now resolve the RELEASE COMMIT -- the newest main commit that introduced the current version string into the package's own package.json (the release PR's merge commit) -- then: - verify it is reachable from main, - verify any pre-existing tag points AT it (not at HEAD), - detach onto it so typecheck/pack build the exact tree the version number describes, - create the annotated tag at it explicitly. Unresolvable ⇒ hard abort, never a head fallback. The release-please dispatch path (dispatched against the tag itself) is byte-identical in effect: there HEAD already IS the release commit. Regression pins in test/unit/publish-release-tag-pins.test.ts cover the resolver, the fail-loud arm, the detach, and the death of every head-tagging form across all four workflows. --- .github/workflows/publish-engine.yml | 45 ++++++++++++++----- .github/workflows/publish-mcp.yml | 45 ++++++++++++++----- .github/workflows/publish-miner.yml | 45 ++++++++++++++----- .github/workflows/publish-ui-kit.yml | 45 ++++++++++++++----- test/unit/publish-release-tag-pins.test.ts | 50 ++++++++++++++++++++++ 5 files changed, 186 insertions(+), 44 deletions(-) create mode 100644 test/unit/publish-release-tag-pins.test.ts diff --git a/.github/workflows/publish-engine.yml b/.github/workflows/publish-engine.yml index 808d08b374..90a6e963da 100644 --- a/.github/workflows/publish-engine.yml +++ b/.github/workflows/publish-engine.yml @@ -38,6 +38,7 @@ jobs: outputs: version: ${{ steps.version.outputs.version }} tag: ${{ steps.version.outputs.tag }} + release_sha: ${{ steps.version.outputs.release_sha }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 @@ -71,19 +72,41 @@ jobs: exit 1 fi TAG="engine-v${VERSION}" + # The RELEASE COMMIT is the newest main commit that introduced this exact version string + # into package.json (the release PR's merge commit) -- NOT whatever main head happens to + # be when this run was dispatched. A bare/reconcile dispatch used to tag-and-pack HEAD, + # which silently swept any commits that landed after the version bump into the tag and the + # published artifact (#8525: ui-kit-v1.1.2 got tagged two commits late during a runner + # backlog, absorbing the 1.1.3 fix and zombifying its release PR). Resolving the commit + # from the version string keeps the release-please-dispatched path a no-op (it dispatches + # against the tag, so HEAD already IS this commit) while making late dispatches correct. + RELEASE_SHA="$(git log -n 1 --format=%H -S "\"version\": \"${VERSION}\"" refs/remotes/origin/main -- packages/loopover-engine/package.json)" + if [ -z "$RELEASE_SHA" ]; then + echo "::error::Could not resolve the commit that introduced version $VERSION into packages/loopover-engine/package.json -- refusing to guess (would tag main head). (#8525)" + exit 1 + fi + if ! git merge-base --is-ancestor "$RELEASE_SHA" refs/remotes/origin/main; then + echo "::error::Resolved release commit $RELEASE_SHA is not reachable from main." + exit 1 + fi if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then - HEAD_SHA="$(git rev-parse HEAD)" TAG_SHA="$(git rev-list -n 1 "$TAG")" - if [ "$TAG_SHA" != "$HEAD_SHA" ]; then - echo "::error::Tag $TAG already exists but points at $TAG_SHA, not the dispatched commit $HEAD_SHA" + if [ "$TAG_SHA" != "$RELEASE_SHA" ]; then + echo "::error::Tag $TAG already exists but points at $TAG_SHA, not the resolved release commit $RELEASE_SHA" exit 1 fi - echo "Tag $TAG already exists and matches HEAD." + echo "Tag $TAG already exists and matches the resolved release commit." else - echo "Tag $TAG does not exist yet; the publish job will create it." + echo "Tag $TAG does not exist yet; the publish job will create it at $RELEASE_SHA." fi - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "tag=$TAG" >> "$GITHUB_OUTPUT" + # Build/typecheck/pack against the release commit's own tree, so the published artifact is + # byte-for-byte the content the version number describes. + git checkout --detach "$RELEASE_SHA" + { + echo "version=$VERSION" + echo "tag=$TAG" + echo "release_sha=$RELEASE_SHA" + } >> "$GITHUB_OUTPUT" - name: Install dependencies run: npm ci @@ -168,16 +191,16 @@ jobs: GH_TOKEN: ${{ github.token }} TAG: ${{ needs.validate.outputs.tag }} VERSION: ${{ needs.validate.outputs.version }} + RELEASE_SHA: ${{ needs.validate.outputs.release_sha }} run: | set -euo pipefail - HEAD_SHA="$(git rev-parse HEAD)" if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then - echo "Tag $TAG already exists (verified against HEAD by the validate job)." + echo "Tag $TAG already exists (verified against the resolved release commit by the validate job)." else - echo "Creating tag $TAG at HEAD ($HEAD_SHA)." + echo "Creating tag $TAG at the resolved release commit ($RELEASE_SHA) -- never at HEAD (#8525)." git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git tag -a "$TAG" -m "@loopover/engine v${VERSION}" + git tag -a "$TAG" -m "@loopover/engine v${VERSION}" "$RELEASE_SHA" git remote set-url origin "https://github.com/${GITHUB_REPOSITORY}.git" gh auth setup-git git push origin "$TAG" diff --git a/.github/workflows/publish-mcp.yml b/.github/workflows/publish-mcp.yml index d5bc231290..42e7c281e4 100644 --- a/.github/workflows/publish-mcp.yml +++ b/.github/workflows/publish-mcp.yml @@ -33,6 +33,7 @@ jobs: outputs: version: ${{ steps.version.outputs.version }} tag: ${{ steps.version.outputs.tag }} + release_sha: ${{ steps.version.outputs.release_sha }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 @@ -71,19 +72,41 @@ jobs: exit 1 fi TAG="mcp-v${VERSION}" + # The RELEASE COMMIT is the newest main commit that introduced this exact version string + # into package.json (the release PR's merge commit) -- NOT whatever main head happens to + # be when this run was dispatched. A bare/reconcile dispatch used to tag-and-pack HEAD, + # which silently swept any commits that landed after the version bump into the tag and the + # published artifact (#8525: ui-kit-v1.1.2 got tagged two commits late during a runner + # backlog, absorbing the 1.1.3 fix and zombifying its release PR). Resolving the commit + # from the version string keeps the release-please-dispatched path a no-op (it dispatches + # against the tag, so HEAD already IS this commit) while making late dispatches correct. + RELEASE_SHA="$(git log -n 1 --format=%H -S "\"version\": \"${VERSION}\"" refs/remotes/origin/main -- packages/loopover-mcp/package.json)" + if [ -z "$RELEASE_SHA" ]; then + echo "::error::Could not resolve the commit that introduced version $VERSION into packages/loopover-mcp/package.json -- refusing to guess (would tag main head). (#8525)" + exit 1 + fi + if ! git merge-base --is-ancestor "$RELEASE_SHA" refs/remotes/origin/main; then + echo "::error::Resolved release commit $RELEASE_SHA is not reachable from main." + exit 1 + fi if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then - HEAD_SHA="$(git rev-parse HEAD)" TAG_SHA="$(git rev-list -n 1 "$TAG")" - if [ "$TAG_SHA" != "$HEAD_SHA" ]; then - echo "::error::Tag $TAG already exists but points at $TAG_SHA, not the dispatched commit $HEAD_SHA" + if [ "$TAG_SHA" != "$RELEASE_SHA" ]; then + echo "::error::Tag $TAG already exists but points at $TAG_SHA, not the resolved release commit $RELEASE_SHA" exit 1 fi - echo "Tag $TAG already exists and matches HEAD." + echo "Tag $TAG already exists and matches the resolved release commit." else - echo "Tag $TAG does not exist yet; the publish job will create it." + echo "Tag $TAG does not exist yet; the publish job will create it at $RELEASE_SHA." fi - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "tag=$TAG" >> "$GITHUB_OUTPUT" + # Build/typecheck/pack against the release commit's own tree, so the published artifact is + # byte-for-byte the content the version number describes. + git checkout --detach "$RELEASE_SHA" + { + echo "version=$VERSION" + echo "tag=$TAG" + echo "release_sha=$RELEASE_SHA" + } >> "$GITHUB_OUTPUT" - name: Install dependencies run: npm ci @@ -185,16 +208,16 @@ jobs: GH_TOKEN: ${{ github.token }} TAG: ${{ needs.validate.outputs.tag }} VERSION: ${{ needs.validate.outputs.version }} + RELEASE_SHA: ${{ needs.validate.outputs.release_sha }} run: | set -euo pipefail - HEAD_SHA="$(git rev-parse HEAD)" if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then - echo "Tag $TAG already exists (verified against HEAD by the validate job)." + echo "Tag $TAG already exists (verified against the resolved release commit by the validate job)." else - echo "Creating tag $TAG at HEAD ($HEAD_SHA)." + echo "Creating tag $TAG at the resolved release commit ($RELEASE_SHA) -- never at HEAD (#8525)." git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git tag -a "$TAG" -m "@loopover/mcp v${VERSION}" + git tag -a "$TAG" -m "@loopover/mcp v${VERSION}" "$RELEASE_SHA" git remote set-url origin "https://github.com/${GITHUB_REPOSITORY}.git" gh auth setup-git git push origin "$TAG" diff --git a/.github/workflows/publish-miner.yml b/.github/workflows/publish-miner.yml index 01307a4ccb..58b17ef773 100644 --- a/.github/workflows/publish-miner.yml +++ b/.github/workflows/publish-miner.yml @@ -39,6 +39,7 @@ jobs: outputs: version: ${{ steps.version.outputs.version }} tag: ${{ steps.version.outputs.tag }} + release_sha: ${{ steps.version.outputs.release_sha }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 @@ -72,19 +73,41 @@ jobs: exit 1 fi TAG="miner-v${VERSION}" + # The RELEASE COMMIT is the newest main commit that introduced this exact version string + # into package.json (the release PR's merge commit) -- NOT whatever main head happens to + # be when this run was dispatched. A bare/reconcile dispatch used to tag-and-pack HEAD, + # which silently swept any commits that landed after the version bump into the tag and the + # published artifact (#8525: ui-kit-v1.1.2 got tagged two commits late during a runner + # backlog, absorbing the 1.1.3 fix and zombifying its release PR). Resolving the commit + # from the version string keeps the release-please-dispatched path a no-op (it dispatches + # against the tag, so HEAD already IS this commit) while making late dispatches correct. + RELEASE_SHA="$(git log -n 1 --format=%H -S "\"version\": \"${VERSION}\"" refs/remotes/origin/main -- packages/loopover-miner/package.json)" + if [ -z "$RELEASE_SHA" ]; then + echo "::error::Could not resolve the commit that introduced version $VERSION into packages/loopover-miner/package.json -- refusing to guess (would tag main head). (#8525)" + exit 1 + fi + if ! git merge-base --is-ancestor "$RELEASE_SHA" refs/remotes/origin/main; then + echo "::error::Resolved release commit $RELEASE_SHA is not reachable from main." + exit 1 + fi if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then - HEAD_SHA="$(git rev-parse HEAD)" TAG_SHA="$(git rev-list -n 1 "$TAG")" - if [ "$TAG_SHA" != "$HEAD_SHA" ]; then - echo "::error::Tag $TAG already exists but points at $TAG_SHA, not the dispatched commit $HEAD_SHA" + if [ "$TAG_SHA" != "$RELEASE_SHA" ]; then + echo "::error::Tag $TAG already exists but points at $TAG_SHA, not the resolved release commit $RELEASE_SHA" exit 1 fi - echo "Tag $TAG already exists and matches HEAD." + echo "Tag $TAG already exists and matches the resolved release commit." else - echo "Tag $TAG does not exist yet; the publish job will create it." + echo "Tag $TAG does not exist yet; the publish job will create it at $RELEASE_SHA." fi - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "tag=$TAG" >> "$GITHUB_OUTPUT" + # Build/typecheck/pack against the release commit's own tree, so the published artifact is + # byte-for-byte the content the version number describes. + git checkout --detach "$RELEASE_SHA" + { + echo "version=$VERSION" + echo "tag=$TAG" + echo "release_sha=$RELEASE_SHA" + } >> "$GITHUB_OUTPUT" - name: Install dependencies run: npm ci @@ -177,16 +200,16 @@ jobs: GH_TOKEN: ${{ github.token }} TAG: ${{ needs.validate.outputs.tag }} VERSION: ${{ needs.validate.outputs.version }} + RELEASE_SHA: ${{ needs.validate.outputs.release_sha }} run: | set -euo pipefail - HEAD_SHA="$(git rev-parse HEAD)" if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then - echo "Tag $TAG already exists (verified against HEAD by the validate job)." + echo "Tag $TAG already exists (verified against the resolved release commit by the validate job)." else - echo "Creating tag $TAG at HEAD ($HEAD_SHA)." + echo "Creating tag $TAG at the resolved release commit ($RELEASE_SHA) -- never at HEAD (#8525)." git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git tag -a "$TAG" -m "@loopover/miner v${VERSION}" + git tag -a "$TAG" -m "@loopover/miner v${VERSION}" "$RELEASE_SHA" git remote set-url origin "https://github.com/${GITHUB_REPOSITORY}.git" gh auth setup-git git push origin "$TAG" diff --git a/.github/workflows/publish-ui-kit.yml b/.github/workflows/publish-ui-kit.yml index 2c360fa61d..15804c7ecb 100644 --- a/.github/workflows/publish-ui-kit.yml +++ b/.github/workflows/publish-ui-kit.yml @@ -38,6 +38,7 @@ jobs: outputs: version: ${{ steps.version.outputs.version }} tag: ${{ steps.version.outputs.tag }} + release_sha: ${{ steps.version.outputs.release_sha }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 @@ -71,19 +72,41 @@ jobs: exit 1 fi TAG="ui-kit-v${VERSION}" + # The RELEASE COMMIT is the newest main commit that introduced this exact version string + # into package.json (the release PR's merge commit) -- NOT whatever main head happens to + # be when this run was dispatched. A bare/reconcile dispatch used to tag-and-pack HEAD, + # which silently swept any commits that landed after the version bump into the tag and the + # published artifact (#8525: ui-kit-v1.1.2 got tagged two commits late during a runner + # backlog, absorbing the 1.1.3 fix and zombifying its release PR). Resolving the commit + # from the version string keeps the release-please-dispatched path a no-op (it dispatches + # against the tag, so HEAD already IS this commit) while making late dispatches correct. + RELEASE_SHA="$(git log -n 1 --format=%H -S "\"version\": \"${VERSION}\"" refs/remotes/origin/main -- packages/loopover-ui-kit/package.json)" + if [ -z "$RELEASE_SHA" ]; then + echo "::error::Could not resolve the commit that introduced version $VERSION into packages/loopover-ui-kit/package.json -- refusing to guess (would tag main head). (#8525)" + exit 1 + fi + if ! git merge-base --is-ancestor "$RELEASE_SHA" refs/remotes/origin/main; then + echo "::error::Resolved release commit $RELEASE_SHA is not reachable from main." + exit 1 + fi if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then - HEAD_SHA="$(git rev-parse HEAD)" TAG_SHA="$(git rev-list -n 1 "$TAG")" - if [ "$TAG_SHA" != "$HEAD_SHA" ]; then - echo "::error::Tag $TAG already exists but points at $TAG_SHA, not the dispatched commit $HEAD_SHA" + if [ "$TAG_SHA" != "$RELEASE_SHA" ]; then + echo "::error::Tag $TAG already exists but points at $TAG_SHA, not the resolved release commit $RELEASE_SHA" exit 1 fi - echo "Tag $TAG already exists and matches HEAD." + echo "Tag $TAG already exists and matches the resolved release commit." else - echo "Tag $TAG does not exist yet; the publish job will create it." + echo "Tag $TAG does not exist yet; the publish job will create it at $RELEASE_SHA." fi - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "tag=$TAG" >> "$GITHUB_OUTPUT" + # Build/typecheck/pack against the release commit's own tree, so the published artifact is + # byte-for-byte the content the version number describes. + git checkout --detach "$RELEASE_SHA" + { + echo "version=$VERSION" + echo "tag=$TAG" + echo "release_sha=$RELEASE_SHA" + } >> "$GITHUB_OUTPUT" - name: Install dependencies run: npm ci @@ -167,16 +190,16 @@ jobs: GH_TOKEN: ${{ github.token }} TAG: ${{ needs.validate.outputs.tag }} VERSION: ${{ needs.validate.outputs.version }} + RELEASE_SHA: ${{ needs.validate.outputs.release_sha }} run: | set -euo pipefail - HEAD_SHA="$(git rev-parse HEAD)" if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then - echo "Tag $TAG already exists (verified against HEAD by the validate job)." + echo "Tag $TAG already exists (verified against the resolved release commit by the validate job)." else - echo "Creating tag $TAG at HEAD ($HEAD_SHA)." + echo "Creating tag $TAG at the resolved release commit ($RELEASE_SHA) -- never at HEAD (#8525)." git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git tag -a "$TAG" -m "@loopover/ui-kit v${VERSION}" + git tag -a "$TAG" -m "@loopover/ui-kit v${VERSION}" "$RELEASE_SHA" git remote set-url origin "https://github.com/${GITHUB_REPOSITORY}.git" gh auth setup-git git push origin "$TAG" diff --git a/test/unit/publish-release-tag-pins.test.ts b/test/unit/publish-release-tag-pins.test.ts new file mode 100644 index 0000000000..6269e5ae86 --- /dev/null +++ b/test/unit/publish-release-tag-pins.test.ts @@ -0,0 +1,50 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +// Regression pins for #8525: the reconcile self-heal dispatches publish-*.yml bare (against main +// head), and the tag-creation path used to tag-and-pack HEAD -- during a runner backlog that placed +// ui-kit-v1.1.2 two commits late, silently sweeping the would-be 1.1.3 fix into the 1.1.2 tag and +// published artifact, and zombifying the open v1.1.3 release PR (release-please saw "No commits for +// path" behind the misplaced tag and could neither regenerate nor prune it). Every publish workflow +// must resolve the commit that INTRODUCED the current version into its package.json and tag/pack +// THERE; an unresolvable release commit must abort, never fall back to head. + +const WORKFLOWS = [ + ".github/workflows/publish-engine.yml", + ".github/workflows/publish-mcp.yml", + ".github/workflows/publish-miner.yml", + ".github/workflows/publish-ui-kit.yml", +] as const; + +const read = (path: string) => readFileSync(path, "utf8"); + +describe("publish workflows tag the resolved release commit, never HEAD (#8525)", () => { + it.each(WORKFLOWS)("%s resolves the version-introducing commit and aborts when it cannot", (path) => { + const workflow = read(path); + // The resolver: newest main commit whose diff introduced this exact version string into the + // package's own package.json (the release PR's merge commit). + expect(workflow).toContain('-S "\\"version\\": \\"${VERSION}\\"" refs/remotes/origin/main --'); + // Fail-loud contract: no resolved commit ⇒ hard exit before any tag work. + expect(workflow).toContain("refusing to guess (would tag main head). (#8525)"); + // The resolved commit must be verified reachable from main before use. + expect(workflow).toContain('git merge-base --is-ancestor "$RELEASE_SHA" refs/remotes/origin/main'); + // The artifact is packed from the release commit's own tree, not the dispatch head. + expect(workflow).toContain('git checkout --detach "$RELEASE_SHA"'); + }); + + it.each(WORKFLOWS)("%s creates the annotated tag at $RELEASE_SHA and never at bare HEAD", (path) => { + const workflow = read(path); + // The tag object is anchored to the resolved release commit passed across the job boundary. + expect(workflow).toContain("RELEASE_SHA: ${{ needs.validate.outputs.release_sha }}"); + expect(workflow).toMatch(/git tag -a "\$TAG" -m "[^"]+" "\$RELEASE_SHA"/); + // The old head-tagging form must stay dead in both jobs. + expect(workflow).not.toContain("Creating tag $TAG at HEAD"); + expect(workflow).not.toMatch(/git tag -a "\$TAG" -m "[^"]+"\s*\n/); + }); + + it.each(WORKFLOWS)("%s's pre-existing-tag check compares against the release commit, not HEAD", (path) => { + const workflow = read(path); + expect(workflow).toContain('if [ "$TAG_SHA" != "$RELEASE_SHA" ]; then'); + expect(workflow).not.toContain('if [ "$TAG_SHA" != "$HEAD_SHA" ]; then'); + }); +});