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 | Builds it, reads upstream changelogs, posts a verdict. Never merges. |
pr-review.yml |
ready_for_review, agent:review |
Second-opinion review, deeper on sensitive paths |
issue-triage.yml |
issue opened, agent:triage, @claude triage |
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 |
react-doctor.yml |
pull request, React repos | Static analysis of React/TS source. Advisory by default. |
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 |
| 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 |
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 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 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.
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.
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 still applies to the build step
alone.
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.xmlsetup-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 uses home dirs, so the per-runner named volumes are actually read. GitHub cache stays off there.
~/.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.
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.
pick-runner.yml takes a semantic weight and resolves it:
weight |
Selector | Use |
|---|---|---|
light |
self-hosted,Linux,light |
lint, typecheck, checks, releases, reading a diff |
heavy |
self-hosted,Linux,large |
builds, Docker, full suites |
apple |
self-hosted,macOS,ARM64 |
Apple toolchain, signing |
hosted |
none | 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.
A selector that cannot be reached falls back to fallback, the light pool by
default, so an unreachable large pool costs capacity rather than hosted minutes.
Set fallback: ubuntu-latest for a job that must finish even with the whole
fleet offline, because a self-hosted fallback queues instead.
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 .releaserc.json, 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.
{ 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.
| 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,
shellchecks the scripts, validates the Renovate preset, and runs actionlint.
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.