diff --git a/.github/workflows/orb-beta-release.yml b/.github/workflows/orb-beta-release.yml new file mode 100644 index 0000000000..c13c9431cc --- /dev/null +++ b/.github/workflows/orb-beta-release.yml @@ -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 diff --git a/.github/workflows/release-selfhost.yml b/.github/workflows/release-selfhost.yml index 64e888e656..143d5c64f4 100644 --- a/.github/workflows/release-selfhost.yml +++ b/.github/workflows/release-selfhost.yml @@ -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 @@ -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 @@ -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 }} @@ -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 @@ -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 @@ -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[@]}" \ diff --git a/orb-manifest.json b/orb-manifest.json new file mode 100644 index 0000000000..f398f109cd --- /dev/null +++ b/orb-manifest.json @@ -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." +} diff --git a/scripts/check-orb-release-due.mjs b/scripts/check-orb-release-due.mjs new file mode 100644 index 0000000000..fe46b11d67 --- /dev/null +++ b/scripts/check-orb-release-due.mjs @@ -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(); diff --git a/scripts/orb-release-core.d.mts b/scripts/orb-release-core.d.mts new file mode 100644 index 0000000000..1f905bcfd2 --- /dev/null +++ b/scripts/orb-release-core.d.mts @@ -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(commits: T[]): T[]; +export function buildOrbReleaseReport(input: { + tags: string[]; + manifestVersion: string | null; + commits: { sinceStable: OrbReleaseCommit[]; sinceLastTag: OrbReleaseCommit[] }; +}): OrbReleaseReport; diff --git a/scripts/orb-release-core.mjs b/scripts/orb-release-core.mjs new file mode 100644 index 0000000000..5a61f8ea9d --- /dev/null +++ b/scripts/orb-release-core.mjs @@ -0,0 +1,192 @@ +// Pure logic for the ORB (self-host container image, ghcr.io/jsonbored/gittensory-selfhost) automated beta +// channel. Deliberately independent of scripts/mcp-release-core.mjs (no shared imports/state) even though the +// shape mirrors it closely -- ORB and the MCP package are versioned, tagged, and published on separate +// schedules by separate automation, and keeping them decoupled means neither can accidentally regress the +// other's release path. +// +// Unlike MCP (whose packages/gittensory-mcp/package.json IS the version manifest, bumped by hand as part of a +// human release-prep PR), ORB has no npm manifest -- orb-manifest.json plays that role. The manifest's +// `version` is the maintainer's OWN stated intent ("we are working toward X.Y.Z"); this module only ever +// reads it, never proposes overwriting it automatically -- see `manifestStale` on the report. Promoting a +// beta to a stable, unsuffixed `orb-vX.Y.Z` tag is a separate, always-manual action. + +const ORB_TAG_PREFIX = "orb-v"; + +// Paths that make the self-host container image itself (src/server.ts's bundle, its DB schema, and the +// image/deploy tooling around it) -- NOT the Cloudflare Worker-only surfaces (UI, browser extension) or the +// separately-versioned MCP/engine/miner packages, which have their own release automation. +const IMAGE_RELEVANT_PREFIXES = [ + "src/", + "migrations/", + "Dockerfile", + "docker-compose.yml", + "scripts/build-selfhost.mjs", + "scripts/deploy-selfhost-image.sh", + "scripts/deploy-selfhost-prebuilt.sh", + "scripts/lib/selfhost-deploy-common.sh", + "scripts/selfhost-post-update-check.sh", + "scripts/validate-selfhost-sourcemap.mjs", + "scripts/gen-selfhost-env-reference.mjs", + "scripts/export-grafana-reporting-db.sh", + ".github/workflows/release-selfhost.yml", +]; + +// Never itself a reason to cut a new image -- these are Worker-only, or genuinely orthogonal to what runs +// inside the self-host container. +const EXCLUDED_PREFIXES = [ + "apps/gittensory-ui/", + "apps/gittensory-extension/", + "packages/gittensory-mcp/", + "packages/gittensory-miner/", + "src/mcp/", + "src/env.d.ts", // ambient Worker binding types only -- never reachable at self-host runtime +]; + +export function parseConventionalSubject(subject) { + const trimmed = subject.trim(); + const match = /^(?[a-z]+)(?:\((?[^)]+)\))?(?!)?:\s*(?.+)$/.exec(trimmed); + if (match?.groups) { + return { + type: match.groups.type, + scope: match.groups.scope ?? null, + breaking: Boolean(match.groups.breaking), + description: match.groups.description.trim(), + conventional: true, + }; + } + return { type: null, scope: null, breaking: false, description: trimmed, conventional: false }; +} + +export function parseSemver(version) { + const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(version ?? "").trim()); + if (!match) return null; + return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]), prerelease: match[4] ?? null }; +} + +/** Parses ONLY the beta-channel shape this repo actually uses (`X.Y.Z-beta.N`) -- any other prerelease label + * (an `-rc.N`, a bare stable tag, or an unrecognized suffix) returns `betaNumber: null`, since it isn't a + * beta-channel tag this module's counter logic applies to. */ +export function parseOrbBetaVersion(version) { + const parsed = parseSemver(version); + if (!parsed) return null; + const betaMatch = /^beta\.(\d+)$/.exec(parsed.prerelease ?? ""); + return { ...parsed, betaNumber: betaMatch ? Number(betaMatch[1]) : null }; +} + +export function compareSemver(leftVersion, rightVersion) { + const left = parseSemver(leftVersion); + const right = parseSemver(rightVersion); + if (!left || !right) return null; + for (const part of ["major", "minor", "patch"]) { + if (left[part] !== right[part]) return left[part] < right[part] ? -1 : 1; + } + if (left.prerelease === right.prerelease) return 0; + if (!left.prerelease) return 1; + if (!right.prerelease) return -1; + const comparison = left.prerelease.localeCompare(right.prerelease, undefined, { numeric: true, sensitivity: "base" }); + return comparison === 0 ? 0 : comparison < 0 ? -1 : 1; +} + +export function bumpVersion(version, releaseType) { + const parsed = parseSemver(version); + if (!parsed) throw new Error(`Invalid semver version: ${version}`); + if (releaseType === "major") return `${parsed.major + 1}.0.0`; + if (releaseType === "minor") return `${parsed.major}.${parsed.minor + 1}.0`; + return `${parsed.major}.${parsed.minor}.${parsed.patch + 1}`; +} + +/** The highest STABLE (no prerelease suffix) orb-v tag -- the baseline every commit-scan/bump calculation + * measures from, regardless of how many beta snapshots have been cut since. */ +export function latestStableOrbTag(tags) { + return tags + .map((tag) => ({ tag, version: tag.startsWith(ORB_TAG_PREFIX) ? tag.slice(ORB_TAG_PREFIX.length) : null })) + .filter(({ version }) => version && parseSemver(version) !== null && parseSemver(version).prerelease === null) + .sort((left, right) => compareSemver(right.version, left.version) ?? 0)[0] ?? null; +} + +/** The highest orb-v tag of ANY kind (stable or beta) -- where the next commit-scan window starts, and what + * the next beta number counts up from when the target version hasn't changed. */ +export function latestOrbTag(tags) { + return tags + .map((tag) => ({ tag, version: tag.startsWith(ORB_TAG_PREFIX) ? tag.slice(ORB_TAG_PREFIX.length) : null })) + .filter(({ version }) => version && parseSemver(version)) + .sort((left, right) => compareSemver(right.version, left.version) ?? 0)[0] ?? null; +} + +export function isImageRelevantCommit(commit) { + const subject = (commit.subject ?? "").trim(); + if (!subject) return false; + if (/^merge\b/i.test(subject)) return false; + const files = commit.files ?? []; + if (files.length === 0) return false; + const relevantFiles = files.filter((file) => matchesAnyPrefix(file, IMAGE_RELEVANT_PREFIXES) && !matchesAnyPrefix(file, EXCLUDED_PREFIXES)); + return relevantFiles.length > 0; +} + +export function selectImageRelevantCommits(commits) { + return commits.filter((commit) => isImageRelevantCommit(commit)); +} + +function matchesAnyPrefix(file, prefixes) { + return prefixes.some((prefix) => (prefix.endsWith("/") ? file.startsWith(prefix) : file === prefix)); +} + +function inferReleaseType(commits) { + if (commits.length === 0) return null; + let type = "patch"; + for (const commit of commits) { + const parsed = parseConventionalSubject(commit.subject ?? ""); + if (parsed.breaking || /BREAKING CHANGE:/i.test(commit.body ?? "")) return "major"; + if (parsed.type === "feat") type = "minor"; + } + return type; +} + +/** + * Decide whether a new beta snapshot is due, and for which version. `manifestVersion` is the maintainer's + * OWN stated target (orb-manifest.json), never overwritten by this function -- `manifestStale: true` only + * FLAGS that the commits since the last stable tag imply a bigger bump than the manifest currently declares + * (e.g. a `feat:` landed but the manifest still says a patch-level target), for a human to act on. + */ +export function buildOrbReleaseReport({ tags, manifestVersion, commits }) { + const stableTag = latestStableOrbTag(tags); + const anyTag = latestOrbTag(tags); + const stableVersion = stableTag?.version ?? "0.0.0"; + + const commitsSinceStable = selectImageRelevantCommits(commits.sinceStable ?? []); + const inferredReleaseType = inferReleaseType(commitsSinceStable); + const inferredVersion = inferredReleaseType ? bumpVersion(stableVersion, inferredReleaseType) : stableVersion; + const manifestStale = Boolean(manifestVersion) && compareSemver(inferredVersion, manifestVersion) === 1; + + const targetVersion = manifestVersion || inferredVersion; + const commitsSinceLastTag = selectImageRelevantCommits(commits.sinceLastTag ?? []); + // A STABLE tag already exists for targetVersion (the manifest's own declared intent has already fully + // shipped) -- cutting a beta for that exact version now would either collide with a pre-promotion beta + // tag or silently mean "another beta of an already-released version," neither of which this pipeline is + // meant to do. Nothing is due until a human moves the manifest's target forward (manifestStale above + // already signals that a bigger bump than the manifest declares may be warranted). + const targetAlreadyStable = stableTag !== null && stableTag.version === targetVersion; + const due = commitsSinceLastTag.length > 0 && !targetAlreadyStable; + + // The next beta number: restart at 1 when the last tag isn't itself a beta OF targetVersion (a version + // bump happened since, or -- critically -- the last tag targeting this version is its STABLE promotion, + // not a beta at all); otherwise increment the last beta seen for this version. Checking betaNumber !== + // null (not just matching major.minor.patch) is what keeps a stable tag from being misread as "the beta + // to continue counting from." + const anyTagBeta = anyTag ? parseOrbBetaVersion(anyTag.version) : null; + const anyTagIsBetaOfTargetVersion = + anyTagBeta !== null && anyTagBeta.betaNumber !== null && `${anyTagBeta.major}.${anyTagBeta.minor}.${anyTagBeta.patch}` === targetVersion; + const nextBetaNumber = anyTagIsBetaOfTargetVersion ? anyTagBeta.betaNumber + 1 : 1; + + return { + due, + targetVersion, + nextTag: `${ORB_TAG_PREFIX}${targetVersion}-beta.${nextBetaNumber}`, + manifestVersion, + manifestStale, + inferredVersion, + latestStableTag: stableTag?.tag ?? null, + latestTag: anyTag?.tag ?? null, + commits: commitsSinceLastTag, + }; +} diff --git a/test/unit/orb-release.test.ts b/test/unit/orb-release.test.ts new file mode 100644 index 0000000000..7957acc673 --- /dev/null +++ b/test/unit/orb-release.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from "vitest"; +import { + bumpVersion, + buildOrbReleaseReport, + compareSemver, + isImageRelevantCommit, + latestOrbTag, + latestStableOrbTag, + parseOrbBetaVersion, + parseSemver, + selectImageRelevantCommits, +} from "../../scripts/orb-release-core.mjs"; + +type TestCommit = { sha: string; subject: string; body?: string; files: string[] }; + +function commit(subject: string, files: string[], over: Partial = {}): TestCommit { + return { sha: subject.padEnd(40, "0").slice(0, 40), subject, files, ...over }; +} + +describe("ORB image-relevant commit detection", () => { + it("includes a commit touching src/**", () => { + expect(selectImageRelevantCommits([commit("fix(queue): handle a null author", ["src/queue/processors.ts"])])).toHaveLength(1); + }); + + it("includes a commit touching migrations/**", () => { + expect(selectImageRelevantCommits([commit("feat(db): add a column", ["migrations/0130_add_column.sql"])])).toHaveLength(1); + }); + + it("includes a commit touching the Dockerfile or docker-compose.yml", () => { + expect(selectImageRelevantCommits([commit("ci: bump base image", ["Dockerfile"])])).toHaveLength(1); + expect(selectImageRelevantCommits([commit("ci: add a profile", ["docker-compose.yml"])])).toHaveLength(1); + }); + + it("excludes a UI-only commit", () => { + expect(selectImageRelevantCommits([commit("feat(ui): add a button", ["apps/gittensory-ui/src/routes/app.index.tsx"])])).toEqual([]); + }); + + it("excludes an MCP-package-only commit", () => { + expect(selectImageRelevantCommits([commit("feat(mcp): add a tool", ["packages/gittensory-mcp/src/index.ts"])])).toEqual([]); + }); + + it("excludes src/mcp/** even though it's under the generally-relevant src/ prefix", () => { + expect(selectImageRelevantCommits([commit("feat(mcp): add a server tool", ["src/mcp/server.ts"])])).toEqual([]); + }); + + it("excludes a merge commit", () => { + expect(selectImageRelevantCommits([commit("Merge pull request #1 from acme/branch", ["src/queue/processors.ts"])])).toEqual([]); + }); + + it("excludes a commit with no files (e.g. an empty/metadata-only commit)", () => { + expect(selectImageRelevantCommits([commit("chore: bump", [])])).toEqual([]); + }); + + it("includes a mixed commit as long as AT LEAST ONE file is relevant and not excluded", () => { + const mixed = commit("feat(review): add a finding and its UI badge", ["src/review/visual-findings.ts", "apps/gittensory-ui/src/routes/app.index.tsx"]); + expect(selectImageRelevantCommits([mixed])).toEqual([mixed]); + }); + + it("isImageRelevantCommit is the single-commit form of the same check", () => { + expect(isImageRelevantCommit(commit("fix(queue): x", ["src/queue/processors.ts"]))).toBe(true); + expect(isImageRelevantCommit(commit("feat(ui): x", ["apps/gittensory-ui/src/x.tsx"]))).toBe(false); + }); +}); + +describe("semver helpers", () => { + it("parseSemver parses a stable and a beta version", () => { + expect(parseSemver("0.4.0")).toEqual({ major: 0, minor: 4, patch: 0, prerelease: null }); + expect(parseSemver("0.4.0-beta.3")).toEqual({ major: 0, minor: 4, patch: 0, prerelease: "beta.3" }); + expect(parseSemver("not-a-version")).toBeNull(); + }); + + it("parseOrbBetaVersion extracts the beta number, or null for a non-beta prerelease/stable version", () => { + expect(parseOrbBetaVersion("0.4.0-beta.7")?.betaNumber).toBe(7); + expect(parseOrbBetaVersion("0.4.0")?.betaNumber).toBeNull(); + expect(parseOrbBetaVersion("0.4.0-rc.1")?.betaNumber).toBeNull(); + expect(parseOrbBetaVersion("garbage")).toBeNull(); + }); + + it("compareSemver orders stable > beta, and beta.N numerically not lexicographically", () => { + expect(compareSemver("0.4.0", "0.4.0-beta.9")).toBe(1); + expect(compareSemver("0.4.0-beta.9", "0.4.0-beta.10")).toBe(-1); // numeric, not string, comparison + expect(compareSemver("0.4.0", "0.4.0")).toBe(0); + expect(compareSemver("bad", "0.4.0")).toBeNull(); + }); + + it("bumpVersion bumps major/minor/patch and resets the lower components", () => { + expect(bumpVersion("0.4.2", "patch")).toBe("0.4.3"); + expect(bumpVersion("0.4.2", "minor")).toBe("0.5.0"); + expect(bumpVersion("0.4.2", "major")).toBe("1.0.0"); + }); + + it("latestStableOrbTag picks the highest tag with no prerelease suffix, ignoring betas", () => { + const tags = ["orb-v0.1.0", "orb-v0.2.0", "orb-v0.3.0", "orb-v0.4.0-beta.1", "orb-v0.4.0-beta.5"]; + expect(latestStableOrbTag(tags)?.tag).toBe("orb-v0.3.0"); + }); + + it("latestOrbTag picks the highest tag of any kind, beta included", () => { + const tags = ["orb-v0.3.0", "orb-v0.4.0-beta.1", "orb-v0.4.0-beta.5"]; + expect(latestOrbTag(tags)?.tag).toBe("orb-v0.4.0-beta.5"); + }); + + it("both tag helpers ignore non-orb-v tags and malformed versions", () => { + expect(latestStableOrbTag(["mcp-v1.0.0", "orb-vgarbage"])).toBeNull(); + expect(latestOrbTag([])).toBeNull(); + }); +}); + +describe("buildOrbReleaseReport", () => { + const noCommits = { sinceStable: [], sinceLastTag: [] }; + + it("is not due when no image-relevant commits landed since the last tag", () => { + const report = buildOrbReleaseReport({ tags: ["orb-v0.3.0", "orb-v0.4.0-beta.5"], manifestVersion: "0.4.0", commits: noCommits }); + expect(report.due).toBe(false); + expect(report.targetVersion).toBe("0.4.0"); + }); + + it("is due and proposes the next beta number for the SAME target version when only patch-level commits landed", () => { + const report = buildOrbReleaseReport({ + tags: ["orb-v0.3.0", "orb-v0.4.0-beta.5"], + manifestVersion: "0.4.0", + commits: { sinceStable: [commit("fix(queue): x", ["src/queue/processors.ts"])], sinceLastTag: [commit("fix(queue): x", ["src/queue/processors.ts"])] }, + }); + expect(report).toMatchObject({ due: true, targetVersion: "0.4.0", nextTag: "orb-v0.4.0-beta.6", manifestStale: false }); + }); + + it("restarts the beta counter at 1 when the manifest has moved to a new target version since the last tag", () => { + const report = buildOrbReleaseReport({ + tags: ["orb-v0.3.0", "orb-v0.4.0-beta.5"], + manifestVersion: "0.5.0", // maintainer bumped the manifest by hand + commits: { sinceStable: [commit("feat(review): x", ["src/review/x.ts"])], sinceLastTag: [commit("chore: bump manifest", ["orb-manifest.json"])] }, + }); + expect(report.targetVersion).toBe("0.5.0"); + expect(report.nextTag).toBe("orb-v0.5.0-beta.1"); + }); + + it("flags manifestStale when commits imply a bigger bump than the manifest currently declares, without overriding the manifest's own target", () => { + const report = buildOrbReleaseReport({ + tags: ["orb-v0.3.0"], + manifestVersion: "0.3.1", // maintainer only expected a patch + commits: { sinceStable: [commit("feat(review): a real new feature", ["src/review/x.ts"])], sinceLastTag: [commit("feat(review): a real new feature", ["src/review/x.ts"])] }, + }); + expect(report.inferredVersion).toBe("0.4.0"); // feat: implies minor, not the manifest's patch guess + expect(report.manifestStale).toBe(true); + expect(report.targetVersion).toBe("0.3.1"); // still the human-declared target -- never silently overridden + }); + + it("falls back to the inferred version when no manifest version is supplied", () => { + const report = buildOrbReleaseReport({ + tags: ["orb-v0.3.0"], + manifestVersion: null, + commits: { sinceStable: [commit("fix(queue): x", ["src/queue/processors.ts"])], sinceLastTag: [commit("fix(queue): x", ["src/queue/processors.ts"])] }, + }); + expect(report.manifestStale).toBe(false); + expect(report.targetVersion).toBe("0.3.1"); + expect(report.nextTag).toBe("orb-v0.3.1-beta.1"); + }); + + it("is not due when the only commits since the last tag are UI/MCP-only (excluded), even though commits exist", () => { + const report = buildOrbReleaseReport({ + tags: ["orb-v0.3.0", "orb-v0.4.0-beta.5"], + manifestVersion: "0.4.0", + commits: { sinceStable: [], sinceLastTag: [commit("feat(ui): x", ["apps/gittensory-ui/src/x.tsx"])] }, + }); + expect(report.due).toBe(false); + }); + + it("handles a brand-new repo with zero stable tags yet (baseline 0.0.0)", () => { + const report = buildOrbReleaseReport({ + tags: [], + manifestVersion: "0.1.0", + commits: { sinceStable: [commit("feat(review): first cut", ["src/review/x.ts"])], sinceLastTag: [commit("feat(review): first cut", ["src/review/x.ts"])] }, + }); + expect(report.latestStableTag).toBeNull(); + expect(report.latestTag).toBeNull(); + expect(report.nextTag).toBe("orb-v0.1.0-beta.1"); + }); + + it("is not due once targetVersion's own STABLE tag already exists, even though new commits landed and the manifest wasn't bumped forward", () => { + // orb-v0.4.0-beta.1..5 were cut leading up to the stable orb-v0.4.0 promotion; the manifest still says + // "0.4.0" (the maintainer hasn't moved the target forward yet) and a new image-relevant commit lands. + // Proposing another beta for 0.4.0 now would either collide with beta.1 (a tag that already exists from + // before the promotion) or just be a nonsensical "beta of an already-shipped version". + const report = buildOrbReleaseReport({ + tags: ["orb-v0.3.0", "orb-v0.4.0-beta.1", "orb-v0.4.0-beta.5", "orb-v0.4.0"], + manifestVersion: "0.4.0", + commits: { sinceStable: [], sinceLastTag: [commit("fix(queue): x", ["src/queue/processors.ts"])] }, + }); + expect(report.due).toBe(false); + expect(report.latestStableTag).toBe("orb-v0.4.0"); + expect(report.latestTag).toBe("orb-v0.4.0"); + }); + + it("does not mistake the STABLE tag for a beta to continue counting from when the manifest is bumped forward again", () => { + // Even with the manifest correctly bumped to a new target, latestOrbTag can still resolve to the prior + // stable release (nothing has been tagged for the new target yet) -- the beta counter must restart at 1 + // from that stable tag, not read a betaNumber off it (it has none; parseOrbBetaVersion returns + // betaNumber: null for a version with no prerelease suffix at all, not null itself). + const report = buildOrbReleaseReport({ + tags: ["orb-v0.4.0-beta.5", "orb-v0.4.0"], + manifestVersion: "0.5.0", + commits: { sinceStable: [commit("feat(review): x", ["src/review/x.ts"])], sinceLastTag: [commit("feat(review): x", ["src/review/x.ts"])] }, + }); + expect(report.due).toBe(true); + expect(report.nextTag).toBe("orb-v0.5.0-beta.1"); + }); +});