Org-wide GitHub defaults for dodi-smart. Issue templates every repo inherits,
reusable workflows every repo calls, the composite actions those are built from,
and the shared Renovate preset.
A repo here calls a workflow instead of copying one. Its CI becomes a caller file of about twenty lines, and everything below that line is maintained once, in this repo, for every repo at the same time.
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.
Add one file to your repo. That is the whole integration.
# .github/workflows/pr-checks.yml
name: PR checks
on:
pull_request:
branches: [main, develop]
jobs:
checks:
uses: dodi-smart/.github/.github/workflows/pr-checks.yml@v1
with:
stack: bun
secrets: inheritThat gets you lint, typecheck, test and build on the right runner, with the package cache set up correctly for that runner, and it stays correct when the fleet changes.
| Part | What it is for |
|---|---|
uses: |
Which workflow, pinned to @v1. Always pin. |
with: |
Inputs. Stack, commands, runner weight, timeouts. |
secrets: inherit |
Passes the calling repo's secrets through. Nothing is stored here. |
on: and concurrency: |
Stay with you. Triggers and path filters depend on your branches and layout. |
| Secret | Needed for |
|---|---|
CLAUDE_CODE_OAUTH_TOKEN |
every agent workflow |
GH_APP_CLIENT_ID + GH_APP_PRIVATE_KEY |
validating the runner selector against the live fleet |
GH_APP_CLIENT_ID is the one name for that secret. Migrate a repo still carrying
the older GH_APP_ID. Without it the picker cannot read the org runner list, so
it skips validation, and an unvalidated selector that matches nothing looks
exactly like a busy fleet.
| Workflow | Fires on | Does |
|---|---|---|
pr-checks.yml |
pull request | Lint, typecheck, test, build, per stack |
deps-verify.yml |
Renovate/Dependabot PRs | Reads the PR checks result, reads upstream changelogs, fixes code the update broke, posts a one-comment verdict. Never merges. |
pr-review.yml |
ready_for_review, agent:review |
Second-opinion review, deeper on sensitive paths |
issue-triage.yml |
issue opened or reopened, agent:triage, @claude triage, manual dispatch with issue-number |
Classifies, sets fields, then plans or asks blocking questions |
issue-implement.yml |
agent:implement, @claude implement |
Branch, code, draft PR. Requires a plan. Never merges. |
claude-assist.yml |
@claude <anything else> |
The general assistant |
release.yml |
push to a release branch | semantic-release, single or multi-module |
supabase-deploy.yml |
called after release.yml |
Pushes a Supabase project's schema and functions for the tag a release just cut |
react-doctor.yml |
pull request, React repos | Static analysis of React/TS source. Advisory by default. pr-checks.yml can run it as a job instead (react-doctor: true) |
zavet-check.yml |
pull request | Knowledge-layer checks, for repos that have one. Report-only on dependency and automation bot PRs. pr-checks.yml can run it as a job instead (zavet: true) |
supabase-checks.yml |
pull request, Supabase repos | Deno edge-function check, generated-types check, pgTAP tests. Hosted only. |
pick-runner.yml |
called by the others | Chooses a runner and validates the choice |
release.yml reports what it did through workflow_call outputs, so a caller
can chain a deploy job on an actual release rather than a green job:
| Output | Meaning |
|---|---|
released |
'true' when at least one new version tag was pushed, 'false' otherwise |
version |
Newest released version, without the v prefix (e.g. 1.4.0) |
tag |
Newest released tag (e.g. v1.4.0) |
tags |
JSON array of ALL new tags this run, for multi-module repos |
They come from a tag diff taken around the release step, not from parsing
semantic-release's own output, so they work the same way whether the caller
uses modules or a custom release-command.
release.yml can also merge a release branch back into a prerelease branch:
| Input | Default | Meaning |
|---|---|---|
backmerge |
false |
Merge backmerge-from into backmerge-to after releasing |
backmerge-from |
"main" |
Branch the release was cut from |
backmerge-to |
"develop" |
Prerelease branch to merge into |
backmerge-resolve-paths |
"" |
Extra paths to auto-resolve toward backmerge-from on conflict, newline- or space-separated (e.g. a subdirectory manifest and its lockfile) |
A stable release after a prerelease always conflicts on the files both commits
rewrote, so the job auto-resolves package.json, package-lock.json,
bun.lock, pnpm-lock.yaml, yarn.lock and CHANGELOG.md toward
backmerge-from and fails on any other conflict. backmerge-resolve-paths
extends that list; it does not replace it.
The backmerge is the last step of the release job, not a job of its own, so
it reuses that job's token and checkout. It fetches backmerge-from again
before merging, and a failed backmerge fails the job, so a caller chaining a
deploy on release.yml starts it after the backmerge.
When it drives semantic-release itself (no release-command), release.yml
installs the tooling into a private prefix under RUNNER_TEMP, never into the
checkout, so the caller's own dependency tree is not installed alongside it.
semantic-release-packages empty (the default) installs the versions pinned in
actions/release-tooling, from a lockfile, cached on its hash. A list there
replaces that set and installs exactly those packages, unpinned.
supabase-deploy.yml is not triggered on its own. It is a job the caller
chains on release.yml with needs:, gated on release.yml's outputs, so it
deploys the tag a release actually cut rather than the push that started the
run:
jobs:
release:
uses: dodi-smart/.github/.github/workflows/release.yml@v1
secrets: inherit
deploy:
needs: release
# Production: only a cut, non-prerelease tag on main.
if: ${{ !cancelled() && needs.release.outputs.released == 'true' && github.ref == 'refs/heads/main' && !contains(needs.release.outputs.tag, '-') }}
uses: dodi-smart/.github/.github/workflows/supabase-deploy.yml@v1
with:
ref: ${{ needs.release.outputs.tag }}
cli-version: 2.117.0 # renovate: datasource=npm depName=supabase
environment: production
secrets:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
SUPABASE_DB_PASSWORD: ${{ secrets.SUPABASE_DB_PASSWORD }}
SUPABASE_PROJECT_ID: ${{ secrets.SUPABASE_PROJECT_ID }}A staging caller drops the released clause from the if: and deploys on
every push to its branch instead, since staging has nothing to gate on a
release output for.
Do not reach for on: workflow_run: { workflows: ["Release"] } instead. It
fires on completion, including a run that released nothing, so a no-op release
run still triggers a deploy of whatever HEAD happens to be at that moment. And
by default it checks out the SHA that started the original run, not the tag
@semantic-release/git pushes afterwards, so a workflow_run-triggered deploy
ships the commit before the release it is meant to deploy. Chaining with
needs: inside the same run is what makes the actual tag available as an
output at all.
cli-version is not defaulted by this workflow. A default here is a version
Renovate cannot see, so the CLI would upgrade itself with no diff for anyone to
review. Pin it in the caller with a Renovate regex-manager comment, as in the
example above, so a CLI release opens a normal pull request instead of taking
every project's next deploy down at once, the way an undetected supabase/cli
regression once did.
| Input | Default | Meaning |
|---|---|---|
ref |
(required) | Tag or sha to check out and deploy |
cli-version |
(required) | Supabase CLI version, pinned and Renovate-marked in the caller |
environment |
"" |
Name of a GitHub environment configured in the caller's repo. Empty means no environment: no protection rules, no environment secrets |
seed |
false |
Pass --include-seed to supabase db push |
functions |
true |
Deploy supabase/functions when it holds anything; false skips it even if it does |
health-url |
"" |
Base URL to probe after deploy. Empty skips the health check entirely |
health-routes |
"/" |
Space-separated routes appended to health-url |
health-attempts |
6 |
Retries before the health check fails the job |
health-wait-environment |
"" |
Wait for a successful GitHub Deployment of the deployed commit in this environment before probing. See below |
health-sha-route |
"" |
Route on health-url whose body carries the deployed commit's sha. The other proof, for a host that posts no Deployments |
health-wait-timeout-minutes |
10 |
How long the whole wait may take before the job fails |
timeout-minutes |
15 |
Job timeout. Raise it to cover health-wait-timeout-minutes plus the deploy itself |
| Secret | Required | Meaning |
|---|---|---|
SUPABASE_ACCESS_TOKEN |
yes | Supabase CLI auth |
SUPABASE_DB_PASSWORD |
yes | Non-interactive supabase link / db push |
SUPABASE_PROJECT_ID |
yes | The project ref to link and deploy against |
FUNCTION_SECRETS |
no | Multiline KEY=value, one per line, passed to supabase secrets set. Built by the caller from its own secrets (FUNCTION_SECRETS: | followed by NAME=${{ secrets.NAME }} lines) — nothing here is hard-coded with a function secret's name or value. Unset skips the step entirely |
The health check FAILS the job after health-attempts, deliberately: a deploy
that leaves every route erroring must not go green. A warning-only check is
strictly worse than no check, because it reads as a passing signal that
nobody then goes back to question.
On its own, only that health-url answers. A frontend host builds the same push
in parallel with this job and moves its stable URL when its own build ends, so a
check that runs right after db push usually reaches the deployment that was
already live. With neither wait input set, the job prints a notice saying so.
Set one, and the check waits for this commit first:
health-wait-environmentpolls the repository's GitHub Deployments for the deployed commit in that environment, and continues on asuccessstatus. Afailureorerrorstatus, orhealth-wait-timeout-minutespassing, fails the job. Hosts post these as their build progresses. The deployment this job's ownenvironment:creates is ignored, since it succeeds when the job does. The token needsdeployments: read; a caller that narrowspermissions:must keep it.- A release commit carries
[skip ci], which hosts honour, so the host builds the commit before it. When the deployed commit's message asks CI to skip it, the deployment of its parent counts too. health-sha-routeis the fallback for a host without Deployments. The caller exposes a route that returns the commit sha, and the wait passes when the body contains the first seven characters of the deployed commit or of its parent under the same rule.- The routes are then probed at
health-url.
The log shows the deployment id and commit before the routes are probed.
| Action | Purpose |
|---|---|
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, resolves cache isolation, supplies conventional commands |
actions/sticky-comment |
One keyed comment per pull request, rewritten in place on every later run |
actions/deps-intent |
Decides from a dependency PR's diff whether deps-verify.yml has anything new to judge. Internal to that workflow |
actions/release-tooling |
Installs semantic-release and its default plugins from a pinned lockfile into a private prefix, cached on the lockfile hash, and puts semantic-release on PATH |
actions/semantic-release-config |
Links the shared semantic-release config into a consumer's node_modules from a private prefix, never from a registry and never into the checkout it came with |
actions/lcov-report |
Overall and changed-file line coverage from an LCOV file, posted as a sticky comment, with optional floors. What pr-checks.yml's coverage: lcov calls |
actions/changed-files |
A pull request's changed files through the API, with no checkout, and whether any, or every one, match a set of globs |
actions/run-phases |
Runs a job's install, lint, design-lint, typecheck, test, build and smoke commands as timed phases, each in its own subshell, and writes a timing table to the job summary. log-dir also writes each phase's output to a file; keep-going: true runs every phase and the failed output lists the ones that failed. What pr-checks.yml and deps-verify.yml run their commands with |
actions/wait-for-deployment |
Waits for the frontend host's successful GitHub Deployment of the checked-out commit, or for a route to report its sha. What supabase-deploy.yml's health check calls |
actions/supabase-start |
Starts the local Supabase database with the stack's Docker images restored from the cache, saved on a miss. What both jobs of supabase-checks.yml call |
actions/zavet-check |
Verifies a repo's .zavet/ knowledge layer: decision checks, guard trailers, an optional audit, and a comment only on failure. What zavet-check.yml and pr-checks.yml's zavet job run. The caller checks out with full history first. |
actions/react-doctor |
Checks out with full history and runs React Doctor. What react-doctor.yml and pr-checks.yml's react-doctor job run. |
actions/pick-runner |
Resolves a runner weight to a selector, validated against the live fleet. What pick-runner.yml calls, and what a job that already runs hosted (like pr-checks.yml's pick) calls directly to pick more than once without a second hosted job. |
actions/changed-files needs no checkout. It takes patterns (newline-separated
paths or globs, fnmatch-style, so * crosses /; a pattern also matches everything under it),
and outputs files (one path per line, a rename under both names), matched
and all-matched. Skip work only when matched is false. It is true
whenever the answer cannot be known. That covers an event that is not a pull
request, no patterns, an empty or unreadable list, and a list at the API's
3000-file cap, which is cut off. all-matched is the opposite claim, for a
skip that needs every file inside the paths (a docs-only change): it is true
only when the whole list was read and every file matches, and false on every
one of those doubts. Skip work on all-matched only when it is true. The
token needs pull-requests: read.
- id: changes
uses: dodi-smart/.github/actions/changed-files@v1
with:
patterns: |
db/*
**.sql
- if: steps.changes.outputs.matched == 'true'
run: ./heavy-check.shagent-gate inputs and outputs a caller may use beyond the basics.
| Name | Meaning |
|---|---|
input bots |
reject (default): stop on any non-human author. only: proceed for dependency bots only. allow: ignore the author. |
input number |
Override for the issue or PR number. The gate reads it from the event payload, so set it only where the payload has none (a workflow_dispatch). Before a run proceeds, the gate reads the item's current labels and stops on agent:no-touch added after the event. A failed read warns and proceeds. Needs issues: read or pull-requests: read on the gate job. |
input events |
Space-separated github.event_name values the workflow handles, checked right after agent:no-touch. Any other event stops the run. Empty (default) allows every event. issue-triage.yml passes issues issue_comment workflow_dispatch, so a person's PR review never reaches it. |
output author-kind |
dependency (Renovate, Dependabot), agent (claude[bot]), automation (any other [bot] or app/ login) or human. Written before every rule, agent:no-touch included, so it is set on a stopped run too. A workflow that needs only the classification can call the gate for it and ignore proceed. |
output dependency-bots |
The comma-separated dependency-bot logins, for claude-code-action's allowed_bots. deps-verify.yml reads it. |
The dependency-bot list lives in actions/agent-gate/gate.sh and nowhere else.
sticky-comment delete mode. delete: true finds the comment for key
(bot-authored only, like an update) and deletes it, or does nothing when there
is none. It needs no body-file. The action output is deleted or none.
zavet-check.yml uses it to retract its comment once a pull request is clean.
zavet-check.yml and automation PRs. A dependency or automation bot's pull
request is report-only; claude[bot] and people still fail closed. An
automation that opens PRs with GITHUB_TOKEN triggers no pull request workflows
at all, so nothing here would run on its PR. Open them with the org App token
(actions/create-github-app-token) instead.
Adopting a workflow here removes a class of problem rather than a file.
- Runner selection. Ask for
lightorheavyand stop naming hardware. When the fleet changes, the mapping changes here, once, instead of in every caller in every repo. - Cache correctness. Where a package cache lives depends on the runner, the
job and the stack.
setup-stackdecides it. Getting this wrong costs a day. - Agent safety. The kill switch, the bot rules and the draft rules are one
implementation with tests, not one
if:per workflow that drifts. - Drift between copies. A hand-written CI file diverges the moment two repos edit it. This is the same file for all of them.
- Silent breakage. The picker checks its own selector against the live fleet and annotates the run when it matches nothing.
A repo wired up to these workflows carries this badge in its README:
It is a claim about that repo, so it is worth knowing what backs it. The badge
means the repo calls the workflows above at @v1, that those workflows are
active rather than sitting disabled, that it carries the shared label set and
an area map, and that its bot configs emit the label names the workflows expect.
An onboarding tool asserts all of it and fails the repo if the badge is
present while any of it is not. A badge nobody checks is decoration within one
drift, and worse than none, because it is read as a guarantee by everyone who
does not go and look.
Two org-level repository custom properties carry the same facts in queryable form:
| Property | Answers |
|---|---|
onboarded |
Which generation of these workflows the repo is on: v1, or none |
client |
Which body of work the repo belongs to |
onboarded holds a version rather than a yes/no, because the question actually
worth asking is which repos still need migrating, and a version answers it as a
single search instead of an audit. It is required with a default of none, so a
repo created next year answers it too rather than being quietly absent from
every count.
Property values are visible to org members only. The badge is the public half, and it deliberately names no repo but this one.
agent:no-touch stops everything. 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 after an
early return silently stops covering that path. One implementation in
actions/agent-gate, with tests across every workflow shape.
No agent merges anything. Dependency verification posts a verdict, and may push a fix commit for code the update broke, but leaves merge policy to Renovate's own rules. The implement workflow opens a draft pull request 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.
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.
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
agent:no-touch -> stop everything, no exemptions
agent:implement does nothing unless the issue is planned. That is a field
comparison in the gate job, which also picks the runner once that comparison
passes, 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.
The stack is an input, not a separate template: bun, node, gradle,
android, flutter, rust, xcode, none.
with:
stack: gradle
install: "" # "" means this repo has no such step
build: ./gradlew assembleDebug ktlintCheck"@stack", the default, means that stack's conventional command. "" means the
step does not exist here. Those are different intentions, and a plain default
cannot express both, because Gradle and Cargo resolve on demand and genuinely
have no install step.
There used to be a caller template per stack. It failed the way templates fail: the Gradle one carried one product's task name, in a file every other Gradle repo was told to copy.
An optional step for a design-system verifier (oxlint hosting @shadcn/lint),
separate from lint because it has its own exit code and its own ratchet.
Default "" means the step does not exist -- every caller pinned at @v1 is
unaffected until it opts in. No @stack default: there is no conventional
command for this, on any stack.
with:
design-lint: bun run lint:design --format=githubWarnings are advisory. --max-warnings N in the caller's own command is the
ratchet -- tighten it there as the count comes down.
An opt-in step that runs the caller's own hk config over
the whole tree, so the same hk.pkl that drives the local git hooks reports in
the shared CI. Default false: every caller that never sets it behaves exactly
as before, and no new permission is needed. It sits beside lint and replaces
nothing.
jobs:
pr-checks:
uses: dodi-smart/.github/.github/workflows/pr-checks.yml@v1
with:
stack: bun
hk: trueWith hk: true, checks (or all, with single-job) runs install, then
jdx/mise-action (which installs the tools the caller's mise.toml pins), then
hk check --all --sarif hk.sarif, then the remaining phases. --all, never
--pr: type-aware and cross-file rules need the whole tree.
Findings show in the job log, and the first 50 KB of hk's output is appended to
the job summary. The SARIF file is kept as the hk-sarif artifact (one day),
even when hk check failed. This workflow does not upload it to code scanning:
that needs security-events: write, and GitHub checks a called workflow's
permissions when the run starts, so every caller would have to grant it, hk on
or off. A repo that wants code scanning downloads the artifact in its own
workflow and runs github/codeql-action/upload-sarif there.
pr-checks.yml splits light work from heavy by default. single-job: true
collapses it onto the heavy runner.
Use it for Gradle. Three jobs mean three configuration phases and no shared
daemon. One job keeps one checkout and one warm GRADLE_USER_HOME. The split
stays the right default for a cheap, independent lint.
env (newline KEY=VALUE) reaches every command step, which is where build
tuning like GRADLE_OPTS belongs. build-env applies to the build step
alone, and is parsed exactly like env: blank lines and # comment lines are
skipped, and the first = splits the name from the value.
with:
stack: android
single-job: true
lint: ""
typecheck: ""
test: ""
build: ./gradlew --build-cache --parallel assembleDebug ktlintCheck --continue
env: |
GRADLE_OPTS=-Dorg.gradle.daemon=false -XX:MaxMetaspaceSize=512m
coverage: kover
coverage-path: app/build/reports/kover/reportDebug.xmlEither shape runs behind ONE hosted pick job, not two. It resolves both the
light and the heavy runner (skipping whichever single-job does not need), so
picking twice costs one hosted job instead of two. It needs no checkout: it
reads the changed files through the API.
coverage picks how a coverage comment is made. It is none by default, which
posts nothing and adds no job. Otherwise a small coverage job on the light pool
posts the comment from the report your tests write, after they pass. It runs none
of your code. AGENTS.md has the reasoning.
coverage |
Your test command must write | coverage-path |
Comment from |
|---|---|---|---|
none |
nothing | ignored | none |
vitest |
coverage/coverage-summary.json and coverage/coverage-final.json (reporters json-summary and json) |
ignored | vitest-coverage-report-action. Thresholds come from your vitest config |
kover |
a Kover XML report | required, for example app/build/reports/kover/reportDebug.xml |
kover-report |
lcov |
an LCOV file | optional, default coverage/lcov.info |
actions/lcov-report |
coverage-min-overall and coverage-min-changed are percentages that fail the
coverage job, and so pr-checks, when a figure is below them. 0, the
default, means no floor. vitest takes its floors from the vitest config
instead. With kover, only coverage-min-overall fails the job;
coverage-min-changed is marked in the comment but not enforced, because
kover-report cannot tell "no changed file in the report" from 0%.
lcov takes anything that emits LCOV. Overall coverage is lines hit over lines
found across the whole file. Changed-file coverage covers only the files the
pull request touches that the report lists, so docs and config do not count as
0%. The comment shows both figures and the lowest changed files.
# bun
with:
stack: bun
test: bun test --coverage --coverage-reporter=lcov
coverage: lcov
coverage-min-overall: 70
# Rust
with:
stack: rust
test: cargo llvm-cov --lcov --output-path lcov.info
coverage: lcov
coverage-path: lcov.infoIn the split shape build starts right after pick, beside checks and
test, so a push waits for the slowest of the three and not for two in a row.
That takes about a minute off every PR, and a heavy job queues on its own pool
instead of spilling onto light runners. The cost is one build spent on a push
that then fails lint. The pr-checks summary needs checks, so a failed lint
fails the required context all the same. single-job is unaffected.
checks, test, build and all run their commands through
actions/run-phases. Each job's summary lists every phase that has a command,
in order (install, lint, design-lint, typecheck, test, build, smoke), with
its result and its seconds:
| phase | result | seconds |
|---|---|---|
| install | passed | 14 |
| lint | passed | 9 |
| typecheck | failed (exit 2) | 21 |
| test | not run | - |
Each phase is a collapsed log group. The first failure stops the run, and the
phases after it are listed as not run. A phase with no command is left out.
pick also decides whether the change is docs-only: every file the PR touches
must match one of docs-only-paths (newline-separated globs, default **.md
and docs/**), read from the pull request's file list through the API. When it is, checks, test, build and
all all skip -- but commitlint and zavet still run, because they check the commit
message and the knowledge layer, not the files. The default catches a Markdown-only change; widen it
per caller for e.g. docs/** design/**.
with:
stack: bun
docs-only-paths: |
**.md
design/**A skip is only ever a positive finding. On workflow_dispatch, or whenever the
file list cannot be read in full (an API error, or a pull request of 3000 files
or more, where the API stops listing), docs_only is false and every job runs
as normal.
pr-checks.yml grants contents: read at the workflow level, so a job that
needs more asks for it: pick and commitlint read pull requests, zavet and
react-doctor write them for their comments, and only the coverage job
writes them for the coverage comment, so test and all run your tests with no
write token. A checkout in these workflows never keeps the job
token in its git config, so a caller's install, test and build scripts cannot
read it. Only zavet and react-doctor clone full history.
This is also why a caller should not add paths-ignore: ['**.md'] to its
own on: pull_request: trigger to get the same effect. paths-ignore skips
the entire workflow, which means the workflow never runs and creates no
status-check context at all -- and a branch ruleset that requires one then
waits on that pull request forever, because a check that was never created can
never turn green. docs-only-paths gets the same skip without losing the
context.
That context is pr-checks, a summary job that runs unconditionally
(if: always()) after everything else, whether or not anything was skipped.
It fails if checks, test, build, all or commitlint failed or was
cancelled, and passes -- printing "docs-only change, checks skipped" -- when
they were only skipped. It is the one job a branch ruleset should require:
<caller job id> / pr-checks exists on every push in both single-job and
split mode, where checks / checks and checks / all do not -- exactly one
of those two is always skipped depending on single-job, so neither can be
named in a ruleset that has to work for every caller.
A repo that calls pr-checks.yml, zavet-check.yml and react-doctor.yml
starts three workflow runs per push, and each of the last two pays for its own
hosted runner picker. pr-checks.yml already has one hosted pick job, so
zavet and react-doctor can be opt-in jobs beside checks, on the light
pool. That saves about 2 billed hosted minutes per push, per repo that used
both. commitlint no longer starts a hosted job either: it runs on the light
pool, so a repo with it enabled saves a third.
jobs:
checks:
uses: dodi-smart/.github/.github/workflows/pr-checks.yml@v1
with:
stack: bun
zavet: true
zavet-stack: bun # the toolchain the decision checks run on
react-doctor: true
# Optional: zavet-audit (report-only sweep), zavet-install (defaults to
# install; set it when a check reads build output), react-doctor-paths
# (defaults to **.ts, **.tsx, **.js, **.jsx and package.json).
secrets: inheritThen delete the caller's zavet-check and react-doctor jobs, and their
paths: filter if it existed only for React Doctor.
zavet-dir sets where the layer lives and react-doctor-directory which project to scan. The React Doctor job uses React
Doctor's defaults, so it stays advisory (blocking: none, scope: changed). A
repo that needs to tune scope, blocking or version keeps react-doctor.yml.
Neither result is part of the pr-checks summary. The zavet and
react-doctor jobs are separate status contexts. Turning them on does not make
<caller job id> / pr-checks wait for them or fail on them, so it never becomes
a required check by accident. A repo that wants zavet required names
<caller job id> / Zavet Check in its ruleset. React Doctor stays advisory: a
pull request touching no matching file shows a skipped job, and a required
context that is never created would wait forever.
commitlint runs the commitlint CLI (actions/commitlint, pinned by lockfile)
on the light runner. It reads the pull request's commit messages through the
API and checks out only the config file. config-conventional is always there,
and any other package a JSON config extends is installed beside it.
Inputs beyond stack and the command overrides:
| Input | Default | Meaning |
|---|---|---|
env |
empty | Newline KEY=VALUE for every command step. Written to the file named by the env-file output, because $GITHUB_ENV refuses NODE_OPTIONS. |
build-env |
empty | The same format and the same parser, for the build command only. Written to the build-env-file output. |
bun-version |
auto |
See below. |
rust-toolchain |
stable |
Passed to the pinned rust toolchain action: stable, nightly, 1.89.0, or any rustup specifier. |
Outputs: env-file and build-env-file (both always written, empty when
nothing was given, so source them unconditionally), plus the resolved install,
lint, typecheck, test, build and design-lint commands.
. "$ENV_FILE" # every command step
. "$BUILD_ENV_FILE" # the build step onlyBun follows the repo. bun-version: auto (or empty) installs the version the
repo pins, so CI runs what developers run and a frozen install does not fail on a
lockfile format the newest bun changed. It checks, from the checkout root and in
this order: package.json packageManager (bun@x.y.z, any +sha suffix
dropped), .bun-version, .tool-versions (bun x.y.z), mise.toml and
.mise.toml (bun = "x.y.z" under [tools]). The step log says what it chose
and from where. When none names bun it installs latest and prints a notice.
Any explicit version, latest included, is used as given. The repo must be
checked out before setup-stack runs.
Rust is pinned. The toolchain action is pinned to a commit of its master
branch (it publishes no version tags), and Renovate can still move that pin.
setup-stack resolves the mode from isolate, cache and the runner:
- Verification jobs isolate.
deps-verifypins caches toRUNNER_TEMPwith GitHub cache off, because a verification job that can see yesterday's tree is not verifying. - Self-hosted Linux uses home dirs, so the per-runner named volumes are actually read. GitHub cache stays off there.
- Self-hosted macOS caches like hosted. Those runners are image-based VMs
that start every job with an empty home, so
cache: autois true there and nothing else would persist. ~/.pub-cacheis job-scoped in every mode. A home dir is only worth using if a volume backs it, and the image mounts none for pub.- Hosted uses one mechanism per stack,
setup-gradle/rust-cache/flutter-action. Never a package store, and neverrestore-keyson one, which is how a partial tarball comes back on every retry. - Gradle repos with a cache on get
setup-gradle, includingstack: xcodewhen the repo has a rootgradlew. A repo whosegradle/libs.versions.tomlnames the multiplatform plugin and akotlinversion also caches~/.konan, Kotlin/Native's toolchain, keyed on OS, arch and that version. Both need the repo checked out beforesetup-stack.
Never cache a project build directory. Not build/, not */build, not
.gradle. */build/intermediates holds absolute paths and the workspace root is
not stable between runners, so AGP rejects its own inputs. A per-SHA key also
misses on every commit by construction, then falls through restore-keys to
whatever another branch left behind. It surfaces as a compile error in a file the
pull request never touched. Reuse of compiled output is the build tool's job, by
content hash, and setup-stack already wires it up.
supabase-checks.yml covers the Supabase-side checks a bun/node pr-checks.yml
run never touches: a Deno edge-function check, a generated-types check, and
pgTAP tests. It runs on ubuntu-latest only, with no runner picker — the
self-hosted fleet runs jobs inside containers on a shared daemon, so supabase db start publishes postgres's ports on the HOST while the CLI polls the
CONTAINER's own localhost. concurrency, paths: and the dispatch-aware draft
gate stay with you, same as pr-checks.yml.
| Input | Default | Meaning |
|---|---|---|
cli-version |
(required) | Supabase CLI version, pinned exact with a renovate: datasource=npm depName=supabase marker tracking the same supabase devDependency the repo installs from. Generated types must come from that same CLI or the diff below fails on formatting, not schema. |
types-path |
"" |
Path of the committed generated types. Empty disables the types job (reported skipped, not failed). |
migrations-paths |
supabase/migrations, supabase/seed.sql |
Newline-separated paths (a directory covers what is under it) or globs whose change triggers the heavy steps of the types and pgtap jobs on a pull request (pgtap also on a change under supabase/tests). Any event that is not a pull request always runs them. |
seed-check |
false |
Re-apply supabase/seed.sql after db start to prove it is re-runnable. |
deno-dir |
"" |
Directory of Deno-only source, e.g. supabase/functions. Empty disables the deno job. |
pgtap |
false |
Run supabase test db in its own job. |
deno-version |
v2.x |
Passed straight to denoland/setup-deno. |
timeout-minutes |
30 |
Per job. |
The types and pgtap jobs read the pull request's file list through the API
instead of cloning the history, and always report success, never skipped, when
nothing relevant changed. Reading it needs pull-requests: read on the token; a
caller that narrows permissions: must keep it, and without it the jobs run
everything rather than skip. The local stack's Docker images are cached between
runs under an exact key: CLI version, runner OS and architecture, and a hash of
supabase/config.toml and any supabase/.temp/*-version. A pull request's first
run pulls them, since a cache saved on a pull request is visible to that pull
request alone. Run these checks on pushes to the default branch as well, and every
pull request reads what those runs saved.
jobs:
supabase:
if: github.event_name == 'workflow_dispatch' || github.event.pull_request.draft == false
uses: dodi-smart/.github/.github/workflows/supabase-checks.yml@v1
with:
# renovate: datasource=npm depName=supabase
cli-version: 2.116.0
types-path: src/lib/supabase/database.types.ts
seed-check: true
deno-dir: supabase/functions
pgtap: truepick-runner.yml takes a semantic weight and resolves it. It is a thin
wrapper around actions/pick-runner, the composite action that does the actual
selecting; call the action directly from inside a job that is already hosted
(as pr-checks.yml's pick job does, twice, and as every agent workflow's
gate job does, once, after agent-gate decides the run should proceed)
rather than paying for a second hosted job just to reuse the workflow.
weight |
Selector | Falls back to | Use |
|---|---|---|---|
light |
self-hosted,Linux,light |
the same light pool, when no runner is online and idle (fallback-when: busy) |
lint, typecheck, checks, releases, reading a diff |
heavy |
self-hosted,Linux,large |
hosted-runner, only when no runner is online (fallback-when: offline); a busy pool queues |
builds, Docker, full suites |
apple |
self-hosted,macOS,ARM64 |
nothing: it queues, and warns when no runner is online | Apple toolchain, signing |
hosted |
none | not applicable | forces hosted-runner, ubuntu-latest by default |
Selectors name capability labels, never an architecture and never a machine name. A runner of any arch that joins a pool is picked up with no change here, and a machine name would not survive re-registration.
light and heavy are tiers of intent, so ask for the one that describes your
work. They stay apart even when one pool could serve both, because re-tiering is
then two lines here instead of an audit of every caller.
The picker reads the org's runner list once, then decides from it. A runner "matches" when it carries every label in the selector.
fallback-when: busygoes to the fallback when no matching runner is online and idle. Short jobs would rather run now than wait.fallback-when: offlinegoes to the fallback only when no matching runner is online. If some are online but busy, the job gets the primary selector and queues. Long jobs want this: hosted costs more than the queue.
heavy never falls back to the light pool. A heavy build there runs out of
memory beside the light pool's other jobs (exit 137). A busy large pool queues
the job, and a large pool with nothing online sends it to hosted.
Both inputs default to the weight's own policy in the table, so leave them empty
unless you know better. An explicit fallback or fallback-when always wins.
A labels selector with no explicit fallback keeps the original behaviour:
self-hosted,Linux,light, when no runner is idle.
If the fleet cannot be read (no GitHub App token, or an API error), the picker cannot tell busy from offline, so it emits the primary selector and the job queues, with a warning. It no longer sends that job to the fallback.
A self-hosted fallback queues when the whole fleet is offline, where a hosted one
runs. Set fallback: ubuntu-latest for a light job that must finish even then.
The fell-back output is true when the picked runner is the fallback, so a
caller can widen max-parallel on hosted. The list is cached for the rest of the
job, so actions/pick-runner called twice in one job (light, then heavy) mints one
token and reads the fleet once.
Public repos and fork pull requests 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 the next job inherits.
That path does not read fallback.
Pin @v1. It is a moving tag, and it moves on its own: every release from
main force-advances it to the new version. A breaking input change cuts v2
rather than redefining v1.
So merging to main is a rollout. There is no staging step. The moment a
release is cut, every repo pinned to @v1 is running the new code, including the
picker and composite actions this repo's own workflows call internally. Verify in
the pull request, because after the merge it is already live everywhere.
Releases are cut by semantic-release from main, so the version comes from the
commit messages. feat: opens a minor, fix: a patch, and a breaking change
footer a major.
The major tag is derived from the version, so this works unchanged at v2 and
beyond. Releasing 2.0.0 creates v2 and stops touching v1, which freezes
at the last 1.x. Callers pinned to @v1 keep the old major until they choose
to move, which is the whole point of pinning a major.
One consequence to know before you cut a v2: main is the only release branch,
so once 2.0.0 ships there is no way to release a 1.x patch. That needs a
maintenance branch added to release.config.mjs, for example
branches: ["main", "1.x"], and it is easier to add before you need it than
during an incident.
chore(deps) also cuts a patch, which is specific to this repo. Renovate labels
every dependency update chore(deps), and elsewhere that deliberately releases
nothing. Here the dependencies are the action versions these workflows run on, so
a bump that never reached a release would leave every caller pinned to @v1 on
the old ones. A plain chore: with no deps scope still releases nothing.
Do not pin @main even so. @v1 still moves only when a release is cut, so a
commit that releases nothing, a docs: or a ci: change, never reaches a
caller. @main picks up every commit. @v1 is also a version you can name in a
rollback, and @main is not.
To roll back, point the tag at the previous release and force it:
git tag -f v1 v1.0.1 && git push -f origin v1One thing pinning does not buy you here. claude-code-action refuses to run when
the workflow file differs from the default-branch copy, which is a correct
control, since a pull request could otherwise edit the reviewer to exfiltrate its
token. It means a change to an agent workflow cannot be exercised on the pull
request that makes it, only after merging.
semantic-release/ is an npm workspace in this repo that holds the org's
shared semantic-release configuration, @dodi-smart/semantic-release-config.
It carries the release rules, the changelog sections and the plugin suite,
pinned to versions that agree with each other, so a consuming repo does not
have to work that out on its own.
The package is never published to any registry. It reaches a consumer through
actions/semantic-release-config, which copies this checkout's root
manifests, lockfile and semantic-release/ into a private prefix, installs
the plugin dependencies there, then links that prefix's semantic-release
directory into the consumer's node_modules by name. The checkout the
action was fetched with is left untouched, so nothing sharing it loses a
dependency. The shared release.yml runs that action automatically before
semantic-release, gated by its shared-config input (default true); a
caller whose release-command does not run semantic-release sets it false
to skip the install. A repo that hand-rolls its own release job adds one
step, after its own install and before semantic-release:
- uses: dodi-smart/.github/actions/semantic-release-config@v1actions/semantic-release-config/test.sh checks this the way Self test
runs it for the other actions: by resolving the linked package and its
plugins from a scratch consumer, not by reading the installer's output.
There are two ways to use the config once it is linked. Extend it whole, for
a single-package repo released from main with develop as a prerelease
channel:
// .releaserc.json
{ "extends": "@dodi-smart/semantic-release-config" }Or compose, when the repo needs its own plugin list, for example a version
file to rewrite. No extends line: import the helpers you want and list
them.
// release.config.mjs
import { branches, commitAnalyzer, releaseNotes, changelog, git, github } from "@dodi-smart/semantic-release-config";
export default {
branches,
plugins: [commitAnalyzer(), releaseNotes(), changelog, git({ assets: ["pubspec.yaml", "CHANGELOG.md"] }), github()],
};A consumer installs semantic-release and nothing else. Every plugin the
config names is a dependency of the package itself, and the package arrives
by the link, not by an install, so nothing is added to the consumer's
package.json or its lockfile.
Updates arrive the way workflow updates do: someone merges a change here, the
@v1 tag moves, and every consumer is on the new config the next time it
releases. There is no per-repo version to bump, and so no way for a consumer
to lag.
Watch for effect, not hidden. Changelog section entries used to hide a type
with a boolean hidden property; the preset that renders them now reads
effect: "bump" | "hidden" instead and does not warn on the old key, so a type
still carrying hidden: true renders anyway and leaks into the notes as an
untitled bullet. Compose your own types list with effect.
commitAnalyzer({ releaseRules }) replaces a shared rule of the same type and
scope rather than adding beside it, because the analyzer treats a
release: false match as undecided and lets a later matching rule win; a
shared rule could otherwise never be turned off. npm is always in the
default config, since that config is for a Node package; a repo that is not
one, even with an incidental package.json, composes and leaves npm out.
exec is a plugin path with no options of its own; pass your own *Cmd
entries as [exec, { successCmd: "..." }]. github() takes
{ releasedLabels }; pass github({ releasedLabels: false }) in a repo
without release:prod / release:staging labels.
Two plugins in semantic-release/package.json are pinned to exact beta
versions, because their stable releases cannot render this package's v10
changelog preset. Renovate in this repo offers the matching stable release
automatically once one exists, because the pin is exact rather than a caret
over a prerelease. When it lands, the pins here move to stable and every
consumer picks it up on its next release, the same way any other change here
reaches them.
Every plugin this package exports is an absolute path, resolved from inside
this package, not a bare package name. That is what makes the composed form
work with no extends. semantic-release resolves a plugin named in a config
from its own directory first, so a bare name would get whatever copy
semantic-release itself depends on; the extends redirect only fixes that
for plugins the extended config lists, and --extends <file> on the command
line, the reusable release workflow's modules path, replaces the config's
own extends entirely. A path sidesteps all three. Both usage modes are
covered by real dry runs in the package's end-to-end test, run through the
installer.
{ extends: ["github>dodi-smart/.github"] }default.json 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.
The filename matters. For a bare github>owner/repo, Renovate fetches
default.json and no other name, then falls back to renovate.json -- which
extends this preset, so resolution goes circular and every repo silently drops
to stock defaults. Renovate parses a .json preset as JSONC, so it keeps its
comments.
Updates arrive weekly, before 6am on Monday. Branches outside that window are
left alone (updateNotScheduled: false) and rebased only on a conflict
(rebaseWhen: conflicted), so a repo's CI is not rerun all week by Renovate.
At most eight PRs are open at once; security PRs ignore the schedule and the
limit. Non-major Action bumps wait three days after release
before a PR opens, then automerge.
deps-verify.yml gets the build verdict from the repo's own pr-checks run on
the PR's head commit, and builds nothing itself when that run exists. It then
hands the verdict and the failed jobs' logs to an agent. The agent reads the
release notes, looks for breaking or deprecated APIs this repo actually calls,
and fixes them when it can. The job posts one comment, rewritten on every run,
and sets exactly one label:
| Label | Means |
|---|---|
deps:verified |
CI green, nothing breaking reaches this repo |
deps:fixed |
Green after a fix commit on the branch. Read the fix before merging |
deps:needs-manual |
Still red, or a fix needs something the job may not do |
Why it reads CI instead of building:
- A second build ran out of memory. Building here as well ran a second full
build of the same commit next to
pr-checks, on the same runner hosts. The pair ran out of memory, and both reported a fine update as red. - One failure on the runner gets a retry. A
pr-checksrun that failed on the runner (exit 137, a killed Gradle daemon, a lost runner, a full disk) is re-run once, failed jobs only. A second failure like that is reported asred:infra. - Only a repo with no
pr-checksbuilds here. When none of the repo's workflows callsci-workflow, or it is set to"", the job builds the PR itself, isolated. That is read from the workflow files, not guessed from a timer.
The rules on fixes:
- Fix commits are code only. A commit that touches a lockfile or a version catalog is not pushed, because the versions are Renovate's choice.
- At most two fix rounds per PR. After that the job reports and stops.
- The job pushes, never the agent. It checks every commit's author and files first, and pushes with the org App's token, so the checks run on the new commit.
- The commits carry the author the preset lists under
gitIgnoredAuthors, so Renovate still owns the branch. If Renovate rebases and drops the fix, the next run makes it again.
A red build is needs-manual even when the agent finds the cause was already on
the base branch. The comment says so in one line.
Which runner it takes:
- A green build waits and reads on a light runner. A
waitjob starts on the light pool, polls thepr-checksrun and decides the verdict. Theverifyjob then runs on the light pool too when the verdict is green, so a green PR never holds a heavy slot. It takes the heavy pool only to build or fix: a red or infra verdict, or a repo with nopr-checksthat builds here. With anappleorhostedrunner-weight, or explicitrunner-labels, there is no lighter pool, so both jobs use that selector. - A rebase of an unchanged update runs nothing.
waithashes the changed lines of every file except lockfiles and records the hash in the comment. When the build is green, the PR already carriesdeps:verified(ordeps:fixedwith its fix commits still on the branch) and the hash is the same,verifydoes not start, and the label and comment stay. A changed diff, a missing record, or any red build goes to the agent, as does a repo that builds here, since there is no verdict to trust yet. - Lock file maintenance is judged by the build. A green build with only
lockfiles changed gets
deps:verifiedand a one-line comment, with no agent. A red one goes to the agent as before.
env and build-env mean what they mean in pr-checks: same format, same
parser, and build-env reaches the build command only. They matter when the
agent reproduces a failure to fix it, and when the job builds on its own. Copy
the caller's pr-checks block across:
with:
stack: bun
env: |
NEXT_PUBLIC_SUPABASE_URL=http://localhost:54321
build-env: |
NODE_OPTIONS=--max-old-space-size=4096Commands run one per subshell, so install: cd app && bun install does not
leave the next step inside app/.
| 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.
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. Composite
actions have no such rule, so they take a single segment:
dodi-smart/.github/actions/agent-gate@v1.
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".
gh api /repos/dodi-smart/<repo>/actions/workflows --jq '.workflows[]|"\(.state)\t\(.name)"'Otherwise: the pull request 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.
Self test runs on every pull request touching actions/, .github/workflows/
or the Renovate preset. It asserts the kill switch across every workflow shape,
checks the runner presets against the table above, parses every YAML file,
validates the Renovate preset, and runs hk check --all. That runs shellcheck,
actionlint, zizmor (workflow security, config in zizmor.yml), typos and the
whitespace and merge-marker checks defined in hk.pkl.
Lint runs as git hooks through hk, configured in hk.pkl. Running mise install turns them on for your clone: the postinstall hook in mise.toml runs hk install --mise. CI skips it. The hooks include a conventional-commit check on the message.
- Run the checks by hand with
hk check --all, or fix what can be fixed withhk fix --all. - Skip the hooks for one commit with
HK=0 git commit .... - The hook config is shared by every worktree of the clone. On a branch without
hk.pklthe hooks do nothing, as long as hk is available globally:mise use -g aqua:jdx/hk@2.4.0. Alternatively, install once per machine withhk install --global --mise(Git 2.54 or later), which skips repos without hk config.
Read AGENTS.md before changing anything. If you add a workflow, add its rule to
the table there with the one line that says why, and extend
actions/agent-gate/test.sh if it introduces a new gate shape.