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
32 changes: 32 additions & 0 deletions .github/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Categorization for GitHub's auto-generated release notes on orb-v<semver> 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:
- "*"
59 changes: 51 additions & 8 deletions .github/workflows/release-selfhost.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
200 changes: 200 additions & 0 deletions test/unit/release-selfhost-notes.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading