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
54 changes: 6 additions & 48 deletions .github/workflows/backfill-release-notes.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,68 +24,26 @@ jobs:
env:
TAG: ${{ inputs.tag }}
steps:
- id: app-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}

- uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1
with:
fetch-depth: 0
persist-credentials: false

- uses: ./.github/actions/setup

# semantic-release computes the *next* release based on commits since the
# latest channel tag. To re-derive notes for an existing tag, delete the
# tag locally and synthesise a branch name that matches the channel
# config in apps/cli/package.json (main / develop) at the tag's commit.
- name: Re-stage repo as if $TAG were unpublished
run: |
set -euo pipefail
sha=$(git rev-list -n 1 "$TAG")
git tag -d "$TAG"
if [[ "$TAG" == *-beta.* ]]; then
branch=develop
else
branch=main
fi
git checkout -B "$branch" "$sha"
echo "Re-staged on $branch @ $sha (without tag $TAG)"

- id: sr
uses: cycjimmy/semantic-release-action@b12c8f6015dc215fe37bc154d4ad456dd3833c90 # v6.0.0
with:
working_directory: apps/cli
dry_run: true
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}

- name: Verify computed version matches $TAG
env:
SR_VERSION: ${{ steps.sr.outputs.new_release_version }}
run: |
set -euo pipefail
want="${TAG#v}"
if [[ "$SR_VERSION" != "$want" ]]; then
echo "::error::semantic-release computed v$SR_VERSION but tag is $TAG; check channel config / tag history"
exit 1
fi
- name: Compute release notes (dry-run)
run: pnpm exec bun apps/cli/scripts/backfill-release-notes.ts --tag "${TAG}" | tee notes.md

- name: Show generated notes
env:
NOTES: ${{ steps.sr.outputs.new_release_notes }}
- name: Publish notes to job summary
run: |
{
echo "## Notes for $TAG"
echo "## Notes for ${TAG}"
echo
printf '%s\n' "$NOTES"
cat notes.md
} >> "$GITHUB_STEP_SUMMARY"
printf '%s\n' "$NOTES" > notes.md

- name: Update GitHub Release body
if: inputs.apply
env:
GH_TOKEN: ${{ github.token }}
run: gh release edit "$TAG" --notes-file notes.md
run: pnpm exec bun apps/cli/scripts/backfill-release-notes.ts --tag "${TAG}" --apply
4 changes: 3 additions & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"posthog-node": "^5.34.3",
"react": "^19.2.6",
"react-devtools-core": "^7.0.1",
"semantic-release": "^24.2.9",
"vitest": "catalog:"
},
"optionalDependencies": {
Expand Down Expand Up @@ -114,7 +115,8 @@
"@typescript/native-preview",
"oxfmt",
"oxlint",
"oxlint-tsgolint"
"oxlint-tsgolint",
"semantic-release"
]
},
"nx": {
Expand Down
192 changes: 192 additions & 0 deletions apps/cli/scripts/backfill-release-notes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
#!/usr/bin/env bun
// Re-derive a GitHub Release's changelog from its tag's commit using the
// *current* semantic-release config, regardless of what apps/cli/package.json
// looked like when the tag was cut. Used both as a local debugging tool and
// as the engine behind .github/workflows/backfill-release-notes.yml.
//
// Why every step matters: when backfilling an old tag, semantic-release
// trips on several things at once - it picks the wrong branch from CI env
// vars, can't read channel notes for historical tags, refuses to proceed if
// the local branch is "behind" the real remote, and uses whatever
// release.branches/plugins config existed at the tag's commit (which on
// this repo pre-dates the `channel: "beta"` fix from commit 2515885 and
// the release-notes-generator plugin from #5316). The script works around
// each of those in a temp clone so the original workspace stays clean.
//
// Usage:
// bun apps/cli/scripts/backfill-release-notes.ts --tag v2.99.0-beta.1
// bun apps/cli/scripts/backfill-release-notes.ts --tag v2.100.1 --apply
//
// --tag Required. Release tag to refresh (e.g. v2.99.0-beta.1).
// --apply Update the GitHub Release body via `gh release edit`.
// Without it, raw markdown notes are printed to stdout.
import { $ } from "bun";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import process from "node:process";
import { parseArgs } from "node:util";
import semanticRelease from "semantic-release";

const { values } = parseArgs({
options: {
tag: { type: "string" },
apply: { type: "boolean", default: false },
},
strict: true,
});

const tag = values.tag;
if (!tag) {
console.error("--tag is required (e.g. --tag v2.99.0-beta.1)");
process.exit(2);
}
const apply = values.apply ?? false;

const repoRoot = (await $`git rev-parse --show-toplevel`.text()).trim();
const cliDir = path.join(repoRoot, "apps/cli");

const rootPkg = JSON.parse(await readFile(path.join(cliDir, "package.json"), "utf8"));
const repoField = rootPkg.repository?.url ?? rootPkg.repository ?? "";
const repoUrl = `${String(repoField)
.replace(/^git\+/, "")
.replace(/\.git$/, "")
.replace(/\/$/, "")}.git`;
if (!repoUrl.startsWith("http")) {
console.error(`Could not derive repository URL from apps/cli/package.json (got: ${repoUrl})`);
process.exit(1);
}

const tagCheck = await $`git rev-parse -q --verify refs/tags/${tag}`
.cwd(repoRoot)
.nothrow()
.quiet();
if (tagCheck.exitCode !== 0) {
console.error(`Tag ${tag} not found locally. Try: git fetch --tags origin`);
process.exit(1);
}

const branch = tag.includes("-beta.") ? "develop" : "main";

const work = await mkdtemp(path.join(tmpdir(), "backfill-release-notes."));
const clone = path.join(work, "repo");

try {
console.error(`==> Cloning ${repoRoot} -> ${clone}`);
await $`git clone --quiet --no-local ${repoRoot} ${clone}`;

const originUrl = (await $`git -C ${repoRoot} remote get-url origin`.text()).trim();

// Notes refs aren't fetched by `git clone`. Pull from the source repo first
// (network-free), then from origin so even tags whose notes haven't been
// sync'd locally yet are available.
await $`git -C ${clone} fetch --no-tags --quiet ${repoRoot} +refs/notes/*:refs/notes/*`
.nothrow()
.quiet();
await $`git -C ${clone} fetch --no-tags --quiet ${originUrl} +refs/notes/*:refs/notes/*`
.nothrow()
.quiet();
await $`git -C ${clone} fetch --no-tags --quiet ${originUrl} +refs/heads/main:refs/remotes/origin/main +refs/heads/develop:refs/remotes/origin/develop`
.nothrow()
.quiet();

const sha = (await $`git -C ${clone} rev-list -n 1 ${tag}`.text()).trim();
await $`git -C ${clone} tag -d ${tag}`.quiet();
await $`git -C ${clone} checkout -B ${branch} ${sha} --quiet`;

// The clone only carries refs/heads/$BRANCH locally; seed the other
// configured branch from origin's tracking ref so semantic-release's
// branch validator sees both.
for (const cfg of ["main", "develop"]) {
if (cfg === branch) continue;
const refSha = await $`git -C ${clone} rev-parse --verify -q refs/remotes/origin/${cfg}`
.nothrow()
.quiet();
if (refSha.exitCode === 0) {
await $`git -C ${clone} update-ref refs/heads/${cfg} ${refSha.text().trim()}`;
}
}

// semantic-release's `git log --notes=refs/notes/semantic-release*` reader
// returns channels=[null] for any tag missing an annotation. With the
// current prerelease filter that drops the tag entirely, so the lastRelease
// walks past unannotated tags and ends up far enough back to drag
// unrelated commits into the changelog. Seed a channel note for every
// reachable tag that lacks one; convention is taken from the tag name.
const mergedTagsOut = await $`git -C ${clone} tag --merged HEAD --sort=v:refname`.text();
const mergedTags = mergedTagsOut.split("\n").filter((t) => t && t !== tag);
for (const prevTag of mergedTags) {
const noteCheck = await $`git -C ${clone} notes --ref semantic-release show ${prevTag}`
.nothrow()
.quiet();
if (noteCheck.exitCode === 0) continue;
const channel = prevTag.includes("-beta.")
? "beta"
: prevTag.includes("-alpha.")
? "alpha"
: "latest";
const payload = JSON.stringify({ channels: [channel] });
await $`git -C ${clone} notes --ref semantic-release add -f -m ${payload} ${prevTag}^{commit}`
.nothrow()
.quiet();
}

// Apply the *current* release config to the historical checkout. Before
// commit 2515885 (May 11) the develop branch had no explicit `channel`,
// which silently broke prerelease tag matching; before #5316 the plugin
// chain didn't include release-notes-generator. Using the current config
// gives the right notes shape regardless of what shipped at the tag.
const clonePkgPath = path.join(clone, "apps/cli/package.json");
const clonePkg = JSON.parse(await readFile(clonePkgPath, "utf8"));
clonePkg.release = rootPkg.release;
await writeFile(clonePkgPath, `${JSON.stringify(clonePkg, null, 2)}\n`);

// semantic-release runs `git ls-remote <repositoryUrl> <branch>` and
// silently exits with "behind remote" when the remote tip differs from
// HEAD - which it always does when backfilling an old tag. Use git's
// insteadOf to redirect the real GitHub URL to the local clone for the
// duration of this run; semantic-release still treats repositoryUrl as
// the GitHub URL so commit/PR links in the rendered notes are correct.
await $`git -C ${clone} config --local url.file://${clone}.insteadOf ${repoUrl}`;

console.error(`==> Re-staged on ${branch} @ ${sha} (without tag ${tag})`);
console.error(`==> Running semantic-release --dry-run`);

const result = await semanticRelease(
{ dryRun: true, noCi: true, repositoryUrl: repoUrl },
{
cwd: path.join(clone, "apps/cli"),
env: process.env,
stdout: process.stderr,
stderr: process.stderr,
},
);

if (!result || !result.nextRelease) {
console.error(`semantic-release did not compute a next release for ${tag}`);
process.exit(1);
}

const expected = tag.replace(/^v/, "");
if (result.nextRelease.version !== expected) {
console.error(
`semantic-release computed v${result.nextRelease.version} but expected ${tag}; ` +
`check channel notes and release config`,
);
process.exit(1);
}

const notes = result.nextRelease.notes ?? "";

if (apply) {
const notesFile = path.join(work, "notes.md");
await writeFile(notesFile, notes);
console.error(`==> Updating GitHub Release body for ${tag}`);
await $`gh release edit ${tag} --notes-file ${notesFile}`;
} else {
process.stdout.write(notes);
if (!notes.endsWith("\n")) process.stdout.write("\n");
}
} finally {
await rm(work, { recursive: true, force: true });
}
Loading
Loading