claude-release-watch — the real bugs found via actionlint

Five PR cycles of guessing didn't fix this. Running actionlint locally surfaced both bugs in 30 seconds.

Bug 1 — heredoc body breaks YAML block scalar

actionlint output:

.github/workflows/claude-release-watch.yml:114:0:
  could not parse as YAML: could not find expected ':' [syntax-check]
  | 114 | **Auto-filed by cc-release-watch** — see ...

The BODY=$(cat <<BODY ... BODY) heredoc body started at column 1. YAML's run: | block scalar requires every line to be indented at least as much as the block's base indentation (column 11 in this file). YAML's parser sees the unindented heredoc line, decides the block ENDED, and tries to parse **Auto-filed by ... as a top-level YAML construct — fails. GHA accepts the file but silently fails to register name: and workflow_dispatch:.

Fix: replace the heredoc with printf '%s\n' with each line as a separate quoted argument. Every line stays indented inside the YAML block, bash sees a multi-line string with embedded newlines.

BODY=$(printf '%s\n' \
  "**Auto-filed by cc-release-watch** — see ..." \
  "" \
  "**Key:** \`${KEY}\`" \
  ...)

Bug 2 — secrets context not allowed in step-level if:

actionlint output:

.github/workflows/claude-release-watch.yml:55:17:
  context "secrets" is not allowed here.
  available contexts are "env", "github", "inputs", "job", "matrix", "needs",
  "runner", "steps", "strategy", "vars". [expression]
  | 55 | if: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN != '' }}

This was a bug I introduced in the M130 hotfix PR #1591. I "fixed" the original env.CLAUDE_CODE_OAUTH_TOKEN by switching to secrets.X, on the wrong belief that step-level env: can't be read in step-level if:. Step-level env CAN'T, but the secrets context isn't available in if: at all.

Fix: hoist CLAUDE_CODE_OAUTH_TOKEN to job-level env:. Job-level env IS available in step-level if:, so the original if: ${{ env.CLAUDE_CODE_OAUTH_TOKEN != '' }} works correctly. Bonus: the redundant step-level env: blocks for the same secrets are now removed (DRY).

jobs:
  watch:
    env:
      CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
      GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    steps:
      ...
      - name: LLM-assisted feature extraction
        if: ${{ env.CLAUDE_CODE_OAUTH_TOKEN != '' }}
        run: node scripts/cc-triage.mjs

What I should have done from the start

  1. Run actionlint .github/workflows/*.yml on every workflow change. It's installed (/usr/local/bin/actionlint) and caught both bugs immediately.
  2. Add an actionlint pre-commit hook to make this automatic. Already partially done via .github/workflows/... CI but apparently not enforcing on PRs to non-workflow files? — verify and harden.
  3. Stop guessing. The five PRs (#1592 explicit form, #1593 rename, #1594 cleanup) were all built on hypotheses without running the validator. That cost ~45 minutes of round-trip CI time and four unnecessary main-branch commits.

Verification

  1. This PR merges to main.
  2. gh workflow list shows Claude Release Watch as a friendly name (not file path).
  3. gh workflow run "Claude Release Watch" returns a run URL.
  4. The dispatched run actually executes the LLM triage step end-to-end.