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
41 changes: 41 additions & 0 deletions .github/scripts/previous-stable-tag.mjs
Original file line number Diff line number Diff line change
@@ -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 <tag>..` 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+/)));
}
78 changes: 78 additions & 0 deletions .github/scripts/previous-stable-tag.test.mjs
Original file line number Diff line number Diff line change
@@ -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");
});
});
38 changes: 6 additions & 32 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:-<none>}"
fi
echo "Computed notes_start_tag=${NOTES_START_TAG} for tag=${TAG}"

echo "tag=$TAG" >> "$GITHUB_OUTPUT"
Expand Down
Loading