name: Release cadence

# Fleet's release cadence is evidence-driven and AUTO-GREEN BY DEFAULT. A scheduled
# tick decides, plans, and — when the agentic gates pass — EXECUTES the cut -> push
# -> CI witness -> tag -> GitHub release with no human in the loop (#1355, epic
# #1354). This is the root-cause fix for a fast trunk whose @latest rots behind HEAD:
# releases happen on a regular cadence the moment substantive green work accumulates,
# so the published binary tracks the kernel instead of lagging ~2000 commits.
#
# Auto-cut carries NO new trust. It executes ONLY what release_decide's blocker
# taxonomy permits at that instant: NOTHING_TO_SHIP / BELOW_SIGNIFICANCE (the >=1
# substantive-commit floor that stops empty/noise tags), VERSION_DRIFT,
# WORKFLOW_UNPARSEABLE, and CI_BASE_RED (the latest decisive main CI run must be
# green). The cut/tag/publish helpers refuse on their own invariants, and the
# single-writer concurrency group serializes ticks. "All agentic gates pass" is
# exactly the green condition under which it ships.
#
# KILL SWITCH: set the repo variable FAK_AUTO_RELEASE = '0' to fall back to
# dry-run-only scheduled ticks (decide + plan, no mutation). Any other value (unset
# included) keeps auto-cut ON. A manual workflow_dispatch is always gated by its own
# dry_run input regardless of the variable.
on:
  schedule:
    # Every 2 hours — a regular cadence. A tick with no substantive green work simply
    # holds (BELOW_SIGNIFICANCE / NOTHING_TO_SHIP), so frequency costs nothing but a
    # decide; it just means a shippable change waits at most ~2h for its tag.
    - cron: "37 */2 * * *"
  workflow_dispatch:
    inputs:
      dry_run:
        description: "Decide and plan the cut, but do not commit, push, or tag"
        type: boolean
        default: true
      require_ci_green:
        description: "Block when the current trunk CI state cannot be confirmed green"
        type: boolean
        default: true
      force:
        description: "Bypass only the substantive-commit floor; CI/drift/parse gates still bind"
        type: boolean
        default: false

permissions:
  contents: read

concurrency:
  group: release-cadence
  cancel-in-progress: false

jobs:
  cadence:
    name: decide, plan, and optionally release
    if: github.ref_name == 'main' || github.ref_name == 'master'
    runs-on: ubuntu-latest
    timeout-minutes: 90
    env:
      GOTOOLCHAIN: auto
    permissions:
      contents: write
      actions: read
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.ref_name }}
          fetch-depth: 0

      - uses: actions/setup-python@v5
        with:
          python-version: "3.13"

      - uses: actions/setup-go@v5
        with:
          go-version-file: go.mod

      - name: Release status snapshot
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          set -euo pipefail
          go run ./cmd/fak release status --json --require-ci-green --skip-gh --skip-cut-plan --limit-commits 50 > release-status.json
          cat release-status.json
          action="$(python -c 'import json; print(json.load(open("release-status.json"))["next_action"]["kind"])')"
          detail="$(python -c 'import json; print(json.load(open("release-status.json"))["next_action"]["detail"])')"
          {
            echo "## Release status"
            echo ""
            echo "\`$action\` - $detail"
          } >> "$GITHUB_STEP_SUMMARY"

      - name: Decide release readiness
        id: decide
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          set -euo pipefail
          flags=(--json --limit-commits 50)
          if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
            [ "${{ inputs.require_ci_green }}" = "true" ] && flags+=(--require-ci-green)
            [ "${{ inputs.force }}" = "true" ] && flags+=(--force)
          else
            flags+=(--require-ci-green)
          fi

          set +e
          python tools/release_decide.py "${flags[@]}" > decision.json
          code=$?
          set -e
          cat decision.json
          if [ "$code" -ne 0 ] && [ "$code" -ne 2 ]; then
            exit "$code"
          fi

          decision="$(python -c 'import json; print(json.load(open("decision.json"))["decision"])')"
          level="$(python -c 'import json; print(json.load(open("decision.json")).get("level") or "")')"
          version="$(python -c 'import json; print(json.load(open("decision.json")).get("next_version") or "")')"
          reason="$(python -c 'import json; print(json.load(open("decision.json")).get("reason") or "")')"
          {
            echo "decision=$decision"
            echo "level=$level"
            echo "version=$version"
          } >> "$GITHUB_OUTPUT"
          {
            echo "## Release cadence: $decision"
            echo ""
            echo "$reason"
            if [ "$decision" = "release" ]; then
              echo ""
              echo "Planned: \`v$version\` ($level)"
            fi
          } >> "$GITHUB_STEP_SUMMARY"

      - name: Arm execute decision
        id: arm
        env:
          # Default-ON: a scheduled tick executes UNLESS this repo variable is '0'
          # (the kill switch). Unset / any other value keeps auto-cut armed.
          AUTO_RELEASE: ${{ vars.FAK_AUTO_RELEASE }}
        run: |
          set -euo pipefail
          # execute = a manual dispatch with dry_run=false, OR a scheduled tick with
          # auto-cut not killed. Anything else stops after the plan (dry-run).
          execute=false
          if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.dry_run }}" = "false" ]; then
            execute=true
          elif [ "${{ github.event_name }}" = "schedule" ] && [ "${AUTO_RELEASE:-}" != "0" ]; then
            execute=true
          fi
          echo "execute=$execute" >> "$GITHUB_OUTPUT"
          {
            echo "## Execute decision"
            echo ""
            echo "event=${{ github.event_name }} · auto_release=${AUTO_RELEASE:-unset(=on)} · execute=$execute"
            echo ""
            echo "Auto-cut is DEFAULT-ON for scheduled ticks; set repo var FAK_AUTO_RELEASE=0 to kill."
          } >> "$GITHUB_STEP_SUMMARY"

      - name: Hold without mutation
        if: steps.decide.outputs.decision != 'release'
        run: |
          echo "cadence: hold; no release work to perform."

      - name: Plan release cut
        if: steps.decide.outputs.decision == 'release'
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          set -euo pipefail
          python tools/release_cut.py --json --require-ci-green --limit-commits 50 > cut-plan.json
          cat cut-plan.json
          ok="$(python -c 'import json; print(json.load(open("cut-plan.json"))["ok"])')"
          if [ "$ok" != "True" ]; then
            echo "::error::release_cut dry-run refused"
            exit 1
          fi

      - name: Dry-run stop
        if: >-
          steps.decide.outputs.decision == 'release' &&
          steps.arm.outputs.execute != 'true'
        run: |
          echo "## Dry-run only" >> "$GITHUB_STEP_SUMMARY"
          echo "This run stops after the plan (a manual dispatch with dry_run=true, or scheduled auto-cut killed via FAK_AUTO_RELEASE=0). Dispatch with dry_run=false to execute now (#1355)." >> "$GITHUB_STEP_SUMMARY"

      - name: Acquire release lock
        id: release_lock
        if: >-
          steps.decide.outputs.decision == 'release' &&
          steps.arm.outputs.execute == 'true'
        env:
          FAK_RELEASE_OWNER: release-cadence-${{ github.run_id }}-${{ github.run_attempt }}
        run: |
          set -euo pipefail
          set +e
          python tools/release_lock.py acquire \
            --ttl 5400 \
            --note "release-cadence ${GITHUB_RUN_ID}/${GITHUB_RUN_ATTEMPT}" > release-lock.json
          code=$?
          set -e
          cat release-lock.json
          echo "owner=$FAK_RELEASE_OWNER" >> "$GITHUB_OUTPUT"
          if [ "$code" -eq 0 ]; then
            echo "acquired=true" >> "$GITHUB_OUTPUT"
            exit 0
          fi
          echo "acquired=false" >> "$GITHUB_OUTPUT"
          if [ "$code" -eq 3 ]; then
            {
              echo "## Release lock held"
              echo ""
              echo "Another release owns \`.release.lock\`; this cadence tick defers to the next run."
            } >> "$GITHUB_STEP_SUMMARY"
            exit 0
          fi
          exit "$code"

      - name: Cut release commit
        id: cut
        if: >-
          steps.decide.outputs.decision == 'release' &&
          steps.arm.outputs.execute == 'true' &&
          steps.release_lock.outputs.acquired == 'true'
        env:
          GH_TOKEN: ${{ github.token }}
          FAK_RELEASE_OWNER: ${{ steps.release_lock.outputs.owner }}
        run: |
          set -euo pipefail
          git config user.name "fleet-release-cadence[bot]"
          git config user.email "fleet-release-cadence@users.noreply.github.com"
          python tools/release_cut.py --execute --json --require-ci-green --limit-commits 50 --lock-already-held > cut.json
          cat cut.json
          ok="$(python -c 'import json; print(json.load(open("cut.json"))["ok"])')"
          if [ "$ok" != "True" ]; then
            echo "::error::release_cut execute refused"
            exit 1
          fi
          version="$(python -c 'import json; print(json.load(open("cut.json"))["version"])')"
          sha="$(git rev-parse HEAD)"
          {
            echo "version=$version"
            echo "sha=$sha"
          } >> "$GITHUB_OUTPUT"

      - name: Push trunk
        if: steps.cut.outputs.sha != ''
        run: |
          set -euo pipefail
          python tools/fleet_control_pane.py publish --apply --json > publish.json
          cat publish.json
          ok="$(python -c 'import json; print(json.load(open("publish.json"))["ok"])')"
          if [ "$ok" != "True" ]; then
            echo "::error::checked publish refused"
            exit 1
          fi

      - name: Tag after green CI
        id: tag
        if: steps.cut.outputs.sha != ''
        env:
          GH_TOKEN: ${{ github.token }}
          FAK_RELEASE_OWNER: ${{ steps.release_lock.outputs.owner }}
        run: |
          set -euo pipefail
          python tools/release_tag.py \
            --version "${{ steps.cut.outputs.version }}" \
            --ref HEAD \
            --require-ci \
            --wait-ci \
            --execute \
            --push \
            --lock-already-held \
            --json > tag.json
          cat tag.json
          ok="$(python -c 'import json; print(json.load(open("tag.json"))["ok"])')"
          if [ "$ok" != "True" ]; then
            echo "::error::release_tag refused"
            exit 1
          fi

      - name: GitHub release
        if: steps.cut.outputs.sha != ''
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          set -euo pipefail
          python tools/release_publish.py \
            --version "${{ steps.cut.outputs.version }}" \
            --execute \
            --json > release-publish.json
          cat release-publish.json
          ok="$(python -c 'import json; print(json.load(open("release-publish.json"))["ok"])')"
          if [ "$ok" != "True" ]; then
            echo "::error::release_publish refused"
            exit 1
          fi

      - name: Release release lock
        if: always() && steps.release_lock.outputs.acquired == 'true'
        env:
          FAK_RELEASE_OWNER: ${{ steps.release_lock.outputs.owner }}
        run: |
          set -euo pipefail
          python tools/release_lock.py release
