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
144 changes: 144 additions & 0 deletions .github/workflows/orb-stable-release-pr.yml
Original file line number Diff line number Diff line change
@@ -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
84 changes: 84 additions & 0 deletions .github/workflows/orb-stable-release-tag.yml
Original file line number Diff line number Diff line change
@@ -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
74 changes: 74 additions & 0 deletions scripts/check-orb-stable-release-due.mjs
Original file line number Diff line number Diff line change
@@ -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();
12 changes: 12 additions & 0 deletions scripts/orb-release-core.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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): {
Expand All @@ -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<T extends OrbReleaseCommit>(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;
31 changes: 30 additions & 1 deletion scripts/orb-release-core.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
};
}
Loading