Skip to content
rishitankPublic

About

Shared CI — reusable GitHub Actions workflows, Renovate presets and default community health files for all repos on this account.

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Shared CI

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.

Using a workflow

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

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

Available workflows

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.

Advisory-first inputs

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

Repository policy: settings, rulesets and auto-merge

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-gate on the default branch: CodeRabbit's approval of the latest commit (dismissed on push), every thread resolved, the CodeRabbit check 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-gate on the default branch: requires one check named ci-gate from GitHub Actions. It is written active only on repositories that publish that check (a top-level job named ci-gate in 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: a pull_request or pull_request_target trigger whose branch filters cover it, with no paths filter and no types list that drops opened, synchronize or reopened. The job to copy is under "The ci-gate contract" below. This repository's own is the ci-gate job in self-check.yml, which needs actionlint, Renovate presets and Scenario tests.
  • long-lived-branches: main, master, develop and everything under release/ can't be deleted. delete_branch_on_merge deletes a merged pull request's head branch, so without this a pull request from develop into main would take develop with 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.

The ci-gate contract

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; }
          done

Each line is there for a reason:

  • The check is named ci-gate: the job's name:, 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 success fails, skipped and cancelled included. So a job that may legitimately skip itself (a docs-only shortcut, say) does not belong in needs.
  • The workflow runs on every pull request into the default branch. A workflow skipped by a paths or branch filter never reports, and the pull request waits on the check forever. The reconciler leaves ci-gate disabled on a repository whose only ci-gate job is filtered like that (see "Publish" above).
  • Only the jobs in needs are 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: does the app still work?

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

Security tooling, and what is deliberately absent

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-action sub-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, not cargo 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.

Renovate

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

CI minute budget

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_request sets concurrency with cancel-in-progress: true, keyed on the caller's ref.
  • No duplicate runs. Merges are squash-only from up-to-date branches, so a push: main run re-checks a tree the pull request already checked. Keep a push trigger only where something needs it: a deployment, a visual baseline, or rust-ci.yml, whose release build follows the caller's trigger and build-release input.
  • 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.

Runner selection

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-on unset in callers. An explicit value, ubuntu-latest included, always wins, so it also switches the fallback off.
  • CI_RUNNER is the calling repository's variable. In a called workflow vars is the caller's. The fallback dispatcher sets it to the fallback runner's label when the included Actions minutes run out, and back to ubuntu-latest when 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.repository too, so they are covered. An event without it reads as not private and stays on ubuntu-latest: the guard fails closed.

A caller's own jobs (ones that do not call a workflow here) should use the same expression.

Changing a workflow

A change here lands on every consuming repo at once. Treat it accordingly:

  1. Open a PR. self-check.yml runs actionlint over the change, and the scenario tests in tests/ for the reconciler and the sweep.
  2. Test against one consuming repo by pinning it to the branch (@my-branch instead of @v1) and watching a real run go green.
  3. Merge, then move the v1 tag: run the move-major-tag workflow (it refuses any commit that is not on main), or by hand git push -f origin <sha>:refs/tags/v1. Always the full refs/tags/ path: this repository also has a stale branch named v1.

Visibility

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.

About

Shared CI — reusable GitHub Actions workflows, Renovate presets and default community health files for all repos on this account.

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages