release-orb #132
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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 |