Skip to content

release-orb

release-orb #56

# Orb image releases (#980). Cutting an `orb-v<semver>` tag builds the multi-arch image, pushes it
# to GHCR with version + latest + sha tags (with provenance + SBOM), and opens a GitHub Release.
#
# git tag orb-v0.1.0 && git push origin orb-v0.1.0
#
# Pull: docker pull ghcr.io/<owner>/loopover-selfhost:orb-v0.1.0
#
# #4777: publishing under the pre-rename "gittensory-selfhost" name stopped -- see the "Image metadata"
# step below. Already-published tags/digests under that name are untouched and keep resolving forever
# (GHCR has no server-side alias); only new pushes moved to "loopover-selfhost" exclusively.
#
# Prerelease tags (#1937): orb-v0.1.0-rc.1 / orb-v0.1.0-beta.1 run the identical pipeline but never move
# `latest` and are marked prerelease on the GitHub Release -- for beta-testing an image before it becomes
# the stable/latest recommendation.
#
# git tag orb-v0.1.0-rc.1 && git push origin orb-v0.1.0-rc.1
name: release-orb
on:
push:
tags:
- "orb-v*"
workflow_dispatch:
inputs:
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
packages: write # push to GHCR
concurrency:
group: release-orb-${{ github.ref_name }}
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
timeout-minutes: 40
# Environment gate. Only the trusted orb-beta-release automation may use `release-beta` (no
# required reviewers): it dispatches this workflow as github-actions[bot] on a tag ref, asks to
# create the GitHub Release, and the validation below requires that exact beta tag to already point
# at this commit. Human workflow_dispatch runs, branch dispatches, and direct beta tag pushes stay on
# `release`, which requires reviewer approval under repo Settings > Environments. This expression
# reads only event fields because a job's `environment:` is resolved before any step runs.
environment: ${{ github.event_name == 'workflow_dispatch' && github.actor == 'github-actions[bot]' && github.ref_type == 'tag' && inputs.create_github_release && contains(inputs.version, '-beta.') && 'release-beta' || 'release' }}
env:
SENTRY_ORG: jsonbored
SENTRY_PROJECT: gittensory
SENTRY_CLI_PACKAGE: "@sentry/cli@3.6.0"
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
fetch-depth: 0
persist-credentials: false
- name: Verify release commit is on main
env:
RELEASE_SHA: ${{ github.sha }}
run: |
git fetch --no-tags origin main
if ! git merge-base --is-ancestor "$RELEASE_SHA" origin/main; then
echo "::error::Self-host releases must be cut from a commit reachable from main."
exit 1
fi
- name: Resolve version
id: version
env:
CREATE_GITHUB_RELEASE: ${{ github.event.inputs.create_github_release || 'false' }}
EVENT_NAME: ${{ github.event_name }}
INPUT_VERSION: ${{ github.event.inputs.version }}
REF_NAME: ${{ github.ref_name }}
RELEASE_SHA: ${{ github.sha }}
RUN_ACTOR: ${{ github.actor }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
VERSION="$INPUT_VERSION"
else
case "$REF_NAME" in
orb-v*) VERSION="${REF_NAME#orb-v}" ;;
*) echo "expected an orb-v<semver> tag, got $REF_NAME" >&2; exit 1 ;;
esac
fi
# #1937: a stable X.Y.Z tag is the only kind that ever moved `latest` or an unmarked GitHub
# Release; a prerelease tag (X.Y.Z-rc.N / X.Y.Z-beta.N) publishes the SAME image/provenance/SBOM/
# Sentry pipeline below, just flagged as prerelease and never pushed under `latest` (see the
# "Resolve image tags" and "GitHub Release" steps).
if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-(rc|beta)\.[0-9]+)?$'; then
echo "expected semver version X.Y.Z, or a prerelease X.Y.Z-rc.N / X.Y.Z-beta.N, got $VERSION" >&2
exit 1
fi
PRERELEASE=false
if printf '%s' "$VERSION" | grep -Eq -- '-(rc|beta)\.[0-9]+$'; then
PRERELEASE=true
fi
if printf '%s' "$VERSION" | grep -Eq -- '-beta\.[0-9]+$' \
&& [ "$EVENT_NAME" = "workflow_dispatch" ] \
&& [ "$RUN_ACTOR" = "github-actions[bot]" ] \
&& [ "$CREATE_GITHUB_RELEASE" = "true" ]; then
git fetch --force --tags origin "refs/tags/orb-v${VERSION}:refs/tags/orb-v${VERSION}"
TAG_SHA="$(git rev-list -n 1 "refs/tags/orb-v${VERSION}")"
if [ "$TAG_SHA" != "$RELEASE_SHA" ]; then
echo "automated beta releases must dispatch the tag that was just created for this commit" >&2
exit 1
fi
fi
{
echo "v=${VERSION}"
echo "tag=orb-v${VERSION}"
echo "release=gittensory-orb@${VERSION}"
echo "prerelease=${PRERELEASE}"
} >> "$GITHUB_OUTPUT"
# Release jobs receive publishing/Sentry credentials, so avoid shared dependency caches here.
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: "24.18.0"
- name: Install deps
run: npm ci --ignore-scripts
# #ci-engine-build-order (release pipeline): --all bundles EVERYTHING self-contained, including
# packages/loopover-miner/lib/*.js (committed, pre-built) which imports @loopover/engine.
# That package's dist/ is gitignored and only exists after this build step -- the regular CI smoke
# test's narrower (non --all) build never hits this import chain, so it never caught the gap that
# ci.yml's own validate-code job hit for ordinary backend PRs (fixed there separately).
- name: Build engine package
run: npm run build --workspace @loopover/engine
- name: Build self-host bundle for release
run: node scripts/build-selfhost.mjs --all
- name: Validate release source map
run: node scripts/validate-selfhost-sourcemap.mjs
- name: Detect Sentry release token
id: sentry
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: |
if [ -n "$SENTRY_AUTH_TOKEN" ]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
fi
- name: Require Sentry token for official release
if: github.repository == 'JSONbored/loopover' && steps.sentry.outputs.enabled != 'true'
run: |
echo "::error::Configure SENTRY_AUTH_TOKEN in the release environment before publishing official Orb images."
exit 1
- name: Upload Sentry source maps
if: steps.sentry.outputs.enabled == 'true'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG || 'jsonbored' }}
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT || 'gittensory' }}
SENTRY_URL: ${{ vars.SENTRY_URL }}
SENTRY_RELEASE: ${{ steps.version.outputs.release }}
SENTRY_REPOSITORY: ${{ github.repository }}
SENTRY_COMMIT_SHA: ${{ github.sha }}
run: |
set -euo pipefail
test -n "$SENTRY_AUTH_TOKEN"
test -n "$SENTRY_ORG"
test -n "$SENTRY_PROJECT"
if [ -z "${SENTRY_URL:-}" ]; then unset SENTRY_URL; fi
npx -y "$SENTRY_CLI_PACKAGE" releases new "$SENTRY_RELEASE"
# `sentry-cli releases set-commits --commit` (even without --ignore-missing) can silently leave
# the release with ZERO associated commits against a repo connected via the modern GitHub App
# integration -- confirmed empirically cutting the first orb-v0.1.0-beta.1 release: the CLI
# printed a success table but `GET .../releases/<v>/commits/` stayed `[]`. A direct PUT to the
# release resource with an inline `commits` array resolves the same repo/commit against the
# SAME integration correctly and immediately, so use that instead of relying on the CLI here --
# it's what the later "Validate Sentry release" step's `SENTRY_REQUIRE_COMMITS=true` actually
# depends on being correct.
curl -sf -X PUT \
-H "Authorization: Bearer ${SENTRY_AUTH_TOKEN}" \
-H "Content-Type: application/json" \
"${SENTRY_URL:-https://sentry.io}/api/0/organizations/${SENTRY_ORG}/releases/$(node -e "process.stdout.write(encodeURIComponent(process.env.SENTRY_RELEASE))")/" \
-d "$(node -e "process.stdout.write(JSON.stringify({commits:[{repository: process.env.SENTRY_REPOSITORY, id: process.env.SENTRY_COMMIT_SHA}]}))")" \
>/dev/null
npx -y "$SENTRY_CLI_PACKAGE" sourcemaps inject dist
node scripts/validate-selfhost-sourcemap.mjs
npx -y "$SENTRY_CLI_PACKAGE" sourcemaps upload --release="$SENTRY_RELEASE" --validate --wait --strict dist
- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Log in to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# #1937: `latest` must never move to a prerelease build -- an operator who blindly pulls `latest`
# for a trial should always land on the newest STABLE image, not an in-flight rc/beta.
- name: Resolve image tags
id: tags
env:
PRERELEASE: ${{ steps.version.outputs.prerelease }}
VERSION_TAG: ${{ steps.version.outputs.tag }}
run: |
set -euo pipefail
{
echo "list<<GTORBTAGS"
echo "type=raw,value=${VERSION_TAG}"
if [ "$PRERELEASE" != "true" ]; then
echo "type=raw,value=latest"
fi
echo "type=sha,format=short"
echo "GTORBTAGS"
} >> "$GITHUB_OUTPUT"
# #4777: publishes only under the "loopover-selfhost" name now. The pre-rename "gittensory-selfhost"
# name is no longer pushed to; GHCR has no server-side alias, so its already-published exact-version
# and digest pins keep resolving forever regardless -- this only stops new tags from landing there.
- name: Image metadata
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
with:
images: |
ghcr.io/${{ github.repository_owner }}/loopover-selfhost
tags: ${{ steps.tags.outputs.list }}
labels: |
org.opencontainers.image.title=loopover-orb
org.opencontainers.image.description=Self-hostable LoopOver review engine
org.opencontainers.image.version=${{ steps.version.outputs.tag }}
org.opencontainers.image.revision=${{ github.sha }}
- name: Build + push (linux/amd64 + linux/arm64)
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
target: runtime-prebuilt
platforms: linux/amd64,linux/arm64
push: true
# Visual-capture convergence (#3607/#4111): every official image ships puppeteer-core so a
# self-hoster can turn on before/after screenshot capture with just BROWSER_WS_ENDPOINT +
# LOOPOVER_REVIEW_SCREENSHOTS=true at runtime -- no custom image build required. Inert either
# way until those runtime flags are set (Dockerfile's INSTALL_VISUAL_REVIEW only controls
# whether the dependency is installed, never whether the feature runs).
build-args: |
LOOPOVER_VERSION=${{ steps.version.outputs.release }}
INSTALL_VISUAL_REVIEW=true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
provenance: true
sbom: true
# #2502 originally shared selfhost.yml's default (unscoped) GHA cache bucket here for speed --
# reverted once a security scan correctly flagged it: that bucket is written to by regular CI on
# every push/PR to this repo, so a publish/release path reading from it is a cache-poisoning
# vector into an officially published, public image. `scope=release-orb` fixes this properly
# instead of just going cold forever: it's a completely separate GHA cache namespace that ONLY
# this workflow (tag pushes on `main` / `workflow_dispatch`, both already gated by "Verify release
# commit is on main" + the `release` environment) ever reads from or writes to -- selfhost.yml's
# CI never touches it. The first release still builds cold; every release after that (rc.2, a
# later stable, a patch) reuses the arm64/QEMU-emulated apt-get/npm-install layers from the PRIOR
# release build in this same isolated scope, with zero exposure to anything a regular CI run ever
# produced.
cache-from: type=gha,scope=release-orb
cache-to: type=gha,mode=max,scope=release-orb
- name: Finalize Sentry release
if: steps.sentry.outputs.enabled == 'true'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG || 'jsonbored' }}
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT || 'gittensory' }}
SENTRY_URL: ${{ vars.SENTRY_URL }}
SENTRY_RELEASE: ${{ steps.version.outputs.release }}
run: |
set -euo pipefail
if [ -z "${SENTRY_URL:-}" ]; then unset SENTRY_URL; fi
npx -y "$SENTRY_CLI_PACKAGE" releases finalize "$SENTRY_RELEASE"
- name: Validate Sentry release
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG || 'jsonbored' }}
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT || 'gittensory' }}
SENTRY_URL: ${{ vars.SENTRY_URL }}
SENTRY_RELEASE: ${{ steps.version.outputs.release }}
SENTRY_REPOSITORY: ${{ github.repository }}
SENTRY_COMMIT_SHA: ${{ github.sha }}
SENTRY_REQUIRE_COMMITS: "true"
SENTRY_REQUIRE_DEPLOY: "false"
SENTRY_REQUIRE_FINALIZED: "true"
run: |
set -euo pipefail
# The CLI's `set-commits` was replaced with a direct PUT above after it silently no-op'd on
# orb-v0.1.0-beta.1, but Sentry's release-commits read path can still lag briefly behind a
# write. review-enrichment/src/upload-sourcemaps.ts's runReleaseValidation() already
# retry-polls this same script for REES's Railway deploy path -- mirror that here instead of
# failing the whole release build on a transient read-lag.
attempts=5
delay_secs=10
for attempt in $(seq 1 "$attempts"); do
if node review-enrichment/scripts/validate-sentry-release.mjs; then
exit 0
fi
if [ "$attempt" -lt "$attempts" ]; then
echo "::warning::Sentry release validation attempt ${attempt}/${attempts} failed; retrying in ${delay_secs}s"
sleep "$delay_secs"
fi
done
echo "::error::Sentry release validation failed after ${attempts} attempts"
exit 1
- name: GitHub Release
# 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 }}
RELEASE_VERSION: ${{ steps.version.outputs.v }}
RELEASE_TAG: ${{ steps.version.outputs.tag }}
RELEASE_ID: ${{ steps.version.outputs.release }}
REPOSITORY_OWNER: ${{ github.repository_owner }}
PRERELEASE: ${{ steps.version.outputs.prerelease }}
run: |
set -euo pipefail
# GHCR (like every Docker registry) rejects a mixed-case repository path client-side --
# `docker pull ghcr.io/JSONbored/...` fails with "must be lowercase" before it even reaches the
# registry. `github.repository_owner` preserves the org's actual casing ("JSONbored"), so the
# notes must lowercase it themselves -- docker/metadata-action (used for the actual image tags
# above) does this automatically, but this hand-written notes block doesn't go through it.
REPOSITORY_OWNER_LOWER="${REPOSITORY_OWNER,,}"
NOTES="$(cat <<EOF
LoopOver Orb container image:
\`\`\`bash
docker pull ghcr.io/${REPOSITORY_OWNER_LOWER}/loopover-selfhost:${RELEASE_TAG}
\`\`\`
Multi-arch (linux/amd64 + linux/arm64). See https://loopover.ai/docs/maintainer-self-hosting for setup.
Includes the Claude Code / Codex subscription CLIs by default; credentials stay runtime-only.
Sentry release id baked into the image: \`${RELEASE_ID}\`.
EOF
)"
# #1937: a prerelease tag never becomes the repo's "Latest release" and is visibly marked as such
# on GitHub -- distinct from the image-tag `latest` decision above (Resolve image tags), which
# this flag also drives at the version-resolution step.
PRERELEASE_ARGS=()
if [ "$PRERELEASE" = "true" ]; then
PRERELEASE_ARGS=(--prerelease --latest=false)
fi
# Find the orb-v tag immediately preceding this one, by the best available local tag-date
# ordering (`--sort=-creatordate`: the tag's own date for an annotated tag, or its tagged
# commit's date for a lightweight one) -- NOT a semver sort, which mis-orders a prerelease
# against its own later stable tag (e.g. 0.1.0-beta.2 vs 0.1.0), and NOT the Release Notes
# API's own "previous release" auto-detection either (this repo's release list also carries
# mcp-v* releases on an independent cadence, which could get picked up by mistake once the two
# schemes' timestamps interleave). This is NOT a guarantee of true creation order for two
# lightweight orb-v tags pointing at the SAME commit (e.g. promoting an -rc straight to stable
# with no new commits) -- that tie is broken by git's own secondary sort, not by which tag was
# 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 "$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
# bound the diff -- that's why `--generate-notes` was dropped entirely rather than just
# pinning its range. Every release from orb-v0.1.0 onward has a real previous tag (confirmed:
# orb-v0.1.0 -> orb-v0.2.0 generates ~11K chars, comfortably under the limit), so fetch the
# PR changelog explicitly scoped to that range via the same API `--generate-notes` uses,
# categorized by `.github/release.yml`. PREV_TAG empty (no prior orb-v tag) skips this and
# falls back to the plain notes below, the same as the original safe behavior.
CHANGELOG=""
if [ -n "$PREV_TAG" ]; then
if ! CHANGELOG=$(gh api "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \
-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}...${RELEASE_TAG}) failed; publishing without it."
CHANGELOG=""
fi
fi
FULL_NOTES="$NOTES"
if [ -n "$CHANGELOG" ]; then
FULL_NOTES="${NOTES}"$'\n\n'"${CHANGELOG}"
fi
# Never let an outlier changelog (an unusually large PR history, or a future change to what
# the categorization API returns) block publishing the image itself -- fall back to the plain
# 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}...${RELEASE_TAG}_"
fi
if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
--title "loopover-orb ${RELEASE_TAG}" \
--notes "$FULL_NOTES" \
"${PRERELEASE_ARGS[@]}"
else
gh release create "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
--verify-tag \
--title "loopover-orb ${RELEASE_TAG}" \
"${PRERELEASE_ARGS[@]}" \
--notes "$FULL_NOTES"
fi