Skip to content

release-orb

release-orb #132

# 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:
POSTHOG_CLI_PACKAGE: "@posthog/cli@0.9.1"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # 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/
# PostHog 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=loopover-orb@${VERSION}"
echo "prerelease=${PRERELEASE}"
} >> "$GITHUB_OUTPUT"
# Release jobs receive publishing/PostHog credentials, so avoid shared dependency caches here.
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version-file: .nvmrc
- 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).
# Built before the engine for the same reason the Dockerfile does: src/'s import graph reaches
# @loopover/contract, whose package exports resolve to dist/, so anything type-checking or
# bundling src/ needs it emitted first. Zod-only leaf, no workspace dependencies of its own.
- name: Build contract package
run: npm run build --workspace @loopover/contract
- name: Build engine package
run: npm run build --workspace @loopover/engine
- name: Build self-host bundle for release
run: node --experimental-strip-types scripts/build-selfhost.ts --all
- name: Validate release source map
run: node --experimental-strip-types scripts/validate-selfhost-sourcemap.ts
- name: Detect PostHog release token
id: posthog
env:
POSTHOG_CLI_API_KEY: ${{ secrets.POSTHOG_CLI_API_KEY }}
run: |
if [ -n "$POSTHOG_CLI_API_KEY" ]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
fi
- name: Require PostHog token for official release
if: github.repository == 'JSONbored/loopover' && steps.posthog.outputs.enabled != 'true'
run: |
echo "::error::Configure POSTHOG_CLI_API_KEY (a personal API key) in the release environment before publishing official Orb images."
exit 1
- name: Upload PostHog source maps
if: steps.posthog.outputs.enabled == 'true'
env:
POSTHOG_CLI_API_KEY: ${{ secrets.POSTHOG_CLI_API_KEY }}
POSTHOG_CLI_PROJECT_ID: ${{ secrets.POSTHOG_CLI_PROJECT_ID }}
POSTHOG_CLI_HOST: ${{ secrets.POSTHOG_CLI_HOST }}
# Passed as separate --release-name/--release-version flags below rather than one combined
# --release-version string: posthog-cli otherwise auto-derives its own release-name from git/
# package.json (this repo resolves to "loopover") and prepends it, so an unqualified
# --release-version "loopover-orb@$VERSION" silently becomes the stored release
# "loopover@loopover-orb@$VERSION" -- never matching what "Validate PostHog release" below looks up.
POSTHOG_RELEASE_NAME: loopover-orb
POSTHOG_RELEASE_VERSION: ${{ steps.version.outputs.v }}
run: |
set -euo pipefail
test -n "$POSTHOG_CLI_API_KEY"
test -n "$POSTHOG_CLI_PROJECT_ID"
# posthog-cli treats a PRESENT-but-empty POSTHOG_CLI_HOST as an explicit (invalid) URL, not as
# "unset, use the default" -- unset it outright when blank, same guard this pipeline's old
# Sentry-org-URL override once needed for the identical reason.
if [ -z "${POSTHOG_CLI_HOST:-}" ]; then unset POSTHOG_CLI_HOST; fi
# No separate "create release" step -- PostHog release metadata is a byproduct of the inject/
# upload calls below, unlike Sentry's releases/commits/deploys/finalize lifecycle this replaces.
npx -y "$POSTHOG_CLI_PACKAGE" sourcemap inject --directory dist --release-name "$POSTHOG_RELEASE_NAME" --release-version "$POSTHOG_RELEASE_VERSION"
node --experimental-strip-types scripts/validate-selfhost-sourcemap.ts
npx -y "$POSTHOG_CLI_PACKAGE" sourcemap upload --directory dist --release-name "$POSTHOG_RELEASE_NAME" --release-version "$POSTHOG_RELEASE_VERSION"
- uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
- uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
- name: Log in to GHCR
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4
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: Validate PostHog release
if: steps.posthog.outputs.enabled == 'true'
env:
POSTHOG_CLI_API_KEY: ${{ secrets.POSTHOG_CLI_API_KEY }}
POSTHOG_CLI_PROJECT_ID: ${{ secrets.POSTHOG_CLI_PROJECT_ID }}
POSTHOG_CLI_HOST: ${{ secrets.POSTHOG_CLI_HOST }}
POSTHOG_RELEASE: ${{ steps.version.outputs.release }}
run: |
set -euo pipefail
if [ -z "${POSTHOG_CLI_HOST:-}" ]; then unset POSTHOG_CLI_HOST; fi
# PostHog's release-read path can lag briefly behind the upload write above.
# review-enrichment/src/upload-sourcemaps.ts's runReleaseValidation() already retry-polls this
# same script for REES's own 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 --experimental-strip-types review-enrichment/scripts/validate-posthog-release.ts; then
exit 0
fi
if [ "$attempt" -lt "$attempts" ]; then
echo "::warning::PostHog release validation attempt ${attempt}/${attempts} failed; retrying in ${delay_secs}s"
sleep "$delay_secs"
fi
done
echo "::error::PostHog 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.
PostHog 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