# CLEO release pipeline — Rollback workflow.
#
# Generated from packages/cleo/templates/workflows/release-rollback.yml.tmpl by
# `cleo init --workflows` (T9531). DO NOT EDIT the rendered file directly —
# extend via `.workflow-overrides.yml` per SPEC-T9345 R-260.
#
# Implements SPEC-T9345-release-pipeline-v2.md §5.4 (R-250 — R-257, R-260 —
# R-263). Triggers ONLY on `workflow_dispatch` with `version`, `mode`
# (`metadata-only|full`), and `reason` inputs. For `mode=full`, opens a
# revert PR against `main` (NEVER pushes directly to `main` per ADR-065),
# then runs `npm deprecate` (best-effort) and finally records the rollback
# in the provenance graph via `cleo release reconcile --rollback`.
#
# Placeholders (replaced at scaffold time — see workflows/README.md):
#   <NODE_VERSION>     Node.js version           (e.g. "22.x")
#   <PUBLISHERS>       Space-separated publisher list (e.g. "npm cargo")
#   <NPM_PACKAGES>     Space-separated npm package names to deprecate
#                      (e.g. "@cleocode/cleo @cleocode/core")
#   <CARGO_CRATES>     Space-separated cargo crate names to yank
#                      (e.g. "cleo-core cleo-cli")
# (Angle-brackets in this doc comment are intentional — they avoid being
#  substituted by the {{...}} regex pass. The placeholders below use {{...}}.)

name: Release Rollback

# R-250: workflow_dispatch only — rollback is an explicit operator action,
# never an automatic response to push/release events.
# R-251: required inputs `version`, `mode` (choice), `reason` (free text).
on:
  workflow_dispatch:
    inputs:
      version:
        description: 'Version to roll back (e.g. v2026.6.0)'
        type: string
        required: true
      mode:
        description: 'Rollback mode — metadata-only deprecates packages; full also opens a revert PR'
        type: choice
        required: true
        options:
          - metadata-only
          - full
      reason:
        description: 'Human-readable reason recorded with the rollback'
        type: string
        required: true

# R-252: workflow-level grants minimum read scope. The `revert` and
# `deprecate` jobs escalate per-job. `pull-requests: write` and
# `packages: write` MUST NOT be inherited workflow-wide so reconcile
# CANNOT silently widen its blast radius.
permissions:
  contents: read

# R-257: serialize rollbacks against the same version. cancel-in-progress
# is FALSE so an in-flight rollback completes rather than leaving the
# revert PR / npm deprecate state half-applied.
concurrency:
  group: rollback-${{ inputs.version }}
  cancel-in-progress: false

# R-262: bash everywhere — no cross-platform shell drift.
defaults:
  run:
    shell: bash

jobs:
  # R-253 (1/4): validate the rollback request. Confirms the tag exists
  # and resolves the release commit SHA so the `revert` job has a fixed
  # target. A missing tag MUST fail fast — no downstream side effects.
  validate:
    name: Validate rollback request
    runs-on: ubuntu-latest
    timeout-minutes: 5
    outputs:
      release_sha: ${{ steps.find.outputs.release_sha }}
    steps:
      # R-263: third-party Actions pinned to major+minor.
      - name: Checkout
        uses: actions/checkout@v4.1
        with:
          fetch-depth: 0
        timeout-minutes: 5

      - name: Resolve release commit SHA
        id: find
        run: |
          set -euo pipefail
          VERSION="${{ inputs.version }}"
          if ! git rev-parse --verify "$VERSION" >/dev/null 2>&1; then
            echo "::error::Tag $VERSION does not exist — cannot roll back a release that was never tagged."
            exit 1
          fi
          # The release commit is the commit the tag points to (peeled).
          RELEASE_SHA=$(git rev-list -n 1 "$VERSION")
          if [ -z "$RELEASE_SHA" ]; then
            echo "::error::Could not resolve commit SHA for tag $VERSION"
            exit 1
          fi
          echo "release_sha=$RELEASE_SHA" >> "$GITHUB_OUTPUT"
          echo "Resolved $VERSION -> $RELEASE_SHA"
        timeout-minutes: 2

  # R-253 (2/4) + R-254: open a revert PR for `mode=full`. NEVER pushes
  # directly to `main` (ADR-065). The PR title MUST be
  # `Revert release <version>: <reason>` and MUST carry the `rollback`
  # label so branch-protection / triage automation can route it.
  revert:
    name: Open revert PR
    needs: validate
    if: inputs.mode == 'full'
    runs-on: ubuntu-latest
    timeout-minutes: 15
    # R-252: per-job escalation — `contents: write` for the revert
    # branch commit; `pull-requests: write` for `gh pr create`. NO
    # `packages: write` here — that belongs to the `deprecate` job only.
    permissions:
      contents: write
      pull-requests: write
    env:
      GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      VERSION: ${{ inputs.version }}
      REASON: ${{ inputs.reason }}
      RELEASE_SHA: ${{ needs.validate.outputs.release_sha }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4.1
        with:
          fetch-depth: 0
          token: ${{ secrets.GITHUB_TOKEN }}
        timeout-minutes: 5

      - name: Configure git identity
        run: |
          git config user.email "actions@github.com"
          git config user.name "GitHub Actions"
        timeout-minutes: 1

      # R-254: cut `revert/<version>` branch, run `git revert -n` against
      # the resolved release commit, commit with the canonical subject.
      # Branch is cut from the action's checked-out main HEAD so the
      # revert applies on top of current main.
      - name: Create revert branch and commit
        run: |
          set -euo pipefail
          BRANCH="revert/${VERSION}"
          git checkout -b "$BRANCH"
          # `-n` (no-commit) lets us craft the subject line ourselves.
          # `-m 1` selects the first parent when the release commit is a
          # merge (the bump-PR merge into main), which is the canonical
          # release-commit shape for ADR-065 PR-gated releases.
          if git rev-parse --verify "${RELEASE_SHA}^2" >/dev/null 2>&1; then
            git revert -n -m 1 "$RELEASE_SHA"
          else
            git revert -n "$RELEASE_SHA"
          fi
          git commit -m "Revert release ${VERSION}: ${REASON}"
        timeout-minutes: 5

      # R-254 (continued): push the revert branch — NEVER `git push origin
      # main`. Branch protection on main is the second line of defence,
      # but the workflow MUST NOT rely on it.
      - name: Push revert branch
        run: git push -u origin "revert/${VERSION}"
        timeout-minutes: 5

      # R-254 (continued): open the revert PR with the canonical title
      # and the `rollback` label. The PR body links back to this
      # workflow run so reviewers can audit the rollback context.
      - name: Open revert PR
        run: |
          set -euo pipefail
          gh pr create \
            --base main \
            --head "revert/${VERSION}" \
            --title "Revert release ${VERSION}: ${REASON}" \
            --label rollback \
            --body "Automated rollback PR for release **${VERSION}**.

**Reason**: ${REASON}

This PR reverts commit \`${RELEASE_SHA}\` (the merge commit the
release tag points to). It was opened by the \`release-rollback.yml\`
workflow — see the workflow run for full context:
${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}

After this PR merges, the package registries have already been marked
as deprecated by the \`deprecate\` job, and the provenance graph has
been updated by the \`reconcile-rollback\` job."
        timeout-minutes: 5

  # R-253 (3/4) + R-255: deprecate published artifacts. Best-effort —
  # `continue-on-error: true` so a transient registry hiccup CANNOT
  # block the reconcile-rollback step. Runs for BOTH `metadata-only`
  # and `full` modes since deprecation is the core rollback signal.
  deprecate:
    name: Deprecate published artifacts
    needs: validate
    runs-on: ubuntu-latest
    timeout-minutes: 15
    continue-on-error: true
    # R-252: per-job `packages: write` for npm deprecate. NO
    # `contents: write` — this job MUST NOT touch git refs.
    permissions:
      contents: read
      packages: write
    env:
      VERSION: ${{ inputs.version }}
      REASON: ${{ inputs.reason }}
      # Hoisted into env so downstream `contains(env.PUBLISHERS, ...)`
      # is a dynamic expression (avoids actionlint constant-expression
      # warnings — same pattern as release-publish.yml.tmpl).
      PUBLISHERS: '{{PUBLISHERS}}'
      NPM_PACKAGES: '{{NPM_PACKAGES}}'
      CARGO_CRATES: '{{CARGO_CRATES}}'
    steps:
      - name: Set up Node.js
        uses: actions/setup-node@v4.0
        with:
          node-version: '{{NODE_VERSION}}'
          registry-url: 'https://registry.npmjs.org'
        timeout-minutes: 3

      # R-255: `npm deprecate <pkg>@<version> "Rolled back: <reason>"`
      # per published artifact. Failure is NON-FATAL — each iteration
      # is wrapped so one package's failure cannot stop the others.
      - name: Deprecate npm packages
        if: contains(env.PUBLISHERS, 'npm')
        run: |
          set -uo pipefail
          STRIPPED="${VERSION#v}"
          for pkg in ${NPM_PACKAGES}; do
            if npm deprecate "$pkg@$STRIPPED" "Rolled back: ${REASON}"; then
              echo "Deprecated $pkg@$STRIPPED"
            else
              echo "::warning::Failed to deprecate $pkg@$STRIPPED — continuing"
            fi
          done
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
        timeout-minutes: 10

      # R-255 (extension for cargo): `cargo yank` is the cargo analogue
      # of `npm deprecate`. Also best-effort — a missing crate or a
      # rate-limit response MUST NOT block reconcile.
      - name: Yank cargo crates
        if: contains(env.PUBLISHERS, 'cargo')
        run: |
          set -uo pipefail
          STRIPPED="${VERSION#v}"
          for crate in ${CARGO_CRATES}; do
            if cargo yank --version "$STRIPPED" "$crate"; then
              echo "Yanked $crate@$STRIPPED"
            else
              echo "::warning::Failed to yank $crate@$STRIPPED — continuing"
            fi
          done
        env:
          CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_TOKEN }}
        timeout-minutes: 10

  # R-253 (4/4) + R-256: record the rollback in the provenance graph via
  # `cleo release reconcile <version> --rollback --reason "<reason>"`.
  # This writes `releases.rolled_back_at` and inserts a
  # `release_changes` row with `change_type='revert'`. Runs after
  # `validate` regardless of `revert` / `deprecate` outcome — provenance
  # must record the operator's intent even if individual side-effects
  # failed.
  reconcile-rollback:
    name: Record rollback in provenance graph
    needs:
      - validate
    if: always() && needs.validate.result == 'success'
    runs-on: ubuntu-latest
    timeout-minutes: 10
    env:
      VERSION: ${{ inputs.version }}
      REASON: ${{ inputs.reason }}
      GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4.1
        with:
          fetch-depth: 0
        timeout-minutes: 5

      - name: Set up Node.js
        uses: actions/setup-node@v4.0
        with:
          node-version: '{{NODE_VERSION}}'
          registry-url: 'https://registry.npmjs.org'
        timeout-minutes: 3

      - name: Install cleo
        run: npm install -g @cleocode/cleo
        timeout-minutes: 5

      # R-256: reconcile MUST write rollback marker into `releases` +
      # `release_changes`. The CLI is the canonical writer — workflow
      # YAML never touches the DB directly.
      - name: Record rollback
        run: cleo release reconcile "${VERSION}" --rollback --reason "${REASON}"
        timeout-minutes: 5

      # Recovery hint in the job summary so an operator who sees a
      # half-applied rollback can finish the job from a local checkout.
      - name: Emit recovery hint
        if: always()
        run: |
          {
            echo "## Release Rollback — ${VERSION}"
            echo ""
            echo "**Mode**: \`${{ inputs.mode }}\`"
            echo "**Reason**: ${REASON}"
            echo ""
            echo "**Recovery commands** (if anything went wrong):"
            echo ""
            echo "\`\`\`"
            echo "cleo release reconcile ${VERSION} --rollback --reason \"${REASON}\""
            echo "\`\`\`"
          } >> "$GITHUB_STEP_SUMMARY"
        timeout-minutes: 1
