diff --git a/.github/scripts/previous-stable-tag.mjs b/.github/scripts/previous-stable-tag.mjs new file mode 100644 index 000000000..6b42864e9 --- /dev/null +++ b/.github/scripts/previous-stable-tag.mjs @@ -0,0 +1,41 @@ +// Prints the tag a release's notes start from: the highest stable tag (vX.Y.Z, +// no suffix) below the given version, by SemVer, or an empty line when there is +// none. The tags come on stdin as `git tag -l 'v*'` lists them: every tag, +// whether or not it is reachable from the release commit. +// +// build.yml used to guess that tag's name by arithmetic and fall back to +// `git describe` when the guess did not exist (#1002). 2.0.0 guessed v1.0.0, +// which never existed, and `git describe` on release/v2.0.0 found the release's +// own last RC: v2.0.0 shipped with an empty changelog. A guess that did exist +// could still be wrong: 1.13.0 compared against v1.12.0 and repeated the PRs of +// v1.12.1 and v1.12.2. Only a tag that exists can come out of here, which also +// keeps the RC notes' `git log ..` from dying on `unknown revision`, as +// v1.9.4-rc.1's did when the guess named a 1.9.3 that never shipped. + +import { readFileSync } from "node:fs"; +import { argv } from "node:process"; + +const STABLE_TAG = /^v(\d+)\.(\d+)\.(\d+)$/; + +const parse = (tag) => tag.match(STABLE_TAG)?.slice(1).map(Number); +const compare = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2]; + +/** + * @param {string} version Stable version being released, e.g. "2.0.0". + * @param {string[]} tags Tag names; anything but vX.Y.Z is ignored. + * @returns {string} The highest vX.Y.Z below `version`, or "" when there is none. + */ +export function previousStableTag(version, tags) { + const current = parse(`v${version}`); + if (!current) throw new Error(`invalid version ${JSON.stringify(version)}; expected X.Y.Z`); + const below = tags + .map((tag) => [tag, parse(tag)]) + .filter(([, parts]) => parts && compare(parts, current) < 0); + below.sort(([, a], [, b]) => compare(b, a)); + return below[0]?.[0] ?? ""; +} + +// Only run when invoked directly, so the test can import the function. +if (import.meta.filename === argv[1]) { + console.log(previousStableTag(argv[2], readFileSync(0, "utf8").split(/\s+/))); +} diff --git a/.github/scripts/previous-stable-tag.test.mjs b/.github/scripts/previous-stable-tag.test.mjs new file mode 100644 index 000000000..38be7047c --- /dev/null +++ b/.github/scripts/previous-stable-tag.test.mjs @@ -0,0 +1,78 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { previousStableTag } from "./previous-stable-tag.mjs"; + +// Real tags of this repository, in the order `git tag -l` prints them: by name, so v1.9.x +// comes after v1.13.0 and picking by position or as text gets it wrong. The v0.0.0-* tags +// host release assets; `git describe` once picked one of them for v2.0.0-rc.1's notes. +const TAGS = [ + "v0.0.0", + "v0.0.0-onnxruntime-1.27.1", + "v1.0.1", + "v1.10.0", + "v1.12.0", + "v1.12.1", + "v1.12.1-rc.1", + "v1.12.2", + "v1.12.2-rc.1", + "v1.13.0", + "v1.13.0-rc.6", + "v1.9.2", + "v1.9.3-rc.1", + "v1.9.6", + "v2.0.0", + "v2.0.0-rc.15", +]; + +describe("previousStableTag", () => { + it("compares a major against the last stable of the line before", () => { + // The arithmetic guessed v1.0.0, which does not exist, and the fallback found + // v2.0.0-rc.15: v2.0.0 shipped with an empty changelog (#1002). + expect(previousStableTag("2.0.0", TAGS)).toBe("v1.13.0"); + }); + + it("compares a minor against the last patch, not the .0", () => { + // Against v1.12.0, 1.13.0's notes repeated every PR of v1.12.1 and v1.12.2. + expect(previousStableTag("1.13.0", TAGS)).toBe("v1.12.2"); + }); + + it("compares a patch against the patch before it", () => { + expect(previousStableTag("1.12.2", TAGS)).toBe("v1.12.1"); + }); + + it("is empty when no stable tag sorts below", () => { + // A first release: its RCs exist, nothing before them does. + expect(previousStableTag("1.0.0", ["v1.0.0", "v1.0.0-rc.1", "v1.0.0-rc.2"])).toBe(""); + }); + + it("ignores pre-release tags", () => { + // 1.9.3 shipped only as rc.1, so the last release before 1.9.4 is v1.9.2. + expect(previousStableTag("1.9.4", TAGS)).toBe("v1.9.2"); + }); + + it("rejects a version that is not X.Y.Z", () => { + expect(() => previousStableTag("2.0.0-rc.1", TAGS)).toThrow(/invalid version/); + }); + + // The direct-invocation guard makes the script print nothing at all if it ever stops + // matching, and the workflow would read that as "no previous release". So this runs it + // exactly as build.yml does: relative path from the repo root, the tags on stdin. + it("runs the way build.yml invokes it", () => { + const repoRoot = join(import.meta.dirname, "../.."); + const workflow = readFileSync(join(repoRoot, ".github/workflows/build.yml"), "utf8"); + expect(workflow).toContain( + `git tag -l 'v*' | node .github/scripts/previous-stable-tag.mjs "$STABLE_VERSION"`, + ); + + const run = (version) => + execFileSync("node", [".github/scripts/previous-stable-tag.mjs", version], { + cwd: repoRoot, + input: `${TAGS.join("\n")}\n`, + encoding: "utf8", + }); + expect(run("2.0.0")).toBe("v1.13.0\n"); + expect(run("0.0.0")).toBe("\n"); + }); +}); diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index dd0f477a6..f073e4849 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -780,8 +780,8 @@ jobs: - name: Checkout code uses: actions/checkout@v7 with: - # Full history + tags: the RC notes below are built from `git log` over the - # range since the previous RC tag, and resolving that tag needs the tags. + # Full history + tags: the previous release's tag is picked from every tag in the + # repo, and the RC notes below are built from `git log` over the range since it. fetch-depth: 0 - name: Resolve release tag @@ -820,16 +820,11 @@ jobs: # Compute the previous stable tag for auto-generated release notes. We don't use # GitHub's "most recent prior release by date" because the fork carries re-published # upstream releases whose published_at is more recent than the fork's own first release. - # For SemVer X.Y.Z: previous is vX.Y.(Z-1) if Z>0, else vX.(Y-1).0, else v(X-1).0.0. + # Picked by SemVer among the tags that exist, never guessed from the version number: + # the script says what guessing cost (#1002). Left EMPTY when no stable tag sorts + # below this version; each consumer below decides what "no previous release" means. STABLE_VERSION="${VERSION%%-*}" - IFS='.' read -r PX PY PZ <<< "$STABLE_VERSION" - if (( PZ > 0 )); then - NOTES_START_TAG="v${PX}.${PY}.$((PZ - 1))" - elif (( PY > 0 )); then - NOTES_START_TAG="v${PX}.$((PY - 1)).0" - else - NOTES_START_TAG="v$((PX - 1)).0.0" - fi + NOTES_START_TAG="$(git tag -l 'v*' | node .github/scripts/previous-stable-tag.mjs "$STABLE_VERSION")" # For an RC, compare against the PREVIOUS RC of the same line, not the previous # stable. Deriving the start tag from STABLE_VERSION alone made every RC of a # line span the same range, so each re-cut just repeated the last RC's notes @@ -845,27 +840,6 @@ jobs: fi done fi - # Everything above computes what the previous release was *called* and - # never checks that it exists. A version line that stopped at its RC - # makes that a name for nothing: 1.9.3 shipped only as rc.1, so - # v1.9.4-rc.1 asked git for v1.9.3..v1.9.4-rc.1 and the publish step - # died on `unknown revision` -- after all four platforms had already - # built, and with publish-msstore sitting behind publish-release, so - # the same gap would silently block a stable release's Store - # deployment too. The rc walk-down above handles a skipped RC; nothing - # handled a skipped stable, and it does not run at all for an rc.1. - if ! git rev-parse -q --verify "refs/tags/${NOTES_START_TAG}" >/dev/null; then - # The nearest tag reachable from the release commit's parent, which - # is what "since the last release" meant in the first place. Left - # EMPTY when no tag is reachable at all, rather than filled with the - # root commit: the stable path hands this to `gh release create - # --notes-start-tag`, which is the API's previous_tag_name and takes - # a tag NAME -- a commit SHA there is not a lenient fallback, it is - # an invalid argument. Each consumer below decides what "no previous - # release" means for it. - NOTES_START_TAG="$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)" - echo "Previous-release tag did not exist; using ${NOTES_START_TAG:-}" - fi echo "Computed notes_start_tag=${NOTES_START_TAG} for tag=${TAG}" echo "tag=$TAG" >> "$GITHUB_OUTPUT"