Skip to content

orb-beta-release

orb-beta-release #112

# Automated ORB (self-host container image, ghcr.io/jsonbored/loopover-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.ts / scripts/orb-release-core.ts) 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.ts) -- see scripts/orb-release-core.ts'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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
persist-credentials: false
- name: Setup workspace
uses: ./.github/actions/setup-workspace
- name: Check whether an ORB beta is due
id: report
# check-orb-release-due.ts imports orb-release-core.ts directly via a `.js` specifier, so it needs tsx
# (not plain node) to resolve that local .ts import.
run: |
set -euo pipefail
npx tsx scripts/check-orb-release-due.ts --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 whenever that alone will work, 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 / publish-mcp.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.ts'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:
# A tag pointing at a commit that touches any .github/workflows/* file needs a token with the
# `workflow` OAuth scope (classic PAT) / "Workflows: write" (fine-grained PAT) to push -- GitHub
# hardcodes this against the default GITHUB_TOKEN regardless of this job's own `permissions:`
# block; there is no permissions-block setting that lifts it (confirmed live: the push failed with
# "refusing to allow a GitHub App to create or update workflow .github/workflows/ci.yml without
# `workflows` permission" even with contents/actions both set to write above). ORB_RELEASE_WORKFLOW_TOKEN
# is an OPTIONAL repo secret (a fine-grained PAT scoped to Contents: write + Workflows: write on
# this repo only) held in reserve for exactly that case -- the push below always tries the default
# token FIRST and only reaches for this one on the specific failure it's meant to fix. Using it
# unconditionally would defeat the recursion suppression above on every cut, not just the rare one
# that actually needs it (confirmed live: 2026-07-21, orb-v3.2.0-beta.11 produced duplicate runs
# 29821878879 and 29821880057 for the same tag because this env used to be
# `${{ secrets.ORB_RELEASE_WORKFLOW_TOKEN || github.token }}` unconditionally).
GH_TOKEN: ${{ github.token }}
ORB_RELEASE_WORKFLOW_TOKEN: ${{ secrets.ORB_RELEASE_WORKFLOW_TOKEN }}
TAG: ${{ steps.report.outputs.tag }}
VERSION: ${{ steps.report.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, 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 "loopover-orb ${VERSION}"
git remote set-url origin "https://github.com/${GITHUB_REPOSITORY}.git"
gh auth setup-git
if ! git push origin "$TAG" 2>push-error.log; then
cat push-error.log >&2
if [ -n "$ORB_RELEASE_WORKFLOW_TOKEN" ] && grep -qi "workflow" push-error.log; then
echo "::warning::Default token can't push $TAG (its history touches .github/workflows/*); retrying with ORB_RELEASE_WORKFLOW_TOKEN. That token is a PAT, not GITHUB_TOKEN, so this push will ALSO fire release-selfhost.yml's own tag-push trigger -- expect a duplicate run alongside the dispatch step below for this cut only."
export GH_TOKEN="$ORB_RELEASE_WORKFLOW_TOKEN"
gh auth setup-git
git push origin "$TAG"
else
exit 1
fi
fi
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.
#
# Dispatch against the fully qualified TAG ref, not `main`: `main` is a floating ref, and this repo
# merges fast enough that a commit can land in the gap between the tag push above and this dispatch.
# `--ref main` would then resolve `github.sha` inside release-selfhost.yml to that NEWER commit, while
# $TAG (pushed moments ago, immutable) still points at the older one it was actually cut for -- tripping
# that workflow's own TAG_SHA-must-equal-RELEASE_SHA fail-safe and aborting the release. `refs/tags/$TAG`
# preserves that race fix without letting a same-named branch shadow the tag namespace.
- name: Dispatch the ORB release build
if: steps.report.outputs.due == 'true' && steps.tag.outputs.created == 'true'
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.report.outputs.tag }}
VERSION: ${{ steps.report.outputs.version }}
run: gh workflow run release-selfhost.yml --ref "refs/tags/$TAG" -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