Skip to content

Repository files navigation

.github

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.

Quick start

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: inherit

That 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.

What a caller controls

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.

Secrets the caller needs

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.

The workflows

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.

Release, then deploy

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.

Inputs

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

Secrets

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.

What the health check proves

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-environment polls the repository's GitHub Deployments for the deployed commit in that environment, and continues on a success status. A failure or error status, or health-wait-timeout-minutes passing, fails the job. Hosts post these as their build progresses. The deployment this job's own environment: creates is ignored, since it succeeds when the job does. The token needs deployments: read; a caller that narrows permissions: 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-route is 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.

Composite actions

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.sh

agent-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.

What you stop maintaining

Adopting a workflow here removes a class of problem rather than a file.

  • Runner selection. Ask for light or heavy and 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-stack decides 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.

The badge that brought you here

A repo wired up to these workflows carries this badge in its README:

shared workflows v1

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.

Two things you can rely on

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 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.

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.

Stacks

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.

Design lint

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=github

Warnings are advisory. --max-warnings N in the caller's own command is the ratchet -- tighten it there as the count comes down.

hk

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: true

With 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.

One job or three

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.xml

Either 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

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.info

When build starts

In 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.

Timings in the job summary

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.

Docs-only changes, and the one context to require

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.

One workflow instead of three

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: inherit

Then 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.

setup-stack inputs and outputs

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 only

Bun 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.

Caches

setup-stack resolves the mode from isolate, cache and the runner:

  • Verification jobs isolate. deps-verify pins caches to RUNNER_TEMP with 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: auto is true there and nothing else would persist.
  • ~/.pub-cache is 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 never restore-keys on one, which is how a partial tarball comes back on every retry.
  • Gradle repos with a cache on get setup-gradle, including stack: xcode when the repo has a root gradlew. A repo whose gradle/libs.versions.toml names the multiplatform plugin and a kotlin version also caches ~/.konan, Kotlin/Native's toolchain, keyed on OS, arch and that version. Both need the repo checked out before setup-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

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: true

Runners

pick-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.

Falling back

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: busy goes to the fallback when no matching runner is online and idle. Short jobs would rather run now than wait.
  • fallback-when: offline goes 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.

Versioning

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 v1

One 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.

Shared semantic-release config

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@v1

actions/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.

Renovate

{ 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.

Dependency verification

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-checks run 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 as red:infra.
  • Only a repo with no pr-checks builds here. When none of the repo's workflows calls ci-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 wait job starts on the light pool, polls the pr-checks run and decides the verdict. The verify job 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 no pr-checks that builds here. With an apple or hosted runner-weight, or explicit runner-labels, there is no lighter pool, so both jobs use that selector.
  • A rebase of an unchanged update runs nothing. wait hashes the changed lines of every file except lockfiles and records the hash in the comment. When the build is green, the PR already carries deps:verified (or deps:fixed with its fix commits still on the branch) and the hash is the same, verify does 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:verified and 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=4096

Commands run one per subshell, so install: cd app && bun install does not leave the next step inside app/.

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. Composite actions have no such rule, so they take 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".

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.

Contributing

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.

Git hooks

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 with hk 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.pkl the hooks do nothing, as long as hk is available globally: mise use -g aqua:jdx/hk@2.4.0. Alternatively, install once per machine with hk 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.

About

Org-wide GitHub defaults for dodi-smart: reusable workflows, composite actions, inherited issue templates, and the shared Renovate preset.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages