diff --git a/.github/actions/CLAUDE-CI.md b/.github/actions/CLAUDE-CI.md new file mode 100644 index 0000000..ba0e858 --- /dev/null +++ b/.github/actions/CLAUDE-CI.md @@ -0,0 +1,361 @@ +# Claude CI + +The Claude-powered checks MegaETH repositories run on pull requests and +issues: an incremental PR reviewer, a label check, issue triage, and the +interactive `@claude` handler. Four composite actions in this directory, +consumed at `@main`; each has its own reference README (`claude-pr-review/`, +`claude-label-check/`, `claude-issue-triage/`, `claude-interactive/`). They +were migrated here from `megaeth-labs/documentation`. + +Contents: + +1. [What it does, and the choices behind it](#what-it-does-and-the-choices-behind-it) +2. [One review round, step by step](#one-review-round-step-by-step) +3. [Installing it in a repository](#installing-it-in-a-repository) +4. [Options](#options) +5. [Inspecting a review run](#inspecting-a-review-run) +6. [Reference](#reference) + +## What it does, and the choices behind it + +| Action | Runs on | Does | +|---|---|---| +| `claude-pr-review` | `pull_request` (and, opt-in, `issue_comment`) | a staged, incremental review: one atomic GitHub review per round with inline findings, open questions with a lifecycle, and a sticky status comment carrying a durable manifest | +| `claude-label-check` | `pull_request` | checks the PR's labels against the repository's conventions; skips PRs the CI app opened itself | +| `claude-issue-triage` | `issues: opened` | triages a new issue | +| `claude-interactive` | `@claude` mentions in comments and reviews | the interactive handler, with centralised permissions | + +The choices that shape the reviewer: + +- **A pipeline, not a chat.** Deterministic preparation and publication + bracket one bounded model session that can only read and return + schema-constrained data; it cannot post, edit or resolve anything itself. +- **Durable state in the PR.** The sticky status comment carries a hidden, + versioned manifest (last published head, reviewer and rubric versions, + finding and question IDs, thread IDs, dispositions), so later rounds are + incremental and re-check open items instead of starting over. +- **Per-repository conventions win.** The prompts tell Claude to read the + consumer's own `REVIEW.md`, `README.md`, `CLAUDE.md`, `AGENTS.md` and + similar files, and those take precedence over the canonical prompt. +- **The app's own PRs are skipped.** Release candidates, settle PRs and + dependency bumps opened by the CI app are routed to `skip` before any + model step. +- **One merge here ships to every consumer.** Consumers pin `@main`; the + `Actions` workflow in this repository (unit tests of the pipeline) is the + only gate. + +## One review round, step by step + +The PR reviewer is an explicit staged pipeline: + +1. A deterministic preparation step freezes the base and head SHAs, loads the durable review + manifest, fetches prior automated threads, computes the full or incremental diff, and + selects the model tier. It then posts or updates the sticky status comment to + `๐Ÿ”„ Review in progress`, so the PR shows the round has started instead of staying silent + until the review lands minutes later. +2. Claude performs semantic analysis and verification with read-only tools. + It returns schema-constrained data and cannot publish comments or resolve threads. +3. A deterministic compiler validates findings, enforces severity budgets, checks RIGHT-side + anchors, formats the standard human-facing messages, and suppresses internal review + machinery. +4. A deterministic publisher rechecks the live head, submits at most one review, optionally + resolves addressed automated threads, and updates one sticky status comment. + +The sticky comment contains a hidden, versioned manifest with the last published head, +reviewer and rubric versions, stable finding IDs, thread IDs, and finding dispositions. +Later runs use that manifest as a checkpoint and fall back to GitHub review history if the +manifest is unavailable. +The manifest never contains complete diffs, PR prose, tool output, secrets, or Claude session +transcripts. +Inline findings are linked back to the exact published review and comment IDs, with bounded +retries for GitHub API propagation. +When `github_identity_token` is configured, the publisher does not mark a GitHub thread +resolved unless GitHub confirms the resolution mutation. +The configured identity is stored in the review manifest while historical `claude` and +`github-actions` state remains readable for migration. + +The action performs a full review when there is no valid checkpoint, the previous head is not +an ancestor, or the pipeline or rubric version changed. +Otherwise it reviews only the delta since the last published head and rechecks open findings. +It discards output if the PR head changes during analysis. + +Internal production-failure analysis is never named in GitHub review output. +Confirmed issues become ordinary findings. +Useful uncertainty becomes an `Open question` with medium or low confidence and a concrete +verification request. +Rejected candidates and an empty internal analysis remain invisible. + +Open questions have the same durable lifecycle as findings. +Each one gets a stable ID and a hidden marker on its status line, and the manifest records +which review asked it. +Each question is published inside a `
` block that starts expanded. +A later round dispositions every open question as `open`, `answered`, or `withdrawn`, and the +publisher edits the original review body in place so the summary line reads +`โœ… **Answered**` or `๐Ÿšซ **Withdrawn**` with a one-line reason, and the block collapses. +The rationale bullets are kept rather than deleted, so an answered question stays one green +line that expands to the original question, why it mattered, and how to verify it. +Editing a submitted review body creates no new review and no new notification, and rewriting +reproduces the same header (marker included), so the update is idempotent and retried on the +next round if GitHub rejects it. +Questions published before the `
` shape existed fall back to a single-line rewrite. +A question that is already open is never re-asked; the original stays the copy the author +answers. + +**Self-authored pull requests are skipped, not reviewed.** When the PR was +opened by the reviewer's own identity (the app behind `github_identity_token`, +e.g. `mega-maxwell[bot]` โ€” release candidates, settle PRs, dependency bumps), +`prepare` routes the round to `skip` before any model step and the status +comment says so ("โญ๏ธ Review skipped โ€ฆ opened by mega-maxwell[bot], the +reviewer's own identity"). This is decided from the PR author, independently +of `allowed_bots`, which still governs which bots may *trigger* a review of +someone else's PR. + +## Installing it in a repository + +Before a repository can reference these actions, a maintainer of this +repository must have enabled Settings โ†’ Actions โ†’ General โ†’ Access โ†’ +"Accessible from repositories in the megaeth-labs organization" (done). + +Every consumer job runs `actions/checkout` first, provides the +`CLAUDE_CODE_OAUTH_TOKEN` secret, and sets the permissions its action needs: + +- `claude-interactive`: `contents: write`, `pull-requests: write`, `issues: write`, `id-token: write`, `actions: read` +- `claude-pr-review`: `contents: read`, `pull-requests: write`, `id-token: write`, `actions: read` +- `claude-label-check`: `contents: read`, `pull-requests: write`, `id-token: write` +- `claude-issue-triage`: `contents: read`, `issues: write`, `id-token: write` + +A minimal review job: + +```yaml +jobs: + pr-review: + runs-on: ubuntu-24.04 + permissions: + contents: read + pull-requests: write + id-token: write + actions: read + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 1 + + - uses: megaeth-labs/.github/.github/actions/claude-pr-review@main + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + extra_allowed_tools: "Bash(cargo:*)" + extra_prompt: | + Add repository-specific review instructions here. +``` + +> Note: a PR that _modifies the calling repo's own_ `claude.yml` skips the `pr-review` +> job. +> `claude-code-action` validates that workflow against the default branch before it exchanges +> its app token, so self-modifying PRs cannot run the review step safely. +> This only affects the repo that changed its own workflow. +> It does not affect consumers pinned to `@main` in normal operation. + +Give the `pr-review` job a timeout and a concurrency group: + +Consumers should give the `pr-review` job a `timeout-minutes` value of at least `25` plus a +job-level concurrency group with `cancel-in-progress: true`. +The publisher revalidates the live PR base and head immediately before each GitHub mutation, +and review submissions are pinned to the frozen head commit. If either revision changes, +publication stops without advancing the manifest. +Latest-only cancellation avoids spending review time on queued, obsolete heads: + +```yaml +pr-review: + timeout-minutes: 25 + concurrency: + group: claude-pr-review-${{ github.event.pull_request.number }} + cancel-in-progress: true +``` + +Consumers that already create a GitHub App token can make it the single +identity for reviews, status comments and thread resolution: + +```yaml +with: + github_identity_token: ${{ steps.app-token.outputs.token }} +``` + +A PR that *modifies the calling repository's own* `claude.yml` skips the +review job by design: `claude-code-action` validates that workflow against +the default branch before it exchanges its app token, so self-modifying PRs +cannot run the review step safely. It only affects the repository that +changed its own workflow, and only until the change merges. + +### Comment-triggered reconciliation (opt-in) + +By default the review only runs on `pull_request` events, so an author who answers an open +question in a PR comment sees nothing happen until the next push. A consumer can also let a +comment drive a reconcile round by adding an `issue_comment` trigger: + +```yaml +on: + pull_request: + types: [opened, synchronize, ready_for_review, reopened] + issue_comment: + types: [created] + +jobs: + pr-review: + # Any PR comment except the reviewer's own status comment, which is posted + # by the CI app and would otherwise retrigger the review. Other bots are + # allowed โ€” their comments may answer a question or push back on a finding. + # Replace mega-maxwell[bot] with your reviewer app's login. + if: >- + github.event_name != 'issue_comment' || + (github.event.issue.pull_request != null && + github.event.comment.user.login != 'mega-maxwell[bot]') + concurrency: + # issue_comment payloads carry issue.number, not pull_request.number. + group: claude-pr-review-${{ github.event.pull_request.number || github.event.issue.number }} + cancel-in-progress: false +``` + +The action gates the round cheaply so routine chatter does not spend a review: on an +`issue_comment` event, `prepare` skips unless the PR still has an **open question or open +finding** in the manifest (something a comment could answer, justify, or invalidate). When it +does run it is an incremental round that reuses the same sticky-comment manifest โ€” so the +reviewer keeps its full prior context, unlike a fresh `@claude` session โ€” and it runs on the +cheaper incremental model tier. If the comment turns out not to change anything, the publisher +posts nothing (no new review, no notification). A comment with no new commit reconciles the +discussion against the existing head; a comment that races a push reviews the new delta too. + +## Options + +All actions accept: + +- `claude_code_oauth_token` - required. Pass `${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}`. +- `allowed_bots` - optional, defaults to `mega-putin`. +- `extra_allowed_tools` - optional, appended to the canonical `--allowedTools` list. Rust repos can pass `Bash(cargo:*)` here. + +Prompt-bearing actions (`pr-review`, `label-check`, and `issue-triage`) also accept: + +- `extra_prompt` - optional, appended after a blank line for per-repo prompt deltas. + +The `interactive` action does not accept `extra_prompt` because `@claude` is native. + +The `pr-review` action additionally accepts: + +- `github_identity_token` - optional, defaults to empty. + When supplied, this token is the single identity for creating reviews, + posting or updating status comments, and resolving addressed automated + review threads. + When omitted, publication uses the job token and leaves GitHub thread state + unchanged. +- `model` - optional, defaults to `claude-opus-4-7`. + This strong model handles initial, high-risk, and explicitly deep reviews. +- `incremental_model` - optional, defaults to empty, which uses the Claude Code default + (Sonnet class). + It handles low-risk incremental reviews. +- `review_depth` - optional, defaults to `standard`. + Set it to `deep` to make the semantic-analysis stage fan out relevant review dimensions + and adversarially verify the candidates before returning one structured result. +- `max_turns` - optional, defaults to empty. + Overrides the per-invocation turn budget (`--max-turns`) that is otherwise derived from the + model tier and review depth. Set a positive integer to pin the main analysis ceiling; the + retry keeps its 1.5x headroom relative to it. A non-integer or non-positive value fails the run. +- `premortem` - optional, defaults to `auto`. + Automatic mode runs the independent production-failure analysis for initial and high-risk + reviews, but skips it for ordinary incremental updates. + `on` always enables it and `off` disables it. +- `state_artifact` - optional, defaults to `true`. + Uploads the run's `.pr-review` state directory as an artifact. Set it to `false` only if the + repository forbids artifacts. +- `state_artifact_retention_days` - optional, defaults to `14`. +- `debug_logs` - deprecated and ignored. The analysis session's full output is now always + printed. Consumers still passing it are unaffected; drop it at your convenience. + +The semantic-analysis stage runs under a turn budget: 12 for a low-risk incremental review, +44 for a strong-tier one, and 56 for `deep`. Set `max_turns` to override any of these. +Roughly ten turns go on mandated context โ€” six pipeline files plus repo guidance โ€” before the +diff is read, and a small diff inside a large file spends many more paging through it, so the +budget tracks files to understand rather than lines changed. +The retry gets half again as many turns as the first attempt, because exhausting the budget is +deterministic and replaying it with the same budget cannot succeed. + +Consumers that already create a GitHub App token can opt into the unified identity with: + +```yaml +with: + github_identity_token: ${{ steps.app-token.outputs.token }} +``` + +The semantic stage is bounded to 12 turns for fast incremental reviews, 44 for standard +full or high-risk reviews, and 56 for explicit deep reviews, unless `max_turns` overrides it. + +## Inspecting a review run + +The published review says what the reviewer concluded. These say how it got there, and they +are the starting point for tuning the rubric, the prompt, or the turn budget. + +**Step summary** (the run's front page) carries the routing decision and its reason, the head +range, the model and turn budget the analysis actually ran with, the model tier, high-risk and +pre-mortem flags, and this round's counts โ€” new findings split into inline and review-body, +new questions, prior findings resolved. It then folds in two blocks: the **pipeline trace** and +the **raw model output before compilation**. A failed run gets the same trace, which shows how +far the round got before it stopped. + +**Job log** groups, in step order: + +- `Review routing` (prepare) โ€” mode and why, prior state source and whether its version still + matches, previous and current head, the compare status and file count, both diff sizes, how + much of the PR conversation was included versus truncated, the paths in scope, and every open + prior finding and question the model was handed. +- `Analysis settings` (compose) โ€” model, tier, depth, pre-mortem, turn budgets, allowed tools. +- The analysis step itself prints the session's full output, always: every tool call, which + files it opened, and which it never read. The step is collapsed until you expand it. +- `Model output (raw, before compilation)` and `Compilation decisions` (compile) โ€” one line per + model result the compiler accepted, suppressed as a duplicate of an open finding, dropped at + the per-severity cap of five, or rerouted to the review body because its line is not + commentable, plus each prior finding and question disposition. +- `Publication` (publish) โ€” review ID, inline comments requested versus posted, whether inline + publication fell back to the review body, threads resolved, and the sticky comment ID. + +**Run artifact** `pr-review-state---` holds the bytes themselves, for 14 days +by default: `review-input.json` (everything the model was given), `review.diff` and `full.diff`, +`analysis-transcript.json` (the session's turn-by-turn record, plus a `-retry-` twin when the +retry ran), `structured-output.json`, `model-output.json`, `review-payload.json` (the compiled +review, including its decision trace), `publish-result.json`, and `trace.log`. + +"The model missed it" and "the pipeline dropped it" look identical in the published review and +different in these. Compare `model-output.json` against the `Compilation decisions` group first. + +## Reference + +The prompt-bearing actions instruct Claude to read and respect a consumer repo's own agent +instruction files when they exist (`REVIEW.md`, `README.md`, `CLAUDE.md`, `AGENTS.md`, and any +other repo-level agent guidance), with those per-repo rules taking precedence over the +canonical inline prompt. Use these files for repo-specific rules; reserve `extra_prompt` for +small deltas that do not belong in a checked-in convention file. + +`pr-review` tags every inline finding with a bold severity label (`**[Critical]**`, +`**[Major]**`, `**[Minor]**`, or `**[Nit]**`). +Clean reviews and re-reviews update the sticky status without creating another review +notification. +Rounds with findings or new open questions submit one atomic review and update the same sticky +status. +The sticky status comment states up front that it is a living comment rewritten in place on +every run, so a reader who meets it mid-thread can tell it describes the current head rather +than the moment it first appeared. +It carries the reviewed range, an update timestamp, this round's counts, and a roll-up of the +questions still awaiting an answer with a link to the review that asked each one. +It moves through three phases: `๐Ÿ”„ Review in progress` from preparation, then either the +finished verdict or `๐Ÿ› ๏ธ Review did not finish` for a round that ends without publishing. +Every non-publishing path โ€” a failure, a discarded stale head โ€” retires the in-progress phase +itself, so the comment never sits at "in progress" after the job ends. A skip-mode round +publishes nothing and is never announced. +When old automated review threads are addressed, the deterministic publisher resolves them +without adding confirmation replies. +A consumer repo's `REVIEW.md` may override or extend the semantic severity guidance. + +Per-action input and output tables, the steps each action runs, and the +errors it reports are generated from `action.yml` into each action's README: +[`claude-pr-review`](claude-pr-review/README.md), +[`claude-label-check`](claude-label-check/README.md), +[`claude-issue-triage`](claude-issue-triage/README.md), +[`claude-interactive`](claude-interactive/README.md). diff --git a/.github/actions/README.md b/.github/actions/README.md index 5315e38..d62af2a 100644 --- a/.github/actions/README.md +++ b/.github/actions/README.md @@ -1,355 +1,58 @@ -# Claude CI Composite Actions - -Reusable Tier-1 Claude CI actions for MegaETH repositories. - -> **Home:** these actions live in `megaeth-labs/.github`. They were migrated here -> from `megaeth-labs/documentation`; reference them at -> `megaeth-labs/.github/.github/actions/claude-`. - -## Actions - -- `.github/actions/claude-interactive` - interactive `@claude` handling. -- `.github/actions/claude-pr-review` - pull request review. -- `.github/actions/claude-label-check` - pull request label validation (skips PRs opened by the CI app itself โ€” `self_authored_logins`). -- `.github/actions/claude-issue-triage` - newly opened issue triage. - -- `.github/actions/pr-lint` - lint the PR (currently: PR title against Conventional Commits, with a sticky comment on failure). - -### pr-lint - -Run it as a step inside a job the consumer owns and names. The status-check -context is that job's name, so a repo whose branch ruleset requires an exact -check name (e.g. mega-reth requires `Validate PR title is Conventional Commit`) -just names the job accordingly: - -```yaml -name: PR Lint -on: - pull_request: - types: [opened, reopened, edited, synchronize] - merge_group: -jobs: - conventional-title: - name: Validate PR title is Conventional Commit - runs-on: ubuntu-latest - permissions: - pull-requests: write - steps: - - uses: megaeth-labs/.github/.github/actions/pr-lint@main -``` - -`types` (newline-separated allowed Conventional Commit types) can be overridden; -it defaults to the org convention. - -## Inputs - -All actions accept: - -- `claude_code_oauth_token` - required. Pass `${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}`. -- `allowed_bots` - optional, defaults to `mega-putin`. -- `extra_allowed_tools` - optional, appended to the canonical `--allowedTools` list. Rust repos can pass `Bash(cargo:*)` here. - -Prompt-bearing actions (`pr-review`, `label-check`, and `issue-triage`) also accept: - -- `extra_prompt` - optional, appended after a blank line for per-repo prompt deltas. - -The `interactive` action does not accept `extra_prompt` because `@claude` is native. - -The `pr-review` action additionally accepts: - -- `github_identity_token` - optional, defaults to empty. - When supplied, this token is the single identity for creating reviews, - posting or updating status comments, and resolving addressed automated - review threads. - When omitted, publication uses the job token and leaves GitHub thread state - unchanged. -- `model` - optional, defaults to `claude-opus-4-7`. - This strong model handles initial, high-risk, and explicitly deep reviews. -- `incremental_model` - optional, defaults to empty, which uses the Claude Code default - (Sonnet class). - It handles low-risk incremental reviews. -- `review_depth` - optional, defaults to `standard`. - Set it to `deep` to make the semantic-analysis stage fan out relevant review dimensions - and adversarially verify the candidates before returning one structured result. -- `max_turns` - optional, defaults to empty. - Overrides the per-invocation turn budget (`--max-turns`) that is otherwise derived from the - model tier and review depth. Set a positive integer to pin the main analysis ceiling; the - retry keeps its 1.5x headroom relative to it. A non-integer or non-positive value fails the run. -- `premortem` - optional, defaults to `auto`. - Automatic mode runs the independent production-failure analysis for initial and high-risk - reviews, but skips it for ordinary incremental updates. - `on` always enables it and `off` disables it. -- `state_artifact` - optional, defaults to `true`. - Uploads the run's `.pr-review` state directory as an artifact. Set it to `false` only if the - repository forbids artifacts. -- `state_artifact_retention_days` - optional, defaults to `14`. -- `debug_logs` - deprecated and ignored. The analysis session's full output is now always - printed. Consumers still passing it are unaffected; drop it at your convenience. - - -The semantic-analysis stage runs under a turn budget: 12 for a low-risk incremental review, -44 for a strong-tier one, and 56 for `deep`. Set `max_turns` to override any of these. -Roughly ten turns go on mandated context โ€” six pipeline files plus repo guidance โ€” before the -diff is read, and a small diff inside a large file spends many more paging through it, so the -budget tracks files to understand rather than lines changed. -The retry gets half again as many turns as the first attempt, because exhausting the budget is -deterministic and replaying it with the same budget cannot succeed. - -Consumers that already create a GitHub App token can opt into the unified identity with: - -```yaml -with: - github_identity_token: ${{ steps.app-token.outputs.token }} -``` - -The semantic stage is bounded to 12 turns for fast incremental reviews, 44 for standard -full or high-risk reviews, and 56 for explicit deep reviews, unless `max_turns` overrides it. - -### PR review pipeline - -The PR reviewer is an explicit staged pipeline: - -1. A deterministic preparation step freezes the base and head SHAs, loads the durable review - manifest, fetches prior automated threads, computes the full or incremental diff, and - selects the model tier. It then posts or updates the sticky status comment to - `๐Ÿ”„ Review in progress`, so the PR shows the round has started instead of staying silent - until the review lands minutes later. -2. Claude performs semantic analysis and verification with read-only tools. - It returns schema-constrained data and cannot publish comments or resolve threads. -3. A deterministic compiler validates findings, enforces severity budgets, checks RIGHT-side - anchors, formats the standard human-facing messages, and suppresses internal review - machinery. -4. A deterministic publisher rechecks the live head, submits at most one review, optionally - resolves addressed automated threads, and updates one sticky status comment. - -The sticky comment contains a hidden, versioned manifest with the last published head, -reviewer and rubric versions, stable finding IDs, thread IDs, and finding dispositions. -Later runs use that manifest as a checkpoint and fall back to GitHub review history if the -manifest is unavailable. -The manifest never contains complete diffs, PR prose, tool output, secrets, or Claude session -transcripts. -Inline findings are linked back to the exact published review and comment IDs, with bounded -retries for GitHub API propagation. -When `github_identity_token` is configured, the publisher does not mark a GitHub thread -resolved unless GitHub confirms the resolution mutation. -The configured identity is stored in the review manifest while historical `claude` and -`github-actions` state remains readable for migration. - -The action performs a full review when there is no valid checkpoint, the previous head is not -an ancestor, or the pipeline or rubric version changed. -Otherwise it reviews only the delta since the last published head and rechecks open findings. -It discards output if the PR head changes during analysis. - -Internal production-failure analysis is never named in GitHub review output. -Confirmed issues become ordinary findings. -Useful uncertainty becomes an `Open question` with medium or low confidence and a concrete -verification request. -Rejected candidates and an empty internal analysis remain invisible. - -Open questions have the same durable lifecycle as findings. -Each one gets a stable ID and a hidden marker on its status line, and the manifest records -which review asked it. -Each question is published inside a `
` block that starts expanded. -A later round dispositions every open question as `open`, `answered`, or `withdrawn`, and the -publisher edits the original review body in place so the summary line reads -`โœ… **Answered**` or `๐Ÿšซ **Withdrawn**` with a one-line reason, and the block collapses. -The rationale bullets are kept rather than deleted, so an answered question stays one green -line that expands to the original question, why it mattered, and how to verify it. -Editing a submitted review body creates no new review and no new notification, and rewriting -reproduces the same header (marker included), so the update is idempotent and retried on the -next round if GitHub rejects it. -Questions published before the `
` shape existed fall back to a single-line rewrite. -A question that is already open is never re-asked; the original stays the copy the author -answers. - -**Self-authored pull requests are skipped, not reviewed.** When the PR was -opened by the reviewer's own identity (the app behind `github_identity_token`, -e.g. `mega-maxwell[bot]` โ€” release candidates, settle PRs, dependency bumps), -`prepare` routes the round to `skip` before any model step and the status -comment says so ("โญ๏ธ Review skipped โ€ฆ opened by mega-maxwell[bot], the -reviewer's own identity"). This is decided from the PR author, independently -of `allowed_bots`, which still governs which bots may *trigger* a review of -someone else's PR. - -### Inspecting a review run - -The published review says what the reviewer concluded. These say how it got there, and they -are the starting point for tuning the rubric, the prompt, or the turn budget. - -**Step summary** (the run's front page) carries the routing decision and its reason, the head -range, the model and turn budget the analysis actually ran with, the model tier, high-risk and -pre-mortem flags, and this round's counts โ€” new findings split into inline and review-body, -new questions, prior findings resolved. It then folds in two blocks: the **pipeline trace** and -the **raw model output before compilation**. A failed run gets the same trace, which shows how -far the round got before it stopped. - -**Job log** groups, in step order: - -- `Review routing` (prepare) โ€” mode and why, prior state source and whether its version still - matches, previous and current head, the compare status and file count, both diff sizes, how - much of the PR conversation was included versus truncated, the paths in scope, and every open - prior finding and question the model was handed. -- `Analysis settings` (compose) โ€” model, tier, depth, pre-mortem, turn budgets, allowed tools. -- The analysis step itself prints the session's full output, always: every tool call, which - files it opened, and which it never read. The step is collapsed until you expand it. -- `Model output (raw, before compilation)` and `Compilation decisions` (compile) โ€” one line per - model result the compiler accepted, suppressed as a duplicate of an open finding, dropped at - the per-severity cap of five, or rerouted to the review body because its line is not - commentable, plus each prior finding and question disposition. -- `Publication` (publish) โ€” review ID, inline comments requested versus posted, whether inline - publication fell back to the review body, threads resolved, and the sticky comment ID. - -**Run artifact** `pr-review-state---` holds the bytes themselves, for 14 days -by default: `review-input.json` (everything the model was given), `review.diff` and `full.diff`, -`analysis-transcript.json` (the session's turn-by-turn record, plus a `-retry-` twin when the -retry ran), `structured-output.json`, `model-output.json`, `review-payload.json` (the compiled -review, including its decision trace), `publish-result.json`, and `trace.log`. - -"The model missed it" and "the pipeline dropped it" look identical in the published review and -different in these. Compare `model-output.json` against the `Compilation decisions` group first. - -### Comment-triggered reconciliation (opt-in) - - -By default the review only runs on `pull_request` events, so an author who answers an open -question in a PR comment sees nothing happen until the next push. A consumer can also let a -comment drive a reconcile round by adding an `issue_comment` trigger: - -```yaml -on: - pull_request: - types: [opened, synchronize, ready_for_review, reopened] - issue_comment: - types: [created] - -jobs: - pr-review: - # Any PR comment except the reviewer's own status comment, which is posted - # by the CI app and would otherwise retrigger the review. Other bots are - # allowed โ€” their comments may answer a question or push back on a finding. - # Replace mega-maxwell[bot] with your reviewer app's login. - if: >- - github.event_name != 'issue_comment' || - (github.event.issue.pull_request != null && - github.event.comment.user.login != 'mega-maxwell[bot]') - concurrency: - # issue_comment payloads carry issue.number, not pull_request.number. - group: claude-pr-review-${{ github.event.pull_request.number || github.event.issue.number }} - cancel-in-progress: false -``` - -The action gates the round cheaply so routine chatter does not spend a review: on an -`issue_comment` event, `prepare` skips unless the PR still has an **open question or open -finding** in the manifest (something a comment could answer, justify, or invalidate). When it -does run it is an incremental round that reuses the same sticky-comment manifest โ€” so the -reviewer keeps its full prior context, unlike a fresh `@claude` session โ€” and it runs on the -cheaper incremental model tier. If the comment turns out not to change anything, the publisher -posts nothing (no new review, no notification). A comment with no new commit reconciles the -discussion against the existing head; a comment that races a push reviews the new delta too. - -## Per-Repo Conventions - -The prompt-bearing actions instruct Claude to read and respect a consumer repo's own agent -instruction files when they exist (`REVIEW.md`, `README.md`, `CLAUDE.md`, `AGENTS.md`, and any -other repo-level agent guidance), with those per-repo rules taking precedence over the -canonical inline prompt. Use these files for repo-specific rules; reserve `extra_prompt` for -small deltas that do not belong in a checked-in convention file. - -`pr-review` tags every inline finding with a bold severity label (`**[Critical]**`, -`**[Major]**`, `**[Minor]**`, or `**[Nit]**`). -Clean reviews and re-reviews update the sticky status without creating another review -notification. -Rounds with findings or new open questions submit one atomic review and update the same sticky -status. -The sticky status comment states up front that it is a living comment rewritten in place on -every run, so a reader who meets it mid-thread can tell it describes the current head rather -than the moment it first appeared. -It carries the reviewed range, an update timestamp, this round's counts, and a roll-up of the -questions still awaiting an answer with a link to the review that asked each one. -It moves through three phases: `๐Ÿ”„ Review in progress` from preparation, then either the -finished verdict or `๐Ÿ› ๏ธ Review did not finish` for a round that ends without publishing. -Every non-publishing path โ€” a failure, a discarded stale head โ€” retires the in-progress phase -itself, so the comment never sits at "in progress" after the job ends. A skip-mode round -publishes nothing and is never announced. -When old automated review threads are addressed, the deterministic publisher resolves them -without adding confirmation replies. -A consumer repo's `REVIEW.md` may override or extend the semantic severity guidance. - -## Consumer Requirements - -Consumer jobs should pin these actions to `@main`. A merge to this repository's `main` goes live -for every consumer automatically, with no consumer workflow edits required. -Use `megaeth-labs/.github/.github/actions/claude-interactive@main`, -`megaeth-labs/.github/.github/actions/claude-pr-review@main`, -`megaeth-labs/.github/.github/actions/claude-label-check@main`, or -`megaeth-labs/.github/.github/actions/claude-issue-triage@main`. - -Because the same merge ships to every consumer at once, changes here are covered by the -`Actions` workflow, which runs the `claude-pr-review` pipeline's unit tests on every PR. - -Consumer jobs must run `actions/checkout` before these actions. They must also provide the -`CLAUDE_CODE_OAUTH_TOKEN` secret and set role-appropriate job permissions: - -- `claude-interactive`: `contents: write`, `pull-requests: write`, `issues: write`, `id-token: write`, `actions: read` -- `claude-pr-review`: `contents: read`, `pull-requests: write`, `id-token: write`, `actions: read` -- `claude-label-check`: `contents: read`, `pull-requests: write`, `id-token: write` -- `claude-issue-triage`: `contents: read`, `issues: write`, `id-token: write` - -Before consumer repositories can reference these private actions, maintainers must enable -Settings -> Actions -> General -> Access -> "Accessible from repositories in the megaeth-labs organization". - -## Example - -```yaml -jobs: - pr-review: - runs-on: ubuntu-24.04 - permissions: - contents: read - pull-requests: write - id-token: write - actions: read - steps: - - uses: actions/checkout@v4 - with: - submodules: recursive - fetch-depth: 1 - - - uses: megaeth-labs/.github/.github/actions/claude-pr-review@main - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - extra_allowed_tools: "Bash(cargo:*)" - extra_prompt: | - Add repository-specific review instructions here. -``` - -> Note: a PR that _modifies the calling repo's own_ `claude.yml` skips the `pr-review` -> job. -> `claude-code-action` validates that workflow against the default branch before it exchanges -> its app token, so self-modifying PRs cannot run the review step safely. -> This only affects the repo that changed its own workflow. -> It does not affect consumers pinned to `@main` in normal operation. - -## Concurrency (pr-review) - -Consumers should give the `pr-review` job a `timeout-minutes` value of at least `25` plus a -job-level concurrency group with `cancel-in-progress: true`. -The publisher revalidates the live PR base and head immediately before each GitHub mutation, -and review submissions are pinned to the frozen head commit. If either revision changes, -publication stops without advancing the manifest. -Latest-only cancellation avoids spending review time on queued, obsolete heads: - -```yaml -pr-review: - timeout-minutes: 25 - concurrency: - group: claude-pr-review-${{ github.event.pull_request.number }} - cancel-in-progress: true -``` - -## Release pipeline - -The `release-*` actions and the `workflow-templates/release-*.yml` / -`on-release.yml` callers are documented in [RELEASE.md](RELEASE.md): what the -pipeline does and why, one release step by step, installation in a new -repository, options, publish targets, and recovery. +# Shared actions + +Composite actions every megaeth-labs repository consumes at `@main`. Two +families and one standalone action; each action directory has a README whose +input, output, step and error tables are generated from its `action.yml`. + + +| Action | Family | Does | +|---|---|---| +| [`claude-interactive`](claude-interactive/README.md) | [Claude CI](CLAUDE-CI.md) | Run the interactive @claude handler with centralized MegaETH permissions. | +| [`claude-issue-triage`](claude-issue-triage/README.md) | [Claude CI](CLAUDE-CI.md) | Run the centralized MegaETH Claude issue triage. | +| [`claude-label-check`](claude-label-check/README.md) | [Claude CI](CLAUDE-CI.md) | Run the centralized MegaETH Claude pull request label check. | +| [`claude-pr-review`](claude-pr-review/README.md) | [Claude CI](CLAUDE-CI.md) | Run the staged, incremental MegaETH Claude pull request review. | +| [`pr-lint`](pr-lint/README.md) | Standalone | Lint a pull request. Currently validates that the PR title follows Conventional Commits, posting a sticky comment on failure and removing it once fixed; further PR-level lint steps can be added here over time. Run as a step inside a job the consumer names, so the resulting status-check context is that job name. | +| [`release-assets`](release-assets/README.md) | [Release pipeline](RELEASE.md) | Attach files, plus a generated `SHA256SUMS`, to the GitHub Release for a tag. Re-runs replace assets of the same name (`--clobber`), so the step is idempotent. `dry_run` writes and prints `SHA256SUMS` but attaches nothing. Needs a token with `contents: write` on the repository (the job token is enough). Guide: .github/actions/RELEASE.md in megaeth-labs/.github. | +| [`release-candidate`](release-candidate/README.md) | [Release pipeline](RELEASE.md) | Start a release (trunk-first). `stage: propose` bumps the version file on the default branch, drafts this release's changelog entry (dated at settle) from the commits since the previous tag, syncs the previous release's entry from its tag, and opens a `chore/release-candidate-vX.Y.Z` PR; `stage: cut`, run when that PR merges, creates `release-vX.Y.Z` at the merge commit. No tag is created at either stage โ€” tags come from release-publish, once, at settlement. Run as a step in a job the consumer owns; the consumer checks the repository out first (`fetch-depth: 0`, `persist-credentials: false`). Guide: .github/actions/RELEASE.md in megaeth-labs/.github. | +| [`release-publish`](release-publish/README.md) | [Release pipeline](RELEASE.md) | Publish a settled release. Normally runs when a `chore/release-settle-vX.Y.Z` PR merges into its release branch (or, with `commit` + `version` given, on an explicit commit that `release-settle` in `direct` mode just made): creates the annotated tag `vX.Y.Z` at the merge commit (exactly once โ€” refuses if it exists, or if the branch moved after the settle PR was opened) and publishes the GitHub Release with the changelog section as notes. This is the only place in the release flow that creates a tag; merging the settle PR is the approval. The default branch's changelog catches up in the next release candidate PR โ€” nothing is back-merged. The consumer checks the repository out first (`fetch-depth: 0`, `persist-credentials: false`). Guide: .github/actions/RELEASE.md in megaeth-labs/.github. | +| [`release-publish-rust-crates`](release-publish-rust-crates/README.md) | [Release pipeline](RELEASE.md) | Publish an explicit list of workspace crates to crates.io at the release version. Verifies every listed crate's manifest version first, skips crates already published at that version (so a re-run after a partial failure finishes the rest), publishes the remainder in one `cargo publish -p โ€ฆ -p โ€ฆ` invocation โ€” Cargo orders by dependency and waits for the index between crates (requires Cargo โ‰ฅ 1.90) โ€” then polls crates.io until every crate reports the version. `dry_run` runs `cargo publish --dry-run`: full packaging and build verification, nothing uploaded. Run inside a job that has checked out the release tag and installed the toolchain the crates need. Never use `--workspace`: a crate without `publish = false` that was never meant to be published would go out with it. Guide: .github/actions/RELEASE.md in megaeth-labs/.github. | +| [`release-settle`](release-settle/README.md) | [Release pipeline](RELEASE.md) | Propose settling a release candidate: verify `commit` is the tip of the release branch and carries the expected version, generate release notes from the commits since the previous tag, write them into the changelog (stamping the date onto the candidate's `## vX.Y.Z` entry), and โ€” in the default `pr` mode โ€” open a `chore/release-settle-vX.Y.Z` PR onto the release branch; merging that PR is the settlement decision and release-publish then tags the merge commit once. In `direct` mode the dispatch itself is the decision: the dispatching actor must be authorised by `settlers`, the changelog commit is pushed straight to the release branch (the app must be a bypass actor on that branch's ruleset), and release-publish runs immediately on that commit. Run as a step in a job the consumer owns; the consumer checks the repository out first (`fetch-depth: 0`, `persist-credentials: false`). Needs `gh` and `python3` on the runner. Guide: .github/actions/RELEASE.md in megaeth-labs/.github. | +| [`release-upload-artifact`](release-upload-artifact/README.md) | [Release pipeline](RELEASE.md) | Upload one file to Google Artifact Registry (generic repository) or a Cloud Storage bucket, idempotently: if the destination already holds a file, its hash is compared โ€” identical means "already there" (success, nothing uploaded), different means failure, never a silent overwrite. Every destination is an input; nothing is defaulted. Authentication is the caller's job: run `google-github-actions/auth` (service-account key or Workload Identity Federation) before this step so `gcloud` is authenticated. `dry_run` computes the checksum and checks the destination but uploads nothing. Guide: .github/actions/RELEASE.md in megaeth-labs/.github. | +| [`release-verify-version`](release-verify-version/README.md) | [Release pipeline](RELEASE.md) | Run a command that prints a built artifact's version (`my-binary --version`) and require the output to match the release version, so a binary built from the wrong tree โ€” or a tag that does not match the manifest โ€” never reaches a publish step. By default the expected output is ` ` (the clap shape: the command's basename, then the version without its leading `v`); `expected` overrides it. A command that cannot run at all โ€” missing library, crash, no such flag โ€” fails the step even on a dry run: an artifact that does not execute is never publishable, and its exit code is never swallowed. A mismatch is a warning on a dry run, so a rehearsal reports what a real run would refuse and carries on, and a hard stop otherwise. Guide: .github/actions/RELEASE.md in megaeth-labs/.github. | + + +Family guides, next to the actions they cover: + +- [RELEASE.md](RELEASE.md) โ€” the release pipeline: candidate, settle, + publish, publish targets; how it works, installing it in a repository, + options, recovery. +- [CLAUDE-CI.md](CLAUDE-CI.md) โ€” the Claude checks: PR review, label check, + issue triage, interactive; how a round runs, installing, options, + inspecting a run. + +`release-tools/` is not an action: it holds the text helpers the release +actions share ([README](release-tools/README.md)). + +## Consuming an action + +- Reference it as `megaeth-labs/.github/.github/actions/@main`. A merge + to `main` here reaches every consumer at once; nothing is pinned. +- This repository's Settings โ†’ Actions โ†’ Access allows organisation + repositories to use these actions (already set). +- Actions carry no organisation-specific defaults beyond the CI app's + identity; everything else (secrets, destinations, files) is an input, and + the templates under `workflow-templates/` carry this organisation's values. + +## Changing an action + +- `actions-test.yml` is the only gate: the unit tests of + `claude-pr-review/review_pipeline.py` and `release-tools/*.py`, the + end-to-end drive of `release-verify-version`, and the documentation check. +- After editing an `action.yml`, run `.github/scripts/action_docs.py`: it + rewrites the generated blocks in that action's README and the catalogue + above. CI runs it with `--check` and fails if the docs are stale. Prose + outside the generated blocks is written by hand. +- A new action needs a directory with `action.yml`; the script creates its + README skeleton, whose `Example` and `Notes` sections you then fill in. +- Templates in `workflow-templates/` are the reference callers: keep them in + step with the actions they call. diff --git a/.github/actions/claude-interactive/README.md b/.github/actions/claude-interactive/README.md new file mode 100644 index 0000000..6f576ce --- /dev/null +++ b/.github/actions/claude-interactive/README.md @@ -0,0 +1,67 @@ +# Claude Interactive + +`uses: megaeth-labs/.github/.github/actions/claude-interactive@main` + + +Run the interactive @claude handler with centralized MegaETH permissions. + + +Family: [Claude CI](../CLAUDE-CI.md). + +## Inputs + + +| Input | Required | Default | Description | +|---|---|---|---| +| `claude_code_oauth_token` | yes | | OAuth token for Claude Code. | +| `allowed_bots` | no | `mega-putin` | Bot accounts allowed to trigger Claude Code. | +| `extra_allowed_tools` | no | | Additional Claude tools to append to the canonical allowedTools list. | + + +## Outputs + + +This action sets no outputs. + + +## What it runs + + +1. (unnamed run step) +1. `anthropics/claude-code-action` + + +## Errors it reports + + +Its shell steps report no errors of their own; failures come from the actions and tools it calls. + + +## Example + +```yaml +jobs: + interactive: + if: contains(github.event.comment.body, '@claude') # plus the review/issue event variants + runs-on: ubuntu-24.04 + timeout-minutes: 45 + permissions: + contents: write + pull-requests: write + issues: write + id-token: write + actions: read + steps: + - uses: actions/checkout@v4 + - uses: megaeth-labs/.github/.github/actions/claude-interactive@main + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + extra_allowed_tools: "Bash(just:*),Bash(npm:*)" +``` + +## Notes + +- Handles `@claude` mentions in issue comments, PR review comments and + reviews with the organisation's permission set; there is no + `extra_prompt`, since `@claude` conversations are native. +- `allowed_bots` names the bot accounts whose mentions may trigger it. diff --git a/.github/actions/claude-issue-triage/README.md b/.github/actions/claude-issue-triage/README.md new file mode 100644 index 0000000..c7628b7 --- /dev/null +++ b/.github/actions/claude-issue-triage/README.md @@ -0,0 +1,65 @@ +# Claude Issue Triage + +`uses: megaeth-labs/.github/.github/actions/claude-issue-triage@main` + + +Run the centralized MegaETH Claude issue triage. + + +Family: [Claude CI](../CLAUDE-CI.md). + +## Inputs + + +| Input | Required | Default | Description | +|---|---|---|---| +| `claude_code_oauth_token` | yes | | OAuth token for Claude Code. | +| `allowed_bots` | no | `mega-putin` | Bot accounts allowed to trigger Claude Code. | +| `extra_allowed_tools` | no | | Additional Claude tools to append to the canonical allowedTools list. | +| `extra_prompt` | no | | Additional prompt text appended after the canonical prompt. | + + +## Outputs + + +This action sets no outputs. + + +## What it runs + + +1. (unnamed run step) +1. `anthropics/claude-code-action` + + +## Errors it reports + + +Its shell steps report no errors of their own; failures come from the actions and tools it calls. + + +## Example + +```yaml +on: + issues: + types: [opened] +jobs: + triage: + runs-on: ubuntu-24.04 + permissions: + contents: read + issues: write + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: megaeth-labs/.github/.github/actions/claude-issue-triage@main + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} +``` + +## Notes + +- Triages newly opened issues with the centralised prompt plus the + repository's own convention files; `extra_prompt` appends per-repository + instructions. diff --git a/.github/actions/claude-label-check/README.md b/.github/actions/claude-label-check/README.md new file mode 100644 index 0000000..0b0c350 --- /dev/null +++ b/.github/actions/claude-label-check/README.md @@ -0,0 +1,69 @@ +# Claude Label Check + +`uses: megaeth-labs/.github/.github/actions/claude-label-check@main` + + +Run the centralized MegaETH Claude pull request label check. + + +Family: [Claude CI](../CLAUDE-CI.md). + +## Inputs + + +| Input | Required | Default | Description | +|---|---|---|---| +| `claude_code_oauth_token` | yes | | OAuth token for Claude Code. | +| `allowed_bots` | no | `mega-putin` | Bot accounts allowed to trigger Claude Code. | +| `extra_allowed_tools` | no | | Additional Claude tools to append to the canonical allowedTools list. | +| `self_authored_logins` | no | `mega-maxwell[bot]` | Comma-separated logins whose pull requests are skipped rather than label-checked: the CI app's own PRs (release candidates, settle PRs, dependency bumps). claude-code-action refuses bot-initiated events, so without this the check fails instead of saying it does not apply. | +| `extra_prompt` | no | | Additional prompt text appended after the canonical prompt. | + + +## Outputs + + +This action sets no outputs. + + +## What it runs + + +1. (unnamed run step) +1. (unnamed run step) *(only if `steps.gate.outputs.skip != 'true'`)* +1. `anthropics/claude-code-action` *(only if `steps.gate.outputs.skip != 'true'`)* + + +## Errors it reports + + +Its shell steps report no errors of their own; failures come from the actions and tools it calls. + + +## Example + +```yaml +jobs: + label-check: + runs-on: ubuntu-24.04 + permissions: + contents: read + pull-requests: write + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: megaeth-labs/.github/.github/actions/claude-label-check@main + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + extra_prompt: | + This repository's label conventions are in CONTRIBUTING.md. +``` + +## Notes + +- PRs opened by any login in `self_authored_logins` (the CI app by + default) are skipped rather than checked: the app's release and + dependency PRs carry the labels the settle/candidate actions were given. +- The prompt reads the repository's own convention files first + (`REVIEW.md`, `CLAUDE.md`, `AGENTS.md`, โ€ฆ); `extra_prompt` is for small + deltas only. diff --git a/.github/actions/claude-pr-review/README.md b/.github/actions/claude-pr-review/README.md new file mode 100644 index 0000000..9d228ae --- /dev/null +++ b/.github/actions/claude-pr-review/README.md @@ -0,0 +1,100 @@ +# Claude PR Review + +`uses: megaeth-labs/.github/.github/actions/claude-pr-review@main` + + +Run the staged, incremental MegaETH Claude pull request review. + + +Family: [Claude CI](../CLAUDE-CI.md). + +## Inputs + + +| Input | Required | Default | Description | +|---|---|---|---| +| `claude_code_oauth_token` | yes | | OAuth token for Claude Code. | +| `github_identity_token` | no | | Optional GitHub token used for review publication and state updates. When supplied, it is also used to resolve addressed automated review threads. Empty uses the job token and skips thread resolution. | +| `allowed_bots` | no | `mega-putin` | Bot accounts allowed to trigger Claude Code. | +| `extra_allowed_tools` | no | | Additional read-only tools to append to the canonical allowedTools list. | +| `extra_prompt` | no | | Additional analysis instructions appended after the canonical prompt. | +| `model` | no | `claude-opus-4-7` | Model used for full, high-risk, and deep reviews. | +| `incremental_model` | no | | Model used for low-risk incremental reviews. Empty uses the Claude Code default (Sonnet class). | +| `review_depth` | no | `standard` | `standard` runs one lead review. `deep` asks the lead to fan out review dimensions and adversarially verify them before returning structured data. | +| `max_turns` | no | | Optional override for the per-invocation turn budget (`--max-turns`). Empty (default) uses the built-in budget derived from model tier and review depth (fast 12, standard 44, deep 56). Set a positive integer to pin the ceiling for the main analysis pass; the retry pass keeps its 1.5x headroom relative to this value. | +| `debug_logs` | no | `false` | Deprecated and ignored. The analysis session's full output โ€” its tool calls, the files it opened, and the ones it never read โ€” is now always printed, and the whole `.pr-review` state directory plus the session transcript are uploaded as a run artifact. Kept only so consumers that still pass it do not break. | +| `state_artifact` | no | `true` | `true` uploads the `.pr-review` state directory โ€” routing input, both diffs, the raw model output, the compiled payload, the pipeline trace, and the analysis session transcript โ€” as a run artifact. This is the only copy that outlives the runner, so leave it on unless the repository forbids artifacts. | +| `state_artifact_retention_days` | no | `14` | Retention in days for the review state artifact. | +| `premortem` | no | `auto` | `auto` runs the internal pre-mortem only for full or high-risk reviews. `on` always runs it; `off` disables it. Pre-mortem provenance never appears in the published review. | + + +## Outputs + + +| Output | Description | +|---|---| +| `mode` | Review mode (`full`, `incremental`, or `skip`). | +| `reviewed_head` | Frozen PR head reviewed by this run. | +| `verdict` | Compiled verdict (`clean`, `findings`, or `questions`). | +| `published` | Whether output was published for the frozen head. | + + +## What it runs + + +1. Prepare immutable review context +1. Compose bounded LLM analysis +1. Analyze and verify findings โ€” `anthropics/claude-code-action` *(only if `steps.prepare.outputs.mode != 'skip'`)* +1. Retry missing structured review output โ€” `anthropics/claude-code-action` *(only if `steps.prepare.outputs.mode != 'skip' && (steps.review.outcome == 'failure' || steps.review.outputs.structured_output == '')`)* +1. Capture analysis session transcript *(only if `always()`)* +1. Compile and validate review +1. Publish atomically and persist state +1. Report concise review outcome *(only if `always()`)* +1. Upload review state for inspection โ€” `actions/upload-artifact` *(only if `always() && inputs.state_artifact == 'true'`)* + + +## Errors it reports + + +- `max_turns must be a positive integer, got` + + +## Example + +```yaml +jobs: + pr-review: + runs-on: ubuntu-24.04 + timeout-minutes: 25 + concurrency: + group: claude-pr-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + permissions: + contents: read + pull-requests: write + id-token: write + actions: read + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - uses: megaeth-labs/.github/.github/actions/claude-pr-review@main + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + github_identity_token: ${{ steps.app-token.outputs.token }} # optional: one identity for reviews, comments, thread resolution + extra_allowed_tools: "Bash(cargo:*)" +``` + +## Notes + +- How a round runs, the manifest, the questions lifecycle, the comment- + triggered reconciliation and how to inspect a run: [CLAUDE-CI.md](../CLAUDE-CI.md). +- PRs opened by the identity behind `github_identity_token` (release + candidates, settle PRs, dependency bumps) are skipped before any model + step. +- A PR that edits the calling repository's own `claude.yml` skips the review + by design of `claude-code-action`; it works again once the change merges. +- Turn budgets: 12 for a low-risk incremental round, 44 for a strong-tier + one, 56 for `deep`; `max_turns` overrides, and the retry gets half again + as many. diff --git a/.github/actions/pr-lint/README.md b/.github/actions/pr-lint/README.md new file mode 100644 index 0000000..f383d8e --- /dev/null +++ b/.github/actions/pr-lint/README.md @@ -0,0 +1,79 @@ +# PR Lint + +`uses: megaeth-labs/.github/.github/actions/pr-lint@main` + + +Lint a pull request. Currently validates that the PR title follows Conventional Commits, posting a sticky comment on failure and removing it once fixed; further PR-level lint steps can be added here over time. Run as a step inside a job the consumer names, so the resulting status-check context is that job name. + + +Standalone action; no family guide. + +## Inputs + + +| Input | Required | Default | Description | +|---|---|---|---| +| `types` | no | `feat +fix +chore +test +bench +perf +refactor +docs +ci +revert +deps +` | Newline-separated list of allowed Conventional Commit types. Defaults to the MegaETH org convention. | +| `github_token` | no | `${{ github.token }}` | Token used by the semantic-PR check and the sticky comment. | + + +## Outputs + + +This action sets no outputs. + + +## What it runs + + +1. Skip for merge queue *(only if `github.event_name == 'merge_group'`)* +1. Check title โ€” `amannn/action-semantic-pull-request` *(only if `github.event_name == 'pull_request'`)* +1. Add PR Comment for Invalid Title โ€” `marocchino/sticky-pull-request-comment` *(only if `github.event_name == 'pull_request' && steps.lint_pr_title.outcome == 'failure'`)* +1. Remove Comment for Valid Title โ€” `marocchino/sticky-pull-request-comment` *(only if `github.event_name == 'pull_request' && steps.lint_pr_title.outcome == 'success'`)* +1. Fail if title invalid *(only if `github.event_name == 'pull_request' && steps.lint_pr_title.outcome == 'failure'`)* + + +## Errors it reports + + +Its shell steps report no errors of their own; failures come from the actions and tools it calls. + + +## Example + +```yaml +name: PR Lint +on: + pull_request: + types: [opened, reopened, edited, synchronize] + merge_group: +jobs: + conventional-title: + name: Validate PR title is Conventional Commit + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - uses: megaeth-labs/.github/.github/actions/pr-lint@main +``` + +## Notes + +- Run it as a step inside a job the consumer names: the status-check + context is the job name, so a branch ruleset that requires an exact check + name is satisfied by naming the job accordingly. +- On a bad title it posts a sticky comment and fails; once the title is + fixed the comment is removed. Merge-queue events are skipped. +- `types` overrides the allowed Conventional Commit types; the default is + the organisation's convention. diff --git a/.github/actions/release-assets/README.md b/.github/actions/release-assets/README.md new file mode 100644 index 0000000..d21f5f3 --- /dev/null +++ b/.github/actions/release-assets/README.md @@ -0,0 +1,65 @@ +# Release ยท Assets + +`uses: megaeth-labs/.github/.github/actions/release-assets@main` + + +Attach files, plus a generated `SHA256SUMS`, to the GitHub Release for a tag. Re-runs replace assets of the same name (`--clobber`), so the step is idempotent. `dry_run` writes and prints `SHA256SUMS` but attaches nothing. Needs a token with `contents: write` on the repository (the job token is enough). Guide: .github/actions/RELEASE.md in megaeth-labs/.github. + + +Family: [Release pipeline](../RELEASE.md). + +## Inputs + + +| Input | Required | Default | Description | +|---|---|---|---| +| `tag` | yes | | Tag of the Release to attach to. | +| `files` | yes | | Newline-separated paths to attach. | +| `token` | no | `${{ github.token }}` | Token for the GitHub API calls this action makes. | +| `dry_run` | no | `false` | `true`: do everything except the irreversible step (upload, attach, publish), and report what would have happened. | + + +## Outputs + + +| Output | Description | +|---|---| +| `sha256sums` | Path of the generated SHA256SUMS file. | + + +## What it runs + + +1. Checksums +1. Attach to the Release + + +## Errors it reports + + +- `no files listed` +- `file not found: $f` + + +## Example + +```yaml +- uses: megaeth-labs/.github/.github/actions/release-assets@main + with: + tag: ${{ env.TAG }} + files: | + target/release/my-binary + target/release/my-other-binary + dry_run: ${{ env.DRY_RUN }} +``` + +The job needs `permissions: contents: write`; the default `token` (the job +token) is enough. + +## Notes + +- Generates `SHA256SUMS` with file names only, so it verifies from a + download directory, and attaches it with the files. +- Re-runs replace assets of the same name (`--clobber`), so the step is + idempotent; keep it in a concurrency group so two runs never overlap. +- `dry_run` prints the sums and attaches nothing. diff --git a/.github/actions/release-assets/action.yml b/.github/actions/release-assets/action.yml index 0da0f9a..8ad2c36 100644 --- a/.github/actions/release-assets/action.yml +++ b/.github/actions/release-assets/action.yml @@ -15,9 +15,11 @@ inputs: description: "Newline-separated paths to attach." required: true token: + description: "Token for the GitHub API calls this action makes." required: false default: ${{ github.token }} dry_run: + description: "`true`: do everything except the irreversible step (upload, attach, publish), and report what would have happened." required: false default: "false" diff --git a/.github/actions/release-candidate/README.md b/.github/actions/release-candidate/README.md new file mode 100644 index 0000000..277bac6 --- /dev/null +++ b/.github/actions/release-candidate/README.md @@ -0,0 +1,100 @@ +# Release Candidate + +`uses: megaeth-labs/.github/.github/actions/release-candidate@main` + + +Start a release (trunk-first). `stage: propose` bumps the version file on the default branch, drafts this release's changelog entry (dated at settle) from the commits since the previous tag, syncs the previous release's entry from its tag, and opens a `chore/release-candidate-vX.Y.Z` PR; `stage: cut`, run when that PR merges, creates `release-vX.Y.Z` at the merge commit. No tag is created at either stage โ€” tags come from release-publish, once, at settlement. Run as a step in a job the consumer owns; the consumer checks the repository out first (`fetch-depth: 0`, `persist-credentials: false`). Guide: .github/actions/RELEASE.md in megaeth-labs/.github. + + +Family: [Release pipeline](../RELEASE.md). + +## Inputs + + +| Input | Required | Default | Description | +|---|---|---|---| +| `stage` | yes | | `propose` (on workflow_dispatch) or `cut` (on the candidate PR merging). | +| `token` | yes | | Token that authors the PR and creates the branch. Must be a GitHub App installation token (e.g. the Maxwell app) so the PR triggers CI; the job's GITHUB_TOKEN does not. | +| `version` | no | | Version to release, X.Y.Z or vX.Y.Z. Required for `propose`. | +| `version_file` | yes | | File holding the version (VERSION, Cargo.toml, pyproject.toml, package.json). | +| `version_pattern` | no | `plain` | How the version is stored in `version_file`: plain, toml or json. | +| `bump_command` | no | | Optional shell command run after `version_file` is rewritten, for anything else that must move with the version: lockfiles (`cargo update --workspace`), path-dependency versions, generated files. Runs with `OLD_VERSION` and `NEW_VERSION` in the environment, in the repository root, under `bash -euo pipefail`. Whatever it changes is committed with the bump. Install any toolchain it needs in the calling job before this action. | +| `release_branch_prefix` | no | `release-v` | Release branch name prefix; the branch is `X.Y.Z`. | +| `changelog_file` | no | `CHANGELOG.md` | Changelog to draft into (Keep-a-Changelog style, newest first). Empty disables changelog handling. | +| `pr_labels` | no | | Comma-separated labels for the candidate PR. | +| `pr_author` | no | `mega-maxwell[bot]` | Login the candidate PR must have been opened by for `cut` to proceed โ€” the app identity `propose` runs under. Stops a hand-made `chore/release-candidate-*` branch from driving the cut. Empty disables the check. | +| `git_user_name` | no | `mega-maxwell[bot]` | Committer identity for the bump commit. | +| `git_user_email` | no | `290560214+mega-maxwell[bot]@users.noreply.github.com` | Committer email for the commits this action makes. | + + +## Outputs + + +| Output | Description | +|---|---| +| `version` | Normalised X.Y.Z. | +| `pr_url` | `propose`: URL of the candidate PR. | +| `release_branch` | `cut`: the release branch created. | + + +## What it runs + + +1. Resolve version +1. Configure git auth +1. Guard (propose) *(only if `inputs.stage == 'propose'`)* +1. Bump version file *(only if `inputs.stage == 'propose'`)* +1. Run bump command *(only if `inputs.stage == 'propose' && inputs.bump_command != ''`)* +1. Draft changelog *(only if `inputs.stage == 'propose' && inputs.changelog_file != ''`)* +1. Remove git auth before opening the PR *(only if `inputs.stage == 'propose'`)* +1. Drop a stale candidate branch *(only if `inputs.stage == 'propose'`)* +1. Open candidate PR โ€” `peter-evans/create-pull-request` *(only if `inputs.stage == 'propose'`)* +1. Cut release branch *(only if `inputs.stage == 'cut'`)* +1. Remove git auth *(only if `always()`)* + + +## Errors it reports + + +- `stage must be propose or cut, got` +- `release candidates start from the default branch ($default); this run is on $GITHUB_REF_NAME` +- `$VERSION is not newer than the latest tag ${latest:-}` +- `tag v$VERSION already exists` +- `branch ${PREFIX}${VERSION} already exists` +- `after bump_command, $FILE reads $actual, expected $NEW_VERSION` +- `cut runs on a merged candidate PR (pull_request closed, merged == true)` +- `candidate PR #$PR_NUMBER was opened by` +- `$FILE at $MERGE_SHA says $actual, expected $VERSION` +- `branch $branch already exists` + + +## Example + +The `propose` job on a dispatch, the `cut` job on the candidate PR merging; +both from `workflow-templates/release-candidate.yml`: + +```yaml +- uses: megaeth-labs/.github/.github/actions/release-candidate@main + with: + stage: propose # or: cut + token: ${{ steps.app-token.outputs.token }} + version: ${{ inputs.version }} # propose only + version_file: Cargo.toml + version_pattern: toml + changelog_file: CHANGELOG.md + bump_command: cargo update --workspace # propose only; needs the toolchain installed first +``` + +## Notes + +- `propose` bumps the version file, runs `bump_command` with `OLD_VERSION` + and `NEW_VERSION` set, drafts the `## vX.Y.Z` changelog entry from the + merged PR titles since the last `v*` tag, syncs the previous release's + entry from its tag, drops a stale `chore/release-candidate-X.Y.Z` branch + (unless its PR is still open, in which case that PR is updated, with a + force push of its branch), and opens the candidate PR. +- `cut` runs when that PR merges and creates `X.Y.Z` + at the merge commit; it refuses a PR not opened by `pr_author`. +- `token` must be an App installation token: a `GITHUB_TOKEN` push would not + trigger the consumer's CI on the candidate PR. +- The full flow, installation and recovery: [RELEASE.md](../RELEASE.md). diff --git a/.github/actions/release-candidate/action.yml b/.github/actions/release-candidate/action.yml index 2c9d1a4..cc435d8 100644 --- a/.github/actions/release-candidate/action.yml +++ b/.github/actions/release-candidate/action.yml @@ -69,6 +69,7 @@ inputs: required: false default: "mega-maxwell[bot]" git_user_email: + description: "Committer email for the commits this action makes." required: false default: "290560214+mega-maxwell[bot]@users.noreply.github.com" diff --git a/.github/actions/release-publish-rust-crates/README.md b/.github/actions/release-publish-rust-crates/README.md new file mode 100644 index 0000000..8f01003 --- /dev/null +++ b/.github/actions/release-publish-rust-crates/README.md @@ -0,0 +1,78 @@ +# Release ยท Publish Rust Crates + +`uses: megaeth-labs/.github/.github/actions/release-publish-rust-crates@main` + + +Publish an explicit list of workspace crates to crates.io at the release version. Verifies every listed crate's manifest version first, skips crates already published at that version (so a re-run after a partial failure finishes the rest), publishes the remainder in one `cargo publish -p โ€ฆ -p โ€ฆ` invocation โ€” Cargo orders by dependency and waits for the index between crates (requires Cargo โ‰ฅ 1.90) โ€” then polls crates.io until every crate reports the version. `dry_run` runs `cargo publish --dry-run`: full packaging and build verification, nothing uploaded. Run inside a job that has checked out the release tag and installed the toolchain the crates need. Never use `--workspace`: a crate without `publish = false` that was never meant to be published would go out with it. Guide: .github/actions/RELEASE.md in megaeth-labs/.github. + + +Family: [Release pipeline](../RELEASE.md). + +## Inputs + + +| Input | Required | Default | Description | +|---|---|---|---| +| `crates` | yes | | Newline- or comma-separated crate names, in any order. Only these are published. | +| `version` | yes | | Release version every listed crate must be at; X.Y.Z or vX.Y.Z (the tag). | +| `token` | no | | crates.io API token (CARGO_REGISTRY_TOKEN). Unused in dry_run. | +| `dry_run` | no | `false` | `true`: `cargo publish --dry-run` for every listed crate (even ones already on crates.io), publish nothing. | +| `index_timeout_seconds` | no | `600` | How long to wait for crates.io to report every published version. | +| `extra_args` | no | | Extra arguments appended to `cargo publish` (e.g. `--no-verify`). | + + +## Outputs + + +| Output | Description | +|---|---| +| `published` | Crates published by this run (comma-separated; empty in dry_run). | +| `skipped` | Crates already at this version on crates.io, skipped. | + + +## What it runs + + +1. Verify crate versions +1. Skip crates already published +1. Publish +1. Wait for crates.io to index *(only if `inputs.dry_run != 'true' && steps.publish.outputs.published != ''`)* + + +## Errors it reports + + +- `no crates listed` +- `crates.io returned $code for $c@$VERSION` +- `token is required to publish (or set dry_run: true)` +- `crates.io has not indexed $c@$VERSION after ${TIMEOUT}s` + + +## Example + +From `workflow-templates/on-release.yml`, in a job under +`environment: publish`: + +```yaml +- uses: megaeth-labs/.github/.github/actions/release-publish-rust-crates@main + with: + crates: | + my-crate-core + my-crate + version: ${{ env.TAG }} + token: ${{ env.DRY_RUN != 'true' && secrets.CARGO_REGISTRY_TOKEN || '' }} + dry_run: ${{ env.DRY_RUN }} +``` + +## Notes + +- An explicit allowlist, never `--workspace`: crates without + `publish = false` that are not meant for crates.io would otherwise go out. +- Every listed crate's manifest must already be at `version` (the candidate + PR did that); crates already on crates.io at that version are skipped, so + a re-run is safe. +- One `cargo publish -p โ€ฆ -p โ€ฆ` for the rest โ€” Cargo โ‰ฅ 1.90 orders and + waits between dependent crates โ€” then the index is polled until every + version is visible (`index_timeout_seconds`). +- `dry_run` runs `cargo publish --dry-run` for every listed crate, including + ones already published, and uploads nothing; the token is not needed. diff --git a/.github/actions/release-publish/README.md b/.github/actions/release-publish/README.md new file mode 100644 index 0000000..0f93041 --- /dev/null +++ b/.github/actions/release-publish/README.md @@ -0,0 +1,89 @@ +# Release Publish + +`uses: megaeth-labs/.github/.github/actions/release-publish@main` + + +Publish a settled release. Normally runs when a `chore/release-settle-vX.Y.Z` PR merges into its release branch (or, with `commit` + `version` given, on an explicit commit that `release-settle` in `direct` mode just made): creates the annotated tag `vX.Y.Z` at the merge commit (exactly once โ€” refuses if it exists, or if the branch moved after the settle PR was opened) and publishes the GitHub Release with the changelog section as notes. This is the only place in the release flow that creates a tag; merging the settle PR is the approval. The default branch's changelog catches up in the next release candidate PR โ€” nothing is back-merged. The consumer checks the repository out first (`fetch-depth: 0`, `persist-credentials: false`). Guide: .github/actions/RELEASE.md in megaeth-labs/.github. + + +Family: [Release pipeline](../RELEASE.md). + +## Inputs + + +| Input | Required | Default | Description | +|---|---|---|---| +| `token` | yes | | GitHub App installation token (e.g. the Maxwell app). The tag push must come from it so `push: tags` workflows (artifact builds) fire; the job's GITHUB_TOKEN would not trigger them. Also needs to be a bypass actor on the repository's tag ruleset. | +| `version_file` | yes | | File holding the version (VERSION, Cargo.toml, pyproject.toml, package.json); the same value as in release-candidate. | +| `version_pattern` | no | `plain` | How the version is stored in `version_file`: plain, toml or json. | +| `changelog_file` | no | `CHANGELOG.md` | Changelog the entry is written to (Keep-a-Changelog style, newest first). | +| `release_branch_prefix` | no | `release-v` | Release branch name prefix; the branch is `X.Y.Z`. | +| `commit` | no | | Explicit commit to publish (full SHA), with `version`. Set by `release-settle` in `direct` mode; leave empty when triggered by the settle PR merging. The PR-specific guards (head/base names, author, drift marker) do not apply in this mode. | +| `version` | no | | Version for the explicit-commit mode, X.Y.Z or vX.Y.Z. | +| `pr_author` | no | `mega-maxwell[bot]` | Login the settle PR must have been opened by โ€” the app identity `release-settle` runs under. Stops a hand-made `chore/release-settle-*` branch from reaching the tag step. Empty disables the check. | +| `git_user_name` | no | `mega-maxwell[bot]` | Committer identity for the commits this action makes. | +| `git_user_email` | no | `290560214+mega-maxwell[bot]@users.noreply.github.com` | Committer email for the commits this action makes. | + + +## Outputs + + +| Output | Description | +|---|---| +| `version` | The normalised version, X.Y.Z. | +| `tag` | The tag created, vX.Y.Z. | +| `release_url` | URL of the GitHub Release. | + + +## What it runs + + +1. Configure git auth +1. Guard +1. Create and push tag +1. Publish GitHub Release +1. Remove git auth *(only if `always()`)* + + +## Errors it reports + + +- `explicit mode needs a full 40-hex commit and a version` +- `publish runs on a merged settle PR (pull_request closed, merged == true)` +- `head branch` +- `settle PR #$PR_NUMBER was opened by` +- `settle PR for v$version merged into` +- `settle PR body has no` +- `release branch drifted: settle PR settled $settled but the branch tip at merge was $parent. Re-run release-settle on the current tip.` +- `tag v$version already exists; a release is published once` +- `$FILE at $MERGE_SHA says $actual, expected $version` + + +## Example + +PR mode, as in `workflow-templates/release-publish.yml` (triggered by the +settle PR closing; the job gate checks it merged and was opened by the app): + +```yaml +- uses: megaeth-labs/.github/.github/actions/release-publish@main + with: + token: ${{ steps.app-token.outputs.token }} + version_file: Cargo.toml + version_pattern: toml + changelog_file: CHANGELOG.md +``` + +In direct mode `release-settle` calls this action itself with `commit` and +`version`; a consumer never does. + +## Notes + +- Creates the annotated tag once (refuses if it exists) at the merge commit + in PR mode โ€” refusing if the merge's first parent is not the settled SHA + recorded in the PR, or the PR was not opened by `pr_author` โ€” or at + `commit` in explicit mode; then publishes the GitHub Release, marked + latest, with the changelog section as notes. +- The tag push comes from the app token so `push: tags` and `release` + workflows fire; the app must bypass the `v*` tag ruleset. +- This workflow must exist on the release branch (it does when the branch + was cut from a default branch that has it). diff --git a/.github/actions/release-publish/action.yml b/.github/actions/release-publish/action.yml index 6377ad3..10a0242 100644 --- a/.github/actions/release-publish/action.yml +++ b/.github/actions/release-publish/action.yml @@ -22,14 +22,18 @@ inputs: the repository's tag ruleset. required: true version_file: + description: "File holding the version (VERSION, Cargo.toml, pyproject.toml, package.json); the same value as in release-candidate." required: true version_pattern: + description: "How the version is stored in `version_file`: plain, toml or json." required: false default: plain changelog_file: + description: "Changelog the entry is written to (Keep-a-Changelog style, newest first)." required: false default: CHANGELOG.md release_branch_prefix: + description: "Release branch name prefix; the branch is `X.Y.Z`." required: false default: release-v commit: @@ -52,18 +56,23 @@ inputs: required: false default: "mega-maxwell[bot]" git_user_name: + description: "Committer identity for the commits this action makes." required: false default: "mega-maxwell[bot]" git_user_email: + description: "Committer email for the commits this action makes." required: false default: "290560214+mega-maxwell[bot]@users.noreply.github.com" outputs: version: + description: "The normalised version, X.Y.Z." value: ${{ steps.guard.outputs.version }} tag: + description: "The tag created, vX.Y.Z." value: ${{ steps.tag.outputs.tag }} release_url: + description: "URL of the GitHub Release." value: ${{ steps.release.outputs.url }} runs: diff --git a/.github/actions/release-settle/README.md b/.github/actions/release-settle/README.md new file mode 100644 index 0000000..a579ffd --- /dev/null +++ b/.github/actions/release-settle/README.md @@ -0,0 +1,110 @@ +# Release Settle + +`uses: megaeth-labs/.github/.github/actions/release-settle@main` + + +Propose settling a release candidate: verify `commit` is the tip of the release branch and carries the expected version, generate release notes from the commits since the previous tag, write them into the changelog (stamping the date onto the candidate's `## vX.Y.Z` entry), and โ€” in the default `pr` mode โ€” open a `chore/release-settle-vX.Y.Z` PR onto the release branch; merging that PR is the settlement decision and release-publish then tags the merge commit once. In `direct` mode the dispatch itself is the decision: the dispatching actor must be authorised by `settlers`, the changelog commit is pushed straight to the release branch (the app must be a bypass actor on that branch's ruleset), and release-publish runs immediately on that commit. Run as a step in a job the consumer owns; the consumer checks the repository out first (`fetch-depth: 0`, `persist-credentials: false`). Needs `gh` and `python3` on the runner. Guide: .github/actions/RELEASE.md in megaeth-labs/.github. + + +Family: [Release pipeline](../RELEASE.md). + +## Inputs + + +| Input | Required | Default | Description | +|---|---|---|---| +| `token` | yes | | GitHub App installation token (e.g. the Maxwell app). Pushes the settle branch and opens the PR so that it triggers CI. | +| `version` | yes | | Version being settled, X.Y.Z or vX.Y.Z. | +| `commit` | yes | | Full SHA to settle. Must be the current tip of the release branch: the settlement is a statement about a specific commit, and the tip is the only commit a later merge can build on. | +| `version_file` | yes | | File holding the version (VERSION, Cargo.toml, pyproject.toml, package.json); the same value as in release-candidate. | +| `version_pattern` | no | `plain` | How the version is stored in `version_file`: plain, toml or json. | +| `release_branch` | no | | Defaults to `X.Y.Z`. | +| `release_branch_prefix` | no | `release-v` | Release branch name prefix; the branch is `X.Y.Z`. | +| `changelog_file` | no | `CHANGELOG.md` | Changelog the entry is written to (Keep-a-Changelog style, newest first). | +| `settle_mode` | no | `pr` | `pr` (default): open a settle PR. `direct`: settle and publish now, from this dispatch. | +| `settlers` | no | | Who may settle in `direct` mode: comma-separated GitHub logins, and/or the keyword `admin` (the dispatching actor must have admin permission on the repository, checked with `github_token`). Required for `direct`. | +| `github_token` | no | `${{ github.token }}` | Job token used for the `admin` permission check in `direct` mode. | +| `pr_labels` | no | | Comma-separated labels for the settle PR (for repos whose label gates apply to release branches too). | +| `git_user_name` | no | `mega-maxwell[bot]` | Committer identity for the commits this action makes. | +| `git_user_email` | no | `290560214+mega-maxwell[bot]@users.noreply.github.com` | Committer email for the commits this action makes. | + + +## Outputs + + +| Output | Description | +|---|---| +| `version` | The normalised version, X.Y.Z. | +| `pr_url` | URL of the settle PR. | +| `notes_file` | Path of the generated release-notes markdown. | +| `settled_commit` | `direct` mode: the changelog commit that was tagged. | +| `tag` | `direct` mode: the tag created. | +| `release_url` | `direct` mode: the GitHub Release created. | + + +## What it runs + + +1. Configure git auth +1. Guard +1. Compare workflows with the default branch +1. Authorise the settler (direct mode) *(only if `inputs.settle_mode == 'direct'`)* +1. Generate release notes +1. Write changelog and open settle PR *(only if `inputs.settle_mode != 'direct'`)* +1. Write changelog onto the release branch (direct mode) *(only if `inputs.settle_mode == 'direct'`)* +1. Remove git auth *(only if `always()`)* +1. Tag and publish (direct mode) โ€” `megaeth-labs/.github/.github/actions/release-publish` *(only if `inputs.settle_mode == 'direct'`)* + + +## Errors it reports + + +- `commit must be a full 40-hex SHA, got` +- `release branch $branch does not exist` +- `tag v$version already exists โ€” this release is settled` +- `$COMMIT is not the tip of $branch (tip is $tip). Settle the tip, or move the branch first.` +- `$FILE at $COMMIT says $actual, expected $version` +- `$version is not newer than the latest tag ${latest:-}` +- `settle_mode: direct requires settlers (logins and/or` +- `$ACTOR is not authorised to settle directly (settlers: $SETTLERS)` +- `$CHANGELOG already carries exactly this v$VERSION entry; nothing to settle` +- `could not push the settle commit to $BRANCH โ€” the branch moved since $COMMIT, or the app is not a bypass actor on its ruleset` + + +## Example + +Direct mode, as in `workflow-templates/release-settle.yml` โ€” the job +declares `environment: release`, so the run waits for that environment's +reviewers before this step runs: + +```yaml +- uses: megaeth-labs/.github/.github/actions/release-settle@main + with: + token: ${{ steps.app-token.outputs.token }} + version: ${{ inputs.version }} + commit: ${{ inputs.commit }} # full SHA of the release-branch tip + version_file: Cargo.toml + version_pattern: toml + changelog_file: CHANGELOG.md + settle_mode: direct + settlers: admin +``` + +Leave out `settle_mode` and `settlers` (and the job's `environment:`) to +settle by PR instead. + +## Notes + +- Guards, in order: `commit` is a full SHA; the release branch exists; the + tag does not; `commit` is the branch tip; the version file at `commit` + says the version; the version is newer than the latest `v*` tag. Then a + warning if `commit` lacks a workflow the default branch has (a `release` + event runs `on-release.yml` from the tag's tree). +- `direct`: the dispatcher must match `settlers`; the dated entry is + committed straight onto the release branch (the app must bypass its + ruleset) and `release-publish` runs in the same job with `commit` and + `version` set. +- `pr`: pushes `chore/release-settle-vX.Y.Z` and opens the settle PR. A + re-run closes the previous settle PR, deletes its branch and pushes a fresh + one; nothing is force-pushed. +- The full flow, installation and recovery: [RELEASE.md](../RELEASE.md). diff --git a/.github/actions/release-settle/action.yml b/.github/actions/release-settle/action.yml index 9257396..d888600 100644 --- a/.github/actions/release-settle/action.yml +++ b/.github/actions/release-settle/action.yml @@ -31,8 +31,10 @@ inputs: only commit a later merge can build on. required: true version_file: + description: "File holding the version (VERSION, Cargo.toml, pyproject.toml, package.json); the same value as in release-candidate." required: true version_pattern: + description: "How the version is stored in `version_file`: plain, toml or json." required: false default: plain release_branch: @@ -40,9 +42,11 @@ inputs: required: false default: "" release_branch_prefix: + description: "Release branch name prefix; the branch is `X.Y.Z`." required: false default: release-v changelog_file: + description: "Changelog the entry is written to (Keep-a-Changelog style, newest first)." required: false default: CHANGELOG.md settle_mode: @@ -65,14 +69,17 @@ inputs: required: false default: "" git_user_name: + description: "Committer identity for the commits this action makes." required: false default: "mega-maxwell[bot]" git_user_email: + description: "Committer email for the commits this action makes." required: false default: "290560214+mega-maxwell[bot]@users.noreply.github.com" outputs: version: + description: "The normalised version, X.Y.Z." value: ${{ steps.guard.outputs.version }} pr_url: description: URL of the settle PR. diff --git a/.github/actions/release-tools/README.md b/.github/actions/release-tools/README.md new file mode 100644 index 0000000..7322548 --- /dev/null +++ b/.github/actions/release-tools/README.md @@ -0,0 +1,34 @@ +# release-tools + +Not an action: the pure text helpers the `release-*` actions share, kept out +of the bash steps so they can be unit tested without a repository. Stdlib +only. The actions call them as `python3 $GITHUB_ACTION_PATH/../release-tools/.py โ€ฆ`. + +## `release_tools.py` + +| Subcommand | Does | +|---|---| +| `normalize VERSION` | `X.Y.Z` on stdout (accepts `vX.Y.Z`); exit 1 if not a semantic version | +| `is-greater VERSION LATEST_TAG_OR_EMPTY` | exit 0 if `VERSION` is newer than the tag, else 1 | +| `read-file PATH PATTERN` | the version found in the file (`plain`, `toml`, `json`) | +| `bump-file PATH PATTERN VERSION` | rewrite the version in place; prints the old version | +| `notes REPO VERSION DATE` | stdin `"\t"` lines โ†’ the Markdown section: heading `## vX.Y.Z (DATE)` (no date when `DATE` is empty), commits grouped by Conventional Commit type, PR links from `(#N)` suffixes, `chore(release):` commits dropped | +| `changelog-insert PATH VERSION SECTION_MD` | insert or replace the version's section; prints `inserted` / `replaced`; keeps exactly one blank line between blocks so formatters leave the file alone | +| `changelog-extract PATH VERSION` | the section body on stdout; exit 1 if absent | +| `changelog-copy SRC DST VERSION` | copy the version's section (heading included) from one changelog into another; prints `inserted` / `replaced` / `unchanged` / `absent` | + +Version patterns: `plain` is the whole file, trimmed; `toml` is the first +line of the form `version = "โ€ฆ"` at column 0 (the `[package]` or +`[workspace.package]` version, never a dependency's); `json` is the first +`"version": "โ€ฆ"`. + +## `crates_tools.py` + +Helpers for `release-publish-rust-crates`: parse the crate list, check every +listed crate's manifest version against `cargo metadata`, and partition the +list into crates to publish and crates already on crates.io. + +## Tests + +`test_release_tools.py` and `test_crates_tools.py`, run by `actions-test.yml` +on every PR: `python3 -m unittest discover -s .github/actions/release-tools -p 'test_*.py'`. diff --git a/.github/actions/release-upload-artifact/README.md b/.github/actions/release-upload-artifact/README.md new file mode 100644 index 0000000..2af3a54 --- /dev/null +++ b/.github/actions/release-upload-artifact/README.md @@ -0,0 +1,92 @@ +# Release ยท Upload Artifact + +`uses: megaeth-labs/.github/.github/actions/release-upload-artifact@main` + + +Upload one file to Google Artifact Registry (generic repository) or a Cloud Storage bucket, idempotently: if the destination already holds a file, its hash is compared โ€” identical means "already there" (success, nothing uploaded), different means failure, never a silent overwrite. Every destination is an input; nothing is defaulted. Authentication is the caller's job: run `google-github-actions/auth` (service-account key or Workload Identity Federation) before this step so `gcloud` is authenticated. `dry_run` computes the checksum and checks the destination but uploads nothing. Guide: .github/actions/RELEASE.md in megaeth-labs/.github. + + +Family: [Release pipeline](../RELEASE.md). + +## Inputs + + +| Input | Required | Default | Description | +|---|---|---|---| +| `file` | yes | | Path of the file to upload. | +| `kind` | yes | | `artifact-registry` (generic repository) or `gcs` (bucket object). | +| `dry_run` | no | `false` | `true`: do everything except the irreversible step (upload, attach, publish), and report what would have happened. | +| `project` | no | | GCP project id (artifact-registry). | +| `location` | no | | Registry location, e.g. asia-northeast1 (artifact-registry). | +| `repository` | no | | Generic repository name (artifact-registry). | +| `package` | no | | Package name (artifact-registry). | +| `version` | no | | Package version (artifact-registry); a leading `v` is stripped, so the tag can be passed. | +| `path` | no | | Optional folder inside the package version (artifact-registry). | +| `bucket` | no | | Bucket name, without gs:// (gcs). | +| `object` | no | | Object path inside the bucket (gcs). | + + +## Outputs + + +| Output | Description | +|---|---| +| `sha256` | | +| `uri` | Where the file lives (or would live) after this step. | +| `status` | `uploaded`, `exists` (identical file already there) or `dry-run`. | + + +## What it runs + + +1. Validate inputs and hash the file +1. Upload + + +## Errors it reports + + +- `file not found: $FILE` +- `$v is required for kind artifact-registry` +- `$v is required for kind gcs` +- `kind must be artifact-registry or gcs, got` +- `gcloud is not on this runner` +- `gcloud is not authenticated โ€” run google-github-actions/auth before this step` +- `$uri already exists with a DIFFERENT file (sha256 $have vs $SHA); versions are immutable โ€” refusing` +- `$uri already exists with a DIFFERENT file (sha256 $have vs $SHA) โ€” refusing to overwrite a published artifact` + + +## Example + +Authenticate first, then one step per file: + +```yaml +- uses: google-github-actions/auth@v3 + with: + credentials_json: ${{ secrets.GCP_AUTH_KEY }} + +- uses: megaeth-labs/.github/.github/actions/release-upload-artifact@main + with: + file: target/release/my-binary + kind: artifact-registry + project: my-gcp-project + location: my-region + repository: my-generic-repository + package: my-binary + version: ${{ env.TAG }} + dry_run: ${{ env.DRY_RUN }} +``` + +For a bucket: `kind: gcs`, `bucket`, `object` instead of the registry inputs. + +## Notes + +- Idempotent by hash: an identical file at the destination is `exists` + (success, nothing uploaded); a different one is a failure, never an + overwrite. `status` and `uri` tell the caller what happened. +- Artifact Registry paths are `//`, with an optional + `path` folder inside the version; a leading `v` on `version` is stripped so + the tag can be passed. +- Authentication is the caller's: `google-github-actions/auth` (service + account key or Workload Identity) before this step. The action checks that + `gcloud` holds a usable credential rather than trusting the account list. diff --git a/.github/actions/release-upload-artifact/action.yml b/.github/actions/release-upload-artifact/action.yml index a140c0c..1a14b6f 100644 --- a/.github/actions/release-upload-artifact/action.yml +++ b/.github/actions/release-upload-artifact/action.yml @@ -19,6 +19,7 @@ inputs: description: "`artifact-registry` (generic repository) or `gcs` (bucket object)." required: true dry_run: + description: "`true`: do everything except the irreversible step (upload, attach, publish), and report what would have happened." required: false default: "false" # --- artifact-registry diff --git a/.github/actions/release-verify-version/README.md b/.github/actions/release-verify-version/README.md new file mode 100644 index 0000000..2350c52 --- /dev/null +++ b/.github/actions/release-verify-version/README.md @@ -0,0 +1,65 @@ +# Release ยท Verify Version + +`uses: megaeth-labs/.github/.github/actions/release-verify-version@main` + + +Run a command that prints a built artifact's version (`my-binary --version`) and require the output to match the release version, so a binary built from the wrong tree โ€” or a tag that does not match the manifest โ€” never reaches a publish step. By default the expected output is ` ` (the clap shape: the command's basename, then the version without its leading `v`); `expected` overrides it. A command that cannot run at all โ€” missing library, crash, no such flag โ€” fails the step even on a dry run: an artifact that does not execute is never publishable, and its exit code is never swallowed. A mismatch is a warning on a dry run, so a rehearsal reports what a real run would refuse and carries on, and a hard stop otherwise. Guide: .github/actions/RELEASE.md in megaeth-labs/.github. + + +Family: [Release pipeline](../RELEASE.md). + +## Inputs + + +| Input | Required | Default | Description | +|---|---|---|---| +| `command` | yes | | Shell command that prints the version, e.g. `target/release/my-binary --version`. Runs under `bash -o pipefail`, so a pipe to `head -n1` is fine for tools that print more. | +| `version` | yes | | The release version, `vX.Y.Z` or `X.Y.Z` (the tag is fine). | +| `expected` | no | `{name} {version}` | Expected output, trailing whitespace ignored. `{version}` expands to the version without a leading `v`, `{name}` to the basename of the command's first word. | +| `dry_run` | no | `false` | `true`: do everything except the irreversible step (upload, attach, publish), and report what would have happened. | + + +## Outputs + + +| Output | Description | +|---|---| +| `actual` | What the command printed. | + + +## What it runs + + +1. Verify the version + + +## Errors it reports + + +Its shell steps report no errors of their own; failures come from the actions and tools it calls. + + +## Example + +Between the build and the first publish step, once per binary: + +```yaml +- uses: megaeth-labs/.github/.github/actions/release-verify-version@main + with: + command: target/release/my-binary --version + version: ${{ env.TAG }} # vX.Y.Z is fine; the v is stripped + dry_run: ${{ env.DRY_RUN }} +``` + +Tools that print more than `name version` get `expected`, e.g. +`expected: "v{version}"` or a `command` piped through `head -n1`. + +## Notes + +- A command that exits non-zero fails the step even on a dry run: an + artifact that does not run is never publishable, and its exit code is never + swallowed (stderr goes to the log). +- A mismatch is a warning on a dry run and a failure otherwise. +- Only flags the binary actually answers work: a workspace that builds clap + without its `help` feature has `--version` (where `version` is declared) + but no `--help`. diff --git a/.github/actions/release-verify-version/action.yml b/.github/actions/release-verify-version/action.yml index d20e7a6..a102ee6 100644 --- a/.github/actions/release-verify-version/action.yml +++ b/.github/actions/release-verify-version/action.yml @@ -31,6 +31,7 @@ inputs: required: false default: "{name} {version}" dry_run: + description: "`true`: do everything except the irreversible step (upload, attach, publish), and report what would have happened." required: false default: "false" diff --git a/.github/scripts/action_docs.py b/.github/scripts/action_docs.py new file mode 100755 index 0000000..6f369cf --- /dev/null +++ b/.github/scripts/action_docs.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +"""Render the generated parts of the action documentation from action.yml. + +Every action directory under .github/actions/ carries a README.md whose +hand-written prose surrounds blocks delimited by + + + ... + + +This script rewrites the content of those blocks from the action's +action.yml โ€” its description, inputs, outputs, the steps it runs and the +errors its shell steps report โ€” and the catalogue block in +.github/actions/README.md from every action's name and description. The prose +outside the blocks is never touched. `--check` exits 1 if any file would +change, which is how actions-test.yml keeps the docs honest. + +stdlib only, except PyYAML (present on GitHub-hosted runners). +""" +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[2] +ACTIONS = ROOT / ".github" / "actions" +FAMILIES = { # directory prefix -> (family name, guide) + "release-": ("Release pipeline", "RELEASE.md"), + "claude-": ("Claude CI", "CLAUDE-CI.md"), +} +BLOCK = re.compile(r"(\n)(?P.*?)()", re.S) + + +def family_of(directory: str) -> tuple[str, str | None]: + for prefix, (name, guide) in FAMILIES.items(): + if directory.startswith(prefix): + return name, guide + return "Standalone", None + + +def one_line(text: object) -> str: + return " ".join(str(text or "").split()) + + +def cell(text: str) -> str: + return text.replace("|", "\\|") + + +def load_action(directory: Path) -> dict: + return yaml.safe_load((directory / "action.yml").read_text()) + + +def render_inputs(action: dict) -> str: + inputs = action.get("inputs") or {} + if not inputs: + return "This action takes no inputs.\n" + rows = ["| Input | Required | Default | Description |", "|---|---|---|---|"] + for name, spec in inputs.items(): + spec = spec or {} + required = "yes" if spec.get("required") else "no" + default = spec.get("default", "") + default = "" if default in (None, "") else f"`{default}`" + rows.append(f"| `{name}` | {required} | {cell(default)} | {cell(one_line(spec.get('description')))} |") + return "\n".join(rows) + "\n" + + +def render_outputs(action: dict) -> str: + outputs = action.get("outputs") or {} + if not outputs: + return "This action sets no outputs.\n" + rows = ["| Output | Description |", "|---|---|"] + for name, spec in outputs.items(): + rows.append(f"| `{name}` | {cell(one_line((spec or {}).get('description')))} |") + return "\n".join(rows) + "\n" + + +def render_steps(action: dict) -> str: + steps = ((action.get("runs") or {}).get("steps")) or [] + if not steps: + return "This action runs no steps of its own.\n" + lines = [] + for step in steps: + label = step.get("name") + if not label and "uses" in step: + label = f"`{step['uses'].split('@')[0]}`" + if not label: + label = "(unnamed run step)" + if "uses" in step and step.get("name"): + label += f" โ€” `{step['uses'].split('@')[0]}`" + cond = one_line(step.get("if")) + if cond: + label += f" *(only if `{cond}`)*" + lines.append(f"1. {label}") + return "\n".join(lines) + "\n" + + +def render_errors(action: dict) -> str: + steps = ((action.get("runs") or {}).get("steps")) or [] + seen: list[str] = [] + for step in steps: + for match in re.finditer(r'::error::(.*?)(?:"|\'|$)', str(step.get("run", "")), re.M): + message = match.group(1).strip() + if message and message not in seen: + seen.append(message) + if not seen: + return "Its shell steps report no errors of their own; failures come from the actions and tools it calls.\n" + return "\n".join(f"- `{cell(m)}`" for m in seen) + "\n" + + +def render_description(action: dict) -> str: + return one_line(action.get("description")) + "\n" + + +RENDERERS = { + "description": render_description, + "inputs": render_inputs, + "outputs": render_outputs, + "steps": render_steps, + "errors": render_errors, +} + + +def fill(text: str, renderers: dict, context) -> str: + def replace(match: re.Match) -> str: + name = match.group("name") + if name not in renderers: + raise SystemExit(f"unknown generated block '{name}'") + return f"{match.group(1)}{renderers[name](context)}{match.group(4)}" + + return BLOCK.sub(replace, text) + + +def skeleton(directory: Path, action: dict) -> str: + family, guide = family_of(directory.name) + guide_line = f"Family: [{family}](../{guide})." if guide else "Standalone action; no family guide." + return f"""# {action.get('name', directory.name)} + +`uses: megaeth-labs/.github/.github/actions/{directory.name}@main` + + + + +{guide_line} + +## Inputs + + + + +## Outputs + + + + +## What it runs + + + + +## Errors it reports + + + + +## Example + +(hand-written) + +## Notes + +(hand-written) +""" + + +def render_catalogue(_: object) -> str: + rows = ["| Action | Family | Does |", "|---|---|---|"] + for directory in sorted(p for p in ACTIONS.iterdir() if (p / "action.yml").exists()): + action = load_action(directory) + family, guide = family_of(directory.name) + family_cell = f"[{family}]({guide})" if guide else family + rows.append(f"| [`{directory.name}`]({directory.name}/README.md) | {family_cell} | {cell(one_line(action.get('description')))} |") + return "\n".join(rows) + "\n" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("--check", action="store_true", help="exit 1 if any file would change") + args = parser.parse_args() + changed: list[Path] = [] + for directory in sorted(p for p in ACTIONS.iterdir() if (p / "action.yml").exists()): + readme = directory / "README.md" + action = load_action(directory) + before = readme.read_text() if readme.exists() else skeleton(directory, action) + after = fill(before, RENDERERS, action) + if after != (readme.read_text() if readme.exists() else None): + changed.append(readme) + if not args.check: + readme.write_text(after) + catalogue = ACTIONS / "README.md" + before = catalogue.read_text() + after = fill(before, {"catalogue": render_catalogue}, None) + if after != before: + changed.append(catalogue) + if not args.check: + catalogue.write_text(after) + if args.check and changed: + print("generated documentation is stale; run .github/scripts/action_docs.py:", file=sys.stderr) + for path in changed: + print(f" {path.relative_to(ROOT)}", file=sys.stderr) + return 1 + for path in changed: + print(f"updated {path.relative_to(ROOT)}") + if not changed: + print("documentation up to date") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/actions-test.yml b/.github/workflows/actions-test.yml index 5ce27e3..9760074 100644 --- a/.github/workflows/actions-test.yml +++ b/.github/workflows/actions-test.yml @@ -109,3 +109,20 @@ jobs: [[ "$MISMATCH" == "failure" ]] || { echo "mismatch on a release should fail, got $MISMATCH"; exit 1; } [[ "$BROKEN" == "failure" ]] || { echo "a broken probe should fail on a dry run too, got $BROKEN"; exit 1; } echo "all outcomes as documented" + + # The generated parts of every action README (inputs, outputs, steps, + # errors) and the catalogue come from action.yml; stale docs fail here. + docs: + runs-on: ubuntu-24.04 + permissions: + contents: read + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Check generated action documentation + run: | + python3 -m pip install --quiet pyyaml + python3 .github/scripts/action_docs.py --check diff --git a/README.md b/README.md index a992386..4a38c19 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,18 @@ Organisation-wide CI for megaeth-labs: composite actions consumed at `@main` by every repository, and the workflow templates that show up under "New workflow โ†’ By megaeth-labs". +- [`.github/actions/README.md`](.github/actions/README.md) โ€” the catalogue of + shared actions, how to consume one, how to change one. - [`.github/actions/RELEASE.md`](.github/actions/RELEASE.md) โ€” the release - pipeline: candidate, settle, publish, publish targets; how it works, how to - install it in a repository, options, recovery. -- [`.github/actions/README.md`](.github/actions/README.md) โ€” the Claude CI - actions: PR review, label check, issue triage, interactive. + pipeline: candidate, settle, publish, publish targets. +- [`.github/actions/CLAUDE-CI.md`](.github/actions/CLAUDE-CI.md) โ€” the Claude + checks: PR review, label check, issue triage, interactive. +- `.github/actions//README.md` โ€” one reference per action; the + input, output, step and error tables are generated from `action.yml` by + [`.github/scripts/action_docs.py`](.github/scripts/action_docs.py). - [`workflow-templates/`](workflow-templates/) โ€” the reference callers. - [`profile/`](profile/) โ€” the organisation profile page. `actions-test.yml` is the only gate between a change here and every -consumer's CI; it runs the unit tests of the text helpers and drives the -actions that can be exercised without a repository. +consumer's CI: unit tests of the helpers, an end-to-end drive of the actions +that can run without a repository, and the documentation check.