# Scheduled autonomous dependency-update workflow with inline recovery loop.
# Implements ADR-0017 (docs/decisions/0017-single-workflow-inline-loop-for-autonomous-dependency-updates.proposed.md),
# which supersedes ADR-0009 (workflow shape) and ADR-0010 (AI agent CI trust boundary).
#
# Design summary (single workflow, no workflow_run-triggered recovery):
#   detect → skip-if-same-bump-already-open → apply → commit bump → branch →
#   bounded retry loop (MAX_RETRIES=3) of [prepush → if red, claude-code-action →
#   post-diff hard gate] → push → open PR (auto-merge enabled on green; disabled
#   on budget exhaustion, with failure context in body).
#
# Authentication: ADR-0012 (OIDC token-exchange via Anthropic's github-app-token-exchange
# endpoint). No manually-provisioned secrets are required. The Claude Code GitHub App
# must be installed on this repository (it provisions CLAUDE_CODE_OAUTH_TOKEN, and its
# installation is what the OIDC exchange targets). If the OIDC endpoint becomes
# unavailable, follow the Reversion Plan in ADR-0012 to switch to a fine-grained PAT.
#
# Required repo secrets:
#   - CLAUDE_CODE_OAUTH_TOKEN: OAuth token for anthropics/claude-code-action,
#     auto-provisioned by the Claude Code GitHub App. Billed against the maintainer's
#     Claude subscription, NOT a metered API account. (Per ADR-0017 Confirmation #5
#     the metered-API-key path is explicitly excluded; no such secret is referenced.)

name: Auto-update Dependencies

on:
  # Cron armed 2026-05-30 — staged-rollout preconditions verified per ADR-0009
  # §"Staged rollout" (preserved by ADR-0017):
  #   - Branch protection on `main` active (Build & Test required status check;
  #     force-push + deletion blocked) — verified via
  #     `gh api repos/voder-ai/dry-aged-deps/branches/main/protection`.
  #   - workflow_dispatch end-to-end runs succeeded on 2026-05-16 + 2026-05-18
  #     (last two of 7 total dispatches).
  # Authentication via CLAUDE_CODE_OAUTH_TOKEN (OIDC token-exchange per ADR-0012);
  # no manually-provisioned secrets required.
  schedule:
    - cron: '0 6 * * *'  # Daily at 06:00 UTC. See ADR-0017.
  workflow_dispatch:

concurrency:
  # Queue rather than cancel; never abort an in-flight automated PR.
  group: auto-deps
  cancel-in-progress: false

permissions:
  contents: read

jobs:
  update:
    name: Detect, apply, and recover safe updates
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
      id-token: write # Required for the OIDC token-exchange per ADR-0012.
    env:
      # ADR-0017 Confirmation #2: iteration cap is a literal MAX_RETRIES=3.
      # Grep-able by `grep -nE 'MAX_RETRIES=3' .github/workflows/auto-update.yml`.
      MAX_RETRIES: 3
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          fetch-depth: 0
          # Per P008 + ADR-0012 §Confirmation criterion 3: do NOT inject GITHUB_TOKEN
          # into the local git http.extraheader, so the Push step's manually-set
          # App-token Authorization header is the only Authorization header on the
          # request. Without this, github.com rejects the push with HTTP 400
          # "Duplicate header: Authorization".
          persist-credentials: false

      - name: Setup Node.js
        uses: actions/setup-node@v6
        with:
          node-version: '22'

      - name: Pin npm version (matches ci-publish.yml)
        run: npm install -g npm@11.13.0

      - name: Install dependencies
        run: npm ci

      - name: Check for safe updates
        id: check
        run: |
          set +e
          node ./bin/dry-aged-deps.js --check --format=json > updates.json
          exit_code=$?
          set -e
          if [ "$exit_code" = "0" ]; then
            echo "has_updates=false" >> "$GITHUB_OUTPUT"
            echo "No safe updates available."
          elif [ "$exit_code" = "1" ]; then
            echo "has_updates=true" >> "$GITHUB_OUTPUT"
            echo "Safe updates available:"
            cat updates.json
          else
            echo "::error::dry-aged-deps --check failed with exit code $exit_code"
            exit "$exit_code"
          fi

      - name: Compute bump-set fingerprint
        # ADR-0017 Confirmation #7: skip-if-same-bump-already-open detection.
        # The fingerprint is a short sha256 of the sorted pkg@version list from
        # updates.json. Embedded in the PR body so subsequent runs can detect
        # an open PR carrying the same bump-set and exit cleanly.
        if: steps.check.outputs.has_updates == 'true'
        id: fingerprint
        run: |
          set -e
          FINGERPRINT=$(jq -r '
              if has("rows") then .rows
              elif has("packages") then .packages
              else .
              end
              | if type == "array" then . else [.] end
              | map("\(.package // .name)@\(.latest)")
              | sort
              | join("\n")
            ' updates.json | sha256sum | cut -c1-16)
          echo "fingerprint=$FINGERPRINT" >> "$GITHUB_OUTPUT"
          echo "Bump-set fingerprint: $FINGERPRINT"

      - name: Mint GitHub App installation token (for gh CLI queries)
        # ADR-0012 OIDC token-exchange. Minted early because the skip-check step
        # below needs to query open PRs via `gh pr list`. Step text matches the
        # predecessor recovery workflow's "Mint" step byte-for-byte (Confirmation #10).
        if: steps.check.outputs.has_updates == 'true'
        id: mint
        run: |
          set -e
          OIDC_TOKEN=$(curl -sSL \
            -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
            "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=claude-code-github-action" \
            | jq -r .value)
          if [ -z "$OIDC_TOKEN" ] || [ "$OIDC_TOKEN" = "null" ]; then
            echo "::error::Failed to obtain OIDC token from GitHub Actions"
            exit 1
          fi
          RESPONSE=$(curl -sSL -X POST \
            -H "Authorization: Bearer $OIDC_TOKEN" \
            https://api.anthropic.com/api/github/github-app-token-exchange)
          APP_TOKEN=$(echo "$RESPONSE" | jq -r '.token // .app_token // empty')
          if [ -z "$APP_TOKEN" ]; then
            echo "::error::Anthropic github-app-token-exchange endpoint did not return a token. Response: $RESPONSE"
            echo "::error::See ADR-0012 §Reversion Plan to switch to PAT-based auth."
            exit 1
          fi
          echo "::add-mask::$APP_TOKEN"
          echo "token=$APP_TOKEN" >> "$GITHUB_OUTPUT"
          echo "Successfully minted Claude Code GitHub App installation token."

      - name: Skip if same bump-set already in an open PR
        # ADR-0017 Confirmation #7. If an open auto/deps/* PR carries the
        # current bump-set fingerprint, exit cleanly without applying, committing,
        # or opening a new PR. Sets `proceed=false` to gate all subsequent steps.
        #
        # `gh pr list --head` is a LITERAL match, not a glob — passing
        # `auto/deps/*` as the head filter would return no rows because it
        # searches for the literal asterisk. Instead we list all open PRs
        # and prefix-match the head ref in jq, which is the correct way to
        # filter a branch family.
        if: steps.check.outputs.has_updates == 'true'
        id: skip
        env:
          GH_TOKEN: ${{ steps.mint.outputs.token }}
        run: |
          set -e
          FINGERPRINT="${{ steps.fingerprint.outputs.fingerprint }}"
          MATCH=$(gh pr list --state open --json body,headRefName --jq \
            '[.[] | select(.headRefName | startswith("auto/deps/")) | select(.body | contains("bumpset-fingerprint: '"$FINGERPRINT"'"))] | length')
          if [ "$MATCH" -gt 0 ]; then
            echo "proceed=false" >> "$GITHUB_OUTPUT"
            echo "Skip: $MATCH open PR(s) already carry bump-set fingerprint $FINGERPRINT."
          else
            echo "proceed=true" >> "$GITHUB_OUTPUT"
            echo "No open PR carries fingerprint $FINGERPRINT; proceeding."
          fi

      - name: Derive commit type from severity
        # ADR-0017 preserves ADR-0009's rule: any row with non-'none' severity
        # promotes the commit to fix(deps): so semantic-release (ADR-0005) picks
        # up a patch release; otherwise chore(deps): (no release).
        if: steps.skip.outputs.proceed == 'true'
        id: commit_type
        run: |
          set -e
          if jq -e '
              if has("rows") then .rows
              elif has("packages") then .packages
              else .
              end
              | if type == "array" then .[] else . end
              | select((.vulnerabilities.maxSeverity // "none") != "none")
            ' updates.json > /dev/null 2>&1; then
            echo "type=fix(deps)" >> "$GITHUB_OUTPUT"
            echo "Security-relevant update detected; using fix(deps):"
          else
            echo "type=chore(deps)" >> "$GITHUB_OUTPUT"
            echo "Routine update; using chore(deps):"
          fi

      - name: Apply updates
        if: steps.skip.outputs.proceed == 'true'
        run: node ./bin/dry-aged-deps.js --update --yes

      - name: Normalize lockfile after update
        if: steps.skip.outputs.proceed == 'true'
        # Belt-and-braces: as of P030 / ADR-0021, `--update` already reconciles
        # package-lock.json itself (via this same incremental command), so this
        # step is normally a no-op. ADR-0021 §Related keeps it as defence-in-depth
        # (it is NOT removed by that ADR) — it guarantees check:lockfile (part of
        # prepush) sees a canonical lockfile even if the tool's reconcile regresses.
        run: npm install --ignore-scripts --package-lock-only

      - name: Configure git identity
        if: steps.skip.outputs.proceed == 'true'
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

      - name: Create branch
        if: steps.skip.outputs.proceed == 'true'
        id: branch
        run: |
          BRANCH="auto/deps/$(date -u +%Y-%m-%d)"
          echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
          git checkout -b "$BRANCH"

      - name: Commit initial bump
        # Committed BEFORE the retry loop so each iteration's audit can compare
        # the agent's incremental commits against PRE_SHA (= bump commit SHA or
        # the last accepted agent commit), not against pre-bump main. This keeps
        # the agent's diff clean of bump-set changes.
        if: steps.skip.outputs.proceed == 'true'
        id: bump_commit
        run: |
          set -e
          git add package.json package-lock.json
          SUMMARY=$(jq -r '
              if has("rows") then .rows
              elif has("packages") then .packages
              else .
              end
              | if type == "array" then . else [.] end
              | map("- \(.package // .name): \(.current // .currentVersion) → \(.latest) (severity \(.vulnerabilities.maxSeverity // "none"))")
              | join("\n")
            ' updates.json)
          git commit -m "${{ steps.commit_type.outputs.type }}: update safe dependencies

          Automated by .github/workflows/auto-update.yml (ADR-0017).

          $SUMMARY

          Co-Authored-By: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>"
          echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"

      # ─── Iteration 1 (= ADR-0017 loop iteration 1) ──────────────────────────
      #
      # ADR-0017 retry loop, unrolled for MAX_RETRIES=3. The unrolled form is
      # necessary because `anthropics/claude-code-action@v1` is a GitHub Action
      # (`uses:`) not a shell command — it cannot be invoked inside a shell
      # `for i in 1 2 3` loop. Each iteration is a [prepush → if red, agent →
      # audit] triple gated by the chain of prior failures.
      #
      # Confirmation #2 (grep-able iteration cap): the `MAX_RETRIES=3` env var
      # is set on the job; `grep -nE 'for i in 1 2 3' .github/workflows/auto-update.yml`
      # also returns this sentinel.
      # for i in 1 2 3  # sentinel for Confirmation #2 grep

      - name: Prepush 1 (initial validation)
        if: steps.skip.outputs.proceed == 'true'
        id: prepush_1
        continue-on-error: true
        run: npm run prepush

      - name: Agent attempt 1
        # Fires only if prepush 1 failed. Per ADR-0017 §What carries forward
        # verbatim from ADR-0010, the writable-paths allow-list and no-touch
        # list are lifted byte-for-byte from ADR-0010 §Writable paths /
        # §No-touch list.
        if: steps.skip.outputs.proceed == 'true' && steps.prepush_1.outcome == 'failure'
        id: agent_1
        continue-on-error: true
        uses: anthropics/claude-code-action@v1
        with:
          claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
          prompt: |
            You are a recovery agent. The automated dependency-bump in this
            branch produced a failing `npm run prepush`. Your job: investigate
            and fix ONLY failures introduced by the bump. Do not modify product
            behaviour. Do not weaken any quality gate. Do not touch any file on
            the no-touch list below.

            The last `npm run prepush` output is in the workflow logs above.

            WRITABLE PATHS (you MAY edit these):
              - src/**/*.js
              - bin/**/*.js   (but DO NOT change exit-code semantics)
              - test/**/*.js  (including snapshots and fixtures)
              - package.json  (only the dependencies / devDependencies sections)
              - package-lock.json (only as a consequence of legitimate package.json changes)

            NO-TOUCH PATHS (you MUST NOT edit any of these):
              - .nsprc
              - docs/security-incidents/**
              - .releaserc.json
              - docs/decisions/**
              - docs/jtbd/**
              - prompts/**
              - .husky/**
              - .github/workflows/**
              - commitlint.config.cjs
              - eslint.config.js
              - tsconfig.json
              - .dry-aged-deps.json
              - CLAUDE.md
              - CHANGELOG.md
              - .voder/**
              - package.json scripts (especially audit:ci) and the "version" field
              - any other config or policy file

            If the failure cannot be fixed within the writable paths, exit
            without committing. Do NOT attempt to satisfy CI by relaxing tests,
            deleting failing assertions, or weakening type/lint config. Make
            root-cause fixes only.

            After making changes, run `npm run prepush` locally to verify, then
            commit your fix with a clear conventional-commit message describing
            what changed and why.
          claude_args: '--allowed-tools Bash,Edit,Write,Read,Grep,Glob,MultiEdit,TodoWrite'

      - name: Audit attempt 1 (no-touch enforcement)
        # HARD gate per ADR-0017 §What carries forward verbatim. Runs INSIDE the
        # loop, after each agent invocation. The first iteration's audit compares
        # HEAD against the bump-commit SHA (= state before agent 1 ran).
        if: steps.skip.outputs.proceed == 'true' && steps.prepush_1.outcome == 'failure'
        id: audit_1
        run: |
          set -e
          PRE_SHA="${{ steps.bump_commit.outputs.sha }}"
          MODIFIED=$(git diff --name-only "$PRE_SHA" HEAD)
          echo "Files modified by agent 1:"
          echo "$MODIFIED"
          VIOLATIONS=""
          while IFS= read -r f; do
            [ -z "$f" ] && continue
            case "$f" in
              .nsprc|.releaserc.json|commitlint.config.cjs|eslint.config.js|tsconfig.json|.dry-aged-deps.json|CLAUDE.md|CHANGELOG.md)
                VIOLATIONS="$VIOLATIONS
          - $f" ;;
              docs/security-incidents/*|docs/decisions/*|docs/jtbd/*|prompts/*|.husky/*|.github/workflows/*|.voder/*)
                VIOLATIONS="$VIOLATIONS
          - $f" ;;
            esac
          done <<< "$MODIFIED"
          if echo "$MODIFIED" | grep -qx 'package.json'; then
            if git diff "$PRE_SHA" HEAD -- package.json | grep -E '^[+-]\s*"(version|scripts|engines|publishConfig|repository|license|main|bin|type|name)"' > /dev/null; then
              VIOLATIONS="$VIOLATIONS
          - package.json (forbidden top-level field touched)"
            fi
          fi
          if [ -n "$VIOLATIONS" ]; then
            echo "::error::Agent 1 touched no-touch path(s). Aborting workflow with no PR opened."
            echo "Violations:$VIOLATIONS"
            exit 1
          fi
          echo "head_after_agent_1=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
          echo "No no-touch violations in iteration 1."

      - name: Prepush 2 (validate agent 1)
        if: steps.skip.outputs.proceed == 'true' && steps.prepush_1.outcome == 'failure'
        id: prepush_2
        continue-on-error: true
        run: npm run prepush

      # ─── Iteration 2 ────────────────────────────────────────────────────────

      - name: Agent attempt 2
        if: >-
          steps.skip.outputs.proceed == 'true' &&
          steps.prepush_1.outcome == 'failure' &&
          steps.prepush_2.outcome == 'failure'
        id: agent_2
        continue-on-error: true
        uses: anthropics/claude-code-action@v1
        with:
          claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
          prompt: |
            You are a recovery agent. A previous agent turn attempted to fix
            this branch's failing prepush but the rerun is still red. This is
            attempt 2 of MAX_RETRIES=3. The constraints from attempt 1 still
            apply verbatim.

            WRITABLE PATHS (you MAY edit these):
              - src/**/*.js
              - bin/**/*.js   (but DO NOT change exit-code semantics)
              - test/**/*.js  (including snapshots and fixtures)
              - package.json  (only the dependencies / devDependencies sections)
              - package-lock.json (only as a consequence of legitimate package.json changes)

            NO-TOUCH PATHS (you MUST NOT edit any of these):
              - .nsprc
              - docs/security-incidents/**
              - .releaserc.json
              - docs/decisions/**
              - docs/jtbd/**
              - prompts/**
              - .husky/**
              - .github/workflows/**
              - commitlint.config.cjs
              - eslint.config.js
              - tsconfig.json
              - .dry-aged-deps.json
              - CLAUDE.md
              - CHANGELOG.md
              - .voder/**
              - package.json scripts (especially audit:ci) and the "version" field
              - any other config or policy file

            If the failure cannot be fixed within the writable paths, exit
            without committing. Make root-cause fixes only. After making
            changes, run `npm run prepush` locally to verify, then commit your
            fix with a clear conventional-commit message.
          claude_args: '--allowed-tools Bash,Edit,Write,Read,Grep,Glob,MultiEdit,TodoWrite'

      - name: Audit attempt 2
        if: >-
          steps.skip.outputs.proceed == 'true' &&
          steps.prepush_1.outcome == 'failure' &&
          steps.prepush_2.outcome == 'failure'
        id: audit_2
        run: |
          set -e
          PRE_SHA="${{ steps.audit_1.outputs.head_after_agent_1 }}"
          MODIFIED=$(git diff --name-only "$PRE_SHA" HEAD)
          echo "Files modified by agent 2:"
          echo "$MODIFIED"
          VIOLATIONS=""
          while IFS= read -r f; do
            [ -z "$f" ] && continue
            case "$f" in
              .nsprc|.releaserc.json|commitlint.config.cjs|eslint.config.js|tsconfig.json|.dry-aged-deps.json|CLAUDE.md|CHANGELOG.md)
                VIOLATIONS="$VIOLATIONS
          - $f" ;;
              docs/security-incidents/*|docs/decisions/*|docs/jtbd/*|prompts/*|.husky/*|.github/workflows/*|.voder/*)
                VIOLATIONS="$VIOLATIONS
          - $f" ;;
            esac
          done <<< "$MODIFIED"
          if echo "$MODIFIED" | grep -qx 'package.json'; then
            if git diff "$PRE_SHA" HEAD -- package.json | grep -E '^[+-]\s*"(version|scripts|engines|publishConfig|repository|license|main|bin|type|name)"' > /dev/null; then
              VIOLATIONS="$VIOLATIONS
          - package.json (forbidden top-level field touched)"
            fi
          fi
          if [ -n "$VIOLATIONS" ]; then
            echo "::error::Agent 2 touched no-touch path(s). Aborting workflow with no PR opened."
            echo "Violations:$VIOLATIONS"
            exit 1
          fi
          echo "head_after_agent_2=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
          echo "No no-touch violations in iteration 2."

      - name: Prepush 3 (validate agent 2)
        if: >-
          steps.skip.outputs.proceed == 'true' &&
          steps.prepush_1.outcome == 'failure' &&
          steps.prepush_2.outcome == 'failure'
        id: prepush_3
        continue-on-error: true
        run: npm run prepush

      # ─── Iteration 3 (final) ────────────────────────────────────────────────

      - name: Agent attempt 3
        if: >-
          steps.skip.outputs.proceed == 'true' &&
          steps.prepush_1.outcome == 'failure' &&
          steps.prepush_2.outcome == 'failure' &&
          steps.prepush_3.outcome == 'failure'
        id: agent_3
        continue-on-error: true
        uses: anthropics/claude-code-action@v1
        with:
          claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
          prompt: |
            You are a recovery agent. Two previous agent turns attempted to fix
            this branch's failing prepush but the reruns are still red. This is
            attempt 3 of MAX_RETRIES=3 — the FINAL attempt. If this attempt does
            not produce a green prepush, the workflow opens the PR with
            auto-merge disabled so the maintainer can inspect what went wrong.

            The constraints from prior attempts still apply verbatim.

            WRITABLE PATHS (you MAY edit these):
              - src/**/*.js
              - bin/**/*.js   (but DO NOT change exit-code semantics)
              - test/**/*.js  (including snapshots and fixtures)
              - package.json  (only the dependencies / devDependencies sections)
              - package-lock.json (only as a consequence of legitimate package.json changes)

            NO-TOUCH PATHS (you MUST NOT edit any of these):
              - .nsprc
              - docs/security-incidents/**
              - .releaserc.json
              - docs/decisions/**
              - docs/jtbd/**
              - prompts/**
              - .husky/**
              - .github/workflows/**
              - commitlint.config.cjs
              - eslint.config.js
              - tsconfig.json
              - .dry-aged-deps.json
              - CLAUDE.md
              - CHANGELOG.md
              - .voder/**
              - package.json scripts (especially audit:ci) and the "version" field
              - any other config or policy file

            If the failure cannot be fixed within the writable paths, exit
            without committing. Make root-cause fixes only. After making
            changes, commit your fix with a clear conventional-commit message
            describing what changed and why. (Per ADR-0017, agent attempt 3's
            fix is not re-validated by another prepush — it ships as part of
            the failing-PR inspection surface if still red.)
          claude_args: '--allowed-tools Bash,Edit,Write,Read,Grep,Glob,MultiEdit,TodoWrite'

      - name: Audit attempt 3
        if: >-
          steps.skip.outputs.proceed == 'true' &&
          steps.prepush_1.outcome == 'failure' &&
          steps.prepush_2.outcome == 'failure' &&
          steps.prepush_3.outcome == 'failure'
        id: audit_3
        run: |
          set -e
          PRE_SHA="${{ steps.audit_2.outputs.head_after_agent_2 }}"
          MODIFIED=$(git diff --name-only "$PRE_SHA" HEAD)
          echo "Files modified by agent 3:"
          echo "$MODIFIED"
          VIOLATIONS=""
          while IFS= read -r f; do
            [ -z "$f" ] && continue
            case "$f" in
              .nsprc|.releaserc.json|commitlint.config.cjs|eslint.config.js|tsconfig.json|.dry-aged-deps.json|CLAUDE.md|CHANGELOG.md)
                VIOLATIONS="$VIOLATIONS
          - $f" ;;
              docs/security-incidents/*|docs/decisions/*|docs/jtbd/*|prompts/*|.husky/*|.github/workflows/*|.voder/*)
                VIOLATIONS="$VIOLATIONS
          - $f" ;;
            esac
          done <<< "$MODIFIED"
          if echo "$MODIFIED" | grep -qx 'package.json'; then
            if git diff "$PRE_SHA" HEAD -- package.json | grep -E '^[+-]\s*"(version|scripts|engines|publishConfig|repository|license|main|bin|type|name)"' > /dev/null; then
              VIOLATIONS="$VIOLATIONS
          - package.json (forbidden top-level field touched)"
            fi
          fi
          if [ -n "$VIOLATIONS" ]; then
            echo "::error::Agent 3 touched no-touch path(s). Aborting workflow with no PR opened."
            echo "Violations:$VIOLATIONS"
            exit 1
          fi
          echo "No no-touch violations in iteration 3 (the final iteration's audit IS the last enforcement gate before push, per ADR-0017 Confirmation #3)."

      # ─── Outcome determination ──────────────────────────────────────────────

      - name: Determine outcome
        if: steps.skip.outputs.proceed == 'true'
        id: outcome
        run: |
          set -e
          # Any prepush that returned success means the branch is green.
          if [ "${{ steps.prepush_1.outcome }}" = "success" ] \
              || [ "${{ steps.prepush_2.outcome }}" = "success" ] \
              || [ "${{ steps.prepush_3.outcome }}" = "success" ]; then
            echo "green=true" >> "$GITHUB_OUTPUT"
            echo "Branch is green; auto-merge will be enabled."
          else
            echo "green=false" >> "$GITHUB_OUTPUT"
            echo "Budget exhausted (3 agent attempts, no green prepush). Opening failing PR with auto-merge disabled."
          fi

      - name: Capture last prepush output for failure PR body
        # Only meaningful when budget-exhausted; runs the prepush one more time
        # to capture its output for the PR body. continue-on-error keeps a red
        # prepush here from failing the workflow.
        if: steps.skip.outputs.proceed == 'true' && steps.outcome.outputs.green == 'false'
        id: failure_logs
        continue-on-error: true
        run: |
          set +e
          mkdir -p .recover
          {
            echo "Captured at: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
            echo "Branch HEAD: $(git rev-parse HEAD)"
            echo
            echo "=== Final prepush output ==="
            npm run prepush 2>&1
          } > .recover/failed-final.txt
          echo "logs_path=.recover/failed-final.txt" >> "$GITHUB_OUTPUT"

      - name: Push branch
        # ADR-0017 Confirmation #10 / ADR-0012 transport: HTTP basic with
        # x-access-token is required for git transport. Bearer auth works for
        # the API but NOT for git push.
        if: steps.skip.outputs.proceed == 'true'
        env:
          APP_TOKEN: ${{ steps.mint.outputs.token }}
        run: |
          set -e
          git push --set-upstream \
              "https://x-access-token:${APP_TOKEN}@github.com/${{ github.repository }}.git" \
              "${{ steps.branch.outputs.branch }}"

      - name: Open pull request
        if: steps.skip.outputs.proceed == 'true'
        id: open_pr
        env:
          GH_TOKEN: ${{ steps.mint.outputs.token }}
          FINGERPRINT: ${{ steps.fingerprint.outputs.fingerprint }}
          GREEN: ${{ steps.outcome.outputs.green }}
        run: |
          set -e
          BUMP_LIST=$(jq -r '
              if has("rows") then .rows
              elif has("packages") then .packages
              else .
              end
              | if type == "array" then . else [.] end
              | map("- **\(.package // .name)**: \(.current // .currentVersion) → \(.latest) (age \(.ageInDays // .age // "?")d, severity \(.vulnerabilities.maxSeverity // "none"))")
              | join("\n")
            ' updates.json)

          # ADR-0018 / JTBD-009: surface known-vulnerable-but-unfixable packages
          # in the PR body so the maintainer sees them on every auto-update PR
          # (the durable inspection surface, not just workflow logs).
          UNFIXABLE_ROWS=$(jq -r '(.unfixable // []) | map("- **\(.name)** (\(.severity)) — `\(.advisory)`: \(.reason)") | join("\n")' updates.json)
          UNFIXABLE_BLOCK=""
          if [ -n "$UNFIXABLE_ROWS" ]; then
            UNFIXABLE_BLOCK=$(cat <<UNFIXEOF

          ## Known vulnerabilities without safe fix

          These advisories have no safe, mature fix \`dry-aged-deps\` can apply (transitive dependencies, or no patched version). They are informational — they do not block this PR. See [ADR-0018](../blob/main/docs/decisions/0018-surface-known-vulnerable-but-unfixable-packages.proposed.md).

          $UNFIXABLE_ROWS
          UNFIXEOF
          )
          fi

          # JTBD-010 / ADR-0022: surface safe updates that could NOT land (their
          # peer graph won't resolve without --force) so the maintainer sees them
          # on the auto-update PR and can plan a manual resolution.
          INCOMPAT_ROWS=$(jq -r '(.incompatible // []) | map("- **\(.name)**: \(.current) → \(.latest) — \(.reason)") | join("\n")' updates.json)
          INCOMPAT_BLOCK=""
          if [ -n "$INCOMPAT_ROWS" ]; then
            INCOMPAT_BLOCK=$(cat <<INCOMPATEOF

          ## Updates skipped (incompatible peer dependencies)

          These safe, mature updates could not land — their peer-dependency graph won't resolve without \`--force\` (an npm ERESOLVE). They are informational and do not block this PR; plan a manual resolution (bump the blocking peer, or replace an unmaintained dependency). See [ADR-0022](../blob/main/docs/decisions/0022-update-flags-and-skips-un-landable-updates-via-npm-resolver-bisect.proposed.md).

          $INCOMPAT_ROWS
          INCOMPATEOF
          )
          fi

          if [ "$GREEN" = "true" ]; then
            STATUS_HEADER="✅ Prepush green after $(test -n "${{ steps.agent_1.outcome }}" && echo "agent recovery" || echo "initial validation")."
            FAILURE_BLOCK=""
            TITLE="${{ steps.commit_type.outputs.type }}: update safe dependencies"
          else
            STATUS_HEADER="❌ Budget exhausted (MAX_RETRIES=3). Prepush still red after 3 agent attempts. Auto-merge is disabled — inspect and decide."
            FAILURE_LOGS=""
            if [ -f .recover/failed-final.txt ]; then
              FAILURE_LOGS=$(head -c 60000 .recover/failed-final.txt)
            fi
            FAILURE_BLOCK=$(cat <<FAILEOF

          ## Failure context (last prepush)

          <details>
          <summary>Final prepush output (truncated to 60 KB)</summary>

          \`\`\`
          $FAILURE_LOGS
          \`\`\`

          </details>
          FAILEOF
          )
            TITLE="${{ steps.commit_type.outputs.type }}: update safe dependencies (recovery budget exhausted)"
          fi

          BODY=$(cat <<EOF
          $STATUS_HEADER

          Automated dependency update produced by \`dry-aged-deps\`.

          ## Updates

          $BUMP_LIST
          $UNFIXABLE_BLOCK
          $INCOMPAT_BLOCK
          $FAILURE_BLOCK

          ---

          Opened by \`.github/workflows/auto-update.yml\` per [ADR-0017](../blob/main/docs/decisions/0017-single-workflow-inline-loop-for-autonomous-dependency-updates.proposed.md).
          Authentication per [ADR-0012](../blob/main/docs/decisions/0012-autonomous-workflow-authentication-mechanism.proposed.md).
          Trust boundary (writable paths, no-touch list, audit) preserved from [ADR-0010](../blob/main/docs/decisions/0010-ai-agent-ci-trust-boundary.superseded.md) (superseded).

          <!-- bumpset-fingerprint: $FINGERPRINT -->
          EOF
          )

          PR_NUMBER=$(gh pr create \
            --title "$TITLE" \
            --body "$BODY" \
            --base main \
            --head "${{ steps.branch.outputs.branch }}" \
            | grep -oE '[0-9]+$' | tail -1)
          echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
          echo "Opened PR #$PR_NUMBER"

      - name: Post agent summary comment
        # ADR-0017 Confirmation #8 + ADR-0010 §Audit log bullet 3. Posted on
        # every PR opened by this workflow, summarising the bump set and any
        # recovery iterations applied.
        if: steps.skip.outputs.proceed == 'true'
        env:
          GH_TOKEN: ${{ steps.mint.outputs.token }}
        run: |
          set -e
          PR_NUMBER="${{ steps.open_pr.outputs.pr_number }}"
          GREEN="${{ steps.outcome.outputs.green }}"
          AGENT_1="${{ steps.agent_1.outcome }}"
          AGENT_2="${{ steps.agent_2.outcome }}"
          AGENT_3="${{ steps.agent_3.outcome }}"
          PREPUSH_1="${{ steps.prepush_1.outcome }}"
          PREPUSH_2="${{ steps.prepush_2.outcome }}"
          PREPUSH_3="${{ steps.prepush_3.outcome }}"
          COMMENT=$(cat <<EOF
          ## Workflow summary

          - Initial prepush: \`$PREPUSH_1\`
          - Agent attempt 1: \`${AGENT_1:-skipped}\` → prepush 2: \`${PREPUSH_2:-n/a}\`
          - Agent attempt 2: \`${AGENT_2:-skipped}\` → prepush 3: \`${PREPUSH_3:-n/a}\`
          - Agent attempt 3: \`${AGENT_3:-skipped}\` (no further validation per ADR-0017)
          - Outcome: **$(test "$GREEN" = "true" && echo green / auto-merge enabled || echo budget-exhausted / auto-merge disabled)**

          Recovery agent operated under the ADR-0010 trust boundary (writable
          paths allow-list, no-touch list, post-diff audit per iteration) lifted
          verbatim into ADR-0017.
          EOF
          )
          gh pr comment "$PR_NUMBER" --body "$COMMENT"

      - name: Enable auto-merge (squash) on green
        # Only invoked when the loop produced a green prepush. On budget
        # exhaustion auto-merge stays disabled and the PR sits as the inspection
        # surface per JTBD-008.
        if: steps.skip.outputs.proceed == 'true' && steps.outcome.outputs.green == 'true'
        env:
          GH_TOKEN: ${{ steps.mint.outputs.token }}
        run: gh pr merge --auto --squash "${{ steps.branch.outputs.branch }}"
