Private Repo CI Strategy#
Actions minutes on a private repository are billed. This document is about spending as few of them as possible without giving up the checks.
Layer 0 — local#
Layer 0 LOCAL pre-commit (.env guard, version check) < 1s $0
pre-push → security, hygiene, lint; quick tests ~10–60s $0
when opted in (push-tests quick)
ci_*.sh (lint, tests, build, scan) minutes $0
script-helpers ships stack-specific ci_*.sh commands for running the same
classes of checks locally. It also includes a blocking pre-push hook that
runs security, hygiene and lint checks on the pushed commits. It runs the quick test command of the
stack at the repository root only when the repository opts in. If
script-helpers is vendored at scripts/script-helpers, install the hooks
and opt in to tests with:
Run every applicable ci_*.sh command explicitly in multi-stack repositories;
the hook does not discover nested projects or combine multiple stacks.
Every ci_*.sh runner in script-helpers refuses to run when CI=true. They
are deliberately local tools; the library is built for this model.
How much CI to keep#
Pick a tier and stay in it. The right one depends on how many people push.
Release-only — one contributor, private repo#
The recommended default for a single-maintainer private repo.
.github/workflows/ holds tagging and release, and nothing else:
| Workflow | Trigger | Typical cost |
|---|---|---|
auto-tag-release.yml |
push to main |
~0.2 min |
release-tag-gate.yml |
pull_request from release/* |
~0.2 min |
release.yml |
push of a version tag |
per release, not per push |
Everything else runs locally through the applicable ci_*.sh commands, with
quick tests gated by a blocking pre-push hook (opted in with
git config script-helpers.push-tests quick). With the PR gate gone that
hook is the only automatic gate left, so it must fail the push rather than warn;
git push --no-verify stays as the escape hatch.
The trade is explicit: a local gate is a convention, not a control. It can be skipped, and nothing verifies a contributor's change before merge. That costs nothing at one contributor per repo, and it is the first thing that breaks when that changes.
One exception is worth keeping: a weekly scheduled secret and vulnerability
sweep. Secret scanning is worth more on a server than locally precisely
because it catches what a developer forgot to run, and a schedule: sweep is a
few minutes of Actions per month.
Three layers — two or more contributors#
Layer 0 LOCAL pre-commit + pre-push → checks, opt-in tests $0
Layer 1 PR GATE pr-gate.yml (install once → lint → test) ~2–3 min, 1 job
Layer 2 MAIN GATE ci.yml (full lint + test + build, post-merge) ~5 min, 1–3 jobs
Restore this the moment a repo gains a second regular contributor. Layer 0 catches the author's own mistakes; Layers 1 and 2 exist to catch everyone else's.
Rules that apply in either tier#
ci.yml— NEVER has apull_requesttrigger.push: branches: [main, master]only. Listing both for the same branches runs the full workflow twice per merged PR, which is the single most common waste in this namespace.pr-gate.yml—pull_requestonly; addpaths-ignore: ["docs/**","*.md"]. Without it a README typo pays for a full build.- Use
install_commandso dependencies are installed once;lint_commandandtest_commandreuse them. - Never run a
macos-*orwindows-*job on push or pull request. macOS bills at a 10× minute multiplier and Windows at 2×. Put them behind a manualworkflow_dispatchinput, the wayflutter-release.ymlgates its App Store leg behinddeploy_app_store. - Never use an OS matrix on a private repo for anything but a tagged release. A serialized three-way matrix costs the sum of its legs in wall-clock and the weighted sum in minutes.
What the reusable workflows enforce#
The cost controls live in the workflows, not only in this document:
| Workflow | timeout_minutes default |
concurrency |
|---|---|---|
ci.yml |
20 | cancel-in-progress |
pr-gate.yml |
20 | cancel-in-progress |
release-build.yml |
30 | — |
| every preset | 20 (passed through to ci.yml, or applied to the preset's own job) |
via ci.yml, or the preset's own group |
flutter-release.yml |
60 | — |
Without a timeout, a hung job bills until GitHub's six-hour cap. Every value
above is an optional input with a default. Callers whose jobs fit that default
pass nothing; longer jobs pass a higher timeout_minutes.
Every preset forwards timeout_minutes, default 20, so a caller can raise it
through any preset. scripts/check_engine_inputs.py enforces this: an input in
its pass-through set must be declared and forwarded by every workflow on the way
to the engine, or the commit is refused.
20 minutes is measured, not guessed. A large Go consumer runs 512 test functions and its engine job completes in 40 seconds, cold. Across twelve sampled consumers the slowest successful run takes 7 minutes and the median under 2. Raising the default would weaken a billing cap that nothing is pressing against; a repository that genuinely needs longer has an input that reaches the engine.
Quick Setup#
Step 1 — Install local hooks (once per clone)#
# If script-helpers is vendored as a submodule at scripts/script-helpers/
bash scripts/script-helpers/scripts/setup-hooks.sh
# Or, if script-helpers is at a custom path
git config core.hooksPath .githooks
Step 2 — Standard workflow pair#
.github/workflows/ci.yml (post-merge, main/master only)
name: CI
on:
push:
branches: [main, master]
jobs:
ci:
uses: nikolareljin/ci-helpers/.github/workflows/<stack>.yml@production
with:
# ... stack-specific inputs
.github/workflows/pr-gate.yml (every PR push)
name: PR Gate
on:
pull_request:
paths-ignore:
- "docs/**"
- "*.md"
concurrency:
group: pr-gate-${{ github.ref }}
cancel-in-progress: true
jobs:
pr-gate:
uses: nikolareljin/ci-helpers/.github/workflows/pr-gate.yml@production
with:
# ... stack-specific inputs (see per-stack examples below)
Per-Stack Examples#
Node / TypeScript#
# pr-gate.yml
with:
node_version: "22"
install_command: "npm ci"
lint_command: "npm run build" # type-check / compile
test_command: "npm test"
build_command: ""
# ci.yml (post-merge, full build)
with:
node_version: "22"
lint_command: "npm ci && npm run build"
test_command: "npm test"
build_command: "npm run build"
Python#
# pr-gate.yml
with:
python_version: "3.13"
install_command: "pip install -e '.[dev]'"
lint_command: "ruff check ."
test_command: "pytest -q"
build_command: ""
# ci.yml
with:
python_version: "3.13"
lint_command: "pip install -e '.[dev]' && ruff check . && mypy ."
test_command: "pytest -q"
build_command: "python -m build"
Go#
with:
go_version: "go.mod"
lint_command: "go vet ./..."
test_command: "go test ./..."
build_command: ""
No install_command needed — go fetches modules automatically.
Rust#
with:
rust_toolchain: "stable"
rust_components: "clippy"
lint_command: "cargo check --all-targets && cargo clippy --all-targets -- -D warnings"
test_command: "cargo test"
build_command: ""
Flutter#
with:
flutter_channel: "stable"
install_command: "flutter pub get"
lint_command: "flutter analyze"
test_command: "flutter test"
build_command: ""
Java / Gradle#
lint is an Android Gradle plugin task; a JVM project answers Task 'lint' not
found in root project.
with:
java_version: "17"
lint_command: "./gradlew check -x test"
test_command: "./gradlew test"
build_command: ""
Android (Gradle)#
Android Gradle Plugin projects expose variant-qualified tasks. Depending on the
project, ./gradlew test may be a no-op or run a broader set of variant tests
than expected. Use testDebugUnitTest to run debug unit tests explicitly.
with:
java_version: "17"
lint_command: "./gradlew lintDebug"
test_command: "./gradlew testDebugUnitTest"
build_command: "" # assembleDebug is post-merge work, not a PR gate
On a release-only private repo, run these tasks locally with the vendored Gradle runner. Android task names must be supplied explicitly:
bash scripts/script-helpers/scripts/ci_gradle.sh \
--workdir android \
--build-task assembleDebug \
--test-task testDebugUnitTest \
--lint-task lintDebug \
--skip-detekt
PHP#
with:
php_version: "8.3"
install_command: "composer install --no-interaction"
lint_command: "vendor/bin/phpstan analyse"
test_command: "vendor/bin/phpunit"
build_command: ""
Finding the double-trigger#
A repository runs CI twice per push when it has a pull_request trigger in
ci.yml and a pr-gate.yml on the same branches. This is not a property
of any one toolchain; it is what happens when ci.yml is copied from a
template that has both triggers.
Check a repo with:
gh api "repos/OWNER/REPO/contents/.github/workflows/ci.yml" \
--jq '.content' | base64 -d | grep -A3 '^on:'
Or measure it after the fact — a workflow that runs twice per merge shows up immediately in the run history:
gh api "/repos/OWNER/REPO/actions/runs?per_page=100" --jq '
[.workflow_runs[] | {wf:.name, min: (((.updated_at|fromdate) - (.run_started_at|fromdate))/60)}]
| group_by(.wf) | map({wf:.[0].wf, runs:length, total_min:((map(.min)|add)|round)})
| sort_by(-.total_min) | .[]'
Pattern — edit ci.yml in each affected repo:
# Before
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
# After
on:
push:
branches: [main, master]
Local Testing Scripts (script-helpers)#
These are available after vendoring script-helpers as a submodule under scripts/script-helpers/:
| Script | What it runs |
|---|---|
scripts/script-helpers/scripts/local_test_node.sh |
npm ci → npm test; --quick skips install |
scripts/script-helpers/scripts/local_test_python.sh |
pip install → pytest; --quick skips install |
scripts/script-helpers/scripts/local_test_go.sh |
go vet → go test ./...; --quick skips vet |
scripts/script-helpers/scripts/local_test_rust.sh |
cargo check → cargo clippy → cargo test |
scripts/script-helpers/scripts/local_test_flutter.sh |
flutter pub get → flutter analyze → flutter test |
The pre-push hook (scripts/script-helpers/scripts/git-hooks/pre-push) auto-detects the stack and calls the appropriate runner. Tests run on git push only after the repository opts in; without it the hook runs its security, hygiene and lint checks only. Enable both via: