name: Release Please

on:
  push:
    branches:
      - main
  workflow_dispatch:

# Read-only at workflow level; the single job grants itself the writes it
# needs. OpenSSF Scorecard zeroes Token-Permissions on any workflow-level
# write, and a job-level grant does not -- with one job the scope is identical
# either way, so the read-only default costs nothing.
permissions:
  contents: read

jobs:
  release-please:
    runs-on: ubuntu-latest
    # release-please writes the release branch, the tag and the release, and
    # opens/updates its release PR.
    permissions:
      contents: write
      pull-requests: write
    # Surface the secret as an env var: the `secrets` context is not available
    # in a step-level `if:`, but `env` is, so this is how the app-token step
    # conditions on whether the credential is configured.
    env:
      RELEASE_BOT_APP_ID: ${{ secrets.RELEASE_BOT_APP_ID }}
    steps:
      # Both guards read .release-please-manifest.json and the pushed commit, so
      # this job needs the tree it never used to check out.
      #
      # Full history and tags, not a shallow clone: the changelog assembly step
      # below reads the squash commit that ADDED each fragment (that is where
      # the PR number comes from) and the newest tag (the compare link). Both
      # are invisible at fetch-depth 1.
      #
      # No persisted credential. Checkout otherwise leaves GITHUB_TOKEN in git's
      # config as an Authorization header. Git sends that header in preference
      # to the app token the assembly step puts in its push URL, so every
      # assembly push was attributed to `github-actions[bot]` and started no
      # checks (#1379). Nothing here needs it: release-please writes through the
      # API, and the repository is public, so the assembler's fetch needs no
      # credential.
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
        with:
          fetch-depth: 0
          persist-credentials: false

      - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
        with:
          python-version: "3.11"

      # These versions are PEP 440, not semver: `2.0.1` sorts below `1.6.1` as a
      # string, and `2.0.0rc4` sorts above `2.0.0`. The guard compares them
      # properly, which needs exactly this one dependency.
      - name: Install the version comparator's dependency
        run: python -m pip install --quiet packaging

      # Guard 1 of #681 -- skip a hand-cut release that has just landed.
      #
      # A hand-cut release merges its release commit and pushes the tag second.
      # In that window release-please finds no tag for the version the manifest
      # now carries, falls back to an older baseline and proposes moving the
      # manifest BACKWARDS (#670: 2.0.0 -> 1.6.1, #677: 2.0.1 -> 1.6.1).
      #
      # A bot release looks identical at HEAD -- same `chore(release): release X`
      # subject, same missing tag -- and that run is the one that creates the
      # tag, so skipping it would break bot releases (v1.6.0..v1.6.3 were all cut
      # that way). The only thing that differs is where the commit came from, so
      # that is what is checked. A lookup failure never skips.
      - name: Should release-please run at all?
        id: guard
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          set -euo pipefail
          if ! pr=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/pulls" --jq '.[0] // {}'); then
            echo "::warning::could not resolve the pull request for ${GITHUB_SHA}; running release-please as usual"
            pr='{}'
          fi
          PR_TITLE=$(jq -r '.title // ""' <<<"$pr")
          PR_BRANCH=$(jq -r '.head.ref // ""' <<<"$pr")
          HEAD_SUBJECT=$(git log -1 --format=%s)
          export PR_TITLE PR_BRANCH HEAD_SUBJECT
          python scripts/release_please_guard.py should-run

      # Mint a GitHub App token so the release PR triggers downstream CI.
      # Only runs when the app credentials are configured; otherwise the step
      # is skipped and release-please falls back to the built-in GITHUB_TOKEN
      # below (its own default), so the release pipeline never hard-fails on a
      # missing/rotated app secret.
      - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
        id: app-token
        if: ${{ env.RELEASE_BOT_APP_ID != '' && steps.guard.outputs.skip != 'true' }}
        with:
          app-id: ${{ secrets.RELEASE_BOT_APP_ID }}
          private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}

      # Say it here, where the cause is, as well as in the assembly step, where
      # the consequence is. An app that is configured and yields no token is a
      # credential problem -- expired installation, changed permissions -- and
      # everything downstream silently degrades to `GITHUB_TOKEN`: a release PR
      # whose required checks never start, and commits attributed to
      # github-actions[bot] rather than the release bot (#1180).
      - name: Say so when the release app produced no token
        if: ${{ env.RELEASE_BOT_APP_ID != '' && steps.guard.outputs.skip != 'true' && steps.app-token.outputs.token == '' }}
        run: |
          echo "::warning::RELEASE_BOT_APP_ID is configured but the app token step produced nothing;"\
               "falling back to GITHUB_TOKEN. Check the app installation and its permissions."

      - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5
        id: release-please
        if: ${{ steps.guard.outputs.skip != 'true' }}
        with:
          config-file: release-please-config.json
          manifest-file: .release-please-manifest.json
          token: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }}

      # Guard 2 of #681 -- refuse a backwards version bump.
      #
      # This is the one that catches the class rather than the instance: it does
      # not care why the baseline was wrong, only that a release never moves
      # backwards. A silently wrong PR becomes a red run. It runs even when
      # release-please failed, so a partial run cannot slip past it.
      - name: Refuse a backwards version bump
        id: check-bump
        if: ${{ always() && steps.guard.outputs.skip != 'true' }}
        env:
          RELEASE_PLEASE_OUTPUTS: ${{ toJSON(steps.release-please.outputs) }}
        run: python scripts/release_please_guard.py check-bump

      # The changelog body is no longer release-please's (`skip-changelog: true`
      # in release-please-config.json). Per-PR fragments in `changelog.d/` are
      # folded into a version section here, on the release branch, so the
      # release PR still carries the notes it always did and merging it lands
      # bump + notes in one squash commit -- what changed is only where the
      # prose comes from.
      #
      # Deliberately last, and only on a healthy run: it checks out the release
      # branch, so it must not run before the two guards have read the tree as
      # it stands on main, and there is no point writing notes onto a version
      # bump that either failed or moved backwards.
      - name: Assemble the changelog on the release PR
        if: ${{ steps.guard.outputs.skip != 'true' && steps.release-please.outcome == 'success' && steps.check-bump.outcome == 'success' }}
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          PUSH_TOKEN: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }}
        run: bash scripts/assemble_release_changelog.sh
