Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions github-workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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.:
Expand Down
166 changes: 166 additions & 0 deletions release-notes/action.yml
Original file line number Diff line number Diff line change
@@ -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
103 changes: 103 additions & 0 deletions release-notes/run-release-notes.sh
Original file line number Diff line number Diff line change
@@ -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
Loading