From 828518b9257ff97d829ad38f6217a964f3232817 Mon Sep 17 00:00:00 2001 From: Asen Lekov Date: Tue, 18 Aug 2026 20:44:13 +0300 Subject: [PATCH 1/5] feat(actions): extract the agent gate, the agent runner and stack setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three reusable workflows each carried their own copy of the same gate, the same claude-code-action call, and (in deps-verify) its own toolchain setup. Three copies of a rule is three chances for one of them to be subtly wrong, and one already was: agent:no-touch was correct in all three only because three authors each remembered to put it first. actions/agent-gate evaluates agent:no-touch FIRST, structurally, in one place. Its logic lives in gate.sh rather than inline YAML so it can be run without a runner, and test.sh asserts the kill switch across all five workflow shapes — 22 cases, including no-touch beating workflow_dispatch, an explicit @claude command and a maintainer label. DODI-00008 was a claim about behaviour; it is now a test. actions/run-agent centralises the two footguns that cost real debugging time: --allowed-tools is variadic (a comma-joined list silently denies every Bash call and still reports success), and display_report defaults to false (a run that did nothing looks like a run that worked). Both are now impossible to get wrong. actions/setup-stack makes the stack a VALUE instead of a template. The old shape needed a thin-caller file per stack, which does not survive five stacks across three workflows — and the Gradle one had already grown a hardcoded assembleInfrasensingDebug, one product's task name in a template meant to be copied. Package caches are job-scoped here because a persistent runner shares them and a half-written entry poisons every later run, which restore-keys then faithfully restores. deps-verify takes `stack` instead of `setup`, with a deprecated alias so callers pinned at @main keep working until they are migrated. Commands default to the sentinel "@stack" so an explicit "" can still mean "skip this step" — a Gradle repo genuinely has no install step, and a plain default cannot express both. pick-runner stops failing silently. A missing GH_APP_CLIENT_ID meant validation fell back to a repo-scoped token that cannot read the org runner list, printed "skipping", and exited 0 — indistinguishable from a clean pass. That absence is now a warning that says what it costs. GH_APP_CLIENT_ID stays the single name: upstream has deprecated app-id in favour of client-id, so repos still on GH_APP_ID are migrated rather than accommodated. Adds self-test.yml, so the repo that defines the org's CI finally has some. Why: three copies of the kill switch is not one rule; and a template per stack does not reach android, flutter, ios and rust. Refs: DODI-00005, DODI-00008, DODI-00010, DODI-00012 Co-Authored-By: Claude Opus 5 --- .github/workflows/deps-verify.yml | 211 ++++++++++++++++------------- .github/workflows/issue-triage.yml | 145 +++++++++----------- .github/workflows/pick-runner.yml | 20 ++- .github/workflows/pr-review.yml | 86 ++++++------ .github/workflows/self-test.yml | 65 +++++++++ actions/agent-gate/action.yml | 82 +++++++++++ actions/agent-gate/gate.sh | 87 ++++++++++++ actions/agent-gate/test.sh | 68 ++++++++++ actions/run-agent/action.yml | 84 ++++++++++++ actions/setup-stack/action.yml | 187 +++++++++++++++++++++++++ 10 files changed, 819 insertions(+), 216 deletions(-) create mode 100644 .github/workflows/self-test.yml create mode 100644 actions/agent-gate/action.yml create mode 100755 actions/agent-gate/gate.sh create mode 100755 actions/agent-gate/test.sh create mode 100644 actions/run-agent/action.yml create mode 100644 actions/setup-stack/action.yml diff --git a/.github/workflows/deps-verify.yml b/.github/workflows/deps-verify.yml index 380e7fe..4df5792 100644 --- a/.github/workflows/deps-verify.yml +++ b/.github/workflows/deps-verify.yml @@ -9,22 +9,41 @@ name: Dependency Verification on: workflow_call: inputs: - # Runner selection, passed through to the shared picker. Override per repo - # when the preset does not fit this stack — e.g. an Android build needing an - # SDK-bearing image, or a job needing the `docker` label. runner-weight: type: string default: "heavy" # light | heavy | apple | hosted runner-labels: type: string default: "" # explicit selector; wins over runner-weight - install: { type: string, default: "bun install --frozen-lockfile" } - build: { type: string, default: "bun run build" } - typecheck: { type: string, default: "bun run typecheck" } - test: { type: string, default: "bun run test:run" } + + # WHICH TOOLCHAIN. One input, not one template per stack — see + # actions/setup-stack for why. + # bun | node | gradle | android | flutter | rust | xcode | none + stack: + type: string + default: "" + # DEPRECATED alias for `stack`, kept so callers written against the older + # shape keep working. `java` mapped to what is now `gradle`. Remove once + # every repo has moved. + setup: + type: string + default: "" + + # Commands. The sentinel "@stack" means "whatever this stack conventionally + # uses"; an explicit empty string means "skip this step". Those are + # genuinely different intentions and a plain default cannot express both — + # a Gradle repo really does have no install step. + install: { type: string, default: "@stack" } + build: { type: string, default: "@stack" } + typecheck: { type: string, default: "@stack" } + test: { type: string, default: "@stack" } smoke: { type: string, default: "" } - setup: { type: string, default: "bun" } # bun | node | java | none - java-version: { type: string, default: "21" } + + java-version: { type: string, default: "21" } + node-version: { type: string, default: "24" } + bun-version: { type: string, default: "latest" } + flutter-version: { type: string, default: "" } + rust-toolchain: { type: string, default: "stable" } secrets: CLAUDE_CODE_OAUTH_TOKEN: { required: true } GH_APP_CLIENT_ID: { required: false } @@ -39,35 +58,41 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 outputs: - proceed: ${{ steps.c.outputs.proceed }} + proceed: ${{ steps.gate.outputs.proceed }} + stack: ${{ steps.stack.outputs.stack }} steps: - - id: c + # `bots: only` is the inverse of every other agent workflow: this one runs + # for Renovate and Dependabot and nothing else. It also absorbs the author + # check every caller used to carry in its own `if:`, byte-identically. + - id: gate + uses: dodi-smart/.github/actions/agent-gate@main + with: + labels: ${{ toJson(github.event.pull_request.labels.*.name) }} + event-name: ${{ github.event_name }} + event-action: ${{ github.event.action }} + label: ${{ github.event.label.name }} + request-label: 'agent:review' + author: ${{ github.event.pull_request.user.login }} + draft: ${{ github.event.pull_request.draft }} + bots: 'only' + + - id: stack env: - AUTHOR: ${{ github.event.pull_request.user.login }} - DRAFT: ${{ github.event.pull_request.draft }} - LABELS: ${{ toJson(github.event.pull_request.labels.*.name) }} - ACTION: ${{ github.event.action }} - LABEL: ${{ github.event.label.name }} + STACK: ${{ inputs.stack }} + SETUP: ${{ inputs.setup }} run: | set -euo pipefail - proceed=false - if printf '%s' "$LABELS" | grep -q '"agent:no-touch"'; then - echo "agent:no-touch — stopping" - elif [ "$DRAFT" = "true" ]; then - echo "draft — stopping" - elif printf '%s' "$AUTHOR" | grep -qiE 'renovate|dependabot'; then - proceed=true - else - echo "not a bot dependency PR — stopping" + s="$STACK" + if [ -z "$s" ] && [ -n "$SETUP" ]; then + case "$SETUP" in + java) s=gradle ;; + *) s="$SETUP" ;; + esac + echo "::warning title=Deprecated input::'setup: $SETUP' is deprecated; use 'stack: $s'. The alias will be removed once every repo has moved." fi - - # A `labeled` event only proceeds for the re-run label. Without this, - # ANY label added to a Renovate PR would re-run the whole build. - if [ "$ACTION" = "labeled" ] && [ "$LABEL" != "agent:review" ]; then - proceed=false - echo "labeled '$LABEL' — only agent:review re-runs verification" - fi - echo "proceed=$proceed" >> "$GITHUB_OUTPUT" + [ -z "$s" ] && s=none + echo "stack=$s" >> "$GITHUB_OUTPUT" + echo "stack: $s" pick-runner: needs: [gate] @@ -79,7 +104,7 @@ jobs: secrets: inherit # Deterministic gates FIRST. If the build is red, the compiler has already - # explained why and an agent adds nothing but cost. + # explained why and an agent adds nothing but cost (DODI-00005). build: needs: [gate, pick-runner] if: needs.gate.outputs.proceed == 'true' @@ -89,52 +114,71 @@ jobs: verdict: ${{ steps.result.outputs.verdict }} steps: - uses: actions/checkout@v7 - - if: inputs.setup == 'bun' - uses: oven-sh/setup-bun@v2 - - if: inputs.setup == 'node' - uses: actions/setup-node@v4 - with: { node-version: '24' } - - if: inputs.setup == 'java' - uses: actions/setup-java@v5 + + - id: setup + uses: dodi-smart/.github/actions/setup-stack@main with: - java-version: ${{ inputs.java-version }} - distribution: temurin - - if: inputs.setup == 'java' - uses: gradle/actions/setup-gradle@v4 + stack: ${{ needs.gate.outputs.stack }} + java-version: ${{ inputs.java-version }} + node-version: ${{ inputs.node-version }} + bun-version: ${{ inputs.bun-version }} + flutter-version: ${{ inputs.flutter-version }} + rust-toolchain: ${{ inputs.rust-toolchain }} + + - id: cmd + env: + IN_INSTALL: ${{ inputs.install }} + IN_BUILD: ${{ inputs.build }} + IN_TYPECHECK: ${{ inputs.typecheck }} + IN_TEST: ${{ inputs.test }} + D_INSTALL: ${{ steps.setup.outputs.install }} + D_BUILD: ${{ steps.setup.outputs.build }} + D_TYPECHECK: ${{ steps.setup.outputs.typecheck }} + D_TEST: ${{ steps.setup.outputs.test }} + run: | + set -euo pipefail + # "@stack" -> the stack's conventional command. Anything else, empty + # string included, is taken literally. + pick() { if [ "$1" = "@stack" ]; then printf '%s' "$2"; else printf '%s' "$1"; fi; } + { + echo "install=$(pick "$IN_INSTALL" "$D_INSTALL")" + echo "build=$(pick "$IN_BUILD" "$D_BUILD")" + echo "typecheck=$(pick "$IN_TYPECHECK" "$D_TYPECHECK")" + echo "test=$(pick "$IN_TEST" "$D_TEST")" + } >> "$GITHUB_OUTPUT" - id: steps continue-on-error: true env: - # Job-scoped package caches. On a persistent self-hosted runner the - # default caches are shared between jobs, and a half-written entry - # poisons later runs — `Fail extracting tarball for "next"`, which - # re-runs do NOT clear. Isolating costs a cold install and buys - # determinism, which is the whole point of a verification job. - BUN_INSTALL_CACHE_DIR: ${{ runner.temp }}/bun-cache - GRADLE_USER_HOME: ${{ runner.temp }}/gradle-home + INSTALL: ${{ steps.cmd.outputs.install }} + BUILD: ${{ steps.cmd.outputs.build }} + TYPECHECK: ${{ steps.cmd.outputs.typecheck }} + TEST: ${{ steps.cmd.outputs.test }} + SMOKE: ${{ inputs.smoke }} run: | set -x fail="" run() { [ -z "$2" ] && return 0; eval "$2" || fail="$fail $1"; } - run install "${{ inputs.install }}" - run build "${{ inputs.build }}" - run typecheck "${{ inputs.typecheck }}" - run test "${{ inputs.test }}" - run smoke "${{ inputs.smoke }}" + run install "$INSTALL" + run build "$BUILD" + run typecheck "$TYPECHECK" + run test "$TEST" + run smoke "$SMOKE" echo "failed=${fail# }" >> "$GITHUB_OUTPUT" [ -z "$fail" ] - id: result + env: + FAILED: ${{ steps.steps.outputs.failed }} run: | - f="${{ steps.steps.outputs.failed }}" - if [ -z "$f" ]; then + if [ -z "${FAILED:-}" ]; then echo "verdict=green" >> "$GITHUB_OUTPUT" else - echo "verdict=red:$f" >> "$GITHUB_OUTPUT" + echo "verdict=red:$FAILED" >> "$GITHUB_OUTPUT" # The steps are continue-on-error so the agent still gets to explain # the failure, which means this job goes green regardless. Annotate, # or the run summary claims a build passed when it did not. - echo "::warning title=Dependency build failed::Failed steps:$f — see the verdict comment on the PR." + echo "::warning title=Dependency build failed::Failed steps: $FAILED — see the verdict comment on the PR." fi review: @@ -148,37 +192,22 @@ jobs: id-token: write steps: - uses: actions/checkout@v7 - - uses: anthropics/claude-code-action@v1 + + # Sonnet: this job reads changelogs and greps for usage against a verdict + # the deterministic gate already produced. Mechanical work against a known + # answer, not judgement. + # + # allowed-bots is load-bearing: without it the action aborts with + # "Workflow initiated by non-human actor: renovate (type: Bot)". EVERY run + # here is bot-initiated, so the default makes this workflow impossible. + # Named explicitly rather than '*' — the point is to trust these two bots. + - uses: dodi-smart/.github/actions/run-agent@main with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - use_sticky_comment: true - # Without this the action aborts: "Workflow initiated by non-human - # actor: renovate (type: Bot)". Every run here is bot-initiated, so the - # default makes the workflow impossible. Named explicitly rather than - # '*' — the point is to trust these two bots, not all of them. - allowed_bots: "renovate[bot],dependabot[bot],app/renovate" - # `--allowed-tools ` is VARIADIC — space-separated, each - # entry quoted if it contains parentheses. A comma-joined list parses - # as one meaningless token: nothing matches, every Bash call is denied - # with "This command requires approval", and the job still reports - # SUCCESS. That cost three runs before show_full_output revealed it. - # - # Bash is allowed broadly, and `gh` with it. The bound that makes this - # reasonable is not the allowlist — the action refuses to run for an - # actor without write access ("Actor has write access"), so untrusted - # issue or PR content only reaches the agent when someone trusted - # invokes it. Same profile as running a review agent on a PR. - # - # Two guards still apply and must not be removed: `agent:no-touch` is - # checked before every other condition (DODI-00008), and public repos - # and fork PRs never touch a self-hosted runner (DODI-00010). - # Sonnet: this job reads changelogs and greps for usage against a - # verdict the deterministic gate already produced. Mechanical work - # against a known answer, not judgement. - claude_args: >- - --model claude-sonnet-5 - --allowed-tools Bash "Bash(gh:*)" Read Grep Glob WebFetch - display_report: true + oauth-token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + model: claude-sonnet-5 + sticky-comment: 'true' + allowed-bots: "renovate[bot],dependabot[bot],app/renovate" + allowed-tools: 'Bash "Bash(gh:*)" Read Grep Glob WebFetch' prompt: | Verify dependency PR #${{ github.event.pull_request.number }} in ${{ github.repository }}. diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml index 3015d8a..69679fb 100644 --- a/.github/workflows/issue-triage.yml +++ b/.github/workflows/issue-triage.yml @@ -3,16 +3,21 @@ # Called by a thin wrapper in each repo: # jobs: # triage: -# uses: dodi-smart/.github/.github/workflows/issue-triage.yml@main +# uses: dodi-smart/.github/.github/workflows/issue-triage.yml@v1 # secrets: inherit +# +# TRIGGERS: the `agent:triage` label, or a `@claude triage` / `@claude plan` +# comment. The bare `/triage` and `/plan` forms were removed — three surfaces +# requesting one action meant the exclusion guard in every repo's claude.yml had +# to be hand-synced with this list, and it had already drifted between repos. +# The verb list now lives in the `commands:` input below and nowhere else. name: Issue Triage on: workflow_call: inputs: # Runner selection, passed through to the shared picker. Override per repo - # when the preset does not fit this stack — e.g. an Android build needing an - # SDK-bearing image, or a job needing the `docker` label. + # when the preset does not fit this stack. runner-weight: type: string default: "light" # light | heavy | apple | hosted @@ -24,74 +29,63 @@ on: GH_APP_CLIENT_ID: { required: false } GH_APP_PRIVATE_KEY: { required: false } -# One triage per issue. A second /triage supersedes the first rather than racing it. +# One triage per issue. A second request supersedes the first rather than racing it. concurrency: group: triage-${{ github.repository }}-${{ github.event.issue.number }} cancel-in-progress: true jobs: # Gate BEFORE picking a runner: the picker itself costs a hosted minute, so it - # must never run for an event that will not proceed. + # must never run for an event that will not proceed (DODI-00005). gate: runs-on: ubuntu-latest timeout-minutes: 5 outputs: - proceed: ${{ steps.check.outputs.proceed }} - mode: ${{ steps.check.outputs.mode }} + proceed: ${{ steps.bots.outputs.proceed }} + mode: ${{ steps.bots.outputs.mode }} steps: - - id: check + # agent:no-touch is evaluated inside this action, first and unconditionally + # (DODI-00008). Nothing below may run ahead of it. No checkout is needed: + # the gate reads event payload only, which is why it is cheap enough to + # run before the runner picker. + - id: gate + uses: dodi-smart/.github/actions/agent-gate@main + with: + labels: ${{ toJson(github.event.issue.labels.*.name) }} + event-name: ${{ github.event_name }} + event-action: ${{ github.event.action }} + label: ${{ github.event.label.name }} + request-label: 'agent:triage' + author: ${{ github.event.issue.user.login }} + comment: ${{ github.event.comment.body }} + commands: 'triage plan' + bots: 'allow' + skip-draft: 'false' + + # Bots open issues at machine volume. The Renovate Dependency Dashboard is + # the one bot issue worth reading, and it is a STANDING issue — so triage + # it once on open and never again. This only ever narrows the gate above; + # it can never re-open a run the kill switch stopped. + - id: bots env: - EVENT: ${{ github.event_name }} - ACTION: ${{ github.event.action }} - LABEL: ${{ github.event.label.name }} + PROCEED: ${{ steps.gate.outputs.proceed }} + MODE: ${{ steps.gate.outputs.mode }} AUTHOR: ${{ github.event.issue.user.login }} - COMMENT: ${{ github.event.comment.body }} - LABELS: ${{ toJson(github.event.issue.labels.*.name) }} TITLE: ${{ github.event.issue.title }} + ACTION: ${{ github.event.action }} run: | set -euo pipefail - proceed=false; mode=triage - - # agent:no-touch is absolute and evaluated before anything else (DODI-00008). - if printf '%s' "$LABELS" | grep -q '"agent:no-touch"'; then - echo "agent:no-touch present — stopping." - echo "proceed=false" >> "$GITHUB_OUTPUT"; echo "mode=$mode" >> "$GITHUB_OUTPUT"; exit 0 + proceed="$PROCEED" + if [ "$proceed" = "true" ] && printf '%s' "$AUTHOR" | grep -qiE 'renovate|dependabot|\[bot\]$'; then + proceed=false + if printf '%s' "$TITLE" | grep -qi 'dependency dashboard' && [ "$ACTION" = "opened" ]; then + proceed=true + else + echo "bot-authored issue that is not a newly opened Dependency Dashboard — stopping" + fi fi - - case "$EVENT" in - issues) - # Applying `agent:triage` is the primary way to ask for triage: - # it needs no new account (GitHub Apps cannot be assignees), and - # the label doubles as the progress indicator — present means - # running, and the agent removes it when finished. - if [ "$ACTION" = "labeled" ]; then - if [ "$LABEL" = "agent:triage" ]; then proceed=true; fi - echo "proceed=$proceed" >> "$GITHUB_OUTPUT" - echo "mode=$mode" >> "$GITHUB_OUTPUT" - exit 0 - fi - # Bots open issues at machine volume. The Renovate Dependency - # Dashboard is the one bot issue worth reading, and it is a - # standing issue, so triage it once on open and never again. - if printf '%s' "$AUTHOR" | grep -qiE 'renovate|dependabot|\[bot\]$'; then - if printf '%s' "$TITLE" | grep -qi 'dependency dashboard' && [ "$ACTION" = "opened" ]; then - proceed=true - fi - else - proceed=true - fi - ;; - issue_comment) - case "$COMMENT" in - */triage*) proceed=true; mode=triage ;; - */plan*) proceed=true; mode=plan ;; - esac - ;; - workflow_dispatch) proceed=true ;; - esac - - echo "proceed=$proceed" >> "$GITHUB_OUTPUT" - echo "mode=$mode" >> "$GITHUB_OUTPUT" + echo "proceed=$proceed" >> "$GITHUB_OUTPUT" + echo "mode=${MODE:-triage}" >> "$GITHUB_OUTPUT" pick-runner: needs: [gate] @@ -125,35 +119,15 @@ jobs: echo ' say it is missing and apply none)'; echo '__EOF__'; } >> "$GITHUB_OUTPUT" fi - - uses: anthropics/claude-code-action@v1 + # Opus for triage: it reads unfamiliar code and decides plan-vs-questions, + # which is judgement work. Reviewing a diff is not the same job. + - uses: dodi-smart/.github/actions/run-agent@main with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - # `--allowed-tools ` is VARIADIC — space-separated, each - # entry quoted if it contains parentheses. A comma-joined list parses - # as one meaningless token: nothing matches, every Bash call is denied - # with "This command requires approval", and the job still reports - # SUCCESS. That cost three runs before show_full_output revealed it. - # - # Bash is allowed broadly, and `gh` with it. The bound that makes this - # reasonable is not the allowlist — the action refuses to run for an - # actor without write access ("Actor has write access"), so untrusted - # issue or PR content only reaches the agent when someone trusted - # invokes it. Same profile as running a review agent on a PR. - # - # Two guards still apply and must not be removed: `agent:no-touch` is - # checked before every other condition (DODI-00008), and public repos - # and fork PRs never touch a self-hosted runner (DODI-00010). - # Opus for triage: it reads unfamiliar code and decides plan-vs-questions, - # which is judgement work. Reviewing a diff is not the same job. - claude_args: >- - --model claude-opus-5 - --allowed-tools Bash "Bash(gh:*)" Read Grep Glob - # Without this the agent's output never reaches the log, and a run that - # did nothing is indistinguishable from one that worked. - display_report: true + oauth-token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + model: claude-opus-5 # Live progress comment, so a run in flight is visible rather than a # silence that could equally mean "working" or "broken". - track_progress: true + track-progress: 'true' prompt: | Triage issue #${{ github.event.issue.number }} in ${{ github.repository }}. Mode: ${{ needs.gate.outputs.mode }} @@ -187,7 +161,6 @@ jobs: approach, the test plan, the risks, and a rough effort. Reference real paths you actually opened. Then set: Triage state = Plan ready - and add the `agent:plan` label. **If no** — do NOT write a speculative plan. Post numbered blocking questions in exactly two groups: @@ -202,6 +175,16 @@ jobs: Prefer questions over a guessed plan. A confident plan built on an assumption you never checked costs far more than one extra round-trip. + ## Labels are requests; fields are state + + Set `Triage state` and STOP THERE. Do NOT add an `agent:plan` label — + it no longer exists. The field already says the issue is planned, and + writing the same fact twice means the two disagree within weeks + (DODI-00001, DODI-00016). + + `Triage state = Plan ready` is what makes an issue eligible for + `agent:implement`, so setting it accurately is the whole contract. + ## Fields Set Priority, Effort and Risk only where you can justify them from what diff --git a/.github/workflows/pick-runner.yml b/.github/workflows/pick-runner.yml index 6c9b718..4385c90 100644 --- a/.github/workflows/pick-runner.yml +++ b/.github/workflows/pick-runner.yml @@ -112,13 +112,27 @@ jobs: echo "resolved selector: $sel" fi + # GH_APP_CLIENT_ID is the ONE name for this secret across the org. A repo + # carrying the older GH_APP_ID must be migrated rather than accommodated + # here — teaching this workflow two names is how you end up with repos on + # both forever. + # + # Absence is announced. It used to be silent, and that was the actual bug: + # without the app token, validation below falls back to github.token, + # which is repo-scoped and cannot read /orgs/{org}/actions/runners. The + # step then reported "skipping" and exited 0 — indistinguishable from a + # clean pass, which is precisely the invisibility DODI-00012 exists to end. - id: have-app if: steps.resolve.outputs.hosted == 'false' env: CLIENT_ID: ${{ secrets.GH_APP_CLIENT_ID }} run: | - if [ -n "${CLIENT_ID:-}" ]; then echo "yes=true" >> "$GITHUB_OUTPUT"; - else echo "yes=false" >> "$GITHUB_OUTPUT"; fi + if [ -n "${CLIENT_ID:-}" ]; then + echo "yes=true" >> "$GITHUB_OUTPUT" + else + echo "yes=false" >> "$GITHUB_OUTPUT" + echo "::warning title=Runner selector cannot be validated::GH_APP_CLIENT_ID is not set on this repository, so the org runner list cannot be read. The job will still run, but a selector matching no runner will fall back to hosted WITHOUT telling you. Add GH_APP_CLIENT_ID and GH_APP_PRIVATE_KEY to fix (see DODI-00012)." + fi - id: app-token if: steps.have-app.outputs.yes == 'true' @@ -142,7 +156,7 @@ jobs: runners=$(gh api "/orgs/$ORG/actions/runners" --paginate \ --jq '.runners[] | {name,labels:[.labels[].name]}' 2>/dev/null | jq -s '.' || echo '[]') if [ "$(jq 'length' <<<"$runners")" = "0" ]; then - echo "::notice::could not read the org runner list; skipping selector validation" + echo "::warning title=Runner selector was NOT validated::Could not read the org runner list, so '$SEL' was not checked against the live fleet. A selector matching nothing looks exactly like a busy fleet from here. Most often this means the GitHub App token is missing or lacks org scope." exit 0 fi diff --git a/.github/workflows/pr-review.yml b/.github/workflows/pr-review.yml index 4d1fd02..1005ee2 100644 --- a/.github/workflows/pr-review.yml +++ b/.github/workflows/pr-review.yml @@ -12,9 +12,6 @@ name: PR Review on: workflow_call: inputs: - # Runner selection, passed through to the shared picker. Override per repo - # when the preset does not fit this stack — e.g. an Android build needing an - # SDK-bearing image, or a job needing the `docker` label. runner-weight: type: string default: "light" # light | heavy | apple | hosted @@ -64,17 +61,35 @@ jobs: actions: read pull-requests: read outputs: - proceed: ${{ steps.c.outputs.proceed }} - level: ${{ steps.c.outputs.level }} - reason: ${{ steps.c.outputs.reason }} + proceed: ${{ steps.level.outputs.proceed }} + level: ${{ steps.level.outputs.level }} + reason: ${{ steps.level.outputs.reason }} ci: ${{ steps.ci.outputs.summary }} steps: - - uses: actions/checkout@v7 + # Payload-only rules first, before the checkout — agent:no-touch, drafts + # and bot PRs are decided without fetching anything (DODI-00008, -00005). + # Bot dependency PRs are rejected here because deps-verify owns them; + # reviewing them in both places double-posts on every Renovate PR. + - id: gate + uses: dodi-smart/.github/actions/agent-gate@main + with: + labels: ${{ toJson(github.event.pull_request.labels.*.name) }} + event-name: ${{ github.event_name }} + event-action: ${{ github.event.action }} + label: ${{ github.event.label.name }} + request-label: 'agent:review' + author: ${{ github.event.pull_request.user.login }} + draft: ${{ github.event.pull_request.draft }} + bots: 'reject' + + - if: steps.gate.outputs.proceed == 'true' + uses: actions/checkout@v7 with: { fetch-depth: 0 } - - id: c + + - id: level env: - DRAFT: ${{ github.event.pull_request.draft }} - AUTHOR: ${{ github.event.pull_request.user.login }} + PROCEED: ${{ steps.gate.outputs.proceed }} + REASON: ${{ steps.gate.outputs.reason }} LABELS: ${{ toJson(github.event.pull_request.labels.*.name) }} BASE: ${{ github.event.pull_request.base.sha }} HEAD: ${{ github.event.pull_request.head.sha }} @@ -83,16 +98,7 @@ jobs: ELEVATED: ${{ inputs.elevated-level }} run: | set -euo pipefail - proceed=true; level="$LEVEL"; reason="" - - if printf '%s' "$LABELS" | grep -q '"agent:no-touch"'; then - proceed=false; reason="agent:no-touch" - elif [ "$DRAFT" = "true" ]; then - proceed=false; reason="draft" - elif printf '%s' "$AUTHOR" | grep -qiE 'renovate|dependabot'; then - # deps-verify owns these; reviewing them here would double-cover. - proceed=false; reason="bot dependency PR" - fi + proceed="$PROCEED"; level="$LEVEL"; reason="$REASON" if [ "$proceed" = "true" ]; then files=$(git diff --name-only "$BASE" "$HEAD" || true) @@ -124,11 +130,11 @@ jobs: # instead of the diff in the abstract. Annotations carry file, line and # message, which is far more usable than scraping raw logs. # - # Kept to ONE LINE on purpose: this value is interpolated into - # claude_args, which is a shell-style argument string. A multi-line or - # quote-bearing value would break the quoting and take the whole step with it. + # Kept to ONE LINE on purpose: this value is interpolated into a + # shell-style argument string. A multi-line or quote-bearing value would + # break the quoting and take the whole step with it. - id: ci - if: steps.c.outputs.proceed == 'true' + if: steps.level.outputs.proceed == 'true' env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} @@ -192,24 +198,22 @@ jobs: steps: - uses: actions/checkout@v7 with: { fetch-depth: 0 } - - uses: anthropics/claude-code-action@v1 + + # Opus for review: judging whether a change is correct, and diagnosing a + # failing check, is the expensive half of this job. + # + # The CI context goes in via the system prompt rather than the prompt, so + # the plugin's slash command stays exactly as the plugin expects it. + - uses: dodi-smart/.github/actions/run-agent@main with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - use_sticky_comment: true - additional_permissions: | + oauth-token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + model: claude-opus-5 + sticky-comment: 'true' + additional-permissions: | actions: read - display_report: true - plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' + plugin-marketplaces: 'https://github.com/anthropics/claude-code.git' plugins: 'code-review@claude-code-plugins' - # The CI context goes in via --append-system-prompt rather than the - # prompt, so the plugin's slash command stays exactly as the plugin - # expects it. Tools are allowed so the reviewer can pull a full log - # when the annotations are not enough. - # Opus for review: judging whether a change is correct, and diagnosing a - # failing check, is the expensive half of this job. - claude_args: >- - --model claude-opus-5 - --allowed-tools Bash "Bash(gh:*)" Read Grep Glob - --append-system-prompt "${{ needs.gate.outputs.ci }} - When CI is failing, diagnose the cause and say which change caused it and how to fix it. Lead with that — it is more useful than anything else you could report. Do not repeat a failure the annotations already state verbatim; explain it." + append-system-prompt: >- + ${{ needs.gate.outputs.ci }} + When CI is failing, diagnose the cause and say which change caused it and how to fix it. Lead with that — it is more useful than anything else you could report. Do not repeat a failure the annotations already state verbatim; explain it. prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }} ${{ needs.gate.outputs.level }}' diff --git a/.github/workflows/self-test.yml b/.github/workflows/self-test.yml new file mode 100644 index 0000000..c2671d2 --- /dev/null +++ b/.github/workflows/self-test.yml @@ -0,0 +1,65 @@ +# The standard tests itself. +# +# This repo defines the org's CI and, until now, had none of its own — the one +# repo whose breakage affects every other repo was also the one nothing checked. +# Everything here is deterministic and hosted-only; a public repo never touches +# a self-hosted runner (DODI-00010), and this one is public. +name: Self test + +on: + pull_request: + paths: + - 'actions/**' + - '.github/workflows/**' + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: self-test-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + + # The kill switch is a behavioural claim (DODI-00008). Assert it. + - name: Agent gate behaviour + run: ./actions/agent-gate/test.sh + + - name: Every workflow and action is parseable YAML + run: | + set -euo pipefail + python3 -c " + import sys, glob, yaml + bad = 0 + for f in sorted(glob.glob('.github/workflows/*.yml') + glob.glob('actions/*/action.yml')): + try: + yaml.safe_load(open(f)) + print('ok ' + f) + except Exception as e: + print('FAIL ' + f + ': ' + str(e)); bad += 1 + sys.exit(bad) + " + + - name: Shell scripts pass shellcheck + run: | + sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck + shellcheck actions/*/*.sh + + # `-ignore` on create-github-app-token: actionlint ships a snapshot of + # popular actions' inputs and its copy predates that action moving from + # `app-id` to `client-id`. The workflow is correct; the linter's DB is old. + - name: actionlint + uses: docker://rhysd/actionlint:latest + with: + args: >- + -color + -ignore 'input "client-id" is not defined in action "actions/create-github-app-token' + -ignore 'missing input "app-id" which is required by action "actions/create-github-app-token' diff --git a/actions/agent-gate/action.yml b/actions/agent-gate/action.yml new file mode 100644 index 0000000..78b6993 --- /dev/null +++ b/actions/agent-gate/action.yml @@ -0,0 +1,82 @@ +# Decide whether an agent may run, deterministically and cheaply. +# +# This exists so `agent:no-touch` is evaluated FIRST in exactly one place +# (DODI-00008). It used to be re-implemented in every agent workflow, and a rule +# whose correctness depends on where three different authors chose to put an +# `if:` is not a rule. Position matters as much as existence: a check placed +# after an early return silently stops covering that path. +# +# Every rule below is a plain string test. Nothing here asks a model anything — +# gates run before agents so that a run which will not proceed costs one hosted +# minute rather than twenty (DODI-00005). +name: Agent gate +description: Evaluate agent:no-touch and the standard preconditions, in a fixed order. + +inputs: + labels: + description: 'toJson(...labels.*.name) for the issue or PR. Required for the kill switch to work at all.' + required: true + event-name: + description: 'github.event_name' + required: true + event-action: + description: 'github.event.action' + default: '' + label: + description: 'github.event.label.name, on a `labeled` event' + default: '' + request-label: + description: 'The label that requests this workflow (e.g. agent:triage). A `labeled` event proceeds only for this one.' + default: '' + author: + description: 'Issue or PR author login' + default: '' + draft: + description: 'github.event.pull_request.draft' + default: 'false' + comment: + description: 'github.event.comment.body' + default: '' + commands: + description: 'Space-separated comment verbs that request this workflow, e.g. "triage plan". Matched as `@claude `.' + default: '' + bots: + description: 'How to treat bot authors: reject (default) | only | allow' + default: 'reject' + skip-draft: + description: 'Stop on draft PRs' + default: 'true' + changed-files: + description: 'Newline-separated changed files. When given, a docs-only change stops the run.' + default: '' + +outputs: + proceed: + description: '"true" when the agent may run' + value: ${{ steps.gate.outputs.proceed }} + reason: + description: 'Why it stopped, or why it proceeded' + value: ${{ steps.gate.outputs.reason }} + mode: + description: 'The matched command verb, when one triggered this run' + value: ${{ steps.gate.outputs.mode }} + +runs: + using: composite + steps: + - id: gate + shell: bash + env: + LABELS: ${{ inputs.labels }} + EVENT: ${{ inputs.event-name }} + ACTION: ${{ inputs.event-action }} + LABEL: ${{ inputs.label }} + REQUEST: ${{ inputs.request-label }} + AUTHOR: ${{ inputs.author }} + DRAFT: ${{ inputs.draft }} + COMMENT: ${{ inputs.comment }} + COMMANDS: ${{ inputs.commands }} + BOTS: ${{ inputs.bots }} + SKIPDRAFT: ${{ inputs.skip-draft }} + FILES: ${{ inputs.changed-files }} + run: ${{ github.action_path }}/gate.sh diff --git a/actions/agent-gate/gate.sh b/actions/agent-gate/gate.sh new file mode 100755 index 0000000..e0ad358 --- /dev/null +++ b/actions/agent-gate/gate.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# The gate, as a standalone script so it can be tested without a runner. +# +# Reads its inputs from the environment and writes proceed/reason/mode to +# $GITHUB_OUTPUT. action.yml is a thin wrapper around this file; test.sh runs it +# directly. Keeping the logic out of YAML is what makes the kill switch +# something we can actually assert on (see test.sh). +set -euo pipefail + +stop() { + { + echo "proceed=false" + echo "reason=$1" + echo "mode=" + } >> "$GITHUB_OUTPUT" + echo "gate: STOP — $1" + exit 0 +} +go() { + { + echo "proceed=true" + echo "reason=$1" + echo "mode=${2:-}" + } >> "$GITHUB_OUTPUT" + echo "gate: PROCEED — $1${2:+ (mode: $2)}" + exit 0 +} + +# ------------------------------------------------------------------ +# RULE 0 — the kill switch. First, always, with no exemption: not for +# workflow_dispatch, not for an explicit command, not for a maintainer. +# A kill switch that works on only some code paths is not a kill +# switch (DODI-00008). Do not move this below anything. +# ------------------------------------------------------------------ +if printf '%s' "$LABELS" | grep -q '"agent:no-touch"'; then + stop "agent:no-touch" +fi + +# RULE 1 — drafts. Nothing is ready to be judged yet. +if [ "$SKIPDRAFT" = "true" ] && [ "$DRAFT" = "true" ]; then + stop "draft" +fi + +# RULE 2 — bot authors. `reject` keeps human-review workflows off +# Renovate PRs (deps-verify owns those, and covering both double-posts). +# `only` is the inverse, for deps-verify itself. +is_bot=false +if printf '%s' "$AUTHOR" | grep -qiE 'renovate|dependabot|\[bot\]$'; then + is_bot=true +fi +case "$BOTS" in + reject) [ "$is_bot" = "true" ] && stop "bot author: $AUTHOR" ;; + only) [ "$is_bot" = "true" ] || stop "not a bot dependency PR" ;; + allow) : ;; + *) echo "::error::unknown bots value '$BOTS' (expected reject|only|allow)"; exit 1 ;; +esac + +# RULE 3 — docs-only. Passed in rather than computed, because the +# caller already has the diff and a second checkout is not free. +if [ -n "${FILES:-}" ]; then + if ! printf '%s\n' "$FILES" | grep -qvE '(\.md$|^docs/)'; then + stop "docs only" + fi +fi + +# RULE 4 — a `labeled` event proceeds ONLY for this workflow's request +# label. Without this, adding any label at all re-runs the whole thing. +if [ "$ACTION" = "labeled" ]; then + if [ -n "$REQUEST" ] && [ "$LABEL" = "$REQUEST" ]; then + go "requested by label: $LABEL" + fi + stop "labeled '$LABEL' — only $REQUEST requests this workflow" +fi + +# RULE 5 — comment commands, as `@claude `. The verb list is an +# input so it lives in ONE place; it used to be hand-copied into every +# repo's claude.yml and had already drifted between two of them. +if [ "$EVENT" = "issue_comment" ] && [ -n "${COMMANDS:-}" ]; then + for verb in $COMMANDS; do + if printf '%s' "$COMMENT" | grep -qiE "@claude[[:space:]]+$verb\b"; then + go "requested by comment: @claude $verb" "$verb" + fi + done + stop "comment did not name a command for this workflow" +fi + +go "$EVENT${ACTION:+/$ACTION}" diff --git a/actions/agent-gate/test.sh b/actions/agent-gate/test.sh new file mode 100755 index 0000000..ea89356 --- /dev/null +++ b/actions/agent-gate/test.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Regression tests for the agent gate. +# +# The gate is the org's security boundary: `agent:no-touch` stops every agent +# workflow, and DODI-00008 says it is evaluated FIRST with no exemption. That is +# a claim about behaviour, so it gets asserted rather than reviewed. The first +# block below is the one that matters — if any row in it proceeds, the kill +# switch is broken and the fix is not "adjust the test". +# +# Run: actions/agent-gate/test.sh +set -uo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +# An explicit template is required: BSD/macOS `mktemp -d` with no template +# ignores TMPDIR and uses _CS_DARWIN_USER_TEMP_DIR. This org runs macOS +# runners, so that difference is not hypothetical. +TMP="$(mktemp -d "${TMPDIR:-/tmp}/agent-gate-test.XXXXXX")" +trap 'rm -rf "$TMP"' EXIT + +pass=0; fail=0 +case_() { + local want="$1" desc="$2" + export GITHUB_OUTPUT="$TMP/out"; : > "$GITHUB_OUTPUT" + LABELS="$3" EVENT="$4" ACTION="$5" LABEL="$6" REQUEST="$7" AUTHOR="$8" DRAFT="$9" \ + COMMENT="${10}" COMMANDS="${11}" BOTS="${12}" SKIPDRAFT="${13}" FILES="${14}" \ + bash "$HERE/gate.sh" >"$TMP/log" 2>&1 + local got reason mark + got=$(grep '^proceed=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2) + reason=$(grep '^reason=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2-) + if [ "$got" = "$want" ]; then mark="ok "; pass=$((pass+1)) + else mark="FAIL"; fail=$((fail+1)); fi + printf ' %s %-46s proceed=%-6s %s\n' "$mark" "$desc" "$got" "$reason" +} + +echo "== kill switch: agent:no-touch beats everything (DODI-00008) ==" +case_ false "no-touch + labeled agent:triage" '["agent:no-touch","agent:triage"]' issues labeled agent:triage agent:triage alice false '' 'triage plan' allow false '' +case_ false "no-touch + workflow_dispatch" '["agent:no-touch"]' workflow_dispatch '' '' agent:triage alice false '' 'triage plan' allow false '' +case_ false "no-touch + @claude triage" '["agent:no-touch"]' issue_comment created '' agent:triage alice false '@claude triage now' 'triage plan' allow false '' +case_ false "no-touch + PR ready_for_review" '["agent:no-touch"]' pull_request ready_for_review '' agent:review alice false '' '' reject true 'src/a.ts' +case_ false "no-touch + renovate PR" '["agent:no-touch"]' pull_request opened '' agent:review 'renovate[bot]' false '' '' only true '' + +echo "== triage ==" +case_ true "labeled agent:triage" '["agent:triage"]' issues labeled agent:triage agent:triage alice false '' 'triage plan' allow false '' +case_ false "labeled some other label" '["bug"]' issues labeled bug agent:triage alice false '' 'triage plan' allow false '' +case_ true "issue opened by a human" '[]' issues opened '' agent:triage alice false '' 'triage plan' allow false '' +case_ true "comment @claude triage" '[]' issue_comment created '' agent:triage alice false '@claude triage this' 'triage plan' allow false '' +case_ true "comment @claude plan" '[]' issue_comment created '' agent:triage alice false 'please @claude plan it' 'triage plan' allow false '' +case_ false "comment /triage (verb retired)" '[]' issue_comment created '' agent:triage alice false '/triage' 'triage plan' allow false '' +case_ false "comment @claude " '[]' issue_comment created '' agent:triage alice false '@claude what is this' 'triage plan' allow false '' + +echo "== pr review ==" +case_ false "draft PR" '[]' pull_request ready_for_review '' agent:review alice true '' '' reject true 'src/a.ts' +case_ false "renovate PR (deps-verify owns it)" '[]' pull_request opened '' agent:review 'renovate[bot]' false '' '' reject true 'p.json' +case_ false "docs-only change" '[]' pull_request ready_for_review '' agent:review alice false '' '' reject true 'README.md +docs/x.md' +case_ true "docs + code" '[]' pull_request ready_for_review '' agent:review alice false '' '' reject true 'README.md +src/a.ts' +case_ true "labeled agent:review" '["agent:review"]' pull_request labeled agent:review agent:review alice false '' '' reject true 'src/a.ts' + +echo "== deps-verify (bots: only) ==" +case_ true "renovate PR" '[]' pull_request opened '' agent:review 'renovate[bot]' false '' '' only true '' +case_ false "human PR" '[]' pull_request opened '' agent:review alice false '' '' only true '' +case_ true "dependabot PR" '[]' pull_request synchronize '' agent:review 'dependabot[bot]' false '' '' only true '' +case_ true "renovate + labeled agent:review" '[]' pull_request labeled agent:review agent:review 'renovate[bot]' false '' '' only true '' +case_ false "renovate + labeled deps:major" '[]' pull_request labeled deps:major agent:review 'renovate[bot]' false '' '' only true '' + +echo +echo "pass=$pass fail=$fail" +[ "$fail" -eq 0 ] diff --git a/actions/run-agent/action.yml b/actions/run-agent/action.yml new file mode 100644 index 0000000..30ea7d8 --- /dev/null +++ b/actions/run-agent/action.yml @@ -0,0 +1,84 @@ +# Run claude-code-action with the org's settled defaults. +# +# Three workflows were each carrying their own copy of this call, and each +# carried the same fifteen lines of comment explaining the same two footguns. +# One copy is easier to keep right than three. +# +# The footguns, recorded once here so nobody rediscovers them: +# +# `--allowed-tools ` is VARIADIC — space-separated, each entry +# quoted if it contains parentheses. A comma-joined list parses as ONE +# meaningless token: nothing matches, every Bash call is denied with "This +# command requires approval", and the job still reports SUCCESS. That cost +# three runs to find. The default below is correct; if you override it, keep +# the shape. +# +# `display_report` defaults to false, which means a run that did nothing is +# indistinguishable from a run that worked. It is forced on here. +# +# Bash is allowed broadly on purpose. The bound that makes this reasonable is +# not the allowlist — the action refuses to run for an actor without write +# access, so untrusted issue or PR content only reaches the agent when someone +# trusted invokes it. The real guards are elsewhere and must not be removed: +# `agent:no-touch` is checked before every other condition (DODI-00008), and +# public repos and fork PRs never touch a self-hosted runner (DODI-00010). +name: Run agent +description: Invoke claude-code-action with the org's tool allowlist, model policy and reporting defaults. + +inputs: + oauth-token: + description: 'CLAUDE_CODE_OAUTH_TOKEN' + required: true + prompt: + description: 'The prompt, or a plugin slash command' + required: true + model: + description: >- + Which model. Judgement work (reading unfamiliar code, deciding plan-vs-questions, + reviewing a diff) earns opus. Mechanical work against an answer the deterministic + gate already produced does not. + default: 'claude-opus-5' + allowed-tools: + description: 'Variadic, space-separated. See the header before changing.' + default: 'Bash "Bash(gh:*)" Read Grep Glob' + append-system-prompt: + description: 'Extra system-prompt text. Must be ONE LINE — it is interpolated into a shell-style argument string.' + default: '' + sticky-comment: + description: 'Update one comment instead of adding a new one each run' + default: 'false' + track-progress: + description: 'Post a live progress comment, so a run in flight is visible rather than silent' + default: 'false' + allowed-bots: + description: 'Comma-separated bot logins permitted to initiate. Named explicitly, never "*".' + default: '' + plugins: + description: 'Plugin spec, e.g. code-review@claude-code-plugins' + default: '' + plugin-marketplaces: + description: 'Marketplace URL for the above' + default: '' + additional-permissions: + description: 'YAML block, e.g. "actions: read"' + default: '' + +runs: + using: composite + steps: + - uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ inputs.oauth-token }} + prompt: ${{ inputs.prompt }} + use_sticky_comment: ${{ inputs.sticky-comment }} + track_progress: ${{ inputs.track-progress }} + allowed_bots: ${{ inputs.allowed-bots }} + plugins: ${{ inputs.plugins }} + plugin_marketplaces: ${{ inputs.plugin-marketplaces }} + additional_permissions: ${{ inputs.additional-permissions }} + # Never optional. See the header. + display_report: true + claude_args: >- + --model ${{ inputs.model }} + --allowed-tools ${{ inputs.allowed-tools }} + ${{ inputs.append-system-prompt && format('--append-system-prompt "{0}"', inputs.append-system-prompt) || '' }} diff --git a/actions/setup-stack/action.yml b/actions/setup-stack/action.yml new file mode 100644 index 0000000..2bfdb1f --- /dev/null +++ b/actions/setup-stack/action.yml @@ -0,0 +1,187 @@ +# Install a toolchain, and isolate its package cache. +# +# WHY THIS IS ONE ACTION AND NOT ONE TEMPLATE PER STACK +# +# The old shape was a thin-caller file per stack (deps-verify.yml, +# deps-verify.gradle.yml, ...). Five stacks across three workflows is fifteen +# files that must be kept in agreement, and the Gradle one had already grown a +# hardcoded `assembleInfrasensingDebug` — one product's task name sitting in a +# template every other Gradle repo was meant to copy. Stack is a VALUE, so it +# belongs in an input. +# +# WHY THE CACHE DIRS ARE JOB-SCOPED +# +# Self-hosted runners persist between jobs, so the default per-user caches are +# shared. A half-written entry poisons every later run — `Fail extracting +# tarball for "next"` — and `restore-keys` faithfully restores the poison, so +# re-running does NOT clear it. That cost real days. Isolation costs a cold +# install and buys determinism, which is the entire point of a verification job. +name: Setup stack +description: Install the toolchain for a stack and isolate its package cache. + +inputs: + stack: + description: 'bun | node | gradle | android | flutter | rust | xcode | none' + required: true + node-version: + description: 'For stack=node, and for semantic-release on any stack' + default: '24' + bun-version: + description: 'For stack=bun' + default: 'latest' + java-version: + description: 'For gradle/android/flutter. Empty skips the JDK on flutter.' + default: '21' + flutter-version: + description: 'For stack=flutter. Empty takes the channel head.' + default: '' + flutter-channel: + description: 'For stack=flutter' + default: 'stable' + rust-toolchain: + description: 'For stack=rust' + default: 'stable' + rust-components: + description: 'For stack=rust, e.g. "clippy, rustfmt"' + default: 'clippy, rustfmt' + xcode-version: + description: 'For stack=xcode. Empty takes the runner default.' + default: '' + cache: + description: 'Restore and save the package cache' + default: 'true' + +outputs: + install: + description: 'The conventional install command for this stack, or empty when it resolves on demand' + value: ${{ steps.defaults.outputs.install }} + lint: + description: 'The conventional lint command for this stack' + value: ${{ steps.defaults.outputs.lint }} + typecheck: + description: 'The conventional typecheck command, or empty where the compiler already does it' + value: ${{ steps.defaults.outputs.typecheck }} + test: + description: 'The conventional test command for this stack' + value: ${{ steps.defaults.outputs.test }} + build: + description: 'The conventional build command for this stack' + value: ${{ steps.defaults.outputs.build }} + +runs: + using: composite + steps: + # Point every toolchain at a directory this job owns. See the header. + - id: isolate + shell: bash + env: + STACK: ${{ inputs.stack }} + run: | + set -euo pipefail + case "$STACK" in + bun|node|gradle|android|flutter|rust|xcode|none) : ;; + *) echo "::error::unknown stack '$STACK' (expected bun|node|gradle|android|flutter|rust|xcode|none)"; exit 1 ;; + esac + { + echo "BUN_INSTALL_CACHE_DIR=${RUNNER_TEMP}/bun-cache" + echo "npm_config_cache=${RUNNER_TEMP}/npm-cache" + echo "GRADLE_USER_HOME=${RUNNER_TEMP}/gradle-home" + echo "PUB_CACHE=${RUNNER_TEMP}/pub-cache" + echo "CARGO_HOME=${RUNNER_TEMP}/cargo-home" + } >> "$GITHUB_ENV" + echo "stack: $STACK, caches scoped to $RUNNER_TEMP" + + - if: inputs.stack == 'bun' + uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ inputs.bun-version }} + + - if: inputs.stack == 'node' + uses: actions/setup-node@v7 + with: + node-version: ${{ inputs.node-version }} + + - if: (inputs.stack == 'gradle' || inputs.stack == 'android') || (inputs.stack == 'flutter' && inputs.java-version != '') + uses: actions/setup-java@v5 + with: + java-version: ${{ inputs.java-version }} + distribution: temurin + + - if: inputs.stack == 'gradle' || inputs.stack == 'android' + uses: gradle/actions/setup-gradle@v6 + with: + # Publishing a build scan from a verification job leaks the dependency + # graph of a private repo to a third party. Off unless a repo opts in. + build-scan-publish: false + cache-read-only: ${{ inputs.cache != 'true' }} + + - if: inputs.stack == 'android' + uses: android-actions/setup-android@v4 + + - if: inputs.stack == 'flutter' + uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ inputs.flutter-version }} + channel: ${{ inputs.flutter-channel }} + cache: ${{ inputs.cache }} + + # Pinned to @master with an explicit `toolchain`, which is how this action + # is meant to be parameterised — the `@stable` form the README leads with + # hardcodes the channel in the ref and cannot be overridden by an input. + - if: inputs.stack == 'rust' + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ inputs.rust-toolchain }} + components: ${{ inputs.rust-components }} + + - if: inputs.stack == 'rust' && inputs.cache == 'true' + uses: Swatinem/rust-cache@v2 + + - if: inputs.stack == 'xcode' && inputs.xcode-version != '' + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: ${{ inputs.xcode-version }} + + # Conventional commands per stack, so a caller that has nothing unusual to + # say can name its stack and stop. Anything explicit wins over these. + - id: defaults + shell: bash + env: + STACK: ${{ inputs.stack }} + run: | + set -euo pipefail + install=""; lint=""; typecheck=""; test=""; build="" + case "$STACK" in + bun) + install="bun install --frozen-lockfile" + lint="bun run lint"; typecheck="bun run typecheck" + test="bun run test:run"; build="bun run build" ;; + node) + install="npm ci" + lint="npm run lint"; typecheck="npm run typecheck" + test="npm test"; build="npm run build" ;; + gradle|android) + # Gradle resolves on demand; there is no separate install step. + lint="./gradlew ktlintCheck" + test="./gradlew test"; build="./gradlew assemble" ;; + flutter) + install="flutter pub get" + lint="flutter analyze" + test="flutter test"; build="flutter build apk --debug" ;; + rust) + lint="cargo clippy --all-targets -- -D warnings" + typecheck="cargo check --all-targets" + test="cargo test --all"; build="cargo build --release" ;; + xcode) + lint="swiftlint --strict" + test="xcodebuild test -scheme \"\$SCHEME\" -destination 'platform=iOS Simulator,name=iPhone 15'" + build="xcodebuild build -scheme \"\$SCHEME\"" ;; + none) : ;; + esac + { + echo "install=$install" + echo "lint=$lint" + echo "typecheck=$typecheck" + echo "test=$test" + echo "build=$build" + } >> "$GITHUB_OUTPUT" From 0adfe91274551c5e5af5fba6cac85f54743a2d8f Mon Sep 17 00:00:00 2001 From: Asen Lekov Date: Tue, 18 Aug 2026 20:52:54 +0300 Subject: [PATCH 2/5] feat(workflows): own @claude, implement planned issues, and share pr-checks, release and zavet checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claude-assist replaces the claude.yml every repo hand-maintained. That file was the one agent workflow the standard did not own, and it did not check agent:no-touch — the kill switch had a hole in the most permissive surface in the org, a workflow that answers any mention with write access. It also had to decline the verbs other workflows own, an exclusion hand-copied into every repo in two if: blocks each, which had already drifted: one repo guarded pull_request_review_comment and the other did not. That list is now one input. issue-implement makes agent:implement real: branch, code, draft PR, never merge. The plan requirement is a DETERMINISTIC gate, not a prompt instruction — it reads Triage state with jq and stops before a runner is picked. An agent asked to judge whether a plan is good enough will sometimes accept a two-line issue body, and the failure mode is twenty minutes of confident work on the wrong thing. There is no override, not workflow_dispatch and not a maintainer. Agent mode decides who may trigger it; the field was documented as "policy, not enforcement" and this is the first workflow that enforces it. pr-checks replaces two hand-written CI files that were the same shape with a different toolchain, and differed mostly in ways nobody chose: one had a concurrency group and the other did not, so every push to the busier repo ran a full Next.js build to completion, and neither had a path filter. release collapses five near-identical jobs that each checked out, each set up Node, and each ran the same nine-package npm install before calling semantic-release with a different --extends. They were chained to order them, not to parallelise, so they are now one ordered loop and one install. zavet-check puts the knowledge layer in CI, where it has never been. Its guarantees held only as long as every contributor had the git hooks installed and never used --no-verify. Severity mirrors those hooks exactly: decision checks and guard trailers fail, spec staleness warns. Inverting either would make CI disagree with the hooks, and then people learn to distrust one of them. Guards are checked per commit, because a trailer belongs to the commit that touched the guarded path. setup-stack now resolves command overrides, so callers read a resolved output instead of reimplementing the "@stack" precedence rule — three workflows had started to. default.json5 extracts the ~60% of Renovate config that was identical between the two repos. Only what is true of every repo is in it: ecosystem rules stay with the repo that has that ecosystem, because a rule matching nothing reads as coverage. Why: the kill switch has to cover every agent workflow, and implementing an unplanned issue is the expensive way to find out there was no plan. Refs: DODI-00003, DODI-00004, DODI-00005, DODI-00008, DODI-00011 Co-Authored-By: Claude Opus 5 --- .github/workflows/claude-assist.yml | 115 +++++++++++++ .github/workflows/deps-verify.yml | 36 ++-- .github/workflows/issue-implement.yml | 229 ++++++++++++++++++++++++++ .github/workflows/pr-checks.yml | 186 +++++++++++++++++++++ .github/workflows/pr-review.yml | 8 +- .github/workflows/release.yml | 166 +++++++++++++++++++ .github/workflows/self-test.yml | 13 ++ .github/workflows/zavet-check.yml | 196 ++++++++++++++++++++++ actions/agent-gate/action.yml | 12 ++ actions/agent-gate/gate.sh | 20 ++- actions/agent-gate/test.sh | 10 ++ actions/setup-stack/action.yml | 42 +++-- default.json5 | 70 ++++++++ 13 files changed, 1062 insertions(+), 41 deletions(-) create mode 100644 .github/workflows/claude-assist.yml create mode 100644 .github/workflows/issue-implement.yml create mode 100644 .github/workflows/pr-checks.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/zavet-check.yml create mode 100644 default.json5 diff --git a/.github/workflows/claude-assist.yml b/.github/workflows/claude-assist.yml new file mode 100644 index 0000000..0d552ab --- /dev/null +++ b/.github/workflows/claude-assist.yml @@ -0,0 +1,115 @@ +# Reusable: the general-purpose assistant, for `@claude `. +# +# WHY THIS IS SHARED AND NOT A FILE IN EVERY REPO +# +# Every repo used to carry its own claude.yml. Two problems followed. +# +# First, it was the ONE agent workflow the standard did not own, and it did not +# check `agent:no-touch`. The kill switch had a hole in the most permissive +# surface in the org — a workflow that answers any mention, with write access. +# DODI-00008 says no-touch is evaluated before every other rule in every agent +# workflow; "every" has to include this one. +# +# Second, it had to DECLINE the verbs other workflows own (`@claude triage` +# would otherwise run both this and triage on the same comment). That exclusion +# was hand-copied into every repo, in two `if:` blocks each, and had already +# drifted: one repo guarded pull_request_review_comment and the other did not. +# The verb list now lives in `reserved-commands` below, in one place. +# +# Called by a thin wrapper in each repo: +# jobs: +# assist: +# uses: dodi-smart/.github/.github/workflows/claude-assist.yml@v1 +# secrets: inherit +name: Claude Assist + +on: + workflow_call: + inputs: + runner-weight: + type: string + default: "light" # light | heavy | apple | hosted + runner-labels: + type: string + default: "" + # Verbs owned by another workflow. Adding one here is the ONLY change + # needed to teach every repo about a new command. + reserved-commands: + type: string + default: "triage plan implement" + model: + type: string + default: "claude-opus-5" + timeout-minutes: + type: number + default: 20 + secrets: + CLAUDE_CODE_OAUTH_TOKEN: { required: true } + GH_APP_CLIENT_ID: { required: false } + GH_APP_PRIVATE_KEY: { required: false } + +concurrency: + group: assist-${{ github.repository }}-${{ github.event.issue.number || github.event.pull_request.number || github.ref }} + cancel-in-progress: false + +jobs: + gate: + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + proceed: ${{ steps.gate.outputs.proceed }} + steps: + - id: gate + uses: dodi-smart/.github/actions/agent-gate@main + with: + # An issue and a PR keep their labels in different places; whichever + # is absent is an empty list, so the kill switch reads the right one. + labels: ${{ github.event.issue.number && toJson(github.event.issue.labels.*.name) || toJson(github.event.pull_request.labels.*.name) }} + event-name: ${{ github.event_name }} + event-action: ${{ github.event.action }} + author: ${{ github.event.issue.user.login || github.event.pull_request.user.login }} + # The body to search differs by event: a comment, a review, or the + # issue itself. `||` takes the first non-empty. + comment: >- + ${{ github.event.comment.body + || github.event.review.body + || format('{0} {1}', github.event.issue.title, github.event.issue.body) }} + mention: '@claude' + exclude-commands: ${{ inputs.reserved-commands }} + bots: 'reject' + skip-draft: 'false' + + pick-runner: + needs: [gate] + if: needs.gate.outputs.proceed == 'true' + uses: dodi-smart/.github/.github/workflows/pick-runner.yml@main + with: + weight: ${{ inputs.runner-weight }} + labels: ${{ inputs.runner-labels }} + secrets: inherit + + assist: + needs: [gate, pick-runner] + if: needs.gate.outputs.proceed == 'true' + runs-on: ${{ fromJson(needs.pick-runner.outputs.runner) }} + timeout-minutes: ${{ inputs.timeout-minutes }} + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read + steps: + - uses: actions/checkout@v7 + with: { fetch-depth: 1 } + + # No `prompt`: with none supplied the action follows the instruction in + # the comment that mentioned it, which is the entire point of this + # workflow. Everything else is the org's standard agent configuration. + - uses: dodi-smart/.github/actions/run-agent@main + with: + oauth-token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + model: ${{ inputs.model }} + prompt: '' + additional-permissions: | + actions: read diff --git a/.github/workflows/deps-verify.yml b/.github/workflows/deps-verify.yml index 4df5792..5566ba0 100644 --- a/.github/workflows/deps-verify.yml +++ b/.github/workflows/deps-verify.yml @@ -119,41 +119,25 @@ jobs: uses: dodi-smart/.github/actions/setup-stack@main with: stack: ${{ needs.gate.outputs.stack }} + install: ${{ inputs.install }} + build: ${{ inputs.build }} + typecheck: ${{ inputs.typecheck }} + test: ${{ inputs.test }} java-version: ${{ inputs.java-version }} node-version: ${{ inputs.node-version }} bun-version: ${{ inputs.bun-version }} flutter-version: ${{ inputs.flutter-version }} rust-toolchain: ${{ inputs.rust-toolchain }} - - id: cmd - env: - IN_INSTALL: ${{ inputs.install }} - IN_BUILD: ${{ inputs.build }} - IN_TYPECHECK: ${{ inputs.typecheck }} - IN_TEST: ${{ inputs.test }} - D_INSTALL: ${{ steps.setup.outputs.install }} - D_BUILD: ${{ steps.setup.outputs.build }} - D_TYPECHECK: ${{ steps.setup.outputs.typecheck }} - D_TEST: ${{ steps.setup.outputs.test }} - run: | - set -euo pipefail - # "@stack" -> the stack's conventional command. Anything else, empty - # string included, is taken literally. - pick() { if [ "$1" = "@stack" ]; then printf '%s' "$2"; else printf '%s' "$1"; fi; } - { - echo "install=$(pick "$IN_INSTALL" "$D_INSTALL")" - echo "build=$(pick "$IN_BUILD" "$D_BUILD")" - echo "typecheck=$(pick "$IN_TYPECHECK" "$D_TYPECHECK")" - echo "test=$(pick "$IN_TEST" "$D_TEST")" - } >> "$GITHUB_OUTPUT" - + # Every step runs even after one fails, so the agent below can explain the + # whole picture rather than only the first thing that broke. - id: steps continue-on-error: true env: - INSTALL: ${{ steps.cmd.outputs.install }} - BUILD: ${{ steps.cmd.outputs.build }} - TYPECHECK: ${{ steps.cmd.outputs.typecheck }} - TEST: ${{ steps.cmd.outputs.test }} + INSTALL: ${{ steps.setup.outputs.install }} + BUILD: ${{ steps.setup.outputs.build }} + TYPECHECK: ${{ steps.setup.outputs.typecheck }} + TEST: ${{ steps.setup.outputs.test }} SMOKE: ${{ inputs.smoke }} run: | set -x diff --git a/.github/workflows/issue-implement.yml b/.github/workflows/issue-implement.yml new file mode 100644 index 0000000..9782b4c --- /dev/null +++ b/.github/workflows/issue-implement.yml @@ -0,0 +1,229 @@ +# Reusable: implement a PLANNED issue, as a draft PR. +# +# Requested with the `agent:implement` label or `@claude implement`. +# +# THE GATE IS DETERMINISTIC, AND IT RUNS BEFORE ANYTHING COSTS MONEY +# +# An issue is implemented only when `Triage state` is `Plan ready`. That is +# checked here with jq against a field, NOT by asking the agent whether a plan +# looks good enough — an agent asked that question will sometimes accept a +# two-line issue body, and the failure mode is twenty minutes of confident work +# on the wrong thing plus a PR someone must read to discover it was wrong. +# Deterministic gates run before agent steps (DODI-00005). +# +# There is no override. Not workflow_dispatch, not a maintainer. If an issue is +# not planned, the answer is to plan it. +# +# `Agent mode` decides who may trigger it. The field was documented as "policy, +# not enforcement"; this is the workflow that enforces it. +# +# THE AGENT NEVER MERGES. It opens a draft PR and stops — the same boundary +# DODI-00004 draws for dependency verification. Review happens through the +# normal PR Review path once a human marks it ready. +name: Issue Implement + +on: + workflow_call: + inputs: + runner-weight: + type: string + default: "heavy" # implementing means building; light is the Pi pool + runner-labels: + type: string + default: "" + # Which Triage state makes an issue eligible. Overridable, but think hard + # before widening it — this is the whole safety property. + required-state: + type: string + default: "Plan ready" + base-branch: + type: string + default: "" # defaults to the repo's default branch + model: + type: string + default: "claude-opus-5" + timeout-minutes: + type: number + default: 45 + secrets: + CLAUDE_CODE_OAUTH_TOKEN: { required: true } + GH_APP_CLIENT_ID: { required: false } + GH_APP_PRIVATE_KEY: { required: false } + +concurrency: + group: implement-${{ github.repository }}-${{ github.event.issue.number }} + cancel-in-progress: false # never kill a run that may have pushed a branch + +jobs: + gate: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + issues: write # to explain a refusal and clear the request label + outputs: + proceed: ${{ steps.check.outputs.proceed }} + steps: + # agent:no-touch first, unconditionally (DODI-00008). + - id: gate + uses: dodi-smart/.github/actions/agent-gate@main + with: + labels: ${{ toJson(github.event.issue.labels.*.name) }} + event-name: ${{ github.event_name }} + event-action: ${{ github.event.action }} + label: ${{ github.event.label.name }} + request-label: 'agent:implement' + author: ${{ github.event.issue.user.login }} + comment: ${{ github.event.comment.body }} + commands: 'implement' + bots: 'reject' + skip-draft: 'false' + + # Is there a plan, and may this actor trigger it? Both answers come from + # fields the triage workflow already wrote — which is the payoff for + # keeping state on exactly one axis (DODI-00001, DODI-00016). + - id: check + env: + GH_TOKEN: ${{ github.token }} + PROCEED: ${{ steps.gate.outputs.proceed }} + REPO: ${{ github.repository }} + NUM: ${{ github.event.issue.number }} + ACTOR: ${{ github.actor }} + REQUIRED: ${{ inputs.required-state }} + run: | + set -euo pipefail + if [ "$PROCEED" != "true" ]; then + echo "proceed=false" >> "$GITHUB_OUTPUT"; exit 0 + fi + + owner="${REPO%%/*}"; name="${REPO##*/}" + # shellcheck disable=SC2016 # $o/$r/$n are GraphQL variables, not shell + values=$(gh api graphql -f query='query($o:String!,$r:String!,$n:Int!){ + repository(owner:$o,name:$r){ issue(number:$n){ + issueFieldValues(first:50){ nodes{ + ... on IssueFieldSingleSelectValue { + name field { ... on IssueFieldSingleSelect { name } } } } } } } }' \ + -f o="$owner" -f r="$name" -F n="$NUM" \ + --jq '[.data.repository.issue.issueFieldValues.nodes[] | select(.field.name != null)]') + + get() { jq -r --arg f "$1" '.[]|select(.field.name==$f)|.name' <<<"$values" | head -1; } + state=$(get "Triage state") + mode=$(get "Agent mode") + echo "Triage state='${state:-}' Agent mode='${mode:-}'" + + refuse() { + echo "proceed=false" >> "$GITHUB_OUTPUT" + gh issue comment "$NUM" --repo "$REPO" --body "$1" || true + # Clear the request label so the issue does not look like work in + # flight. Request labels are self-clearing on every path. + gh issue edit "$NUM" --repo "$REPO" --remove-label 'agent:implement' 2>/dev/null || true + echo "REFUSED: $1" + exit 0 + } + + if [ "$state" != "$REQUIRED" ]; then + refuse "**Not implementing this yet.** \`Triage state\` is \`${state:-unset}\`, and implementation requires \`$REQUIRED\`. + + Implementing an unplanned issue produces a confident PR built on assumptions nobody checked. Run triage first — apply \`agent:triage\` or comment \`@claude triage\` — and once a plan is posted and the state reaches \`$REQUIRED\`, ask again." + fi + + case "${mode:-}" in + "Human only") + refuse "**Not implementing this.** \`Agent mode\` is \`Human only\` on this issue, which is a deliberate instruction that no agent should write the code. Change the field if that is no longer intended." ;; + "Supervised"|"") + # Supervised (and unset, which is treated as supervised) requires a + # human with write access to have asked. The action already refuses + # actors without write access; this is the same bar, applied before + # the expensive part rather than inside it. + perm=$(gh api "/repos/$REPO/collaborators/$ACTOR/permission" --jq '.permission' 2>/dev/null || echo none) + case "$perm" in + admin|write|maintain) echo "actor $ACTOR has $perm — proceeding" ;; + *) refuse "**Not implementing this.** \`Agent mode\` is \`${mode:-Supervised (unset)}\`, which requires someone with write access to request it. \`$ACTOR\` has \`$perm\`." ;; + esac ;; + "Autonomous") + echo "Agent mode is Autonomous — proceeding" ;; + *) + refuse "**Not implementing this.** \`Agent mode\` is \`$mode\`, which this workflow does not recognise." ;; + esac + + echo "proceed=true" >> "$GITHUB_OUTPUT" + + pick-runner: + needs: [gate] + if: needs.gate.outputs.proceed == 'true' + uses: dodi-smart/.github/.github/workflows/pick-runner.yml@main + with: + weight: ${{ inputs.runner-weight }} + labels: ${{ inputs.runner-labels }} + secrets: inherit + + implement: + needs: [gate, pick-runner] + if: needs.gate.outputs.proceed == 'true' + runs-on: ${{ fromJson(needs.pick-runner.outputs.runner) }} + timeout-minutes: ${{ inputs.timeout-minutes }} + permissions: + contents: write # to push the branch + pull-requests: write # to open the draft PR + issues: write # to report back and clear the request label + id-token: write + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + ref: ${{ inputs.base-branch }} + + - uses: dodi-smart/.github/actions/run-agent@main + with: + oauth-token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + model: ${{ inputs.model }} + track-progress: 'true' + prompt: | + Implement issue #${{ github.event.issue.number }} in ${{ github.repository }}. + + A plan for this issue already exists — `Triage state` is + `${{ inputs.required-state }}`, which is why this run was allowed to + start. Read the issue and its comments and find that plan. Follow it. + + If, having read the code, you believe the plan is wrong, DO NOT + quietly implement something else. Say so on the issue, explain what + you found, and stop. A plan that survived triage and then turns out + to be wrong is worth a conversation, not a silent substitution. + + ## How to work + + 1. Create a branch off the current HEAD. Name it conventionally — + `feat/`, `fix/`, `chore/`, `refactor/`, `test/` — and include the + issue number, e.g. `fix/158-company-prefill`. + 2. Read the repo's CLAUDE.md / AGENTS.md first if present, and match + the surrounding code: its naming, its comment density, its idiom. + 3. Follow the repo's commit conventions. If it has a commitlint + config, read it — some repos enforce a scope-enum and will reject + a scope you invented. + 4. Write or update tests. A change with no test is not finished + unless the repo genuinely has no test setup, in which case say so. + 5. Run whatever the repo uses to check itself (lint, typecheck, + tests) and fix what you broke. + + ## Opening the PR + + Open it as a **DRAFT**, targeting the branch you started from, with + `Closes #${{ github.event.issue.number }}` in the body. + + Draft is not a formality. It means a human marks it ready when they + have looked, and marking it ready is what triggers PR Review. Do NOT + merge, approve, or mark it ready yourself. + + The PR body should say what you changed and why, what you tested, and + — importantly — anything you were unsure about. An honest "I could + not verify X" is worth more than a confident summary. + + ## Finally + + Remove the `agent:implement` label. It means "implementation + requested / in progress"; leaving it on makes finished work look like + it is still running. + + Do NOT change `Triage state`. Triage state stops at "Ready for agent" + and never mirrors execution — the open PR and the closed issue are + how GitHub already tracks that (DODI-00003). diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml new file mode 100644 index 0000000..13942e2 --- /dev/null +++ b/.github/workflows/pr-checks.yml @@ -0,0 +1,186 @@ +# Reusable: the deterministic checks a pull request must pass. +# +# Replaces a hand-written ci.yml / pr-checks.yml per repo. The two that existed +# were the same shape with a different toolchain — checkout, install, lint, +# typecheck, test, build — and differed mostly in ways nobody chose: one had a +# concurrency group and the other did not, so every push to the busier repo ran +# a full Next.js build to completion; neither had a path filter, so a README-only +# PR built the whole app. Set both in the caller's `on:` and `concurrency:`. +# +# Light work (lint, typecheck) goes to the small pool and heavy work (build, +# tests) to the large one, because that is the split the fleet is sized for. +# Any step whose command resolves to empty is skipped, so a repo that has no +# typecheck simply passes "". +name: PR Checks + +on: + workflow_call: + inputs: + stack: + type: string + required: true # bun | node | gradle | android | flutter | rust | xcode | none + + # "@stack" = this stack's conventional command; "" = this repo has no such + # step. See actions/setup-stack, which resolves these. + install: { type: string, default: "@stack" } + lint: { type: string, default: "@stack" } + typecheck: { type: string, default: "@stack" } + test: { type: string, default: "@stack" } + build: { type: string, default: "@stack" } + + light-weight: { type: string, default: "light" } + heavy-weight: { type: string, default: "heavy" } + + commitlint: { type: boolean, default: false } + commitlint-config: { type: string, default: ".commitlintrc.json" } + + coverage: { type: string, default: "none" } # none | vitest | kover + coverage-path: { type: string, default: "" } + coverage-min-overall: { type: number, default: 0 } + coverage-min-changed: { type: number, default: 0 } + + build-env: + type: string + default: "" # newline-separated KEY=VALUE, placeholders only + timeout-minutes: { type: number, default: 30 } + + java-version: { type: string, default: "21" } + node-version: { type: string, default: "24" } + bun-version: { type: string, default: "latest" } + flutter-version: { type: string, default: "" } + rust-toolchain: { type: string, default: "stable" } + secrets: + GH_APP_CLIENT_ID: { required: false } + GH_APP_PRIVATE_KEY: { required: false } + +jobs: + pick-light: + uses: dodi-smart/.github/.github/workflows/pick-runner.yml@main + with: { weight: "${{ inputs.light-weight }}" } + secrets: inherit + + pick-heavy: + uses: dodi-smart/.github/.github/workflows/pick-runner.yml@main + with: { weight: "${{ inputs.heavy-weight }}" } + secrets: inherit + + # Hosted on purpose: needs full history and no toolchain, so the large pool + # would be wasted on it. + commitlint: + if: inputs.commitlint + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + with: { fetch-depth: 0 } + - uses: wagoid/commitlint-github-action@v6 + with: + configFile: ${{ inputs.commitlint-config }} + + checks: + needs: [pick-light] + runs-on: ${{ fromJson(needs.pick-light.outputs.runner) }} + timeout-minutes: ${{ inputs.timeout-minutes }} + steps: + - uses: actions/checkout@v7 + - id: setup + uses: dodi-smart/.github/actions/setup-stack@main + with: + stack: ${{ inputs.stack }} + install: ${{ inputs.install }} + lint: ${{ inputs.lint }} + typecheck: ${{ inputs.typecheck }} + java-version: ${{ inputs.java-version }} + node-version: ${{ inputs.node-version }} + bun-version: ${{ inputs.bun-version }} + flutter-version: ${{ inputs.flutter-version }} + rust-toolchain: ${{ inputs.rust-toolchain }} + - name: Install + if: steps.setup.outputs.install != '' + run: ${{ steps.setup.outputs.install }} + - name: Lint + if: steps.setup.outputs.lint != '' + run: ${{ steps.setup.outputs.lint }} + - name: Typecheck + if: steps.setup.outputs.typecheck != '' + run: ${{ steps.setup.outputs.typecheck }} + + test: + needs: [pick-heavy] + runs-on: ${{ fromJson(needs.pick-heavy.outputs.runner) }} + timeout-minutes: ${{ inputs.timeout-minutes }} + permissions: + contents: read + pull-requests: write # coverage comment + steps: + - uses: actions/checkout@v7 + with: { fetch-depth: 0 } + - id: setup + uses: dodi-smart/.github/actions/setup-stack@main + with: + stack: ${{ inputs.stack }} + install: ${{ inputs.install }} + test: ${{ inputs.test }} + java-version: ${{ inputs.java-version }} + node-version: ${{ inputs.node-version }} + bun-version: ${{ inputs.bun-version }} + flutter-version: ${{ inputs.flutter-version }} + rust-toolchain: ${{ inputs.rust-toolchain }} + - name: Install + if: steps.setup.outputs.install != '' + run: ${{ steps.setup.outputs.install }} + - name: Test + if: steps.setup.outputs.test != '' + run: ${{ steps.setup.outputs.test }} + + - if: always() && inputs.coverage == 'vitest' + uses: davelosert/vitest-coverage-report-action@v2 + - if: always() && inputs.coverage == 'kover' + uses: mi-kas/kover-report@v2 + with: + path: ${{ inputs.coverage-path }} + token: ${{ github.token }} + title: Code Coverage + update-comment: true + min-coverage-overall: ${{ inputs.coverage-min-overall }} + min-coverage-changed-files: ${{ inputs.coverage-min-changed }} + + build: + needs: [pick-heavy, checks] + runs-on: ${{ fromJson(needs.pick-heavy.outputs.runner) }} + timeout-minutes: ${{ inputs.timeout-minutes }} + steps: + - uses: actions/checkout@v7 + - id: setup + uses: dodi-smart/.github/actions/setup-stack@main + with: + stack: ${{ inputs.stack }} + install: ${{ inputs.install }} + build: ${{ inputs.build }} + java-version: ${{ inputs.java-version }} + node-version: ${{ inputs.node-version }} + bun-version: ${{ inputs.bun-version }} + flutter-version: ${{ inputs.flutter-version }} + rust-toolchain: ${{ inputs.rust-toolchain }} + + # A build often needs config present but not real — a Next.js build reads + # public env at build time and fails without it. These are PLACEHOLDERS + # supplied by the caller in plain sight, never secrets: a value that must + # stay secret has no business being visible in a PR build log. + - name: Build-time configuration + if: inputs.build-env != '' + env: + BUILD_ENV: ${{ inputs.build-env }} + run: | + set -euo pipefail + while IFS= read -r line; do + [ -z "$line" ] && continue + echo "$line" >> "$GITHUB_ENV" + done <<< "$BUILD_ENV" + + - name: Install + if: steps.setup.outputs.install != '' + run: ${{ steps.setup.outputs.install }} + - name: Build + if: steps.setup.outputs.build != '' + run: ${{ steps.setup.outputs.build }} diff --git a/.github/workflows/pr-review.yml b/.github/workflows/pr-review.yml index 1005ee2..8a5ea27 100644 --- a/.github/workflows/pr-review.yml +++ b/.github/workflows/pr-review.yml @@ -120,9 +120,11 @@ jobs: fi fi - echo "proceed=$proceed" >> "$GITHUB_OUTPUT" - echo "level=$level" >> "$GITHUB_OUTPUT" - echo "reason=$reason" >> "$GITHUB_OUTPUT" + { + echo "proceed=$proceed" + echo "level=$level" + echo "reason=$reason" + } >> "$GITHUB_OUTPUT" echo "decision: proceed=$proceed level=$level ${reason:+($reason)}" # Why CI failed, gathered deterministically and handed to the reviewer. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3361ee3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,166 @@ +# Reusable: cut releases with semantic-release. +# +# The multi-module repo in this org ran FIVE near-identical jobs — one per +# module, chained with `needs:` and `always()` — each checking out, each setting +# up Node, and each running the same nine-package `npm install -D` before +# calling semantic-release with a different `--extends`. That is four redundant +# installs and four copies of a job body whose only real difference is one +# filename. +# +# Modules are ordered and released in ONE job here, because they were already +# strictly sequential: the chain existed to order them, not to parallelise. The +# install happens once. +name: Release + +on: + workflow_call: + inputs: + stack: + type: string + default: "node" # the toolchain the release scripts need + + # ORDERED list of semantic-release configs, as a JSON array. Each is run in + # turn with `--extends`. Order is the dependency order between modules. + # modules: '["./.releaserc.probe.js", "./.releaserc.prevention.js"]' + # Leave empty for a single-package repo. + modules: + type: string + default: "[]" + + # For a repo that already wraps semantic-release in its own script + # (e.g. `bun run release`). Wins over `modules` when set. + release-command: + type: string + default: "" + + # Installed only when `release-command` is empty, i.e. when this workflow + # drives semantic-release itself. + semantic-release-packages: + type: string + default: >- + semantic-release + @semantic-release/git + @semantic-release/github + @semantic-release/changelog + @semantic-release/exec + semantic-release-replace-plugin + conventional-changelog-conventionalcommits@9 + @kilianpaquier/semantic-release-backmerge + semantic-release-scope-filter + + # Some release configs read a monotonic build number. + expose-build-number: + type: boolean + default: true + + backmerge: { type: boolean, default: false } + backmerge-from: { type: string, default: "main" } + backmerge-to: { type: string, default: "develop" } + + node-version: { type: string, default: "lts/*" } + bun-version: { type: string, default: "latest" } + timeout-minutes: { type: number, default: 30 } + secrets: + GH_APP_CLIENT_ID: { required: false } + GH_APP_PRIVATE_KEY: { required: false } + +permissions: + contents: write + issues: write + pull-requests: write + +jobs: + release: + runs-on: ubuntu-latest + timeout-minutes: ${{ inputs.timeout-minutes }} + # A release that is half-applied is worse than one that is late, so a second + # run queues behind the first rather than cancelling it. + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN || github.token }} + + - uses: dodi-smart/.github/actions/setup-stack@main + with: + stack: ${{ inputs.stack }} + node-version: ${{ inputs.node-version }} + bun-version: ${{ inputs.bun-version }} + install: '' # the release path installs what it needs, below + + # semantic-release itself always runs on Node, whatever the repo's stack + # is — an Android repo still releases with it. + - if: inputs.stack != 'node' + uses: actions/setup-node@v7 + with: + node-version: ${{ inputs.node-version }} + + - id: build-number + if: inputs.expose-build-number + run: echo "number=$(git rev-list --count HEAD)" >> "$GITHUB_OUTPUT" + + - if: inputs.release-command == '' + name: Install semantic-release + env: + PACKAGES: ${{ inputs.semantic-release-packages }} + run: | + set -euo pipefail + # Unquoted on purpose: PACKAGES is a space-separated list of package + # specs, and word-splitting is exactly what is wanted here. + # shellcheck disable=SC2086 + npm install -D $PACKAGES + + - name: Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN || github.token }} + BUILD_NUMBER: ${{ steps.build-number.outputs.number }} + MODULES: ${{ inputs.modules }} + CMD: ${{ inputs.release-command }} + run: | + set -euo pipefail + if [ -n "$CMD" ]; then + eval "$CMD" + exit 0 + fi + + count=$(jq 'length' <<<"$MODULES") + if [ "$count" = "0" ]; then + npx semantic-release + exit 0 + fi + + # Sequential and fail-fast. If probe fails to release, releasing the + # app that depends on it against a version that does not exist is + # worse than stopping. + for cfg in $(jq -r '.[]' <<<"$MODULES"); do + echo "::group::semantic-release $cfg" + npx semantic-release --extends "$cfg" + echo "::endgroup::" + done + + # A real merge commit, not a force-push: the release commit must stay + # reachable from both branches or the next run recomputes versions from a + # history that no longer matches the tags. + backmerge: + needs: [release] + if: inputs.backmerge && github.ref == format('refs/heads/{0}', inputs.backmerge-from) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + ref: ${{ inputs.backmerge-from }} + token: ${{ secrets.GITHUB_TOKEN || github.token }} + - env: + FROM: ${{ inputs.backmerge-from }} + TO: ${{ inputs.backmerge-to }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git fetch origin "$TO:$TO" + git checkout "$TO" + # [skip ci] so the merge does not trigger the release workflow again. + git merge "$FROM" --no-ff -m "chore(release): merge $FROM into $TO [skip ci]" + git push origin "$TO" diff --git a/.github/workflows/self-test.yml b/.github/workflows/self-test.yml index c2671d2..8da4513 100644 --- a/.github/workflows/self-test.yml +++ b/.github/workflows/self-test.yml @@ -11,6 +11,7 @@ on: paths: - 'actions/**' - '.github/workflows/**' + - 'default.json5' push: branches: [main] workflow_dispatch: @@ -48,6 +49,18 @@ jobs: sys.exit(bad) " + # A JSON5 object literal is also a valid JavaScript expression, so this + # catches a malformed preset without pulling in a JSON5 parser. A broken + # preset silently disables Renovate config in every repo extending it. + - name: Renovate preset parses + run: | + node -e ' + const o = eval("(" + require("fs").readFileSync("default.json5","utf8") + ")"); + if (!Array.isArray(o.packageRules)) throw new Error("packageRules missing"); + if (!o.labels.includes("deps:update")) throw new Error("deps:update label missing"); + console.log("preset OK:", Object.keys(o).join(", ")); + ' + - name: Shell scripts pass shellcheck run: | sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck diff --git a/.github/workflows/zavet-check.yml b/.github/workflows/zavet-check.yml new file mode 100644 index 0000000..cb0bf65 --- /dev/null +++ b/.github/workflows/zavet-check.yml @@ -0,0 +1,196 @@ +# Reusable: verify a repo's .zavet/ knowledge layer on a pull request. +# +# The knowledge layer had local git hooks and nothing in CI, which meant its +# guarantees held exactly as long as every contributor had the hooks installed +# and never used --no-verify. A decision check could rot for months with nothing +# failing — the same shape of invisible failure DODI-00007 exists to prevent, +# one level up. +# +# SEVERITY MIRRORS THE LOCAL HOOKS, deliberately: +# +# decision checks FAIL (a decision that no longer holds is wrong, not stale) +# guard trailers FAIL (the commit-msg hook blocks locally; so does this) +# spec currency WARN (the pre-commit hook is warn-only and always exits 0) +# +# Inverting either of those makes CI disagree with the hooks, and then people +# learn to distrust one of them. +# +# Some decision checks reach the GitHub API on purpose — a decision about a file +# in another repo is verified rather than asserted. That is the cost recorded in +# DODI-00011 for keeping one id sequence across two repos, so this workflow +# needs a token and network. +name: Zavet Check + +on: + workflow_call: + inputs: + checks: { type: boolean, default: true } # run every decision's `checks:` + guards: { type: boolean, default: true } # require trailers on guarded paths + audit: { type: boolean, default: false } # report-only health sweep + zavet-dir: { type: string, default: ".zavet" } + runner-weight: { type: string, default: "light" } + runner-labels: { type: string, default: "" } + yq-version: { type: string, default: "v4.53.3" } + secrets: + GH_APP_CLIENT_ID: { required: false } + GH_APP_PRIVATE_KEY: { required: false } + +jobs: + pick-runner: + uses: dodi-smart/.github/.github/workflows/pick-runner.yml@main + with: + weight: ${{ inputs.runner-weight }} + labels: ${{ inputs.runner-labels }} + secrets: inherit + + check: + needs: [pick-runner] + runs-on: ${{ fromJson(needs.pick-runner.outputs.runner) }} + timeout-minutes: 15 + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v7 + with: { fetch-depth: 0 } + + - id: present + env: + DIR: ${{ inputs.zavet-dir }} + run: | + set -euo pipefail + if [ -d "$DIR" ]; then + echo "yes=true" >> "$GITHUB_OUTPUT" + else + echo "yes=false" >> "$GITHUB_OUTPUT" + echo "::notice::no $DIR/ in this repository — nothing to check" + fi + + # mikefarah yq specifically. check.sh reads decision frontmatter with it, + # and the python `yq` of the same name takes different arguments — an + # easy and confusing way for every check to report NONE. + - if: steps.present.outputs.yes == 'true' + env: + YQ: ${{ inputs.yq-version }} + run: | + set -euo pipefail + if ! command -v yq >/dev/null || ! yq --version 2>&1 | grep -q mikefarah; then + sudo curl -fsSL -o /usr/local/bin/yq \ + "https://github.com/mikefarah/yq/releases/download/${YQ}/yq_linux_amd64" + sudo chmod +x /usr/local/bin/yq + fi + yq --version + + - id: checks + if: steps.present.outputs.yes == 'true' && inputs.checks + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + DIR: ${{ inputs.zavet-dir }} + run: | + set -uo pipefail + out=$("$DIR/check.sh" 2>&1); rc=$? + printf '%s\n' "$out" + { + echo "### Decision checks" + echo '```' + printf '%s\n' "$out" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + printf '%s\n' "$out" | grep -E '^\s*FAIL' | while read -r line; do + echo "::error title=Decision check failed::$line" + done + echo "rc=$rc" >> "$GITHUB_OUTPUT" + exit "$rc" + + # Guards are per-commit: the trailer belongs to the commit that touched + # the guarded path, so checking the squashed diff would let one trailer + # cover changes it never described. + - id: guards + if: steps.present.outputs.yes == 'true' && inputs.guards && github.event_name == 'pull_request' + continue-on-error: true + env: + DIR: ${{ inputs.zavet-dir }} + BASE: ${{ github.event.pull_request.base.sha }} + HEAD: ${{ github.event.pull_request.head.sha }} + run: | + set -uo pipefail + zavet="$DIR/bin/zavet" + if [ ! -x "$zavet" ]; then + echo "::notice::$zavet not present or not executable — skipping guard check" + exit 0 + fi + blocked=0 + for sha in $(git rev-list "$BASE".."$HEAD"); do + subject=$(git log -1 --format=%s "$sha") + # Merge commits touch paths their author never edited. + if [ "$(git rev-list --parents -n 1 "$sha" | wc -w)" -gt 2 ]; then + echo "skip (merge) $sha $subject"; continue + fi + git show --name-only --format= "$sha" > "$RUNNER_TEMP/paths.txt" + git log -1 --format=%B "$sha" > "$RUNNER_TEMP/msg.txt" + if reason=$("$zavet" gate --paths-from "$RUNNER_TEMP/paths.txt" \ + --message-file "$RUNNER_TEMP/msg.txt" --guards-only 2>&1); then + echo "ok $sha $subject" + else + blocked=1 + echo "::error title=Guarded change without a trailer::$sha $subject — $reason" + { echo "### Guard wall"; echo "- \`$sha\` $subject"; echo " - $reason"; } >> "$GITHUB_STEP_SUMMARY" + fi + done + exit "$blocked" + + - id: audit + if: steps.present.outputs.yes == 'true' && inputs.audit + continue-on-error: true + env: + DIR: ${{ inputs.zavet-dir }} + run: | + set -uo pipefail + out=$("$DIR/bin/zavet" audit 2>&1 || true) + printf '%s\n' "$out" + { echo "### Audit (report only)"; echo '```'; printf '%s\n' "$out"; echo '```'; } >> "$GITHUB_STEP_SUMMARY" + + # One comment, updated in place. A new comment per push turns a useful + # signal into something people collapse and stop reading. + - if: always() && steps.present.outputs.yes == 'true' && github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + NUM: ${{ github.event.pull_request.number }} + CHECKS: ${{ steps.checks.outcome }} + GUARDS: ${{ steps.guards.outcome }} + run: | + set -uo pipefail + marker="" + status() { case "$1" in success) echo "passed";; skipped) echo "skipped";; *) echo "**FAILED**";; esac; } + body="$marker + **Knowledge layer** + + | Check | Result | + |---|---| + | Decision checks | $(status "$CHECKS") | + | Guard trailers | $(status "$GUARDS") | + + Spec currency is reported in the job summary and never fails a build — the same severity the local pre-commit hook uses." + + id=$(gh api "/repos/$REPO/issues/$NUM/comments" --paginate \ + --jq "[.[] | select(.body | startswith(\"$marker\"))] | .[0].id" 2>/dev/null || echo "") + if [ -n "$id" ] && [ "$id" != "null" ]; then + gh api -X PATCH "/repos/$REPO/issues/comments/$id" -f body="$body" >/dev/null + else + gh api -X POST "/repos/$REPO/issues/$NUM/comments" -f body="$body" >/dev/null + fi + + - name: Fail if a decision check or guard failed + if: always() && steps.present.outputs.yes == 'true' + env: + CHECKS: ${{ steps.checks.outcome }} + GUARDS: ${{ steps.guards.outcome }} + run: | + set -euo pipefail + bad=0 + [ "$CHECKS" = "failure" ] && bad=1 + [ "$GUARDS" = "failure" ] && bad=1 + [ "$bad" = "0" ] || { echo "knowledge-layer checks failed"; exit 1; } + echo "knowledge layer is consistent" diff --git a/actions/agent-gate/action.yml b/actions/agent-gate/action.yml index 78b6993..f0752d9 100644 --- a/actions/agent-gate/action.yml +++ b/actions/agent-gate/action.yml @@ -40,6 +40,16 @@ inputs: commands: description: 'Space-separated comment verbs that request this workflow, e.g. "triage plan". Matched as `@claude `.' default: '' + mention: + description: >- + For the general assistant: the mention that requests it, e.g. "@claude". + When set, a body without it stops the run. + default: '' + exclude-commands: + description: >- + Verbs another workflow owns, declined here. The counterpart to `commands`, + and the reason both lists live in this action instead of in every repo. + default: '' bots: description: 'How to treat bot authors: reject (default) | only | allow' default: 'reject' @@ -76,6 +86,8 @@ runs: DRAFT: ${{ inputs.draft }} COMMENT: ${{ inputs.comment }} COMMANDS: ${{ inputs.commands }} + MENTION: ${{ inputs.mention }} + EXCLUDE: ${{ inputs.exclude-commands }} BOTS: ${{ inputs.bots }} SKIPDRAFT: ${{ inputs.skip-draft }} FILES: ${{ inputs.changed-files }} diff --git a/actions/agent-gate/gate.sh b/actions/agent-gate/gate.sh index e0ad358..422466e 100755 --- a/actions/agent-gate/gate.sh +++ b/actions/agent-gate/gate.sh @@ -72,7 +72,25 @@ if [ "$ACTION" = "labeled" ]; then stop "labeled '$LABEL' — only $REQUEST requests this workflow" fi -# RULE 5 — comment commands, as `@claude `. The verb list is an +# RULE 5 — the general assistant. Proceeds on a bare mention, and DECLINES +# the verbs another workflow owns. +# +# This is the inverse of RULE 6 and the reason both live here. Every repo used +# to hand-maintain this exclusion in its own claude.yml — twice, once per job — +# and keep it in sync with the triage caller's verb list by hand. It had already +# drifted: one repo guarded pull_request_review_comment and the other did not. +# One list, one place; adding a verb is one commit instead of one per repo. +if [ -n "${MENTION:-}" ]; then + printf '%s' "$COMMENT" | grep -qF "$MENTION" || stop "no $MENTION in the body" + for verb in ${EXCLUDE:-}; do + if printf '%s' "$COMMENT" | grep -qiE "${MENTION}[[:space:]]+${verb}\b"; then + stop "reserved command '$verb' — another workflow owns it" + fi + done + go "mentioned: $MENTION" +fi + +# RULE 6 — comment commands, as `@claude `. The verb list is an # input so it lives in ONE place; it used to be hand-copied into every # repo's claude.yml and had already drifted between two of them. if [ "$EVENT" = "issue_comment" ] && [ -n "${COMMANDS:-}" ]; then diff --git a/actions/agent-gate/test.sh b/actions/agent-gate/test.sh index ea89356..24b4806 100755 --- a/actions/agent-gate/test.sh +++ b/actions/agent-gate/test.sh @@ -22,6 +22,7 @@ case_() { export GITHUB_OUTPUT="$TMP/out"; : > "$GITHUB_OUTPUT" LABELS="$3" EVENT="$4" ACTION="$5" LABEL="$6" REQUEST="$7" AUTHOR="$8" DRAFT="$9" \ COMMENT="${10}" COMMANDS="${11}" BOTS="${12}" SKIPDRAFT="${13}" FILES="${14}" \ + MENTION="${15:-}" EXCLUDE="${16:-}" \ bash "$HERE/gate.sh" >"$TMP/log" 2>&1 local got reason mark got=$(grep '^proceed=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2) @@ -63,6 +64,15 @@ case_ true "dependabot PR" '[]' pull_request synchronize '' case_ true "renovate + labeled agent:review" '[]' pull_request labeled agent:review agent:review 'renovate[bot]' false '' '' only true '' case_ false "renovate + labeled deps:major" '[]' pull_request labeled deps:major agent:review 'renovate[bot]' false '' '' only true '' +echo "== claude-assist (mention, minus the verbs other workflows own) ==" +assist() { case_ "$1" "$2" '[]' issue_comment created '' '' alice false "$3" '' allow false '' '@claude' 'triage plan implement'; } +assist true "@claude " '@claude what does this function do?' +assist false "@claude triage (triage owns it)" '@claude triage this please' +assist false "@claude plan (triage owns it)" 'hey @claude plan it out' +assist false "@claude implement (implement owns it)" '@claude implement the plan' +assist false "comment with no mention" 'just a normal comment' +case_ false "no-touch + @claude free-form" '["agent:no-touch"]' issue_comment created '' '' alice false '@claude help' '' allow false '' '@claude' 'triage plan implement' + echo echo "pass=$pass fail=$fail" [ "$fail" -eq 0 ] diff --git a/actions/setup-stack/action.yml b/actions/setup-stack/action.yml index 2bfdb1f..816711f 100644 --- a/actions/setup-stack/action.yml +++ b/actions/setup-stack/action.yml @@ -51,21 +51,33 @@ inputs: description: 'Restore and save the package cache' default: 'true' + # Command overrides. The sentinel "@stack" means "use this stack's + # conventional command"; an explicit empty string means "this repo has no such + # step". Those are different intentions and a plain default cannot express + # both — a Gradle repo genuinely has no install step. Resolving them HERE + # means callers read `steps.setup.outputs.install` and never reimplement the + # precedence rule; three workflows had started to. + install: { description: 'Override the install command', default: '@stack' } + lint: { description: 'Override the lint command', default: '@stack' } + typecheck: { description: 'Override the typecheck command', default: '@stack' } + test: { description: 'Override the test command', default: '@stack' } + build: { description: 'Override the build command', default: '@stack' } + outputs: install: - description: 'The conventional install command for this stack, or empty when it resolves on demand' + description: 'Resolved install command — the override, or the stack default. Empty means skip.' value: ${{ steps.defaults.outputs.install }} lint: - description: 'The conventional lint command for this stack' + description: 'Resolved lint command. Empty means skip.' value: ${{ steps.defaults.outputs.lint }} typecheck: - description: 'The conventional typecheck command, or empty where the compiler already does it' + description: 'Resolved typecheck command. Empty means skip.' value: ${{ steps.defaults.outputs.typecheck }} test: - description: 'The conventional test command for this stack' + description: 'Resolved test command. Empty means skip.' value: ${{ steps.defaults.outputs.test }} build: - description: 'The conventional build command for this stack' + description: 'Resolved build command. Empty means skip.' value: ${{ steps.defaults.outputs.build }} runs: @@ -147,7 +159,12 @@ runs: - id: defaults shell: bash env: - STACK: ${{ inputs.stack }} + STACK: ${{ inputs.stack }} + O_INSTALL: ${{ inputs.install }} + O_LINT: ${{ inputs.lint }} + O_TYPECHECK: ${{ inputs.typecheck }} + O_TEST: ${{ inputs.test }} + O_BUILD: ${{ inputs.build }} run: | set -euo pipefail install=""; lint=""; typecheck=""; test=""; build="" @@ -178,10 +195,13 @@ runs: build="xcodebuild build -scheme \"\$SCHEME\"" ;; none) : ;; esac + # "@stack" -> the conventional command above. Anything else, empty + # string included, is taken literally. + pick() { if [ "$1" = "@stack" ]; then printf '%s' "$2"; else printf '%s' "$1"; fi; } { - echo "install=$install" - echo "lint=$lint" - echo "typecheck=$typecheck" - echo "test=$test" - echo "build=$build" + echo "install=$(pick "$O_INSTALL" "$install")" + echo "lint=$(pick "$O_LINT" "$lint")" + echo "typecheck=$(pick "$O_TYPECHECK" "$typecheck")" + echo "test=$(pick "$O_TEST" "$test")" + echo "build=$(pick "$O_BUILD" "$build")" } >> "$GITHUB_OUTPUT" diff --git a/default.json5 b/default.json5 new file mode 100644 index 0000000..5fae265 --- /dev/null +++ b/default.json5 @@ -0,0 +1,70 @@ +// Shared Renovate preset for dodi-smart. +// +// Use it from a repo with: +// { extends: ["github>dodi-smart/.github"] } +// +// Only what is TRUE OF EVERY REPO lives here. Ecosystem rules — npm depTypes, +// Gradle version catalogs, Kotlin/KSP lockstep, custom datasources — stay in the +// repo that has that ecosystem, because a rule matching nothing is worse than no +// rule: it reads as coverage. +// +// If preset resolution ever fails, check the filename first. Renovate looks for +// `default.json5` or `default.json` in the repo root; older versions know only +// the latter. +{ + $schema: "https://docs.renovatebot.com/renovate-schema.json", + + extends: [ + "config:best-practices", + // Listed AFTER best-practices so it wins. config:recommended's + // :semanticPrefixFixDepsChoreOthers would otherwise make production deps + // `fix(deps)`, which every .releaserc in this org maps to a PATCH release — + // so a dependency bump would cut a release. Forcing `chore(deps)` everywhere + // means it does not. + ":semanticCommitTypeAll(chore)", + ], + + timezone: "Europe/Sofia", + + // Every repo in this org develops on `develop`. Pinned explicitly rather than + // inherited from the default branch, so behaviour stays correct if the default + // branch ever changes. Note Renovate READS its config from the default branch. + baseBranchPatterns: ["develop"], + + // Deterministic prefix; the default is "auto", which is not. + semanticCommits: "enabled", + dependencyDashboard: true, + + // The label the deps-verify workflow and every saved query expect. Changing it + // means changing Renovate's config FIRST and then renaming the label — never + // delete-and-recreate, which silently strips it from every existing PR + // (DODI-00006). + labels: ["deps:update"], + + // Weekly batch. Security PRs ignore the schedule. + schedule: ["before 6am on monday"], + + // NOTE: vulnerabilityAlerts is on by default via config:recommended and works + // on the Mend hosted community app, but only fires if GitHub "Dependabot + // alerts" are enabled for the repo. osvVulnerabilityAlerts is deliberately NOT + // used — experimental, and uncertain on the community tier. + + packageRules: [ + // Keep Actions on readable version tags. best-practices pins digests, which + // turns every workflow into a wall of SHAs and makes review harder for a + // threat this org is not defending against. + { matchManagers: ["github-actions"], pinDigests: false }, + + // Non-major Action bumps are CI-gated and boring. Auto-merge them. + { + matchManagers: ["github-actions"], + matchUpdateTypes: ["minor", "patch"], + groupName: "github actions", + automerge: true, + }, + + // Majors are labelled for visibility, never auto-merged (automerge defaults + // to false, so this rule only adds the label). + { matchUpdateTypes: ["major"], addLabels: ["deps:major"] }, + ], +} From 99b99e6e1128925778df18f0af8b315fd33c6940 Mon Sep 17 00:00:00 2001 From: Asen Lekov Date: Tue, 18 Aug 2026 21:05:23 +0300 Subject: [PATCH 3/5] docs: make this repo self-contained, so it survives the standards repo going private MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight links pointed at dodi-smart/dev-standards across README, AGENTS and CLAUDE. That repo is becoming private, at which point every one of them is a 404 for anyone outside the org — including anyone reading these workflows to work out what they do. A dead link is worse than no link, because it reads as an offer. So the reasoning is restated here rather than referenced. DODI-nnnnn ids stay as citations, but each now sits next to enough prose to act on without resolving it. That duplication is accepted deliberately: the alternative is a public repo that cannot explain itself, or a second id sequence, which was already rejected because two sequences sharing a prefix eventually mint the same id. README is now a usable public reference — quick start, required secrets, every workflow and action, the stack model, runner selection, versioning, and what to check when nothing happens. AGENTS.md keeps the governing-decision table but writes out each rule instead of pointing at it, and records the facts that have cost real debugging time: --allowed-tools is variadic, display_report defaults off, allowed_bots is load-bearing for deps-verify, the secrets context is not available in a step-level if:, and actionlint's snapshot of create-github-app-token predates client-id replacing app-id. CLAUDE.md was a byte-for-byte copy of AGENTS.md, so every change had to be made twice and the second was sometimes forgotten. It is now a pointer. Also removes three things that should not be published: a machine-class nickname list, and two product names that had crept into examples and comments — one in a release `modules:` sample, one in the comment explaining why per-stack templates were retired. The irony of naming a product in the comment about not naming products in templates is noted. Why: the repo has to stand alone the moment the other one goes private. Refs: DODI-00018 Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 2 +- AGENTS.md | 102 ++++++++------ CLAUDE.md | 55 +------- README.md | 245 +++++++++++++++++++++++++++------ actions/setup-stack/action.yml | 6 +- 5 files changed, 270 insertions(+), 140 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3361ee3..f2169bd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ on: # ORDERED list of semantic-release configs, as a JSON array. Each is run in # turn with `--extends`. Order is the dependency order between modules. - # modules: '["./.releaserc.probe.js", "./.releaserc.prevention.js"]' + # modules: '["./.releaserc.core.js", "./.releaserc.app.js"]' # Leave empty for a single-package repo. modules: type: string diff --git a/AGENTS.md b/AGENTS.md index e6a42df..da12d8a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,53 +1,71 @@ # Agent instructions -## This repo is an extension of `dodi-smart/dev-standards` +This repo holds the org's reusable workflows, the composite actions they are +built from, the org-inherited issue templates, and the shared Renovate preset. +`README.md` explains what each does and how to call it; this file is the part you +must read **before changing anything**. -It is not a separate project. It exists because GitHub mandates two fixed -locations, and only these two things live here: +## Read this first -- `.github/ISSUE_TEMPLATE/` — inherited org-wide; only a repo literally named - `.github` provides that -- `.github/workflows/` — the reusable workflows, kept here because this is where - people look for org CI +The reasoning behind these files is recorded elsewhere, privately, as decision +records with ids like `DODI-00008`. Those records cannot inject themselves into +this repo, so the table below restates each one next to the file it governs. The +ids are citations, not links — treat the prose here as the authority. -**Everything else — the skills, the label manifest, the scripts, and all the -reasoning — lives in [`dodi-smart/dev-standards`](https://github.com/dodi-smart/dev-standards).** -Read that repo's `CLAUDE.md` before changing anything here. +Several of those records carry executable checks that **fetch these files over +the API** and assert on their contents. So a change here can fail a check in +another repo. That is intentional: a decision about a file in another repo should +be verified rather than asserted. -## The `.github/.github/` in every caller is correct +## What governs what -`uses:` is `{owner}/{repo}/{path}@{ref}`. This repo is named `.github`, and -GitHub requires reusable workflows to live in `.github/workflows/` of the source -repo — so the segment appears twice, once as the repo and once as the path. Do -not "fix" it to `dodi-smart/.github/workflows/...`; that path does not resolve. +| File | Rule | Why it exists | +|---|---|---| +| `actions/agent-gate/` | **DODI-00008** — `agent:no-touch` is evaluated FIRST, with no exemption | Position matters as much as existence. A check placed after an early return silently stops covering that path. This action exists so the rule has one implementation instead of one per workflow. `test.sh` asserts it across every workflow shape; do not weaken those cases. | +| all agent workflows | **DODI-00005** — deterministic gates run before any agent step, and the runner picker carries the same `if:` as the job it feeds | An agent explaining a compile error is pure waste, and the picker itself costs a hosted minute, so neither may run for an event that will not proceed. | +| `.github/workflows/pr-review.yml` | **DODI-00005** — no `synchronize` trigger | Reviewing every push is what got the previous review workflow muted, and a muted bot reviews nothing. | +| `.github/workflows/deps-verify.yml` | **DODI-00004** — verification never confers merge authority | It must never merge, approve, or change mergeability. Evidence is only useful if it is allowed to be wrong; merging on a clean verdict forces conservative tuning, which produces noise, which gets the report ignored. | +| `.github/workflows/issue-implement.yml` | **DODI-00019** — a plan is required, `Agent mode` gates who may ask, and the PR is always a draft | The check is a field comparison in a gate job, not a question put to the agent, and it has no override. An agent asked whether a plan is adequate will sometimes accept a two-line issue body. | +| `.github/workflows/issue-implement.yml` | **DODI-00003** — `Triage state` stops at "Ready for agent" | Do not add an "in progress" state. The open draft PR and the closed issue already say it, and a mirror is correct only while someone maintains it. | +| `.github/workflows/claude-assist.yml` | **DODI-00014** — `@claude` mentions are a governed workflow, not a per-repo file | This was the one agent workflow the standard did not own, and the only one that did not check `agent:no-touch` — a hole in the kill switch, in the widest surface in the org. Its reserved-verb list must stay the single place those verbs are named. | +| `.github/workflows/pick-runner.yml` | **DODI-00010** — runners are selected by generic capability; public repos and fork PRs always use hosted | Never select by a hardware nickname or a machine name — those are re-registration-unstable and tie every caller to today's fleet. Public repos and fork PRs have no opt-out: a fork PR would run attacker-authored code on our hardware against a cache that persists into the next job. | +| `.github/workflows/pick-runner.yml` | **DODI-00012** — the picker validates its own selector | Falling back to hosted on error is correct, but it makes a selector matching nothing look exactly like a busy fleet. The validation is why that bug cannot hide again. It **warns rather than fails**: a bad selector still runs correctly on hosted, and failing CI over a cost regression would be worse than the bug. | +| `actions/setup-stack/` | **DODI-00015** — stack is an input; there is no template per stack | Never add a per-stack caller template, and never put a product-specific task, module or scheme name in this repo. The previous Gradle template carried one product's task name into every repo told to copy it. | +| all workflows | **DODI-00017** — callers pin a released tag | `v1` moves only after a change runs green on a real repo. Changing or removing an input is breaking: add an alias and warn (as `deps-verify` does for `setup:`), or cut `v2`. | +| `README.md` | **DODI-00018** — this repo is public and self-contained | Never link to the private standards repo. Restate the reasoning instead: a link into a private repo is a 404 to everyone who follows it, which is worse than no link because it reads as an offer. | -## There is one knowledge layer, and it is not in this repo +## Facts worth not rediscovering -Decisions live in `dev-standards/.zavet/`, with ids `DODI-NNNNN`. This repo -deliberately has no `.zavet/` of its own: two sequences sharing the `DODI` prefix -would eventually both mint the same id, and a citation would stop resolving to a -single record. +- **`--allowed-tools ` is VARIADIC** — space-separated, each entry + quoted if it contains parentheses. A comma-joined list parses as one meaningless + token: nothing matches, every `Bash` call is denied with "This command requires + approval", and the job still reports **SUCCESS**. Use `actions/run-agent`, which + gets this right. +- **`display_report` defaults to false**, which makes a run that did nothing + indistinguishable from one that worked. `run-agent` forces it on. +- **`allowed_bots` is load-bearing for `deps-verify`.** Without it the action + aborts with "Workflow initiated by non-human actor". Every run there is + bot-initiated, so the default makes the workflow impossible. +- **The `secrets` context is not available in a step-level `if:`.** Hoist the + presence check into an env var — `pick-runner` does this. +- **Composite action steps support `if:`**, and reference other actions by + `owner/repo/path@ref` — no checkout needed. +- **`actions/create-github-app-token` takes `client-id`;** `app-id` is deprecated + upstream. actionlint ships a stale snapshot of that action's inputs and will + report both as errors. `Self test` ignores exactly those two messages. +- **BSD/macOS `mktemp -d` with no template ignores `TMPDIR`.** This org has macOS + runners, so always pass an explicit template. +- **Broad `Bash` in the agent allowlist is deliberate.** The bound is not the + allowlist — the action refuses to run for an actor without write access, so + untrusted content only reaches the agent when someone trusted invokes it. -So the records governing the files here cannot inject themselves automatically. -Read them before editing: +## If you add a workflow -| File | Governed by | -|---|---| -| `.github/workflows/deps-verify.yml` | **DODI-00004** — verification never confers merge authority. This workflow must never merge, approve, or change mergeability. | -| all three workflows | **DODI-00005** — deterministic gates run before any agent step, and the runner picker carries the same `if:` as the job it feeds. | -| all three workflows | **DODI-00008** — `agent:no-touch` is checked before every other condition, with no exemption. | -| all three workflows | **DODI-00010** — runners are selected by generic capability (`self-hosted,Linux,ARM64` light, `Linux,X64` heavy); public repos and fork PRs always use hosted. | -| `.github/workflows/pr-review.yml` | **DODI-00005** — no `synchronize` trigger. Reviewing every push is what got the previous review workflow muted. | - -Those decisions carry executable checks that fetch these files over the API, so -they are verified rather than assumed. Run them from `dev-standards`: - -```bash -.zavet/check.sh -``` - -## If you add a workflow here - -Add its governing decision ids to the table above, and add a check in -`dev-standards` that fetches it. A workflow nobody recorded a reason for is one -the next person will "simplify". +1. Gate it with `actions/agent-gate` so `agent:no-touch` is checked first. +2. Run it through `actions/run-agent` rather than calling the action directly. +3. Add its governing decision ids to the table above, **with the reasoning + written out** — a workflow nobody recorded a reason for is one the next person + will "simplify". +4. If it introduces a new gate shape, add cases to `actions/agent-gate/test.sh`. +5. If it adds a `@claude` verb, add that verb to `claude-assist.yml`'s + `reserved-commands` default in the same change, or the assistant answers it too. diff --git a/CLAUDE.md b/CLAUDE.md index e6a42df..caac9fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,53 +1,6 @@ -# Agent instructions +# CLAUDE.md -## This repo is an extension of `dodi-smart/dev-standards` +See **[AGENTS.md](AGENTS.md)**. -It is not a separate project. It exists because GitHub mandates two fixed -locations, and only these two things live here: - -- `.github/ISSUE_TEMPLATE/` — inherited org-wide; only a repo literally named - `.github` provides that -- `.github/workflows/` — the reusable workflows, kept here because this is where - people look for org CI - -**Everything else — the skills, the label manifest, the scripts, and all the -reasoning — lives in [`dodi-smart/dev-standards`](https://github.com/dodi-smart/dev-standards).** -Read that repo's `CLAUDE.md` before changing anything here. - -## The `.github/.github/` in every caller is correct - -`uses:` is `{owner}/{repo}/{path}@{ref}`. This repo is named `.github`, and -GitHub requires reusable workflows to live in `.github/workflows/` of the source -repo — so the segment appears twice, once as the repo and once as the path. Do -not "fix" it to `dodi-smart/.github/workflows/...`; that path does not resolve. - -## There is one knowledge layer, and it is not in this repo - -Decisions live in `dev-standards/.zavet/`, with ids `DODI-NNNNN`. This repo -deliberately has no `.zavet/` of its own: two sequences sharing the `DODI` prefix -would eventually both mint the same id, and a citation would stop resolving to a -single record. - -So the records governing the files here cannot inject themselves automatically. -Read them before editing: - -| File | Governed by | -|---|---| -| `.github/workflows/deps-verify.yml` | **DODI-00004** — verification never confers merge authority. This workflow must never merge, approve, or change mergeability. | -| all three workflows | **DODI-00005** — deterministic gates run before any agent step, and the runner picker carries the same `if:` as the job it feeds. | -| all three workflows | **DODI-00008** — `agent:no-touch` is checked before every other condition, with no exemption. | -| all three workflows | **DODI-00010** — runners are selected by generic capability (`self-hosted,Linux,ARM64` light, `Linux,X64` heavy); public repos and fork PRs always use hosted. | -| `.github/workflows/pr-review.yml` | **DODI-00005** — no `synchronize` trigger. Reviewing every push is what got the previous review workflow muted. | - -Those decisions carry executable checks that fetch these files over the API, so -they are verified rather than assumed. Run them from `dev-standards`: - -```bash -.zavet/check.sh -``` - -## If you add a workflow here - -Add its governing decision ids to the table above, and add a check in -`dev-standards` that fetches it. A workflow nobody recorded a reason for is one -the next person will "simplify". +This file used to be a byte-for-byte copy of it, which meant every change had to +be made twice and the second one was sometimes forgotten. One source. diff --git a/README.md b/README.md index ba27644..51f3211 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,199 @@ # .github -Org-wide GitHub defaults for `dodi-smart`. +Org-wide GitHub defaults for `dodi-smart`: the issue templates every repo +inherits, the reusable workflows every repo calls, the composite actions those +workflows are built from, and the shared Renovate preset. -**This repo is an add-on to [`dodi-smart/dev-standards`](https://github.com/dodi-smart/dev-standards), not a separate project.** -Think of it as that repo's GitHub-mandated mount point. It holds only what has to -live at a fixed path: +This repo is **public** because it has to be: a workflow run must be able to read +the workflows it calls, and a public reusable workflow is the ordinary way to do +that across an org. Nothing here is product-specific — no product names, no +hostnames, no secret values. -- `.github/ISSUE_TEMPLATE/` — issue templates, inherited by every repo in the org - that does not define its own -- `.github/workflows/` — reusable workflows called by thin callers in each repo +It is deliberately self-contained. `DODI-nnnnn` citations appear throughout and +refer to decision records kept privately; every one is written next to enough +reasoning to act on without looking it up. -Everything else — the skills, the label manifest, the scripts, and all the -reasoning — lives in **[`dodi-smart/dev-standards`](https://github.com/dodi-smart/dev-standards)**. -There is one knowledge layer and it is over there; see `AGENTS.md` in this repo -for which decisions govern these workflows, and `DODI-00011` for why the split is -drawn here. +## Quick start -## Issue templates +```yaml +# .github/workflows/triage.yml in your repo +name: Triage +on: + issues: { types: [opened, reopened, labeled] } + issue_comment: { types: [created] } +jobs: + triage: + uses: dodi-smart/.github/.github/workflows/issue-triage.yml@v1 + secrets: inherit +``` -| Template | For | +Required secrets on the calling repo: + +| Secret | Needed for | |---|---| -| Bug | Something behaves incorrectly | -| Feature | A capability that does not exist yet | -| Customer request (unrefined) | Raw customer ask — paste it verbatim and let triage work out the questions | -| Chore | Maintenance with no user-visible change | +| `CLAUDE_CODE_OAUTH_TOKEN` | every agent workflow | +| `GH_APP_CLIENT_ID` + `GH_APP_PRIVATE_KEY` | validating the runner selector against the live fleet | -Blank issues stay enabled: the `gh` CLI, the `file-issue` skill and agents all -create bare issues, and forcing them through a form would break every scripted path. +`GH_APP_CLIENT_ID` is the one name for that secret. Repos carrying the older +`GH_APP_ID` should be migrated: upstream deprecated `app-id` in favour of +`client-id`, and without it the runner picker cannot read the org runner list, so +it skips validation — and an unvalidated selector that matches nothing looks +exactly like a busy fleet (DODI-00012). ## Reusable workflows -| Workflow | Purpose | +| Workflow | Fires on | Does | +|---|---|---| +| `issue-triage.yml` | issue opened · `agent:triage` · `@claude triage` / `@claude plan` | Classifies, labels areas, sets fields, then plans **or** asks blocking questions | +| `issue-implement.yml` | `agent:implement` · `@claude implement` | Branch, code, **draft PR**. Requires a plan. Never merges. | +| `pr-review.yml` | `ready_for_review` · `agent:review` | Second-opinion review, deeper on sensitive paths | +| `deps-verify.yml` | Renovate/Dependabot PRs | Builds, reads upstream changelogs, posts a verdict. **Never merges.** | +| `claude-assist.yml` | `@claude ` | The general assistant | +| `pr-checks.yml` | pull request | Lint, typecheck, test, build — per stack | +| `release.yml` | push to a release branch | semantic-release, single- or multi-module | +| `zavet-check.yml` | pull request | Knowledge-layer checks, for repos that have one | +| `pick-runner.yml` | called by the others | Chooses a runner and validates the choice | + +## Composite actions + +| Action | Purpose | |---|---| -| `issue-triage.yml` | Classify an issue, then plan it or ask what is missing | -| `deps-verify.yml` | Build and verify Renovate/Dependabot PRs. Never merges. | -| `pr-review.yml` | Second-opinion review, with a security pass on sensitive paths | +| `actions/agent-gate` | Decides whether an agent may run. Evaluates `agent:no-touch` first, always | +| `actions/run-agent` | Invokes the agent with the org's tool allowlist and reporting defaults | +| `actions/setup-stack` | Installs a toolchain, isolates its caches, supplies conventional commands | + +## Two things you can rely on + +**`agent:no-touch` stops everything.** It is checked before every other +condition, in every agent workflow, with no exemption — not `workflow_dispatch`, +not an explicit command, not a maintainer. A kill switch that works on only some +code paths is not a kill switch, and position matters as much as existence: a +check placed after an early return silently stops covering that path. It is one +implementation in `actions/agent-gate`, with tests covering every workflow shape +(DODI-00008). + +**No agent merges anything.** Dependency verification posts a verdict and leaves +merge policy to Renovate's own rules; the implement workflow opens a draft PR and +stops. Evidence is only useful if it is allowed to be wrong, and merging on a +clean verdict forces conservative tuning, which produces noise, which gets the +report ignored (DODI-00004, DODI-00019). -Called from a repo as: +## Labels are requests; fields are state + +Labels are the only thing that fires a workflow, so they are how you *ask*. +Fields are queryable across repos, so they are where the answer *lives*. Each +fact sits on exactly one of them, because two sources for one fact disagree +within weeks and then neither is trusted (DODI-00001). + +Every `agent:*` label is **self-clearing**: the workflow removes it when it +finishes, including when it refuses. If it is still there, the work is genuinely +running. + +``` + agent:triage ─► classify, then plan or ask + agent:implement ─► branch + draft PR (only when Triage state = Plan ready) + agent:review ─► review this PR (or re-verify a dependency PR) + agent:no-touch ─► stop everything, no exemptions +``` + +`agent:implement` does nothing unless the issue is planned. That check is a field +comparison in a gate job, run before a runner is picked, with no override — an +agent asked to judge whether a plan is good enough will sometimes accept a +two-line issue body, and the cost is twenty minutes of confident work on the +wrong thing plus a PR someone must read to discover it was wrong (DODI-00019). + +## Stacks + +The stack is an **input**, not a separate template: +`bun`, `node`, `gradle`, `android`, `flutter`, `rust`, `xcode`, `none`. ```yaml -jobs: - triage: - uses: dodi-smart/.github/.github/workflows/issue-triage.yml@main - secrets: inherit +with: + stack: gradle + install: "" # "" means this repo has no such step + build: ./gradlew assembleDebug ktlintCheck ``` -### Why `.github/.github/` +`"@stack"` (the default) means that stack's conventional command; `""` means the +step does not exist here. Those are different intentions — Gradle and Cargo +resolve dependencies on demand and genuinely have no install step — and a plain +default cannot express both. -Not a typo, and not removable. A `uses:` value is `{owner}/{repo}/{path}@{ref}`. -This repo is *named* `.github`, and GitHub requires every reusable workflow to -live in `.github/workflows/` of its source repo — so both segments contain it. +There used to be a caller template per stack. It failed the way templates fail: +the Gradle one carried one product's Gradle task name, in a file every other +Gradle repo was told to copy (DODI-00015). + +Package caches are pinned to job-scoped directories. Self-hosted runners persist +between jobs, so default caches are shared, and a half-written entry poisons +every later run — which `restore-keys` then faithfully restores, so re-running +does not clear it. + +## Runners -It is also the ecosystem norm; the same shape appears wherever a `.github` repo -hosts shared CI: +`pick-runner.yml` takes a semantic `weight` and resolves it: + +| `weight` | Selector | Hardware | +|---|---|---| +| `light` | `self-hosted,Linux,ARM64` | small pool — lint, typecheck, releases, reading a diff | +| `heavy` | `self-hosted,Linux,X64` | large pool — builds, Docker, full suites | +| `apple` | `self-hosted,macOS,ARM64` | Apple toolchain, signing | +| `hosted` | — | forces `ubuntu-latest` | + +Public repos and fork PRs **always** get hosted runners, with no way to opt out: +the runner group refuses public repos, and a fork PR would otherwise run +attacker-authored code on our own hardware against a cache that persists into the +next job (DODI-00010). + +The picker validates its selector against the live fleet and annotates the run +when it matches nothing. That check matters more than the sharing does: falling +back to hosted on error is correct behaviour, but it makes a selector matching +nothing indistinguishable from a busy fleet — both produce a successful hosted +run, with no signal to notice. An org-wide selector asking for a label no runner +carried survived months that way (DODI-00012). + +## Versioning + +Pin `@v1`. It is a moving tag, advanced only after a change has run green on a +real repo. A breaking input change cuts `v2` rather than redefining `v1`. + +Do not pin `@main`. Every caller used to, which meant one commit here took effect +in every repo simultaneously, with no staging step and no way to roll back except +another commit while CI was already broken everywhere (DODI-00017). + +It also gives workflow changes a way to be tested at all: `claude-code-action` +refuses to run when the workflow file differs from the default-branch copy — a +correct control, since a PR could otherwise edit the reviewer to exfiltrate its +token — so a change can only be verified *after* merging. Merge to `main`, verify +on one repo pinned to `main`, then move `v1` (DODI-00013). + +## Renovate + +```json5 +{ extends: ["github>dodi-smart/.github"] } +``` + +`default.json5` carries only what is true of every repo. Ecosystem rules stay in +the repo that has that ecosystem, because a rule matching nothing is worse than +no rule — it reads as coverage. + +## Issue templates + +| Template | For | +|---|---| +| Bug | Something behaves incorrectly | +| Feature | A capability that does not exist yet | +| Customer request (unrefined) | Raw customer ask — paste it verbatim and let triage work out the questions | +| Chore | Maintenance with no user-visible change | + +Blank issues stay enabled: the `gh` CLI and agents create bare issues, and forcing +them through a form would break every scripted path. + +## Why `.github/.github/` + +Not a typo, and not removable. A `uses:` value is `{owner}/{repo}/{path}@{ref}`. +This repo is *named* `.github`, and GitHub requires reusable workflows to live in +`.github/workflows/` of their source repo — so both segments contain it. The same +shape appears wherever a `.github` repo hosts shared CI: ```yaml uses: stylelint/.github/.github/workflows/call-lint.yml@ab79793 @@ -60,14 +201,32 @@ uses: craftcms/.github/.github/workflows/ci.yml@v3 ``` `dodi-smart/.github/workflows/...` would resolve to a file at `workflows/` in the -repo root, which GitHub will not accept as a reusable workflow. The only way to -shorten the line is to host the workflows in a repo *not* named `.github` — which -puts org CI where nobody looks for it (DODI-00011). +repo root, which GitHub will not accept. Composite actions have no such rule, so +they are referenced with a single segment: +`dodi-smart/.github/actions/agent-gate@v1`. + +## When nothing happens + +Check **state** before contents. A `disabled_manually` workflow produces no runs, +no logs and no failures — every signal a person looks for is absent, which reads +exactly like "nothing needed doing". Six workflows across three repos sat that way +for five months (DODI-00007). + +```bash +gh api /repos/dodi-smart//actions/workflows --jq '.workflows[]|"\(.state)\t\(.name)"' +``` + +Otherwise: the PR may change the workflow itself (see Versioning), or +`agent:no-touch` may be set — which is working as intended, and is checked before +everything else, so nothing in the log will hint at it. -Thin caller templates are in -`dev-standards/skills/repo-triage-setup/assets/workflows/`. +## Contributing -## Onboarding a repo +`Self test` runs on every PR touching `actions/`, `.github/workflows/` or the +Renovate preset. It asserts the kill switch across every workflow shape, parses +every YAML file, shellchecks the scripts, validates the Renovate preset, and runs +actionlint. -Ask Claude: *"Use repo-triage-setup to onboard dodi-smart/<repo>"*. -It dry-runs first and stops for confirmation. +If you add a workflow, add its governing decision ids to `AGENTS.md` with the +reasoning written out, and extend `actions/agent-gate/test.sh` if it introduces a +new gate shape. diff --git a/actions/setup-stack/action.yml b/actions/setup-stack/action.yml index 816711f..0a22bff 100644 --- a/actions/setup-stack/action.yml +++ b/actions/setup-stack/action.yml @@ -5,9 +5,9 @@ # The old shape was a thin-caller file per stack (deps-verify.yml, # deps-verify.gradle.yml, ...). Five stacks across three workflows is fifteen # files that must be kept in agreement, and the Gradle one had already grown a -# hardcoded `assembleInfrasensingDebug` — one product's task name sitting in a -# template every other Gradle repo was meant to copy. Stack is a VALUE, so it -# belongs in an input. +# hardcoded product-specific Gradle task name — sitting in a template every other +# Gradle repo was meant to copy, where it produces a build failure whose cause is +# a different product. Stack is a VALUE, so it belongs in an input. # # WHY THE CACHE DIRS ARE JOB-SCOPED # From 597a8c381fb2032459ca6829bd4cc778726b0917 Mon Sep 17 00:00:00 2001 From: Asen Lekov Date: Tue, 18 Aug 2026 21:06:18 +0300 Subject: [PATCH 4/5] docs(agents): record that internal refs still pin @main, and the order to fix it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A @v1-pinned caller currently still picks up main's pick-runner and composite actions, which undercuts what pinning is for. The order is deliberate — repos still on @main would break if the internal refs moved first — but step 4 of that sequence is the one that gets forgotten, so it is written down rather than left as tribal knowledge. Refs: DODI-00017 Co-Authored-By: Claude Opus 5 --- AGENTS.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index da12d8a..eaea863 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,25 @@ be verified rather than asserted. allowlist — the action refuses to run for an actor without write access, so untrusted content only reaches the agent when someone trusted invokes it. +## Known follow-up: the internal self-references still say `@main` + +The workflows here call each other — and their composite actions — at `@main`, +while callers outside are told to pin `@v1`. That is inconsistent, and it means a +`@v1`-pinned caller still picks up `main`'s `pick-runner` and actions. It +undercuts what pinning is for, so it is a gap to close, not a design. + +It is deliberate only in its ORDER. Repos that have not migrated still pin +`@main`, so flipping the internal references to `@v1` before they move would +break them in the window between merge and migration. The sequence is: + +1. Merge with internal references at `@main` — existing `@main` callers keep working. +2. Cut `v1`. +3. Migrate every repo to `@v1`. +4. **Then** flip the internal references to `@v1` and move the tag. + +Step 4 is the one that gets forgotten. Until it is done, `@v1` pins the workflow +bodies but not what they call. + ## If you add a workflow 1. Gate it with `actions/agent-gate` so `agent:no-touch` is checked first. From a0020a3be0a5582755d69c57835e59449c373c22 Mon Sep 17 00:00:00 2001 From: Asen Lekov Date: Tue, 18 Aug 2026 22:09:14 +0300 Subject: [PATCH 5/5] fix(self-test): run actionlint directly; the docker action mangles quoted args MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `uses: docker://rhysd/actionlint` passes `args` through word-splitting that does not honour quotes, so an -ignore pattern containing spaces was torn apart and its tail read as a filename — the job failed with 'could not read "actions/create- github-app-token' -ignore ..."'. Running the pinned binary gives real shell quoting, and pins the version rather than tracking :latest. Co-Authored-By: Claude Opus 5 --- .github/workflows/self-test.yml | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/.github/workflows/self-test.yml b/.github/workflows/self-test.yml index 8da4513..ce86c4f 100644 --- a/.github/workflows/self-test.yml +++ b/.github/workflows/self-test.yml @@ -66,13 +66,20 @@ jobs: sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck shellcheck actions/*/*.sh - # `-ignore` on create-github-app-token: actionlint ships a snapshot of - # popular actions' inputs and its copy predates that action moving from - # `app-id` to `client-id`. The workflow is correct; the linter's DB is old. + # Run the binary rather than `uses: docker://rhysd/actionlint`. That form + # word-splits `args` WITHOUT honouring quotes, so an -ignore pattern + # containing spaces is torn apart and its tail is read as a filename. + # + # The two ignores: actionlint ships a snapshot of popular actions' inputs, + # and its copy predates `actions/create-github-app-token` moving from + # `app-id` to `client-id`. The workflows are correct; the linter's DB is old. - name: actionlint - uses: docker://rhysd/actionlint:latest - with: - args: >- - -color - -ignore 'input "client-id" is not defined in action "actions/create-github-app-token' + env: + ACTIONLINT_VERSION: 1.7.12 + run: | + set -euo pipefail + bash <(curl -fsSL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) \ + "$ACTIONLINT_VERSION" >/dev/null + ./actionlint -color \ + -ignore 'input "client-id" is not defined in action "actions/create-github-app-token' \ -ignore 'missing input "app-id" which is required by action "actions/create-github-app-token'