Reusable GitHub Actions workflows and Renovate presets for every repository under this account.
The point is that CI lives in one place. A repo that adopts these gets a caller
workflow of roughly fifteen lines instead of a hundred-and-fifty-line ci.yml
that drifts away from its siblings the moment someone fixes a bug in one copy
and not the other four.
# .github/workflows/ci.yml in a consuming repo
name: ci
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
# Cancel superseded runs. This belongs in the caller, not the reusable
# workflow, because the group has to be keyed on the caller's ref.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
ci:
uses: rishitank/.github/.github/workflows/python-ci.yml@v1
with:
python-version: "3.12"
verify: python verify.pyThe doubled .github/.github/ is not a typo: the first is the repository name,
the second is the directory inside it.
Pin to @v1. That tag moves forward as these workflows improve, so consuming
repos pick up fixes without a PR each. Pin to a commit SHA instead if a repo
needs to be insulated from that.
| Workflow | For | Notable |
|---|---|---|
python-ci.yml |
Python | one job: ruff + optional mypy + your verify.py gate; optional graceful checkout of a private sibling repo |
node-ci.yml |
Node / TypeScript | npm or pnpm, single job, .nvmrc as the version source of truth |
rust-ci.yml |
Rust | fmt, clippy -D warnings, test, release build, and the no-panics-in-production gate |
security.yml |
any | osv-scanner + gitleaks in one job, both blocking; the advisory ecosystem audit runs weekly (every run if audit-strict) |
lighthouse-ci.yml |
Node / web frontends | lhci autorun against a locally built app, plus a sticky PR comment with the score table; advisory by default |
runtime-gate.yml |
web apps (any stack) | boots the app (Dockerfile, command or static), crawls it in a real browser and fails on runtime errors: exceptions, console errors, hydration errors, failed requests, 4xx/5xx, blank pages; plus advisory screenshot diffs and an advisory Gemini explorer that uses the app like a person. See docs/runtime-gate.md |
workflows-lint.yml |
any | actionlint over .github/workflows/, self-updating and cached |
Every job sets timeout-minutes and a least-privilege permissions block.
Callers still need their own permissions block, because the caller's grant is
the ceiling for everything it calls.
python-ci.yml takes lint-strict, security.yml takes audit-strict,
lighthouse-ci.yml takes strict. All three default to false, so a repo
that has never been linted, audited, or Lighthouse-checked can adopt these
today without a wall of red. They print a step summary (or, for Lighthouse, a
PR comment) saying so.
Leaving them false forever is how a quality gate becomes decoration. Clear the
backlog, flip the flag.
lighthouse-ci.yml doesn't set its own performance/accessibility/SEO budgets —
those live in the consuming repo's own lighthouserc.json (or .js/.cjs),
which lhci autorun discovers on its own. See lighthouserc.example.json in
this repository for a starting point. A repo that wants "advisory on PRs,
blocking on protected branches" rather than one fixed value can compute
strict from the triggering event instead of hardcoding it:
strict: ${{ github.event_name != 'pull_request' }}.
settings.yml is this account's policy for every public, non-archived,
non-fork repository it owns. No repository is named anywhere: both
workflows below find the repositories afresh on every run, so a repository
created tomorrow is covered by the next run with nothing to add. Private
repositories are left as they are, because on the Free plan GitHub drops
auto-merge and refuses rulesets there.
| Workflow | When | Does |
|---|---|---|
repo-settings-reconcile.yml |
push to settings.yml, nightly, by hand (dry run by default) |
Writes the repository: settings (auto-merge, squash, delete branch on merge) and each repo_rulesets: entry into every repository, then reads every write back |
auto-merge-sweep.yml |
every 20 minutes, by hand (dry run by default) | Turns on auto-merge for the owner's open pull requests where the base branch is gated |
The rulesets, one body each, written into every repository:
review-gateon the default branch: CodeRabbit's approval of the latest commit (dismissed on push), every thread resolved, theCodeRabbitcheck green on an up-to-date branch, squash only, no force-push, no deletion. Only Renovate may merge its own pull requests past it. The copies put on by hand on 2026-09-25 have the same name, so they are updated in place, not duplicated.ci-gateon the default branch: requires one check namedci-gatefrom GitHub Actions. It is written active only on repositories that publish that check (a top-level job namedci-gatein a workflow on the default branch) and disabled everywhere else, re-checked every run. So a repository is never blocked on a check it doesn't run, and one that adds the job is gated from the next run on. "Publish" means the workflow runs on every pull request into the default branch: apull_requestorpull_request_targettrigger whose branch filters cover it, with nopathsfilter and notypeslist that drops opened, synchronize or reopened. The job to copy is under "Theci-gatecontract" below. This repository's own is theci-gatejob inself-check.yml, which needs actionlint, Renovate presets and Scenario tests.long-lived-branches:main,master,developand everything underrelease/can't be deleted.delete_branch_on_mergedeletes a merged pull request's head branch, so without this a pull request fromdevelopintomainwould takedevelopwith it.
Any repository ruleset with another name (holocron's "Main Branch
Protection", say) is never touched, and keeps applying alongside these.
Nor is a ruleset on a repository its entry lists under skip_repositories.
It is never created, updated or disabled there, and one already there is left
for a person to remove. Nothing is skipped today: the profile repository
(rishitank/rishitank) publishes ci-gate and its weekly open-source
workflow arms auto-merge only behind review-gate, so it is gated like the
rest.
A repository opts into the ci-gate ruleset by adding one job to a workflow
that runs on its pull requests. It needs no entry in settings.yml: the next
reconcile run sees the job and turns the ruleset on.
ci-gate:
if: always()
needs: [lint, test] # every job that must pass before a merge
runs-on: ubuntu-latest
timeout-minutes: 5
permissions: {}
steps:
- name: Every job passed
env:
RESULTS: ${{ join(needs.*.result, ' ') }}
run: |
set -euo pipefail
for r in ${RESULTS}; do
[ "${r}" = success ] || { echo "::error::a gated job ended '${r}' (${RESULTS})"; exit 1; }
doneEach line is there for a reason:
- The check is named
ci-gate: the job'sname:, or its id when it has none. It must be a top-level job, not one inside a reusable workflow, whose checks are named<caller job> / <job>. if: always(): when a job it needs fails, a job without it is skipped, and GitHub counts a skipped job as a passing required check. With it, the gate runs and fails.- Anything but
successfails,skippedandcancelledincluded. So a job that may legitimately skip itself (a docs-only shortcut, say) does not belong inneeds. - The workflow runs on every pull request into the default branch. A
workflow skipped by a
pathsor branch filter never reports, and the pull request waits on the check forever. The reconciler leavesci-gatedisabled on a repository whose onlyci-gatejob is filtered like that (see "Publish" above). - Only the jobs in
needsare gated. A job left out can fail without blocking a merge.
Auto-merge arms only behind a gate. For each of the owner's pull requests,
auto-merge-sweep.yml reads the rules that actually apply to its base branch
(every ruleset, whatever its name) and arms gh pr merge --auto --squash only
if they require an approval and a status check other than CodeRabbit's.
Otherwise it disarms. GitHub then merges by itself once every rule is met. It
never arms a stranger's pull request (these repositories are public), a
bot's (Renovate merges its own), a draft, or one labelled
needs-human-review; adding that label later disarms it on the next run.
Token. Both need the ACCOUNT_ADMIN_TOKEN secret here, the one
account-wide token every cross-repository workflow in this repository shares
(branch-prune, delete-branch, draft-reminder too): a fine-grained
token for All repositories with Administration, Contents and Pull
requests (read and write). Without it they report NEEDS-TOKEN and change
nothing. When GitHub refuses a single read or write they report NEEDS-TOKEN
for that repository or pull request and carry on with the rest, so a run with
a refusal may still have changed other repositories or armed other pull
requests. Either way the run doesn't fail, so a token only a person can fix
doesn't turn it red every night.
Merges armed with it are made as the owner, so on: push workflows fire
after them, and the review gate still applies: it has no bypass for the owner.
Before the first run, check that CodeRabbit's GitHub App can see every
public repository (Settings > Applications > CodeRabbit > Repository access:
All repositories). On a repository it can't see, review-gate waits for an
approval and a check that never come. It also stops direct pushes to the
default branch there, which matters for any repository you edit in the
browser. Adding such a repository to review-gate's skip_repositories
only stops the reconciler writing the gate there. A review-gate already on
it stays, approval and all, until removed by hand, and an active ci-gate
still refuses a direct push, as any required check does.
The scenario tests for both are in tests/, and run in self-check.yml:
bash tests/reconcile/test.sh . and bash tests/auto-merge-sweep/test.sh .
(they need jq, yq v4 and python3).
runtime-gate.yml exists because a dependency update can pass build, lint and
unit tests and still break the running app. It boots the app the way it is
deployed, uses it in a real browser, and fails the check on runtime errors, so
Renovate cannot merge an update that breaks a page. There are no test cases to
write or maintain. An advisory AI explorer (Gemini free tier, GEMINI_API_KEY
secret) uses the app like a person for major and framework updates. Full
guide, inputs and examples: docs/runtime-gate.md.
The Renovate presets set platformAutomerge: false, so Renovate itself checks
that every status (this gate included) is green before it merges. GitHub's
native auto-merge only waits for checks that branch protection or a ruleset
makes required. A repository that has neither, for example a private
repository on a plan that does not offer them (see GitHub's
protected branches
and rulesets
pages for which plans do), has no required checks, so native auto-merge would
merge the moment the PR opened.
jobs:
runtime:
uses: rishitank/.github/.github/workflows/runtime-gate.yml@<sha>
permissions: { contents: read, actions: read, pull-requests: write }
with:
mode: docker
secrets:
gemini-api-key: ${{ secrets.GEMINI_API_KEY }}There is no CodeQL here and nothing uploads SARIF. Both require GitHub Code Security on private repositories, and most of this estate is private, so a CodeQL workflow would fail on the repos that need it most. What is here works on every repo at no cost:
- osv-scanner — vulnerable dependencies across npm, PyPI, crates and Go.
Uses the
osv-scanner-actionsub-action, not the reusable workflow, because the reusable one uploads SARIF. - gitleaks — secrets, in CLI form. The gitleaks Action requires a licence key for organisation-owned repositories; the MIT-licensed CLI does not.
- npm audit / pip-audit / cargo-audit — ecosystem advisories. Advisory,
and their databases are OSV sources already, so they run on the weekly
schedule and on manual runs only (on every run with
audit-strict: true). cargo-audit comes as a prebuilt binary, notcargo install.
All three run as steps of one job: a finding in one does not stop the others, and a final verdict step fails the job if osv-scanner or gitleaks found anything.
Public repos can and should keep a CodeQL workflow of their own alongside this.
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["github>rishitank/.github:node"]
}Presets: default (implied by github>rishitank/.github), node, python, rust.
Updates come once a month, in a window on the 1st-3rd (London time), and
normally as two pull requests per repository: non-major (automerge)
(non-major GitHub Actions bumps, dev and test tooling, cargo patches; merged by
Renovate on green CI inside the window) and non-major (review) (everything
else non-major, Docker digests included). The scientific stack keeps its own
group. Majors, GitHub Actions majors included, open no pull request until they
are ticked on the Dependency Dashboard. Lockfile maintenance is quarterly; to
run it sooner, tick it under "Awaiting Schedule" on the Dependency Dashboard.
Branches are not updated or rebased outside the window, so a reviewed pull
request merged later needs "Update branch" first. Security fixes are
unaffected: vulnerabilityAlerts ignores the schedule and grouping and opens
a pull request at once. Every Renovate pull
request costs a full CI run plus another on merge, so this is the difference
between about 450 and about 100 pull requests a month across the estate.
default.json includes helpers:pinGitHubActionDigests, which converts every
uses: some/action@v1.2.3 into a commit digest with the version as a trailing
comment, and then keeps those digests current. That is how this estate gets
SHA-pinned actions without anyone hand-maintaining a list of hashes.
The exception is this repository's own reusable workflows: Renovate leaves
TanksterAI/.github and rishitank/.github refs alone, so they stay at @v1
and a change reaches every repository by moving the tag, with no pull request
anywhere. A caller still pinned to a commit SHA must be moved to @v1 once by
hand: Renovate will not touch it again.
If this repository is private, the Renovate app must be installed on it as well as on the consuming repos, or preset resolution will fail.
GitHub bills every job rounded up to a whole minute, and a fallback runner boots a VM per job. In October 2026 the average private job ran 51 seconds, so 46% of the minutes billed were rounding. Rules for anything added here or in a consuming repo:
- One job per concern, steps not jobs. A check that takes seconds belongs as a step of a job that is already running.
- Cancel superseded runs. Every caller that runs on
pull_requestsetsconcurrencywithcancel-in-progress: true, keyed on the caller's ref. - No duplicate runs. Merges are squash-only from up-to-date branches, so a
push: mainrun re-checks a tree the pull request already checked. Keep a push trigger only where something needs it: a deployment, a visual baseline, orrust-ci.yml, whose release build follows the caller's trigger andbuild-releaseinput. - Schedules weekly at most unless the job is the product.
- Reference this repository at
@v1, never by SHA: a SHA pin turns every change into a pull request in every consuming repository.
Every job in the reusable workflows picks its runner with the same expression:
inputs.runs-on || (github.event.repository.private && vars.CI_RUNNER) || 'ubuntu-latest'
- Leave
runs-onunset in callers. An explicit value,ubuntu-latestincluded, always wins, so it also switches the fallback off. CI_RUNNERis the calling repository's variable. In a called workflowvarsis the caller's. The fallback dispatcher sets it to the fallback runner's label when the included Actions minutes run out, and back toubuntu-latestwhen they reset. It must be a single label, not a JSON array: a value no runner matches leaves the job queued, not failed.- Only private repositories follow it. Public repositories' minutes are
free, and a self-hosted runner on a public repository runs any fork's pull
request. Scheduled runs carry
github.event.repositorytoo, so they are covered. An event without it reads as not private and stays onubuntu-latest: the guard fails closed.
A caller's own jobs (ones that do not call a workflow here) should use the same expression.
A change here lands on every consuming repo at once. Treat it accordingly:
- Open a PR.
self-check.ymlruns actionlint over the change, and the scenario tests intests/for the reconciler and the sweep. - Test against one consuming repo by pinning it to the branch
(
@my-branchinstead of@v1) and watching a real run go green. - Merge, then move the
v1tag: run themove-major-tagworkflow (it refuses any commit that is not on main), or by handgit push -f origin <sha>:refs/tags/v1. Always the fullrefs/tags/path: this repository also has a stale branch namedv1.
Reusable workflows in a private repository can only be called by other private repositories owned by the same account, and only once Settings → Actions → General → Access is set to allow it.
A public repository has no such restriction and additionally makes
SECURITY.md, CONTRIBUTING.md and the issue/PR templates here apply
automatically to every repo on the account that lacks its own.