diff --git a/.github/workflows/orb-stable-release-pr.yml b/.github/workflows/orb-stable-release-pr.yml new file mode 100644 index 0000000000..5e91d318bf --- /dev/null +++ b/.github/workflows/orb-stable-release-pr.yml @@ -0,0 +1,144 @@ +# Automated ORB (self-host container image, ghcr.io/jsonbored/gittensory-selfhost) STABLE-channel Release PR -- +# the release-please-equivalent for ORB, whose cross-cutting image-relevant scoping (src/** shared with +# UI/MCP-only subtrees it must exclude -- see orb-release-core.mjs's IMAGE_RELEVANT_PREFIXES/EXCLUDED_PREFIXES) +# doesn't fit release-please's directory-component model the way packages/gittensory-mcp and +# packages/gittensory-engine do (see mcp-release-please.yml). Same UX contract as those, hand-rolled: on the +# same schedule (or on demand), (re)compute the next stable version from conventional commits since the last +# STABLE orb-v tag (scripts/check-orb-stable-release-due.mjs / orb-release-core.mjs's buildOrbStableReleaseReport) +# and keep a standing `release-orb-stable` branch + PR in sync with that proposal. Nothing ships until a +# maintainer reviews and merges it -- see orb-stable-release-tag.yml for what happens then. Never touches the +# daily fully-unattended beta channel (orb-beta-release.yml). +name: orb-stable-release-pr + +on: + workflow_dispatch: + schedule: + - cron: "0 16 */2 * *" + +permissions: + contents: write # push the release-orb-stable branch + pull-requests: write # create/update the Release PR + +concurrency: + group: orb-stable-release-pr + cancel-in-progress: false + +jobs: + refresh-release-pr: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: 24.18.0 + + - name: Check whether a stable ORB release is due + id: report + run: | + set -euo pipefail + node scripts/check-orb-stable-release-due.mjs --json --output orb-stable-release-due.json + node <<'NODE' + const fs = require("node:fs"); + const report = JSON.parse(fs.readFileSync("orb-stable-release-due.json", "utf8")); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `due=${report.due}\n`); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `next_version=${report.nextVersion}\n`); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `release_type=${report.releaseType}\n`); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `commit_count=${report.commits.length}\n`); + NODE + + # Bumps orb-manifest.json's version to the proposal -- this diff, sitting in an open PR nobody has merged + # yet, IS the human-reviewable gate: nothing downstream (tagging, publishing) happens until a maintainer + # merges it. Never writes directly to main. + - name: Update orb-manifest.json + if: steps.report.outputs.due == 'true' + env: + NEXT_VERSION: ${{ steps.report.outputs.next_version }} + run: | + set -euo pipefail + node <<'NODE' + const fs = require("node:fs"); + const manifest = JSON.parse(fs.readFileSync("orb-manifest.json", "utf8")); + manifest.version = process.env.NEXT_VERSION; + fs.writeFileSync("orb-manifest.json", `${JSON.stringify(manifest, null, 2)}\n`); + NODE + + - name: Render release notes + if: steps.report.outputs.due == 'true' + run: | + set -euo pipefail + node <<'NODE' > release-orb-stable-body.md + const fs = require("node:fs"); + const report = JSON.parse(fs.readFileSync("orb-stable-release-due.json", "utf8")); + const lines = [ + `Proposes cutting **orb-v${report.nextVersion}** (a \`${report.releaseType}\` release) from the current` , + `stable release **${report.latestStableTag ?? "(none yet)"}**.`, + "", + "Merging this PR tags `orb-v" + report.nextVersion + "` and dispatches the image build/publish --" , + "which still requires a `release` environment approval before anything reaches GHCR (see" , + "`.github/workflows/release-selfhost.yml`). The daily beta channel is unaffected either way.", + "", + "## Image-relevant commits since the last stable release", + "", + ...report.commits.map((c) => `- ${c.subject} (${c.sha.slice(0, 7)})`), + ]; + console.log(lines.join("\n")); + NODE + + # Standing branch, force-pushed each run so it always reflects the CURRENT proposal (mirrors how a + # release-please Release PR keeps rebasing itself as new commits land) -- never a growing pile of stale + # commits from prior runs. + - name: Push the release-orb-stable branch + if: steps.report.outputs.due == 'true' + env: + GH_TOKEN: ${{ github.token }} + NEXT_VERSION: ${{ steps.report.outputs.next_version }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -B release-orb-stable + git add orb-manifest.json + git commit -m "chore(release): cut orb-v${NEXT_VERSION}" + git remote set-url origin "https://github.com/${GITHUB_REPOSITORY}.git" + gh auth setup-git + git push --force origin release-orb-stable + + - name: Create or refresh the Release PR + if: steps.report.outputs.due == 'true' + env: + GH_TOKEN: ${{ github.token }} + NEXT_VERSION: ${{ steps.report.outputs.next_version }} + run: | + set -euo pipefail + title="chore(release): cut orb-v${NEXT_VERSION}" + existing="$(gh pr list --head release-orb-stable --state open --json number --jq '.[0].number // empty')" + if [ -n "$existing" ]; then + gh pr edit "$existing" --title "$title" --body-file release-orb-stable-body.md + else + gh pr create --head release-orb-stable --base main --title "$title" --body-file release-orb-stable-body.md + fi + + - name: Summarize + if: always() + run: | + node <<'NODE' + const fs = require("node:fs"); + if (!fs.existsSync("orb-stable-release-due.json")) process.exit(0); + const report = JSON.parse(fs.readFileSync("orb-stable-release-due.json", "utf8")); + const lines = [ + "## ORB Stable Release PR", + "", + `- Due: \`${report.due}\``, + `- Current stable: \`${report.latestStableTag ?? "none"}\``, + `- Proposed next version: \`${report.nextVersion}\` (\`${report.releaseType ?? "n/a"}\`)`, + `- Image-relevant commits since stable: \`${report.commits.length}\``, + ]; + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${lines.join("\n")}\n`); + NODE diff --git a/.github/workflows/orb-stable-release-tag.yml b/.github/workflows/orb-stable-release-tag.yml new file mode 100644 index 0000000000..a05c835dc9 --- /dev/null +++ b/.github/workflows/orb-stable-release-tag.yml @@ -0,0 +1,84 @@ +# Fires when a maintainer merges the standing `release-orb-stable` PR opened/refreshed by +# orb-stable-release-pr.yml -- that merge (a real human GitHub-UI action, not a bot-authored push, so none of +# the GITHUB_TOKEN recursion-prevention caveats orb-beta-release.yml/mcp-release-please.yml document apply +# here) is the single deliberate "ship it" gesture for the STABLE channel. Tags `orb-vX.Y.Z` from the +# now-merged orb-manifest.json version and dispatches release-selfhost.yml exactly like the beta workflow does, +# except the version has no `-beta.` suffix so release-selfhost.yml's own environment expression routes it to +# the human-gated `release` environment (reviewer approval required) rather than `release-beta` -- promoting to +# stable still gets a second, independent approval beyond this PR merge. +name: orb-stable-release-tag + +on: + pull_request: + types: [closed] + branches: [main] + +permissions: + contents: write # create + push the stable tag + actions: write # dispatch release-selfhost.yml for the new tag + +concurrency: + group: orb-stable-release-tag + cancel-in-progress: false + +jobs: + tag-stable: + if: github.event.pull_request.merged == true && github.event.pull_request.head.ref == 'release-orb-stable' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: main + fetch-depth: 0 + persist-credentials: false + + - name: Read the released version + id: version + run: | + set -euo pipefail + version="$(node -e 'console.log(JSON.parse(require("node:fs").readFileSync("orb-manifest.json", "utf8")).version)')" + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "tag=orb-v${version}" >> "$GITHUB_OUTPUT" + + # Same idempotency guard as orb-beta-release.yml's own tagging step: refuse an ambiguous branch collision, + # skip (rather than fail) if the tag already exists -- e.g. a second merge to the same target version, + # which the due-check on the next scheduled run would already have prevented from being proposed again, + # but this stays a hard backstop independent of that. + - name: Tag the stable release + id: tag + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.version.outputs.tag }} + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + if git ls-remote --exit-code --heads origin "$TAG" >/dev/null 2>&1; then + echo "::error::Branch $TAG already exists; refusing to create or dispatch an ambiguous release ref." + exit 1 + fi + if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + echo "Tag $TAG already exists; skipping (a previous run likely already tagged it)." + echo "created=false" >> "$GITHUB_OUTPUT" + else + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "$TAG" -m "gittensory-orb ${VERSION}" + git remote set-url origin "https://github.com/${GITHUB_REPOSITORY}.git" + gh auth setup-git + git push origin "$TAG" + echo "created=true" >> "$GITHUB_OUTPUT" + fi + + # Dispatched against the fully qualified TAG ref, not `main`, for the same race-safety reason + # orb-beta-release.yml's identical step documents: main can move between the tag push above and this + # dispatch, and release-selfhost.yml's own TAG_SHA-must-equal-RELEASE_SHA fail-safe would abort if the two + # diverged. + - name: Dispatch the ORB release build + if: steps.tag.outputs.created == 'true' + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.version.outputs.tag }} + VERSION: ${{ steps.version.outputs.version }} + run: gh workflow run release-selfhost.yml --ref "refs/tags/$TAG" -f "version=${VERSION}" -f create_github_release=true diff --git a/scripts/check-orb-stable-release-due.mjs b/scripts/check-orb-stable-release-due.mjs new file mode 100644 index 0000000000..d8fe097382 --- /dev/null +++ b/scripts/check-orb-stable-release-due.mjs @@ -0,0 +1,74 @@ +// Computes whether a new STABLE (non-beta) ORB release is due, and what its proposed version would be. +// Read-only / side-effect-free by design, mirroring check-orb-release-due.mjs's own reasoning: the actual +// orb-manifest.json bump + `git tag` + PR create/update (the consequential actions) happen as explicit, +// auditable steps in .github/workflows/orb-stable-release-pr.yml, not hidden inside this script. See +// scripts/orb-release-core.mjs's buildOrbStableReleaseReport for the underlying logic and rationale. +import { execFileSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import { buildOrbStableReleaseReport } from "./orb-release-core.mjs"; + +function main() { + const args = parseArgs(process.argv.slice(2)); + const tags = git(["tag", "--list", "orb-v*"]).split("\n").filter(Boolean); + const stableTagName = latestStableTagName(tags); + + const report = buildOrbStableReleaseReport({ + tags, + commitsSinceStable: readCommits(stableTagName ? `${stableTagName}..HEAD` : "HEAD"), + }); + + if (args.output) writeFileSync(args.output, `${JSON.stringify(report, null, 2)}\n`); + if (args.json) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + if (!args.json && !args.output) { + process.stdout.write(report.due ? `ORB stable release due: orb-v${report.nextVersion}\n` : "No ORB stable release due.\n"); + } +} + +// Same tag-selection concern as check-orb-release-due.mjs's latestStableTagName -- kept here (not exported +// from the core module) since it's a git-log concern, not a pure-logic one. +function latestStableTagName(tags) { + const stable = tags.filter((tag) => /^orb-v\d+\.\d+\.\d+$/.test(tag)); + return stable.sort(compareTagsDesc)[0] ?? null; +} + +function compareTagsDesc(left, right) { + return right.localeCompare(left, undefined, { numeric: true }); +} + +function parseArgs(argv) { + const args = { json: false, output: null }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--json") { + args.json = true; + } else if (arg === "--output") { + args.output = argv[++index]; + } else { + throw new Error(`Unknown option: ${arg}`); + } + } + return args; +} + +function readCommits(revisionRange) { + const format = "%x1e%H%x1f%s%x1f%B"; + const logOutput = git(["log", "--reverse", "--no-merges", `--format=${format}`, revisionRange]); + return logOutput + .split("\x1e") + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => { + const [sha, subject, ...bodyParts] = entry.split("\x1f"); + return { sha, subject: subject?.split("\n")[0] ?? "", body: bodyParts.join("\x1f"), files: readCommitFiles(sha) }; + }); +} + +function readCommitFiles(sha) { + return git(["diff-tree", "--no-commit-id", "--name-only", "-r", sha]).split("\n").filter(Boolean); +} + +function git(args) { + return execFileSync("git", args, { encoding: "utf8", maxBuffer: 1024 * 1024 * 200 }); +} + +main(); diff --git a/scripts/orb-release-core.d.mts b/scripts/orb-release-core.d.mts index 1f905bcfd2..9b10164718 100644 --- a/scripts/orb-release-core.d.mts +++ b/scripts/orb-release-core.d.mts @@ -26,6 +26,16 @@ export type OrbReleaseReport = { latestStableTag: string | null; latestTag: string | null; commits: OrbReleaseCommit[]; + commitsSinceStable: OrbReleaseCommit[]; +}; + +export type OrbStableReleaseReport = { + due: boolean; + stableVersion: string; + nextVersion: string; + releaseType: "major" | "minor" | "patch" | null; + latestStableTag: string | null; + commits: OrbReleaseCommit[]; }; export function parseConventionalSubject(subject: string): { @@ -43,8 +53,10 @@ export function latestStableOrbTag(tags: string[]): { tag: string; version: stri export function latestOrbTag(tags: string[]): { tag: string; version: string } | null; export function isImageRelevantCommit(commit: OrbReleaseCommit): boolean; export function selectImageRelevantCommits(commits: T[]): T[]; +export function inferReleaseType(commits: OrbReleaseCommit[]): "major" | "minor" | "patch" | null; export function buildOrbReleaseReport(input: { tags: string[]; manifestVersion: string | null; commits: { sinceStable: OrbReleaseCommit[]; sinceLastTag: OrbReleaseCommit[] }; }): OrbReleaseReport; +export function buildOrbStableReleaseReport(input: { tags: string[]; commitsSinceStable?: OrbReleaseCommit[] }): OrbStableReleaseReport; diff --git a/scripts/orb-release-core.mjs b/scripts/orb-release-core.mjs index 5a61f8ea9d..5d23c78be9 100644 --- a/scripts/orb-release-core.mjs +++ b/scripts/orb-release-core.mjs @@ -131,7 +131,7 @@ function matchesAnyPrefix(file, prefixes) { return prefixes.some((prefix) => (prefix.endsWith("/") ? file.startsWith(prefix) : file === prefix)); } -function inferReleaseType(commits) { +export function inferReleaseType(commits) { if (commits.length === 0) return null; let type = "patch"; for (const commit of commits) { @@ -188,5 +188,34 @@ export function buildOrbReleaseReport({ tags, manifestVersion, commits }) { latestStableTag: stableTag?.tag ?? null, latestTag: anyTag?.tag ?? null, commits: commitsSinceLastTag, + commitsSinceStable, + }; +} + +/** + * Decide whether a STABLE (non-beta) ORB release is due, and what its version would be -- the + * `.github/workflows/orb-stable-release-pr.yml` counterpart to {@link buildOrbReleaseReport}'s beta-channel + * logic. Unlike the beta report, this never reads `orb-manifest.json`'s declared target: the whole point of the + * standing Release PR this powers is to PROPOSE the next stable version (inferred purely from conventional + * commits since the last stable tag) for a maintainer to review by merging -- the PR diff writing that proposal + * into orb-manifest.json is itself the human-reviewable gate, so there's nothing left here to compare it + * against. + */ +export function buildOrbStableReleaseReport({ tags, commitsSinceStable }) { + const stableTag = latestStableOrbTag(tags); + const stableVersion = stableTag?.version ?? "0.0.0"; + + const relevantCommits = selectImageRelevantCommits(commitsSinceStable ?? []); + const releaseType = inferReleaseType(relevantCommits); + const nextVersion = releaseType ? bumpVersion(stableVersion, releaseType) : stableVersion; + const due = relevantCommits.length > 0; + + return { + due, + stableVersion, + nextVersion, + releaseType, + latestStableTag: stableTag?.tag ?? null, + commits: relevantCommits, }; } diff --git a/test/unit/orb-release.test.ts b/test/unit/orb-release.test.ts index 7957acc673..9b7604471c 100644 --- a/test/unit/orb-release.test.ts +++ b/test/unit/orb-release.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest"; import { bumpVersion, buildOrbReleaseReport, + buildOrbStableReleaseReport, compareSemver, + inferReleaseType, isImageRelevantCommit, latestOrbTag, latestStableOrbTag, @@ -203,4 +205,88 @@ describe("buildOrbReleaseReport", () => { expect(report.due).toBe(true); expect(report.nextTag).toBe("orb-v0.5.0-beta.1"); }); + + it("also exposes the raw image-relevant commits since the last STABLE tag, independent of the beta-vs-any-tag commits field", () => { + const relevant = commit("fix(queue): x", ["src/queue/processors.ts"]); + const excluded = commit("feat(ui): x", ["apps/gittensory-ui/src/x.tsx"]); + const report = buildOrbReleaseReport({ + tags: ["orb-v0.3.0"], + manifestVersion: "0.4.0", + commits: { sinceStable: [relevant, excluded], sinceLastTag: [relevant] }, + }); + expect(report.commitsSinceStable).toEqual([relevant]); + }); +}); + +describe("inferReleaseType", () => { + it("returns null for an empty commit list", () => { + expect(inferReleaseType([])).toBeNull(); + }); + + it("returns patch when no commit is a feat or breaking change", () => { + expect(inferReleaseType([commit("fix(queue): x", ["src/queue/processors.ts"])])).toBe("patch"); + }); + + it("returns minor when any commit is a feat", () => { + expect(inferReleaseType([commit("fix(queue): x", ["src/x.ts"]), commit("feat(review): x", ["src/y.ts"])])).toBe("minor"); + }); + + it("returns major when a commit subject has the `!` breaking marker, even ahead of a feat", () => { + expect(inferReleaseType([commit("feat(review)!: x", ["src/x.ts"])])).toBe("major"); + }); + + it("returns major when a commit body has a BREAKING CHANGE: footer, even without the `!` marker", () => { + expect(inferReleaseType([commit("fix(review): x", ["src/x.ts"], { body: "fix(review): x\n\nBREAKING CHANGE: removes the old field" })])).toBe("major"); + }); +}); + +describe("buildOrbStableReleaseReport", () => { + it("is not due when there are no image-relevant commits since the last stable tag", () => { + const report = buildOrbStableReleaseReport({ tags: ["orb-v0.3.0", "orb-v0.4.0-beta.27"], commitsSinceStable: [] }); + expect(report).toMatchObject({ due: false, stableVersion: "0.3.0", nextVersion: "0.3.0", releaseType: null }); + }); + + it("is not due when the only commits since stable are UI/MCP-only (excluded)", () => { + const report = buildOrbStableReleaseReport({ + tags: ["orb-v0.3.0"], + commitsSinceStable: [commit("feat(ui): x", ["apps/gittensory-ui/src/x.tsx"])], + }); + expect(report.due).toBe(false); + }); + + it("proposes a patch bump when only fixes landed", () => { + const report = buildOrbStableReleaseReport({ + tags: ["orb-v0.3.0"], + commitsSinceStable: [commit("fix(queue): x", ["src/queue/processors.ts"])], + }); + expect(report).toMatchObject({ due: true, nextVersion: "0.3.1", releaseType: "patch" }); + }); + + it("proposes a minor bump when a feat landed", () => { + const report = buildOrbStableReleaseReport({ + tags: ["orb-v0.3.0"], + commitsSinceStable: [commit("fix(queue): x", ["src/x.ts"]), commit("feat(review): x", ["src/y.ts"])], + }); + expect(report).toMatchObject({ due: true, nextVersion: "0.4.0", releaseType: "minor" }); + }); + + it("proposes a major bump when a breaking change landed", () => { + const report = buildOrbStableReleaseReport({ + tags: ["orb-v0.3.0"], + commitsSinceStable: [commit("feat(review)!: x", ["src/x.ts"])], + }); + expect(report).toMatchObject({ due: true, nextVersion: "1.0.0", releaseType: "major" }); + }); + + it("handles a brand-new repo with zero stable tags yet (baseline 0.0.0)", () => { + const report = buildOrbStableReleaseReport({ tags: [], commitsSinceStable: [commit("feat(review): first cut", ["src/review/x.ts"])] }); + expect(report.latestStableTag).toBeNull(); + expect(report.stableVersion).toBe("0.0.0"); + expect(report.nextVersion).toBe("0.1.0"); + }); + + it("defaults commitsSinceStable to empty when omitted", () => { + const report = buildOrbStableReleaseReport({ tags: ["orb-v0.3.0"] }); + expect(report).toMatchObject({ due: false, commits: [] }); + }); });