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
117 changes: 117 additions & 0 deletions .github/workflows/orb-beta-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# Automated ORB (self-host container image, ghcr.io/jsonbored/gittensory-selfhost) beta channel.
# Daily (or on demand via workflow_dispatch), checks whether any image-relevant commit has landed
# since the last orb-v tag (scripts/check-orb-release-due.mjs / scripts/orb-release-core.mjs) and,
# if so, cuts the next `orb-vX.Y.Z-beta.N` tag and dispatches release-selfhost.yml to build + publish
# it -- fully unattended: that workflow's `environment:` routes an actual beta version to
# `release-beta` (no required reviewers), while a stable/rc version still requires the human-gated
# `release` environment. Promoting a beta to a stable release stays a manual `git tag orb-vX.Y.Z` by
# a maintainer -- this workflow never bumps orb-manifest.json's version or cuts a non-beta tag.
#
# Deliberately independent of the MCP package's release automation (mcp-release-watch.yml /
# mcp-release-core.mjs) -- see scripts/orb-release-core.mjs's own header for why.
name: orb-beta-release

on:
workflow_dispatch:
schedule:
- cron: "10 6 * * *"

permissions:
contents: write # create + push the beta tag
actions: write # dispatch release-selfhost.yml for the new tag

concurrency:
group: orb-beta-release
cancel-in-progress: false

jobs:
cut-beta:
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 an ORB beta is due
id: report
run: |
set -euo pipefail
node scripts/check-orb-release-due.mjs --json --output orb-release-due.json
node <<'NODE'
const fs = require("node:fs");
const report = JSON.parse(fs.readFileSync("orb-release-due.json", "utf8"));
const version = report.nextTag.replace(/^orb-v/, "");
fs.appendFileSync(process.env.GITHUB_OUTPUT, `due=${report.due}\n`);
fs.appendFileSync(process.env.GITHUB_OUTPUT, `tag=${report.nextTag}\n`);
fs.appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\n`);
NODE

# Pushed with the default GITHUB_TOKEN, which does NOT fire release-selfhost.yml's own
# `push: tags:` trigger (GitHub suppresses workflow-triggered-workflow pushes to prevent
# recursion) -- that's why the next step dispatches it explicitly instead of relying on this
# push alone. Mirrors publish-engine.yml / npm-publish.yml's identical reasoning and tagging
# idiom.
# Exposes created=true/false so the dispatch step below never fires against a tag this run didn't
# actually just create -- a defense-in-depth backstop (independent of orb-release-core.mjs's own
# correctness) against ever re-triggering a build for an already-published version/tag.
- name: Tag the new beta
id: tag
if: steps.report.outputs.due == 'true'
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.report.outputs.tag }}
VERSION: ${{ steps.report.outputs.version }}
run: |
set -euo pipefail
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, or it collides with an already-published version)."
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

# create_github_release=true: the tag above was just created and pushed, so release-selfhost.yml's
# `--verify-tag` GitHub Release step can run safely (see that workflow's own comments). Gated on
# steps.tag.outputs.created (not just due) so a no-op tag step -- for any reason -- never triggers a
# rebuild/republish of an existing GHCR image tag with different content.
- name: Dispatch the ORB release build
if: steps.report.outputs.due == 'true' && steps.tag.outputs.created == 'true'
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.report.outputs.version }}
run: gh workflow run release-selfhost.yml --ref main -f "version=${VERSION}" -f create_github_release=true

- name: Summarize
if: always()
run: |
node <<'NODE'
const fs = require("node:fs");
const report = JSON.parse(fs.readFileSync("orb-release-due.json", "utf8"));
const lines = [
"## ORB Beta Release",
"",
`- Due: \`${report.due}\``,
`- Next tag: \`${report.nextTag}\``,
`- Target version: \`${report.targetVersion}\``,
`- Manifest version: \`${report.manifestVersion ?? "none"}\``,
`- Manifest stale (commits imply a bigger bump than the manifest declares): \`${report.manifestStale}\``,
`- Latest stable tag: \`${report.latestStableTag ?? "none"}\``,
`- Latest tag: \`${report.latestTag ?? "none"}\``,
`- Image-relevant commits since last tag: \`${report.commits.length}\``,
];
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${lines.join("\n")}\n`);
NODE
33 changes: 22 additions & 11 deletions .github/workflows/release-selfhost.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ on:
version:
description: "Version to publish (e.g. 0.1.0, or a prerelease 0.1.0-rc.1 / 0.1.0-beta.1)"
required: true
create_github_release:
description: "Also create/update the GitHub Release for this tag (the tag must already exist and be pushed). Used by the automated orb-beta-release dispatch; leave unchecked for an ad-hoc image rebuild."
type: boolean
default: false

permissions:
contents: write # create the GitHub Release
Expand All @@ -34,8 +38,12 @@ jobs:
release:
runs-on: ubuntu-latest
timeout-minutes: 40
# Environment gate — requires reviewer approval before a release runs (configure under repo Settings > Environments).
environment: release
# Environment gate. Only an actual `-beta.N` version routes to `release-beta` (no required
# reviewers -- see orb-beta-release.yml, which dispatches daily with no human in the loop); every
# other version (stable X.Y.Z or an -rc.N) stays on `release`, which requires reviewer approval
# under repo Settings > Environments. The check reads the raw version string directly (not a step
# output), since a job's `environment:` is resolved before any step runs.
environment: ${{ ((github.event_name == 'push' && contains(github.ref_name, '-beta.')) || (github.event_name == 'workflow_dispatch' && contains(inputs.version, '-beta.'))) && 'release-beta' || 'release' }}
env:
SENTRY_ORG: jsonbored
SENTRY_PROJECT: gittensory
Expand Down Expand Up @@ -286,10 +294,13 @@ jobs:
exit 1

- name: GitHub Release
if: github.event_name == 'push'
# A dispatch-triggered run only reaches here when the caller explicitly opted in (used by the
# automated orb-beta-release dispatch, which pushes the tag itself before dispatching --
# `--verify-tag` below needs the tag to already exist). A plain manual dispatch (image rebuild,
# flag left off) skips this step, same as before.
if: github.event_name == 'push' || inputs.create_github_release == true
env:
GH_TOKEN: ${{ github.token }}
REF_NAME: ${{ github.ref_name }}
RELEASE_VERSION: ${{ steps.version.outputs.v }}
RELEASE_TAG: ${{ steps.version.outputs.tag }}
RELEASE_ID: ${{ steps.version.outputs.release }}
Expand Down Expand Up @@ -334,7 +345,7 @@ jobs:
# 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)
PREV_TAG=$(git tag -l 'orb-v*' --sort=-creatordate | grep -vF -x "$RELEASE_TAG" | 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
Expand All @@ -347,13 +358,13 @@ jobs:
CHANGELOG=""
if [ -n "$PREV_TAG" ]; then
if ! CHANGELOG=$(gh api "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \
-f tag_name="$REF_NAME" \
-f tag_name="$RELEASE_TAG" \
-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."
echo "::warning::Fetching the release changelog (${PREV_TAG}...${RELEASE_TAG}) failed; publishing without it."
CHANGELOG=""
fi
fi
Expand All @@ -368,16 +379,16 @@ jobs:
# 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}_"
FULL_NOTES="${NOTES}"$'\n\n'"_Changelog omitted (too large for a GitHub Release body) -- see https://github.com/${GITHUB_REPOSITORY}/compare/${PREV_TAG}...${RELEASE_TAG}_"
fi

if gh release view "$REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release edit "$REF_NAME" --repo "$GITHUB_REPOSITORY" \
if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
--title "gittensory-orb ${RELEASE_TAG}" \
--notes "$FULL_NOTES" \
"${PRERELEASE_ARGS[@]}"
else
gh release create "$REF_NAME" --repo "$GITHUB_REPOSITORY" \
gh release create "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
--verify-tag \
--title "gittensory-orb ${RELEASE_TAG}" \
"${PRERELEASE_ARGS[@]}" \
Expand Down
4 changes: 4 additions & 0 deletions orb-manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"version": "0.4.0",
"description": "Source of truth for the self-hostable gittensory-orb container image's target release version (ghcr.io/jsonbored/gittensory-selfhost). Bumped by a maintainer when a feat/fix/breaking change since the last stable orb-v tag warrants moving to a new target version -- scripts/orb-release-core.mjs and .github/workflows/orb-beta-release.yml read this file to decide what version the next automated beta snapshot targets. Promoting a beta to a stable orb-vX.Y.Z release is still a manual `git tag` -- this manifest only drives the automated beta channel."
}
90 changes: 90 additions & 0 deletions scripts/check-orb-release-due.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Computes whether a new ORB (self-host container image) beta snapshot is due, and what its tag would be.
// Read-only / side-effect-free by design: this script only REPORTS -- the actual `git tag` + `push` (the
// consequential action) happens as explicit, auditable steps in .github/workflows/orb-beta-release.yml, not
// hidden inside this script. See scripts/orb-release-core.mjs for the underlying logic and rationale.
import { execFileSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import { buildOrbReleaseReport } from "./orb-release-core.mjs";

const MANIFEST_PATH = "orb-manifest.json";

function main() {
const args = parseArgs(process.argv.slice(2));
const manifestVersion = JSON.parse(readFileSync(MANIFEST_PATH, "utf8")).version;
const tags = git(["tag", "--list", "orb-v*"]).split("\n").filter(Boolean);

const stableTagName = latestStableTagName(tags);
const anyTagName = latestAnyTagName(tags);

const report = buildOrbReleaseReport({
tags,
manifestVersion,
commits: {
sinceStable: readCommits(stableTagName ? `${stableTagName}..HEAD` : "HEAD"),
sinceLastTag: readCommits(anyTagName ? `${anyTagName}..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 beta due: ${report.nextTag}\n` : "No ORB beta due.\n");
}
}

// Re-derives the same two "latest tag" views orb-release-core.mjs computes internally, purely so this CLI can
// pick the right git revision range for each -- 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 latestAnyTagName(tags) {
const versioned = tags.filter((tag) => /^orb-v\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(tag));
return versioned.sort(compareTagsDesc)[0] ?? null;
}

function compareTagsDesc(left, right) {
// Lexicographic is good enough here purely to pick a `git log` boundary -- buildOrbReleaseReport does the
// real semver-aware comparison for anything that ends up in the report itself.
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();
50 changes: 50 additions & 0 deletions scripts/orb-release-core.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
export type OrbReleaseCommit = {
sha?: string;
subject?: string;
body?: string;
files?: string[];
};

export type OrbSemver = {
major: number;
minor: number;
patch: number;
prerelease: string | null;
};

export type OrbBetaSemver = OrbSemver & {
betaNumber: number | null;
};

export type OrbReleaseReport = {
due: boolean;
targetVersion: string;
nextTag: string;
manifestVersion: string | null;
manifestStale: boolean;
inferredVersion: string;
latestStableTag: string | null;
latestTag: string | null;
commits: OrbReleaseCommit[];
};

export function parseConventionalSubject(subject: string): {
type: string | null;
scope: string | null;
breaking: boolean;
description: string;
conventional: boolean;
};
export function parseSemver(version: string): OrbSemver | null;
export function parseOrbBetaVersion(version: string): OrbBetaSemver | null;
export function compareSemver(leftVersion: string, rightVersion: string): number | null;
export function bumpVersion(version: string, releaseType: "major" | "minor" | "patch"): string;
export function latestStableOrbTag(tags: string[]): { tag: string; version: string } | null;
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 buildOrbReleaseReport(input: {
tags: string[];
manifestVersion: string | null;
commits: { sinceStable: OrbReleaseCommit[]; sinceLastTag: OrbReleaseCommit[] };
}): OrbReleaseReport;
Loading