name: Release

on:
  # Gate releases on green CI: run only AFTER the required workflows finish, and
  # only release when EVERY required check is `success` for that commit (#423).
  # A release must never ship from a red `main`.
  workflow_run:
    workflows: ["CI", "Whole-repo invariants (merge-skew guard)"]
    types: [completed]
  # Manual escape hatch. The automatic path above depends on the post-merge
  # `push:main` event that normally follows a merge-queue landing. That event can
  # go MISSING when an out-of-band bypass push to `main` (a `chore(release)
  # [skip ci]` commit from the release App) races the queue's merge — the merged
  # commit then lands with only its `merge_group` run and no `push` run, so the
  # `workflow_run` trigger never sees `event == 'push'` and the release wedges
  # with no way to retrigger. Dispatching runs the SAME green-gate against
  # `main`'s HEAD (the merge_group checks are attached to that commit), so a
  # manual recovery still never ships from a red `main`.
  workflow_dispatch:
  # Self-heal a dropped `push:main` event (#725). The `push`→`workflow_run` chain
  # is the ONLY automatic release path, so a single dropped event (a transient
  # Actions delivery hiccup, or the release-App bypass push racing the queue —
  # both hit #722/#723) wedges the release until a human dispatches. This periodic
  # tick re-runs the SAME green-gate against `main` HEAD (`github.sha`) and lets
  # `semantic-release` — which is idempotent (no-ops when nothing is releasable,
  # never republishes an existing version) — backfill the missed run automatically.
  # It never ships from a red `main` (same required-check gate) and the gate's
  # schedule-scoped dedup below runs `semantic-release` at most once per HEAD SHA,
  # so a healthy `main` costs only the tiny gate job per tick.
  schedule:
    - cron: "*/15 * * * *"

# Prevent overlapping releases from racing on the same branch.
concurrency:
  group: release-main
  cancel-in-progress: false

permissions:
  contents: read

jobs:
  # Whichever gating workflow finishes LAST (successfully) re-triggers this and
  # finds every required check green → releases. An earlier trigger, while a
  # sibling gate is still pending, resolves to `should_release=false` (neutral —
  # no release, no failure). If any required check fails, no trigger ever sees an
  # all-green commit, so nothing releases.
  gate:
    name: release gate
    runs-on: ubuntu-latest
    if: >-
      github.event_name == 'workflow_dispatch' ||
      github.event_name == 'schedule' ||
      (github.event.workflow_run.event == 'push' &&
       github.event.workflow_run.head_branch == 'main' &&
       github.event.workflow_run.conclusion == 'success')
    permissions:
      actions: read # schedule dedup: list this workflow's prior runs for the HEAD SHA
      checks: read
      contents: read
    outputs:
      should_release: ${{ steps.gate.outputs.should_release }}
    steps:
      - name: Require every required check green for this commit
        id: gate
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
          REPO: ${{ github.repository }}
          EVENT: ${{ github.event_name }}
        run: |
          set -euo pipefail
          # These must ALL be `success` on the release commit before we ship.
          # This is the `main` ruleset's required-check set MINUS the PR-only
          # checks: `Conventional PR title` gates the merge queue but never runs
          # on `push:main` (it has no PR title to lint on a merge commit — see
          # pr-title-lint.yml), so requiring it here would deadlock every release.
          # With the merge queue enabled these checks already passed on the
          # speculative merged commit before it landed; this `push:main` gate is
          # the backstop. Keep in lock-step with the ruleset's required checks.
          required=("typecheck + test (Node)" "whole-repo invariants")
          # Dedupe to ONE row per check name — the latest run (max .id) — so re-runs
          # can't leave us reading a stale conclusion. Slurp every page into a
          # single array with `jq -s` (NOT `gh --slurp`, which gh rejects when
          # combined with `--jq`) so `group_by` sees every run for the SHA; jq's
          # `group_by` sorts by the key first, so unsorted input can't split a name.
          runs="$(gh api "repos/$REPO/commits/$SHA/check-runs?per_page=100" --paginate \
                    | jq -r -s '[.[].check_runs[]] | group_by(.name) | map(max_by(.id))
                          | .[] | [.name, (.conclusion // "pending")] | @tsv')"
          should=true
          for name in "${required[@]}"; do
            concl="$(printf '%s\n' "$runs" | awk -F'\t' -v n="$name" '$1==n{print $2}')"
            echo "gate: '$name' = ${concl:-missing}"
            [ "${concl:-missing}" = "success" ] || should=false
          done
          # Schedule-scoped dedup (#725). The scheduled tick exists ONLY to backfill a
          # release whose `push:main` trigger was dropped. If any prior Release run has
          # already reached a terminal `success` for this exact HEAD SHA, the analyzer
          # already ran (via `push`/`workflow_dispatch`, or an earlier tick) — the tree
          # is unchanged, so re-running would just spin an idempotent no-op every tick
          # (e.g. when HEAD is a green no-release `chore:`/`ci:` commit). This dedup is
          # gated to `schedule` so it never perturbs the push path's two-trigger race,
          # where the neutral early trigger also concludes `success` and the LATE
          # trigger must still be allowed to release. The in-progress current run is not
          # yet `success`, so it never counts itself out.
          if [ "$EVENT" = "schedule" ] && [ "$should" = "true" ]; then
            handled="$(gh api \
              "repos/$REPO/actions/workflows/release.yml/runs?head_sha=$SHA&status=completed&per_page=100" \
              --jq '[.workflow_runs[] | select(.conclusion=="success")] | length')"
            if [ "${handled:-0}" -gt 0 ]; then
              echo "gate: HEAD $SHA already analysed by a prior successful Release run — nothing to backfill."
              should=false
            fi
          fi
          echo "should_release=$should" >> "$GITHUB_OUTPUT"

  release:
    name: semantic-release
    needs: gate
    if: needs.gate.outputs.should_release == 'true'
    runs-on: ubuntu-latest
    permissions:
      # The privileged work (push to `main`, tags, Releases, issue/PR comments) is
      # done with the release App token below — it is the ruleset bypass actor. The
      # job's own GITHUB_TOKEN only needs OIDC for npm Trusted Publishing.
      contents: read
      id-token: write # OIDC for npm Trusted Publishing + provenance (the only auth path)
    steps:
      # Mint a short-lived token for the dedicated release GitHub App. This App is
      # the ruleset's bypass actor, so @semantic-release/git can push the version
      # bump + tags straight to a protected `main` (the default GITHUB_TOKEN can't:
      # it isn't — and at repo level can't be — a ruleset bypass actor). See
      # AGENTS.md "Releases bypass the ruleset via a dedicated App" and the
      # `main` ruleset's bypass_actors.
      - name: Mint release App token
        id: app-token
        uses: actions/create-github-app-token@v1
        with:
          app-id: ${{ secrets.RELEASE_APP_ID }}
          private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}

      - name: Checkout
        uses: actions/checkout@v4
        with:
          ref: ${{ github.event.workflow_run.head_sha || github.sha }} # release the exact gated commit
          fetch-depth: 0 # semantic-release needs full history + tags
          persist-credentials: true # let @semantic-release/git push the release commit
          token: ${{ steps.app-token.outputs.token }} # push as the bypass-actor App

      - name: Resolve App bot user id
        id: app-user
        env:
          GH_TOKEN: ${{ steps.app-token.outputs.token }}
        run: |
          id="$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)"
          echo "id=$id" >> "$GITHUB_OUTPUT"

      - name: Configure git author
        run: |
          git config user.name "${{ steps.app-token.outputs.app-slug }}[bot]"
          git config user.email "${{ steps.app-user.outputs.id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com"

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "24"
          registry-url: "https://registry.npmjs.org"

      - name: Ensure npm supports OIDC Trusted Publishing
        # Pinned (not @latest) for reproducible, supply-chain-safe releases.
        # Any version >= 11.5.1 supports npm Trusted Publishing (OIDC).
        run: npm install -g npm@11.12.1

      - name: Install dependencies
        run: npm ci

      - name: Release
        id: release
        # OIDC Trusted Publishing is the ONLY automated auth path: npm has retired
        # automation tokens that bypass 2FA, so there is no CI token fallback — it is
        # OIDC or a human `npm publish` locally. OIDC is intermittently flaky, though:
        # npm has returned `401 … Failed to generate Web Auth URLs … token is invalid`
        # mid-publish. @semantic-release/git pushes the tag + release commit in the
        # `prepare` step BEFORE @semantic-release/npm `publish`, so an OIDC publish
        # failure orphans a git tag with no npm package (the v0.178.3 phantom).
        # Don't fail the job here — let the OIDC publish-retry step below recover it.
        continue-on-error: true
        env:
          GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} # bypass-actor App token
          # No NPM_TOKEN / NODE_AUTH_TOKEN: publishing is OIDC Trusted Publishing only.
          NPM_CONFIG_PROVENANCE: "true"
        run: npx semantic-release

      # Recovery: if the OIDC publish 401s, semantic-release has already bumped
      # package.json + pushed the tag/release commit, but npm never got the tarball.
      # The failure is INTERMITTENT (a prior version published fine), so retry the
      # OIDC publish of the just-prepared version a few times with backoff — still no
      # token, still OIDC + provenance via the job's id-token. This step is a NO-OP
      # when the release step succeeded or when the version is already on npm, so it
      # can never double-publish. If OIDC stays down through every retry, fail loudly:
      # a human must `npm publish` the pushed tag locally (npm login with 2FA, then
      # `git checkout vX.Y.Z && npm ci && npm publish --provenance --access public`).
      - name: Retry OIDC publish if the release publish failed
        if: steps.release.outcome == 'failure'
        env:
          NPM_CONFIG_PROVENANCE: "true"
          GH_TOKEN: ${{ steps.app-token.outputs.token }} # to create the skipped GitHub Release
        run: |
          set -uo pipefail
          PKG="$(node -p "require('./package.json').name")"
          VERSION="$(node -p "require('./package.json').version")"
          if npm view "${PKG}@${VERSION}" version >/dev/null 2>&1; then
            echo "${PKG}@${VERSION} is already on npm — nothing to recover."
            exit 0
          fi
          echo "semantic-release prepared + tagged ${PKG}@${VERSION} but the OIDC publish failed; retrying."
          published=false
          for attempt in 1 2 3; do
            echo "OIDC publish attempt ${attempt}/3 for ${PKG}@${VERSION}"
            if npm publish --provenance --access public; then
              echo "Published ${PKG}@${VERSION} on retry ${attempt}."
              published=true
              break
            fi
            sleep $((attempt * 30))
          done
          if [ "$published" != true ]; then
            echo "::error::OIDC publish of ${PKG}@${VERSION} still failing after retries. The git tag v${VERSION} is pushed but npm has no tarball. Publish it locally: 'git fetch --tags && git checkout v${VERSION} && npm ci && npm publish --provenance --access public' (npm login with 2FA first)."
            exit 1
          fi
          # semantic-release aborts before @semantic-release/github when the npm
          # publish throws, so the GitHub Release was never created — create it now.
          if ! gh release view "v${VERSION}" >/dev/null 2>&1; then
            echo "Creating the GitHub Release skipped by the aborted semantic-release run."
            gh release create "v${VERSION}" --verify-tag --generate-notes --title "v${VERSION}" || \
              echo "::warning::Published to npm but failed to create GitHub Release v${VERSION}; create it manually."
          fi
