diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000000..31690ce9ea --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,32 @@ +# Categorization for GitHub's auto-generated release notes on orb-v tags. The release-orb +# workflow (.github/workflows/release-selfhost.yml) calls the "Generate release notes content" API +# with this repo's default branch as context, which groups merged PRs since the previous orb-v tag +# into the categories below by label. `gittensor:feature` and `gittensor:bug` are applied +# automatically by gittensory's own review engine on every merged PR, so this works without any +# separate manual-labeling process. +# +# `exclude.authors` matches the exact GitHub API login, which for a bot account includes the `[bot]` +# suffix (confirmed against this repo's own PR history: dependabot's login is literally +# `dependabot[bot]`, not `dependabot`) -- excludes `dependabot[bot]`/`renovate[bot]` (routine dependency +# version bumps) and `github-actions[bot]` (no PRs here currently, but automation-authored chore/release +# PRs are the same operator-facing noise if that ever changes) so none of the three clutter an +# operator-facing image changelog. Deliberately does NOT exclude `sentry[bot]` (Seer-authored fixes for +# real production errors, e.g. #3305, get a normal `gittensor:bug` label and are exactly the kind of +# change this changelog exists to surface) or `gittensory-orb[bot]` (the review engine's own rare, real +# content changes, e.g. #3397). +changelog: + exclude: + authors: + - dependabot[bot] + - renovate[bot] + - github-actions[bot] + categories: + - title: ๐Ÿš€ Features + labels: + - "gittensor:feature" + - title: ๐Ÿ› Fixes + labels: + - "gittensor:bug" + - title: ๐Ÿงน Other Changes + labels: + - "*" diff --git a/.github/workflows/release-selfhost.yml b/.github/workflows/release-selfhost.yml index bb3ff1cec3..e533969160 100644 --- a/.github/workflows/release-selfhost.yml +++ b/.github/workflows/release-selfhost.yml @@ -308,21 +308,64 @@ jobs: if [ "$PRERELEASE" = "true" ]; then PRERELEASE_ARGS=(--prerelease --latest=false) fi - # `--generate-notes` appends GitHub's auto-generated "what's changed" body (every commit/PR - # description since the last release) on top of `$NOTES` -- confirmed by hitting GitHub's - # 125000-char release-body limit cutting the very first release here, where a single - # squash-merged PR's body alone was large. `$NOTES` (the pull command + version metadata) is - # what operators actually need; drop `--generate-notes` so an unusually large commit/PR history - # can never fail release creation outright. + # Find the orb-v tag immediately preceding this one, by the best available local tag-date + # ordering (`--sort=-creatordate`: the tag's own date for an annotated tag, or its tagged + # commit's date for a lightweight one) -- NOT a semver sort, which mis-orders a prerelease + # against its own later stable tag (e.g. 0.1.0-beta.2 vs 0.1.0), and NOT the Release Notes + # API's own "previous release" auto-detection either (this repo's release list also carries + # mcp-v* releases on an independent cadence, which could get picked up by mistake once the two + # schemes' timestamps interleave). This is NOT a guarantee of true creation order for two + # lightweight orb-v tags pointing at the SAME commit (e.g. promoting an -rc straight to stable + # with no new commits) -- that tie is broken by git's own secondary sort, not by which tag was + # actually pushed first. Not a correctness problem in practice: the changelog for that specific + # release would just come out emptier than expected, never wrong or release-blocking. Excludes + # the tag being released so a workflow re-run never diffs a tag against itself. + PREV_TAG=$(git tag -l 'orb-v*' --sort=-creatordate | grep -vF -x "$REF_NAME" | head -1 || true) + + # The very first orb release here hit GitHub's 125000-character release-body limit + # generating notes across the ENTIRE repo history, because no prior orb-v tag existed yet to + # bound the diff -- that's why `--generate-notes` was dropped entirely rather than just + # pinning its range. Every release from orb-v0.1.0 onward has a real previous tag (confirmed: + # orb-v0.1.0 -> orb-v0.2.0 generates ~11K chars, comfortably under the limit), so fetch the + # PR changelog explicitly scoped to that range via the same API `--generate-notes` uses, + # categorized by `.github/release.yml`. PREV_TAG empty (no prior orb-v tag) skips this and + # falls back to the plain notes below, the same as the original safe behavior. + CHANGELOG="" + if [ -n "$PREV_TAG" ]; then + if ! CHANGELOG=$(gh api "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \ + -f tag_name="$REF_NAME" \ + -f previous_tag_name="$PREV_TAG" \ + --jq '.body' 2>/dev/null); then + # Fails open below (plain notes, no changelog) either way -- this warning just tells an + # operator reading the run log that the section is missing because the API call itself + # failed, not because the range between PREV_TAG and this release genuinely had no PRs. + echo "::warning::Fetching the release changelog (${PREV_TAG}...${REF_NAME}) failed; publishing without it." + CHANGELOG="" + fi + fi + + FULL_NOTES="$NOTES" + if [ -n "$CHANGELOG" ]; then + FULL_NOTES="${NOTES}"$'\n\n'"${CHANGELOG}" + fi + + # Never let an outlier changelog (an unusually large PR history, or a future change to what + # the categorization API returns) block publishing the image itself -- fall back to the plain + # pull-command notes plus a compare link if the combined body would exceed GitHub's limit. + if [ "${#FULL_NOTES}" -gt 120000 ]; then + echo "::warning::Generated release notes would be ${#FULL_NOTES} chars, near GitHub's 125000 release-body limit -- falling back to the plain notes without the changelog." + FULL_NOTES="${NOTES}"$'\n\n'"_Changelog omitted (too large for a GitHub Release body) -- see https://github.com/${GITHUB_REPOSITORY}/compare/${PREV_TAG}...${REF_NAME}_" + fi + if gh release view "$REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then gh release edit "$REF_NAME" --repo "$GITHUB_REPOSITORY" \ --title "gittensory-orb ${RELEASE_TAG}" \ - --notes "$NOTES" \ + --notes "$FULL_NOTES" \ "${PRERELEASE_ARGS[@]}" else gh release create "$REF_NAME" --repo "$GITHUB_REPOSITORY" \ --verify-tag \ --title "gittensory-orb ${RELEASE_TAG}" \ "${PRERELEASE_ARGS[@]}" \ - --notes "$NOTES" + --notes "$FULL_NOTES" fi diff --git a/test/unit/release-selfhost-notes.test.ts b/test/unit/release-selfhost-notes.test.ts new file mode 100644 index 0000000000..fa38546173 --- /dev/null +++ b/test/unit/release-selfhost-notes.test.ts @@ -0,0 +1,200 @@ +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { afterEach, describe, expect, it } from "vitest"; +import { parse } from "yaml"; + +const tmpDirs: string[] = []; +afterEach(() => { + for (const dir of tmpDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +// Extracted straight out of the committed workflow YAML (same technique as the "Resolve version" step +// tests in release-selfhost-prerelease.test.ts), so a regression in the actual bash fails this test +// instead of only surfacing on a real tag push. +function readGithubReleaseStep(): string { + const workflow = parse(readFileSync(".github/workflows/release-selfhost.yml", "utf8")) as { + jobs: { release: { steps: Array<{ name?: string; run?: string }> } }; + }; + const step = workflow.jobs.release.steps.find((s) => s.name === "GitHub Release"); + if (!step?.run) throw new Error('step "GitHub Release" not found or has no run: block'); + return step.run; +} + +interface HarnessOptions { + /** Lines "git tag -l 'orb-v*' --sort=-creatordate" should print, newest first. */ + tagList: string[]; + /** Body the fake "gh api .../releases/generate-notes" call returns; omit to simulate the API failing. */ + changelogBody?: string; + /** Whether "gh release view" should report the release as already existing (drives create vs edit). */ + releaseExists?: boolean; +} + +function createHarness(options: HarnessOptions) { + const dir = mkdtempSync(join(tmpdir(), "gtorb-release-notes-")); + const binDir = join(dir, "bin"); + const callsLog = join(dir, "calls.log"); + const notesFile = join(dir, "notes-passed.txt"); + mkdirSync(binDir); + tmpDirs.push(dir); + + writeFileSync(join(dir, "changelog-body.json"), JSON.stringify({ body: options.changelogBody ?? "" })); + + writeFileSync( + join(binDir, "git"), + `#!/usr/bin/env bash +set -euo pipefail +printf 'git %s\\n' "$*" >> "$CALLS_LOG" +if [ "$1" = "tag" ]; then + printf '%s\\n' ${options.tagList.map((t) => `"${t}"`).join(" ")} + exit 0 +fi +printf 'unexpected git invocation: %s\\n' "$*" >&2 +exit 1 +`, + ); + chmodSync(join(binDir, "git"), 0o755); + + const generateNotesExit = options.changelogBody === undefined ? 1 : 0; + writeFileSync( + join(binDir, "gh"), + `#!/usr/bin/env bash +set -euo pipefail +printf 'gh %s\\n' "$*" >> "$CALLS_LOG" +if [ "$1" = "api" ]; then + if [ "${generateNotesExit}" = "1" ]; then + exit 1 + fi + node -e "process.stdout.write(JSON.parse(require('fs').readFileSync(process.env.CHANGELOG_FILE, 'utf8')).body)" + exit 0 +fi +if [ "$1" = "release" ] && [ "$2" = "view" ]; then + exit ${options.releaseExists ? "0" : "1"} +fi +if [ "$1" = "release" ] && { [ "$2" = "create" ] || [ "$2" = "edit" ]; }; then + prev="" + for arg in "$@"; do + if [ "$prev" = "--notes" ]; then + printf '%s' "$arg" > "$NOTES_FILE" + fi + prev="$arg" + done + exit 0 +fi +printf 'unexpected gh invocation: %s\\n' "$*" >&2 +exit 1 +`, + ); + chmodSync(join(binDir, "gh"), 0o755); + + return { + dir, + run() { + const run = readGithubReleaseStep(); + const result = spawnSync("bash", ["-c", run], { + encoding: "utf8", + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH ?? ""}`, + CALLS_LOG: callsLog, + NOTES_FILE: notesFile, + CHANGELOG_FILE: join(dir, "changelog-body.json"), + GH_TOKEN: "test-token", + GITHUB_REPOSITORY: "JSONbored/gittensory", + REF_NAME: "orb-v0.2.0", + RELEASE_VERSION: "0.2.0", + RELEASE_TAG: "orb-v0.2.0", + RELEASE_ID: "gittensory-orb@0.2.0", + REPOSITORY_OWNER: "JSONbored", + PRERELEASE: "false", + }, + }); + return { + status: result.status, + stdout: result.stdout, + stderr: result.stderr, + calls: readOptional(callsLog), + notesPassed: readOptional(notesFile), + }; + }, + }; +} + +function readOptional(path: string): string { + try { + return readFileSync(path, "utf8"); + } catch { + return ""; + } +} + +describe('release-selfhost.yml "GitHub Release" step changelog generation', () => { + it("appends the generated changelog to the plain notes when a previous orb-v tag exists", () => { + const harness = createHarness({ + tagList: ["orb-v0.2.0", "orb-v0.1.0", "orb-v0.1.0-beta.2", "orb-v0.1.0-beta.1"], + changelogBody: "## What's Changed\n* feat: something by @someone in .../pull/1", + }); + const r = harness.run(); + expect(r.status).toBe(0); + expect(r.notesPassed).toContain("docker pull ghcr.io/jsonbored/gittensory-selfhost:orb-v0.2.0"); + expect(r.notesPassed).toContain("## What's Changed"); + expect(r.notesPassed).toContain("feat: something"); + // The tag being released must never be diffed against itself. + expect(r.calls).toContain("previous_tag_name=orb-v0.1.0"); + expect(r.calls).not.toContain("previous_tag_name=orb-v0.2.0"); + // Lock the explicit range as ONE call, not just two substrings present somewhere in the log -- + // tag_name and previous_tag_name must be parameters of the same generate-notes invocation. + const apiCall = r.calls.split("\n").find((line) => line.includes("gh api")); + expect(apiCall).toContain("tag_name=orb-v0.2.0"); + expect(apiCall).toContain("previous_tag_name=orb-v0.1.0"); + }); + + it("falls back to the plain notes with no changelog section when there is no prior orb-v tag", () => { + // The very-first-release case: the tag list contains only the tag being released, so PREV_TAG + // resolves empty and the generate-notes call must be skipped entirely (not even attempted). + const harness = createHarness({ tagList: ["orb-v0.2.0"], changelogBody: "## What's Changed\n* whatever" }); + const r = harness.run(); + expect(r.status).toBe(0); + expect(r.notesPassed).toContain("docker pull ghcr.io/jsonbored/gittensory-selfhost:orb-v0.2.0"); + expect(r.notesPassed).not.toContain("What's Changed"); + expect(r.calls).not.toContain("gh api"); + }); + + it("falls back to a compare-link note instead of a changelog that would exceed GitHub's release-body limit", () => { + const harness = createHarness({ + tagList: ["orb-v0.2.0", "orb-v0.1.0"], + changelogBody: "x".repeat(121000), + }); + const r = harness.run(); + expect(r.status).toBe(0); + expect(r.notesPassed).not.toContain("xxxx"); + expect(r.notesPassed).toContain("Changelog omitted"); + expect(r.notesPassed).toContain("https://github.com/JSONbored/gittensory/compare/orb-v0.1.0...orb-v0.2.0"); + expect(r.notesPassed.length).toBeLessThan(121000); + // The fallback must not drop the operator-critical pull command along with the oversized changelog. + expect(r.notesPassed).toContain("docker pull ghcr.io/jsonbored/gittensory-selfhost:orb-v0.2.0"); + }); + + it("uses release create when the release does not exist yet, and release edit when it does", () => { + const notYetReleased = createHarness({ tagList: ["orb-v0.2.0", "orb-v0.1.0"], changelogBody: "notes", releaseExists: false }); + const r1 = notYetReleased.run(); + expect(r1.calls).toContain("gh release create orb-v0.2.0"); + expect(r1.calls).not.toContain("gh release edit"); + + const alreadyReleased = createHarness({ tagList: ["orb-v0.2.0", "orb-v0.1.0"], changelogBody: "notes", releaseExists: true }); + const r2 = alreadyReleased.run(); + expect(r2.calls).toContain("gh release edit orb-v0.2.0"); + expect(r2.calls).not.toContain("gh release create"); + }); + + it("still publishes with the plain notes if the generate-notes API call itself fails", () => { + const harness = createHarness({ tagList: ["orb-v0.2.0", "orb-v0.1.0"] }); // changelogBody omitted -> API call exits 1 + const r = harness.run(); + expect(r.status).toBe(0); + expect(r.notesPassed).toContain("docker pull ghcr.io/jsonbored/gittensory-selfhost:orb-v0.2.0"); + // A silent empty changelog would be indistinguishable from a genuinely empty PR range -- the run + // log must say the API call itself failed. + expect(r.stdout).toContain("::warning::Fetching the release changelog"); + }); +});