diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 0000000..4533e94 --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,58 @@ +name: Claude Code Review + +# Thin caller: the prompt, severity/approval logic, checkout, and pinned action +# refs all live in the ./claude-review composite action. This file only owns the +# trigger, permissions, and repo-specific review focus (GitHub requires the +# trigger + permissions in the caller, and claude-code-action's tamper guard +# requires the token wired here). +# +# Unlike consumer repos (which pin ShiplightAI/internal-tools/claude-review@v1), +# this repo uses the local ./claude-review so a PR that edits the action is +# reviewed by its own version — the action dogfoods itself. +on: + pull_request: + types: [opened, synchronize] + +jobs: + claude-review: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write # submit the review (approve / request-changes / comment) + issues: read + id-token: write # required: claude-code-action mints its App token via OIDC + steps: + # Needed before `uses: ./claude-review` — a local action must be on disk. + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + - uses: ./claude-review + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + review-focus: | + This repo ships high-trust automation that runs inside *other* repos' + privileged jobs and developer machines. Weight these invariants: + - GitHub Actions security in `claude-review/action.yml`: third-party + actions must stay pinned to a full commit SHA (never a tag or + branch); never interpolate PR-author-controlled data (title, body, + branch name, commit message) into a `run:` block; keep the + `--allowed-tools` allowlist minimal and never grant write-capable + `gh` subcommands beyond `gh pr review`. + - The workflow/action split: the trigger, `permissions:`, and the + OAuth token stay in the caller workflow; prompt, approval logic, + and pinned refs stay in the composite action. Moving `on:` or + `permissions:` into the action breaks every consumer, and moving + prompt logic back into callers defeats the point of the split. + - Least privilege: flag any new `permissions:` scope, especially + `contents: write` or a broadened `id-token`. + - `agent-skills/**` and `.agents/**` are instructions an agent + executes verbatim in someone else's repo. Review them like code: + flag guidance that would have an agent force-push, reset, delete + branches, merge with `--admin`, bypass review, or run `sed`/`tee` + against production config. + - `shell-agent/**` and `scripts/**` run on developer machines — flag + unquoted expansions, `eval` on untrusted input, and anything that + writes outside the repo or `~/.shell-agent`. + - Docs must match reality: `README.md` and `github-workflows/README.md` + describe the install path for consumers, so a stale org/repo path or + action ref in those files is a real defect, not a nit. diff --git a/github-workflows/claude-code-review.yml b/github-workflows/claude-code-review.yml index d458c3b..449f655 100644 --- a/github-workflows/claude-code-review.yml +++ b/github-workflows/claude-code-review.yml @@ -4,7 +4,7 @@ name: Claude Code Review # The trigger + permissions must live here (GitHub requires `on:` in the repo's # own workflow, and claude-code-action's tamper guard wants this file on the # repo's default branch). Everything else — prompt, approval logic, pinned refs — -# lives in the ShiplightAI/internal-agent-skills/claude-review composite action. +# lives in the ShiplightAI/internal-tools/claude-review composite action. on: pull_request: types: [opened, synchronize] @@ -18,7 +18,7 @@ jobs: issues: read id-token: write # required: claude-code-action mints its App token via OIDC steps: - - uses: ShiplightAI/internal-agent-skills/claude-review@v1 + - uses: ShiplightAI/internal-tools/claude-review@v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} # Optional per-repo emphasis, e.g.: diff --git a/release-notes/action.yml b/release-notes/action.yml new file mode 100644 index 0000000..be383c5 --- /dev/null +++ b/release-notes/action.yml @@ -0,0 +1,166 @@ +name: "Shiplight Agent Release Notes" +description: > + Writes user-facing release notes for a commit range using a headless agent + (claude --print, falling back through Codex), and returns them as a file for + `gh release create --notes-file`. Deliberately never fails: a caller that gets + no notes is expected to fall back to `--generate-notes` rather than block a + release on a model outage. Consumer repos keep only a short step plus their + own `notes-focus`. + +inputs: + claude_code_oauth_token: + description: "OAuth token for the Claude GitHub App / CLI." + default: "" + anthropic_api_key: + description: "Alternative to claude_code_oauth_token." + default: "" + openai_api_key: + description: "Enables the Codex leg of the fallback chain. Optional." + default: "" + from-ref: + description: > + Range start, exclusive — normally the previous release tag. When empty the + action uses the repository's first commit, which is the first-release case. + default: "" + to-ref: + description: "Range end, inclusive. Normally the tag being released." + required: true + notes-focus: + description: > + Optional repo-specific guidance appended to the prompt (what this project + is, which changes matter to its users, what to leave out). Trusted config + set by the repo's own workflow — not PR-author input. + default: "" + output-file: + description: "Where to write the notes." + default: "/tmp/release-notes.md" + claude-models: + description: "Space-separated Claude fallback chain, best first." + default: "" + codex-models: + description: "Space-separated Codex fallback chain, best first." + default: "" + +outputs: + notes-file: + description: "Path to the generated notes. Empty string when nothing was produced." + value: ${{ steps.generate.outputs.notes-file }} + generated: + description: "'true' when usable notes were produced, 'false' otherwise." + value: ${{ steps.generate.outputs.generated }} + +runs: + using: composite + steps: + # Deliberately no actions/checkout here, unlike the claude-review action. + # This runs late in a release job, after the artifact has been built and + # packaged; a fresh checkout would wipe those untracked build outputs. The + # caller's checkout is reused and only deepened below. + - name: Ensure enough history for the range + shell: bash + run: | + set -euo pipefail + # Release jobs check out at depth 1, so `git log from..to` would fail. + if [ -f .git/shallow ]; then + git fetch --unshallow --tags --quiet origin + else + git fetch --tags --quiet origin + fi + + - name: Install the Claude CLI + shell: bash + run: npm install -g @anthropic-ai/claude-code + + - name: Install the Codex CLI + if: inputs.openai_api_key != '' + shell: bash + run: npm install -g @openai/codex + + - name: Build the prompt + shell: bash + env: + FROM_REF: ${{ inputs.from-ref }} + TO_REF: ${{ inputs.to-ref }} + NOTES_FOCUS: ${{ inputs.notes-focus }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + FROM="$FROM_REF" + if [ -z "$FROM" ]; then + FROM=$(git rev-list --max-parents=0 HEAD | tail -1) + echo "No from-ref given; using the first commit ($FROM) — first-release case." + fi + RANGE="$FROM..$TO_REF" + echo "Range: $RANGE" + + { + echo "Write the release notes for ${REPO} ${TO_REF}." + echo + echo "Everything you need is inline below. Do not use any tools, do not read" + echo "files, and do not run commands — just write the notes." + echo + echo "Output ONLY the notes, as Markdown, with no preamble, no surrounding" + echo "code fence, and no title line naming the version (the release page" + echo "already shows it)." + echo + echo "Write for someone who USES this software and is deciding whether they" + echo "care about this release:" + echo "- Lead with what changed for them. Group related work under short" + echo " headings; drop headings entirely if there are only a few entries." + echo "- Say what a change does, not which files moved. Prefer one clear" + echo " sentence over a bullet echoing a commit subject." + echo "- Fold purely internal work (refactors, test scaffolding, CI) into a" + echo " brief closing note, or omit it. Do not pad." + echo "- State fixes as the behaviour that is now correct, not as the commit" + echo " that changed it." + echo "- Never invent a change, a number, or an attribution. If a commit's" + echo " user-facing effect is genuinely unclear, describe it factually and" + echo " briefly rather than guessing at intent." + echo "- Call out anything a user must ACT on — a breaking change, a new" + echo " permission, a migration — near the top, even if it is small." + if [ -n "$NOTES_FOCUS" ]; then + echo + echo "Project context:" + printf '%s\n' "$NOTES_FOCUS" + fi + echo + echo "## Commits in $RANGE" + echo + # Untrusted-ish content: commit messages are author-controlled. The + # agent is given no tools, so the worst case is bad prose, not action. + git log --no-merges --pretty=format:'--- %h %an%n%s%n%b' "$RANGE" + echo + echo + echo "## Diffstat" + echo + git diff --stat "$RANGE" | tail -40 + } > /tmp/release-notes-prompt.txt + + echo "Prompt bytes: $(wc -c < /tmp/release-notes-prompt.txt)" + + - name: Generate + id: generate + shell: bash + env: + CLAUDE_CODE_OAUTH_TOKEN: ${{ inputs.claude_code_oauth_token }} + ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }} + OPENAI_API_KEY: ${{ inputs.openai_api_key }} + PROMPT_FILE: /tmp/release-notes-prompt.txt + NOTES_FILE: ${{ inputs.output-file }} + CLAUDE_MODELS: ${{ inputs.claude-models }} + CODEX_MODELS: ${{ inputs.codex-models }} + run: | + set -uo pipefail + # Empty overrides must not shadow the script's own defaults. + [ -z "${CLAUDE_MODELS:-}" ] && unset CLAUDE_MODELS + [ -z "${CODEX_MODELS:-}" ] && unset CODEX_MODELS + bash "${{ github.action_path }}/run-release-notes.sh" + if [ -s "${{ inputs.output-file }}" ]; then + echo "notes-file=${{ inputs.output-file }}" >> "$GITHUB_OUTPUT" + echo "generated=true" >> "$GITHUB_OUTPUT" + echo "--- generated notes ---" + cat "${{ inputs.output-file }}" + else + echo "notes-file=" >> "$GITHUB_OUTPUT" + echo "generated=false" >> "$GITHUB_OUTPUT" + fi diff --git a/release-notes/run-release-notes.sh b/release-notes/run-release-notes.sh new file mode 100755 index 0000000..d0ebd36 --- /dev/null +++ b/release-notes/run-release-notes.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# Generates release notes by running a headless agent over a commit range, +# through the same model fallback chain shape as scripts/run-triage-agent.sh in +# ci-triage: each Claude model in CLAUDE_MODELS is tried in order, then each +# Codex model in CODEX_MODELS, stopping at the first that produces a non-empty +# NOTES_FILE. A model the CI token cannot access fails fast and the next is +# tried, so a missing entitlement degrades instead of dead-ending. +# +# Like that script, this one NEVER fails the job (exit 0 always). Release notes +# are not a gate — a caller that gets no notes is expected to fall back to +# GitHub's own `gh release create --generate-notes` rather than block a release +# on a transient model outage. Success is therefore gated on the artifact being +# non-empty, not on an exit code, which also covers service-unavailable and +# unanticipated errors. +# +# The agent's output is captured from stdout rather than written by the agent +# itself, deliberately: the prompt carries the entire commit range inline, so no +# tools are needed, and an agent with no write access cannot be steered into +# acting on instructions embedded in a commit message. This job runs with the +# caller's token, so that distinction matters. +set -uo pipefail + +PROMPT_FILE="${PROMPT_FILE:?PROMPT_FILE is required}" +NOTES_FILE="${NOTES_FILE:?NOTES_FILE is required}" +AGENT_LOG="${AGENT_LOG:-/tmp/release-notes-agent.log}" + +# Fallback chain (best first), overridable via env. Mirrors ci-triage's rationale: +# pin explicitly, because a runner otherwise inherits a model the CI token may +# not be entitled to. +CLAUDE_MODELS="${CLAUDE_MODELS:-claude-opus-4-8 claude-opus-4-7 claude-sonnet-4-6}" +CODEX_MODELS="${CODEX_MODELS:-gpt-5.5 gpt-5.4}" + +: > "$AGENT_LOG" +rm -f "$NOTES_FILE" + +notes_ready() { [ -s "$NOTES_FILE" ]; } + +# A model can answer "I cannot help" or emit a refusal; that is not usable notes. +# Treat anything under a plausible floor as no output so the next model is tried. +MIN_NOTES_BYTES="${MIN_NOTES_BYTES:-40}" + +capture() { # $1 = candidate output file + local candidate="$1" + if [ -s "$candidate" ] && [ "$(wc -c < "$candidate")" -ge "$MIN_NOTES_BYTES" ]; then + mv "$candidate" "$NOTES_FILE" + return 0 + fi + rm -f "$candidate" + return 1 +} + +run_claude() { # $1 = model id + if ! command -v claude >/dev/null 2>&1; then + echo "claude CLI not found on PATH" >&2; return 127 + fi + if [ -z "${CLAUDE_CODE_OAUTH_TOKEN:-}${ANTHROPIC_API_KEY:-}" ]; then + echo "no Claude credentials (CLAUDE_CODE_OAUTH_TOKEN / ANTHROPIC_API_KEY)" >&2; return 78 + fi + local out="/tmp/release-notes-claude.$$" + claude --print --model "$1" < "$PROMPT_FILE" > "$out" 2>>"$AGENT_LOG" + local rc=$? + capture "$out" || return "$(( rc == 0 ? 1 : rc ))" + return 0 +} + +run_codex() { # $1 = model id + if ! command -v codex >/dev/null 2>&1; then + echo "codex CLI not found on PATH" >&2; return 127 + fi + if [ -z "${OPENAI_API_KEY:-}" ]; then + echo "no Codex credentials (OPENAI_API_KEY)" >&2; return 78 + fi + local out="/tmp/release-notes-codex.$$" + codex exec -m "$1" --skip-git-repo-check - < "$PROMPT_FILE" > "$out" 2>>"$AGENT_LOG" + local rc=$? + capture "$out" || return "$(( rc == 0 ? 1 : rc ))" + return 0 +} + +for model in $CLAUDE_MODELS; do + echo "::group::Release notes — Claude ($model)" + run_claude "$model" || echo "Claude ($model) produced no usable notes ($?)" + echo "::endgroup::" + if notes_ready; then + echo "Notes produced by Claude ($model): $NOTES_FILE" + exit 0 + fi +done + +for model in $CODEX_MODELS; do + echo "::group::Release notes — Codex ($model)" + run_codex "$model" || echo "Codex ($model) produced no usable notes ($?)" + echo "::endgroup::" + if notes_ready; then + echo "Notes produced by Codex ($model): $NOTES_FILE" + exit 0 + fi +done + +echo "::warning::No model produced release notes — the caller should fall back to generated notes" +echo "--- agent log ---" +tail -40 "$AGENT_LOG" || true +exit 0