release-orb #56
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: | |
| 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 |