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
2 changes: 1 addition & 1 deletion docs/release-and-updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ The updater manifest currently contains only `darwin-aarch64`. Windows and Linux

Release tags use canonical SemVer without build metadata, such as `v1.2.3` or `v1.2.3-rc.1`.

1. Run `just release-prepare X.Y.Z`. It generates and prints release notes, requires explicit approval, then creates `release/vX.Y.Z`, synchronizes every release version and Cargo lock entry, updates `CHANGELOG.md`, validates, commits, pushes, and opens a PR.
1. Run `just release-prepare X.Y.Z`. It generates and prints release notes, requires explicit approval, then creates `release/vX.Y.Z`, synchronizes every release version and Cargo lock entry, updates `CHANGELOG.md`, validates, commits, pushes, and opens a PR. Prerelease notes start at the latest release tag; stable notes start at the latest stable tag so they include the full release cycle.
2. Review and squash-merge the release PR after CI passes.
3. Run `just release-publish X.Y.Z`. It resolves the PR's squash-merge commit, verifies the committed release state, creates an annotated tag on that exact commit, and pushes only `refs/tags/vX.Y.Z`.
4. The workflow verifies that the checkout and canonical remote tag resolve to the same main-reachable commit and that the tag is annotated.
Expand Down
4 changes: 2 additions & 2 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -600,8 +600,8 @@ bump-node-runtime *ARGS:

# Draft release notes from commits without mutating GitHub.
[unix]
release-notes from="" to="HEAD":
FROM_REF="{{ from }}" TO_REF="{{ to }}" ./scripts/generate-release-notes.sh
release-notes from="" to="HEAD" compare_from="":
FROM_REF="{{ from }}" TO_REF="{{ to }}" COMPARE_FROM="{{ compare_from }}" ./scripts/generate-release-notes.sh

# ── Utilities ────────────────────────────────────────────────

Expand Down
3 changes: 2 additions & 1 deletion scripts/generate-release-notes.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ fi

FROM_REF="${1:-${FROM_REF:-}}"
TO_REF="${2:-${TO_REF:-HEAD}}"
COMPARE_FROM="${COMPARE_FROM:-$FROM_REF}"

if [[ -z "$FROM_REF" ]]; then
FROM_REF="$(git -C "$REPO_ROOT" describe --tags --abbrev=0 --match 'v*' 2>/dev/null || true)"
Expand Down Expand Up @@ -82,7 +83,7 @@ if [[ "$TO_REF" == "HEAD" ]]; then
fi
NOTES="${NOTES}

**Full Changelog**: https://github.com/${RELEASE_REPOSITORY}/compare/${FROM_REF}...${COMPARE_TO}"
**Full Changelog**: https://github.com/${RELEASE_REPOSITORY}/compare/${COMPARE_FROM}...${COMPARE_TO}"

printf '\n%s\n' "$NOTES"
echo "Draft only; review these notes before release preparation." >&2
128 changes: 120 additions & 8 deletions scripts/release/release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -409,19 +409,56 @@ function refExists(root, ref) {
});
}

function fetchOriginMain(root) {
function fetchReleaseState(root) {
run(
"git",
[
"fetch",
"--prune",
"--no-tags",
"origin",
"refs/heads/main:refs/remotes/origin/main",
"+refs/tags/v*:refs/remotes/berd-release-tags/v*",
],
{ cwd: root },
);
}

function describeRemoteReleaseTag(root, stableOnly) {
const tags = run(
"git",
[
"for-each-ref",
"--format=%(refname:strip=3)",
"refs/remotes/berd-release-tags/v*",
],
{ cwd: root },
)
.split("\n")
.filter((tag) => tag && (!stableOnly || !tag.includes("-")));
if (tags.length === 0) return null;

const args = [
"describe",
"--all",
"--long",
"--abbrev=40",
"--match",
"berd-release-tags/v*",
];
if (stableOnly) args.push("--exclude", "berd-release-tags/v*-*");
const result = commandResult("git", args, { cwd: root });
if (result.status !== 0) return null;
const describedRef = result.stdout.trim().replace(/-\d+-g[0-9a-f]+$/, "");
return (
tags.find((tag) =>
[`remotes/berd-release-tags/${tag}`, `tags/${tag}`].includes(
describedRef,
),
) ?? null
);
}

function remoteTagTarget(root, tag) {
const result = commandResult(
"git",
Expand Down Expand Up @@ -462,9 +499,49 @@ function validateLocalTag(root, tag, expectedTarget) {
}
}

async function generateReviewedNotes(root) {
process.stderr.write("generating release notes...\n");
const notes = run("just", ["release-notes"], { cwd: root });
export function releaseNotesFrom(root, version, changelog) {
const parsed = parseSemver(version);
if (parsed.prerelease.length > 0) {
const previousRelease = describeRemoteReleaseTag(root, false);
if (!previousRelease) fail("no previous release tag found");
return previousRelease;
}
const previousStable = describeRemoteReleaseTag(root, true);
if (previousStable) return previousStable;

const firstPrerelease = changelogEntries(changelog)
.filter(
(entry) =>
sameNumericVersion(entry.version, version) &&
parseSemver(entry.version).prerelease.length > 0,
)
.at(-1);
const from =
/^\*\*Full Changelog\*\*: https:\/\/github\.com\/\S+\/compare\/(.+?)\.\.\.\S+$/m.exec(
firstPrerelease?.body ?? "",
)?.[1];
if (
!from ||
!succeeds("git", ["rev-parse", "--verify", `${from}^{commit}`], {
cwd: root,
})
) {
fail("no previous stable tag or first prerelease changelog baseline found");
}
return from;
}

function releaseNotesRef(from) {
return from.startsWith("v") ? `refs/remotes/berd-release-tags/${from}` : from;
}

async function generateReviewedNotes(root, from) {
process.stderr.write(`generating release notes from ${from}...\n`);
const notes = run(
"just",
["release-notes", releaseNotesRef(from), "HEAD", from],
{ cwd: root },
);
if (!notes) fail("generated release notes are empty");
process.stdout.write(`\n${notes}\n\n`);
const prompt = createInterface({
Expand All @@ -482,6 +559,41 @@ async function generateReviewedNotes(root) {
return notes;
}

async function generateReviewedCurrentNotes(root, version, read) {
while (true) {
const reviewedMain = run("git", ["rev-parse", "refs/remotes/origin/main"], {
cwd: root,
});
const changelog = await read("CHANGELOG.md");
const reviewedFrom = releaseNotesFrom(root, version, changelog);
const notes = await generateReviewedNotes(root, reviewedFrom);
fetchReleaseState(root);
const currentMain = run("git", ["rev-parse", "refs/remotes/origin/main"], {
cwd: root,
});
const currentFrom = releaseNotesFrom(root, version, changelog);
const mainChanged = currentMain !== reviewedMain;
if (!mainChanged && currentFrom === reviewedFrom) return notes;

if (mainChanged) {
const currentBranch = run("git", ["branch", "--show-current"], {
cwd: root,
});
const head = run("git", ["rev-parse", "HEAD"], { cwd: root });
if (currentBranch !== "main" || head !== reviewedMain) {
fail("origin/main advanced; rerun release preparation from main");
}
run("git", ["merge", "--ff-only", "refs/remotes/origin/main"], {
cwd: root,
visible: true,
});
}
process.stderr.write(
"release state changed while release notes were under review; regenerating...\n",
);
}
}

function releasePrs(root, repository, branch) {
const json = run(
"gh",
Expand Down Expand Up @@ -549,13 +661,13 @@ async function prepare(version, notesPath) {
const branch = `release/v${version}`;
const subject = `chore: release v${version}`;
assertClean(root);
run("gh", ["auth", "status", "--hostname", "github.com"], { cwd: root });
fetchReleaseState(root);
Comment thread
kalvinnchau marked this conversation as resolved.
const notes = notesPath
? (await readFile(resolve(notesPath), "utf8")).trim()
: await generateReviewedNotes(root);
: await generateReviewedCurrentNotes(root, version, read);
if (!notes) fail("release notes file must contain reviewed Markdown");
if (notes.includes("\0")) fail("release notes file contains a NUL byte");
run("gh", ["auth", "status", "--hostname", "github.com"], { cwd: root });
fetchOriginMain(root);

const remoteBranchOutput = commandResult(
"git",
Expand Down Expand Up @@ -744,7 +856,7 @@ async function publish(version) {
const subject = `chore: release v${version}`;
assertClean(root);
run("gh", ["auth", "status", "--hostname", "github.com"], { cwd: root });
fetchOriginMain(root);
fetchReleaseState(root);

const prs = releasePrs(root, config.repository, branch);
if (prs.length !== 1) fail(`expected exactly one PR for ${branch}`);
Expand Down
Loading