Presets#
Presets are reusable workflows that wrap ci.yml with sane defaults for
specific stacks or E2E tools, providing default values for common commands.
Every preset accepts and forwards the same pass-through inputs — runner,
working_directory, concurrency_key, fetch_depth, submodules, cache and
timeout_minutes — with the engine's own defaults, which
scripts/check_engine_inputs.py enforces on every commit. Beyond that set they
differ on purpose: docker.yml takes no lint, test or build command, the
database inputs exist only on php.yml, and every preset overrides the engine's
empty command defaults with something useful for its stack.
Related docs:
Use a preset if you want a fast setup with minimal inputs, and override any command or version as needed.
Most presets call ci.yml by relative path, so they run the ci.yml from the
same ref you pinned the preset to: pinning node.yml@<tag> pins the job it runs
as well.
Five do not, and run their own job instead. Four of them -- laravel.yml,
django.yml, wp-phpunit.yml and wp-build.yml -- drive a script-helpers
script; shell.yml runs shellcheck directly, because there is no wrapper worth
having around it. Each of the four drives a script-helpers script --
ci_laravel.sh, ci_django.sh, ci_wp_phpunit.sh, ci_wp_build.sh -- so the
same command runs in CI and on a laptop, and each starts whatever service it
needs itself rather than going through ci.yml's db_image. One mechanism, not
two. They pin script-helpers by commit; scripts/check_script_helpers_pins.sh
refuses a tree where those pins and vendor/script-helpers disagree.
Composite actions have no same-commit uses: form, so the workflows that use
one (gitleaks-scan.yml with gitleaks-scan, tauri-release.yml with
macos-sign and windows-sign) check the library out at job.workflow_sha
into .ci-helpers-actions and call the action from there, then remove it.
Pinning those workflows pins the actions they run.
Concurrency: ci.yml cancels a superseded run in the same group. Without a
concurrency_key, the group is keyed on the caller workflow, the ref, the
working directory, the runner and the toolchain versions (node_version,
java_version, dotnet_version, python_version, go_version,
flutter_version/flutter_channel, php_version, rust_toolchain). Two jobs
in one workflow that share all of those still cancel each other; give each a
distinct concurrency_key (every preset passes it through).
The four presets that run their own job key their own group, and include the
database in it: two legs differing only in db_image would otherwise share a
group and the second would cancel the first.
Shared inputs#
These inputs mean the same thing on every preset that declares them. A preset section below lists only what it adds or what it defaults differently.
Pass-through inputs, forwarded to the job as-is:
| Input | Type | Engine default | What it does |
|---|---|---|---|
runner |
string | ubuntu-latest |
The runs-on label. |
working_directory |
string | . |
Directory every command runs in. |
concurrency_key |
string | "" |
Overrides the computed concurrency group. |
fetch_depth |
number | 0 |
Checkout depth. 0 is full history. |
submodules |
string | false |
false, true or recursive. |
cache |
boolean | true |
Dependency caching, detected from lockfiles. |
timeout_minutes |
number | 20 |
Job timeout. |
Command inputs, run in this order when non-empty:
| Input | Runs |
|---|---|
install_command |
First, so lint and test do not each install. |
lint_command |
Linters and formatters. |
test_command |
Unit tests. |
build_command |
The build. |
docker_command |
A Docker build, after the build. |
e2e_command |
End-to-end tests, after Docker. |
extra_command |
Anything else, last. |
Toolchain inputs (node_version, java_version, python_version,
go_version, php_version, rust_toolchain, rust_components,
dotnet_version) select the version ci.yml installs. Their full description
is under ci.yml.
Shell#
Workflow: .github/workflows/shell.yml
For a repository that is mostly Bash. shellcheck is the point; bats and a formatter check are opt-in.
Defaults:
shellcheck_paths:scripts-- directories are walked for*.sh, files taken as givenshellcheck_severity:warningbats_command: empty (skipped)format_command: empty (skipped)exclude_glob:*/script-helpers/*-- vendored code you do not own; space-separated, several allowedfail_on_findings:true
warning rather than error or info. error alone almost never fires, so it
gates nothing; info includes SC2086, common enough to drown everything else.
warning is the line that catches real problems without burying them.
Two things are treated as failures rather than passes: a shellcheck_paths entry
that does not exist, and a path set that matches no *.sh. Both are the shape a
green run that checked nothing takes.
The number of files excluded is printed alongside the number checked: the
default can hide most of a tree, and a filter that size should not be
invisible. Set
exclude_glob to "" to check everything. Patterns are matched against ./path,
so */vendor/* excludes a top-level vendor/ as well as a nested one. Excluding
everything is an error, reported as that rather than as "no files found".
No entry in shellcheck_paths may contain a space: the list is space-separated,
and one that does is refused by name rather than silently skipped.
shellcheck_command replaces the whole invocation for a repository that has its
own wrapper. shellcheck and bats come from the runner's package manager, so no
tool version is pinned here.
Node#
Workflow: .github/workflows/node.yml
Defaults:
node_version:22install_command:""lint_command:npm ci && npm run --if-present linttest_command:npm ci && npm testbuild_command:npm run build
The lint and test defaults each install, so a run on the defaults installs
twice — npm ci deletes node_modules and installs from the lockfile, so the
second is the whole install again. To install once, set it explicitly and
drop it from the commands:
That is not the default because of what the callers do: three of the six
repositories on these presets override lint_command with their own
npm ci && ..., which an install step would turn into a third install, and one
drives pnpm through this preset, where a default npm ci fails for want of a
package-lock.json. Changing it is a change to make with those call sites in
hand, not a tidy-up.
build_command is not guarded by --if-present, unlike lint: a package that
builds and has lost its build script should fail rather than pass quietly. A
package that does not build sets build_command: "".
Example:
jobs:
node:
uses: nikolareljin/ci-helpers/.github/workflows/node.yml@production
with:
node_version: "22"
React#
Workflow: .github/workflows/react.yml
Defaults: identical to preset-node's, including the double install and how
to avoid it.
That is deliberate: a React project needs nothing in CI that a Node project
does not, and this preset exists so a call site can say what the repository
is rather than what it runs. Keep the two in step — if one gains an input or
a default, the other should too. node-refuses-what-it-must in self-test-web.yml
fails the build if they drift.
Example:
jobs:
react:
uses: nikolareljin/ci-helpers/.github/workflows/react.yml@production
with:
node_version: "22"
Python#
Workflow: .github/workflows/python.yml
Defaults:
python_version:3.13install_command:if [ -f requirements.txt ]; then python -m pip install -r requirements.txt; elif [ -f pyproject.toml ]; then python -m pip install .; filint_command:python -m pip install ruff && ruff check .test_command:python -m pip install pytest && python -m pytest
Example:
jobs:
python:
uses: nikolareljin/ci-helpers/.github/workflows/python.yml@production
with:
python_version: "3.13"
PHP#
Workflow: .github/workflows/php.yml
Defaults:
php_version:8.4node_version:""(empty — set to e.g."22"to install Node.js; also setbuild_commandto run npm/yarn steps)lint_command:composer install --no-interaction --prefer-dist && vendor/bin/phpcs --standard=PSR12 --extensions=phptest_command:vendor/bin/phpunit
php.yml is the only preset that forwards ci.yml's database and environment
inputs: db_image, db_env, db_ports, db_health_cmd, db_wait_seconds
(default 60) and extra_env, all empty by default. They work as described
under ci.yml. For example, MySQL for the tests:
jobs:
php:
uses: nikolareljin/ci-helpers/.github/workflows/php.yml@production
with:
db_image: "mysql:8.0"
db_env: |
MYSQL_ROOT_PASSWORD=root
MYSQL_DATABASE=app_test
db_ports: "3306:3306"
db_health_cmd: "mysqladmin ping -h 127.0.0.1 -proot"
extra_env: |
DB_HOST=127.0.0.1
DB_PORT=3306
Example:
jobs:
php:
uses: nikolareljin/ci-helpers/.github/workflows/php.yml@production
with:
php_version: "8.4"
Pimcore#
Workflow: .github/workflows/pimcore.yml
Runs PHPCS and PHPUnit for a Pimcore bundle inside your own Docker Compose stack, so the checks run against a real PHP + MySQL environment rather than a bare runner.
Defaults:
php_versions:'["8.3", "8.4"]'— each version runs as its own matrix legphp_version:""— set it to test exactly one version; overridesphp_versionsphp_version_env:PHP_VERSION— env var the version is exported ascompose_file:test/docker-compose.ymlphpcs_command:vendor/bin/phpcs --standard=PSR12 --extensions=php src/phpunit_command:vendor/bin/phpunit --testdox
Example:
jobs:
pimcore:
uses: nikolareljin/ci-helpers/.github/workflows/pimcore.yml@production
with:
compose_file: docker-test/docker-compose.yml
Choosing PHP versions#
The preset tests 8.3 and 8.4 by default. Pass any JSON array to test something else — the versions are not checked against an allow-list, so older and newer lines both work:
To test a single version, use php_version; it takes precedence, so you do not
need a one-element array:
The value must be a non-empty JSON array. Anything else fails the run rather than expanding to an empty matrix, which would report as a skipped job and read like a pass.
Making the version reach your containers#
Setting a version is only half of it. The checks run inside your compose
stack, which the workflow cannot see into, so the preset exports the selected
version into the environment — as PHP_VERSION unless you change
php_version_env. Your compose file has to consume it, or every matrix leg will
build the same image and the run will report version coverage it does not have.
# docker-test/docker-compose.yml
services:
php:
build:
context: ..
dockerfile: docker-test/Dockerfile
args:
PHP_VERSION: ${PHP_VERSION:-8.3}
Set php_version_env: "" to export nothing, if your stack pins its PHP version
some other way.
Laravel#
Workflow: .github/workflows/laravel.yml
Runs a Laravel application's lint, front-end build and tests. The tests go
through script-helpers'
ci_laravel.sh,
which writes .env, generates APP_KEY, starts the database in Docker, runs
the migrations and then the tests. The same command runs on a laptop.
Order: lint_command, build_command, then install_command,
migrate_command and test_command inside the script.
Defaults:
php_version:8.4node_version:22(empty skips Node setup)lint_command:composer install --no-interaction --prefer-dist && vendor/bin/pint --testbuild_command:npm ci --include=optional && npm run buildinstall_command:""(the lint step already installs)migrate_command:php artisan config:clear && php artisan migrate --forcetest_command:php artisan testdb_image:mysql:8.0, pinned by digest. Empty runs on SQLite with no container.db_database:laravel_test,db_username:laravel,db_password:secretdb_root_password:root,db_port:3306,db_wait_seconds:60
The database credentials exist only inside the throwaway container. No secrets are needed.
Example:
jobs:
laravel:
uses: nikolareljin/ci-helpers/.github/workflows/laravel.yml@production
with:
php_version: "8.3"
For Postgres, pass a Postgres image; the script reads the engine from the image name:
Django#
Workflow: .github/workflows/django.yml
Runs a Django project's lint and tests through script-helpers'
ci_django.sh.
The script starts the database in Docker, runs the migrations, fails on model
changes that have no migration, then runs the tests.
Order: lint_command, then install_command, migrate_command,
makemigrations_command and test_command inside the script.
Defaults:
python_version:3.12lint_command:""install_command:"", which installsrequirements.txtand thenrequirements-dev.txt, each when it existsmigrate_command:python manage.py migrate --noinputmakemigrations_command:python manage.py makemigrations --check --dry-run --noinput(empty skips the drift check)test_command:python manage.py test --noinputsettings_module:""-DJANGO_SETTINGS_MODULEfor every stepdb_image:postgres:16, pinned by digest. Empty runs on SQLite.db_name:django,db_user:django,db_password:djangodb_root_password:root(MySQL images only),db_port:""(the engine's port),db_wait_seconds:60
Example:
jobs:
django:
uses: nikolareljin/ci-helpers/.github/workflows/django.yml@production
with:
python_version: "3.12"
settings_module: mysite.settings_ci
lint_command: "python -m pip install ruff && ruff check ."
WordPress#
Workflows: .github/workflows/wp-phpunit.yml,
.github/workflows/wp-plugin-check.yml, .github/workflows/wp-build.yml
Three workflows cover a WordPress plugin. Use any of them alone.
| Workflow | Runs | script-helpers |
|---|---|---|
wp-phpunit.yml |
The plugin's PHPUnit tests against the WordPress test library and a MySQL container | ci_wp_phpunit.sh |
wp-plugin-check.yml |
WordPress Plugin Check (wp plugin check) in a Docker stack, optionally over a WordPress/PHP matrix |
ci_wp_plugin_check.sh |
wp-build.yml |
The release zip: production Composer dependencies, built assets, .distignore applied |
ci_wp_build.sh |
Each one runs the script-helpers script, so the same command runs on a laptop.
php-scan.yml also detects WordPress and adds a
WordPress PHPCS standard run and a WP-CLI scan. The
wp-plugin-check composite action runs Plugin
Check as a step in your own job.
What the plugin needs:
phpunit/phpunitandyoast/phpunit-polyfillsas dev dependencies, and atests/bootstrap.phpthat loads the test library fromWP_TESTS_DIR. The workflow provides the library; the plugin carries nobin/install-wp-tests.sh.- A
Version:header in the main plugin file, unless you passversion.wp-build.ymlnames the zip from it. - For
wp-plugin-check.yml,wp_versionorversions, so the workflow generates the WordPress stack. Without either, it uses the compose file named bycompose_file(defaulttest/docker-compose.yml), which the plugin then carries. - Optionally a
.distignore. Without one,wp-build.ymlapplies a default exclude list and prints it.
Services: every database and WordPress container is started by the workflow
and removed afterwards (with wp-plugin-check.yml, unless cleanup is
false). The runner needs Docker, which ubuntu-latest has.
No secrets are needed. Attaching the zip to a release is the caller's step and
the only one that needs contents: write.
Example, with all three:
name: WordPress
on:
pull_request:
push:
branches: [main]
tags: ["*"]
permissions:
contents: read
jobs:
tests:
uses: nikolareljin/ci-helpers/.github/workflows/wp-phpunit.yml@production
with:
php_version: "8.3"
wp_version: "latest"
plugin-check:
uses: nikolareljin/ci-helpers/.github/workflows/wp-plugin-check.yml@production
with:
plugin_slug: my-plugin
versions: '[{"wp":"6.8","php":"8.1"},{"wp":"6.8","php":"8.3"}]'
fail_on_findings: true
build:
needs: [tests, plugin-check]
uses: nikolareljin/ci-helpers/.github/workflows/wp-build.yml@production
with:
slug: my-plugin
release:
if: startsWith(github.ref, 'refs/tags/')
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with:
name: wp-plugin-package
path: dist
- name: Attach the zip to the release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
run: gh release upload "$TAG" dist/*.zip --repo "$GITHUB_REPOSITORY" --clobber
Go#
Workflow: .github/workflows/go.yml
Defaults:
go_version:go.mod(Go from<working_directory>/go.mod, itstoolchainline when present; a version such as1.26overrides it, and is needed when that directory has nogo.mod)lint_command:test -z "$(gofmt -l .)" && go vet ./...test_command: a script that runsgo mod download, thengo test -race ./...where the runner can build it (cgo on and a platform on Go's race list), elsego test ./...with a notice.go-pr-gate.ymlandgo-scan.ymlshare this default.build_command:go build ./...modules: unset, meaning one module atworking_directory
-race needs a C compiler, which Go looks for when it decides whether cgo is
on. Go documents its cost as 2-20x the run time and 5-10x the memory. Set
test_command: "go mod download && go test ./..." to turn it off.
Example:
Several modules in one repository#
modules takes a JSON array of directories relative to working_directory and
runs one leg per entry, so a repository with five go.mod files needs one
uses: block rather than five:
jobs:
go:
uses: nikolareljin/ci-helpers/.github/workflows/go.yml@production
with:
modules: '["api", "worker", "tools/migrate"]'
Each leg's directory appears in its job name, and each runs the lint, test and build commands in that directory.
A module holding go.mod and no packages is skipped rather than failed:
go build ./... tolerates it, but go vet ./... and go test ./... both exit
1 with "no packages to vet". The skip states its reason in the log and in the
job summary, and the predicate is go list -e ./..., which still lists a
package whose imports do not resolve -- so a module that is merely broken
cannot be mistaken for an empty one and passed.
Leave modules unset and nothing changes: the preset runs the single job it
always has, still called ci, so a ruleset requiring that check by name keeps
working.
The input is refused, rather than quietly expanded to nothing, when it is not a
non-empty JSON array, when an entry is absolute or contains .., or when two
entries name the same directory. An empty matrix would report as a skipped job,
and a skipped job reads like a pass.
The pull request gate#
Workflow: .github/workflows/go-pr-gate.yml
go.yml calls the CI engine. For a pull request gate, call go-pr-gate.yml,
which supplies the same defaults to pr-gate.yml:
Without it, a Go repository calling pr-gate.yml directly has two bad options.
Name go_version and it is a local pin that drifts from the shared default;
name nothing and the gate's Setup Go step does not run at all, so the job
builds on whatever Go the runner image ships, with no module cache. A
repository in that state passes until the image moves.
The defaults are go.yml's, identically, so a caller using both gets the same
toolchain on a push and on a pull request. check_release_tag and
release_branch are forwarded, so adopting the preset does not cost access to
the gate's own inputs.
It takes no modules input. go.yml fans out over several go.mod files; the
pull request gate does not, so a repository with several modules gets one leg
here. Such a repository can call go.yml on pull_request instead, which is
what the multi-module pilot does.
Java#
Workflow: .github/workflows/java.yml
Defaults:
java_version:17lint_command:""test_command:mvn -B testbuild_command:mvn -B package
Lint is opt-in. mvn -B -DskipTests checkstyle:check is not a safe default:
it resolves the checkstyle plugin whether or not the project configures it and
then applies Sun's rules. On a conventional two-file project that is
12 violations and a red build on correct code.
A project with a ruleset asks for it:
Example:
jobs:
java:
uses: nikolareljin/ci-helpers/.github/workflows/java.yml@production
with:
java_version: "17"
Java (Gradle)#
Workflow: .github/workflows/java-gradle.yml
Defaults:
java_version:17lint_command:./gradlew check -x testtest_command:./gradlew testbuild_command:./gradlew build
Example:
jobs:
java_gradle:
uses: nikolareljin/ci-helpers/.github/workflows/java-gradle.yml@production
with:
java_version: "17"
Kotlin (Gradle/Android)#
Workflow: .github/workflows/kotlin.yml
Defaults:
java_version:17lint_command:./gradlew check -x testtest_command:./gradlew testbuild_command:./gradlew buildtimeout_minutes:20(passed through toci.yml)
The defaults are the tasks a Kotlin/JVM project has. ./gradlew lint and
./gradlew assembleDebug come from the Android Gradle plugin; on a plain
Kotlin/JVM project Gradle answers Task 'lint' not found in root project.
An Android app passes its own:
with:
lint_command: "./gradlew lintDebug"
test_command: "./gradlew testDebugUnitTest"
build_command: "./gradlew assembleDebug"
Example:
jobs:
kotlin:
uses: nikolareljin/ci-helpers/.github/workflows/kotlin.yml@production
with:
java_version: "17"
In an Android project, ./gradlew test may be a no-op or run a broader set of
variant tests than the gate needs. Use testDebugUnitTest when the CI gate must
run the debug unit tests explicitly:
with:
java_version: "17"
lint_command: "./gradlew lintDebug"
test_command: "./gradlew testDebugUnitTest"
On a private repo, consider running none of this on a server. See
private-repo-ci-strategy.md: script-helpers
ships a local Gradle runner that accepts explicit variant-qualified task names.
Rust#
Workflow: .github/workflows/rust.yml
Defaults:
rust_toolchain:stabletest_command:cargo test --verbosebuild_command:cargo build --verbose
Example:
jobs:
rust:
uses: nikolareljin/ci-helpers/.github/workflows/rust.yml@production
with:
rust_toolchain: "stable"
C#
Workflow: .github/workflows/csharp.yml
Defaults:
dotnet_version:8.0.xproject:""— project or solution to restore, build, test and format, relative toworking_directory. Passed to the default commands; ignored if you override a command.lint_command:dotnet format --verify-no-changes(the SDK built-in).""disables the stage, as elsewhere.
With project set and the commands left at their defaults, the project is passed to each: dotnet format "<project>" --verify-no-changes, dotnet restore "<project>" && dotnet test "<project>", and so on. Override a command and project is not woven into it — your command is used exactly as given.
Pass project unless the directory holds exactly one project or solution. A directory with both a .sln and a .csproj fails a bare dotnet build with MSB1011, and a project in a subdirectory fails with MSB1003. The .sln/.csproj case is subtler than it looks: it only fails when the two base names differ. App.sln beside App.csproj is picked silently, while Solution.sln beside App.csproj is refused — so a repository can appear fine until it renames something.
project is validated before use: letters, digits, dot, underscore, slash, dash and space; no leading or trailing space; and it may not begin with -, which dotnet would read as a flag rather than a path. This is for legibility, not security — the command inputs beside it are arbitrary shell by contract.
test_command:dotnet restore && dotnet testbuild_command:dotnet restore && dotnet build -c Release
Example:
jobs:
csharp:
uses: nikolareljin/ci-helpers/.github/workflows/csharp.yml@production
with:
dotnet_version: "8.0.x"
Windows-only targets#
runner defaults to ubuntu-latest. A project whose target framework is
Windows-only — net8.0-windows, net48, anything with UseWindowsForms or
UseWPF — cannot build there at all: the Windows Desktop targeting pack is
absent and the SDK refuses the build rather than producing something broken.
jobs:
csharp:
uses: nikolareljin/ci-helpers/.github/workflows/csharp.yml@production
with:
runner: windows-latest
project: WindowsApp.csproj
Both directions are covered by a self-test leg: tests/fixtures/csharp-windows
builds on windows-latest and is refused on Linux with a message naming the
platform. The second leg is the one that matters — a runner input exercised
only on the runner it already worked on proves nothing.
Godot#
Workflow: .github/workflows/godot.yml
Defaults:
godot_version:4.3.0godot_use_dotnet:falselint_command: imports the project and fails on anything the import loggedtest_command: none — it fails and tells you to set one, for the reason below
Example:
jobs:
godot:
uses: nikolareljin/ci-helpers/.github/workflows/godot.yml@production
with:
godot_version: "4.3.0"
test_command: ./dev test
Write the version with three parts#
setup-godot parses godot_version as a full version. project.godot records
a two-part one, and copying it across gives Invalid version: 4.3 before any
step runs — the job fails at setup, which reads as infrastructure trouble
rather than a typo. Write 4.3.0.
Import before anything else#
Godot resolves class_name types through
.godot/global_script_class_cache.cfg, which is produced by importing the
project and is not in version control. Every CI run starts without it, so until
the project is imported every class_name is "not declared in the current
scope": scripts referencing one fail to parse, preload() returns an invalid
script, and .tres files load as a bare Resource. The default
lint_command is that import, and it doubles as the parse gate.
But the import cannot be trusted to fail. Measured on 4.3: a project
containing a script with a syntax error imports, prints
SCRIPT ERROR: Parse Error, and exits 0. So a bare
godot --headless --import is a lint step that can never fail — which is the
obvious thing to write, and wrong. The default here reads the import log and
fails on what is in it. If you override lint_command, keep that property.
An error during a run never reaches the exit status#
This is the one that matters, and it is narrower than "Godot always exits 0" — measured on 4.3:
| What went wrong | godot --headless --script ... exits |
|---|---|
The script fails to parse or load (an unresolved class_name, a syntax error) |
1 |
The engine prints ERROR: while the script runs (push_error, a bad format string, a failed resource load) |
0 |
--import over a project whose scripts do not parse |
0 |
So the exit status catches the loud failures and misses the quiet ones — which
are exactly what a test suite is for. There is no honest default test command,
which is why the preset's default fails and asks for one. Put the check in your repository next to your tests, so it runs the
same way locally, and pass it as test_command:
#!/usr/bin/env bash
set -uo pipefail
log="$(mktemp)"
godot --headless --import # build the class cache first
godot --headless --script res://tests/TestRunner.gd 2>&1 | tee "$log"
# Fail on anything Godot printed as an error, minus the one line --headless
# always prints: the dummy renderer reports `Parameter "m" is null` for every
# mesh it cannot realise, which says nothing about your project.
if grep -nE "ERROR:|Failed to load|Parse Error" "$log" | grep -vE 'Parameter "m" is null'; then
echo "Godot printed the errors above and still exited 0." >&2
exit 1
fi
# "No errors" is not "the tests ran" — a runner that dies quietly prints
# nothing and still exits 0. Require the completion line it is supposed to
# print.
grep -q "all tests passed" "$log" || { echo "The runner never reported success." >&2; exit 1; }
Note the direction of that error check: it lists what is known to be
harmless and fails on everything else. An allowlist of error strings to
look for is the tempting shape and it is the wrong one — it ignores any kind
nobody thought of, and a format string with more placeholders than arguments
(Godot reports ERROR: a number is required) reached a physical device that
way while CI reported success.
Unity#
Workflow: .github/workflows/unity.yml
Separate from the C# preset on purpose. Unity needs licence activation against
Unity's servers, a cached Library folder that dwarfs every other cache here,
and a macOS runner for an iOS export. An editor run takes minutes where
dotnet build takes seconds.
A .NET test harness that lives outside the asset tree keeps using csharp.yml.
That harness exists so some tests run on a Linux runner without the editor; this
preset is for the tests that need the editor.
It does nothing until you arm it#
Set the repository variable UNITY_CI to true. Anything else, unset included,
and the workflow reports that it is not armed and stops — no checkout, no editor,
nothing billed.
It is a variable rather than an input so that arming it is a deliberate act in the
repository's settings, visible to anyone with access, rather than a line in a
workflow file that a merge can introduce. The enabled input exists for one
case: a caller passes ${{ vars.UNITY_CI }} through it explicitly. Empty reads
the same variable here.
jobs:
unity:
uses: nikolareljin/ci-helpers/.github/workflows/unity.yml@production
with:
project_path: game
test_mode: editmode
secrets:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
Licence#
Either a Personal licence or a serial, plus the account credentials in both cases:
| secret | for |
|---|---|
UNITY_LICENSE |
the contents of the .ulf file — activate once locally, paste the whole file |
UNITY_SERIAL |
a Plus or Pro serial, instead of UNITY_LICENSE |
UNITY_EMAIL, UNITY_PASSWORD |
the Unity account, needed with either kind |
Credentials are checked by the gate job, in seconds, before a runner starts. The editor otherwise discovers a missing licence several minutes into a billed run and reports it as a missing file rather than a missing secret.
Defaults#
test_mode:editmode—all,editmodeorplaymode; anything else is refusedunity_version:auto, read fromProjectSettings/ProjectVersion.txtcache:true, cachingLibrarykeyed onAssets/,Packages/andProjectSettings/fetch_depth:1, not0— nothing here reads history and these repositories are largetimeout_minutes:60, not20— a coldLibraryimport can take most of itrunner:ubuntu-latest; an iOS export needs macOS, which bills at roughly ten times the ratelfs:true- check out Git LFS objects; a missing object fails deep inside the import otherwiseartifact_name:unity-test-resultsenabled:"", which readsvars.UNITY_CI
What is and is not tested here#
The gate is covered by a self-test leg: it refuses an unknown test_mode, an
absolute or ..-containing project_path, and being armed without a usable
licence; and it stays off, without asking for any credential, when UNITY_CI is
unset.
The editor run itself is not exercised, because this repository holds no Unity licence and an editor run bills by the minute. Treat the first run on a real project as the first proof that the Unity step works.
Go scan#
Workflow: .github/workflows/go-scan.yml
Defaults:
go_version:go.mod(Go from<working_directory>/go.mod, itstoolchainline when present; a version such as1.26overrides it, and is needed when that directory has nogo.mod)govulncheck_version:v1.7.0vulncheck:false
jobs:
scan:
uses: nikolareljin/ci-helpers/.github/workflows/go-scan.yml@production
with:
vulncheck: true
gosec is a static analyser: it reads this repository's own code. It is not a
dependency audit and says nothing about a known advisory in a module you import.
govulncheck is that audit, and it reports only advisories reachable from your
code, so importing a vulnerable module without calling into it is not flagged.
It is off by default. On by default, it would fail a repository that changed nothing on the day an advisory is published.
govulncheck is built with the module's Go, so it can load the module's
code: a binary built with an older Go fails with package requires newer Go
version. When the module's Go is older than 1.25.0, the floor govulncheck
v1.7.0 declares, the install runs with GOTOOLCHAIN=go1.25.0, and Go fetches
1.25.0 from the module proxy, checked against the checksum database. A
govulncheck_version that needs a newer Go fails on such modules until the
floor in the step is raised with it.
govulncheck_version is pinned and validated as vX.Y.Z. A floating @latest
would change the gate's behaviour without a change here, and a new advisory
class would turn every consumer red on a day nobody released anything.
govulncheck exits 3 when it finds something, not 1. The step relies on non-zero rather than a specific code.
Docker#
Workflow: .github/workflows/docker.yml
Defaults:
docker_command:docker build .
Example:
jobs:
docker:
uses: nikolareljin/ci-helpers/.github/workflows/docker.yml@production
with:
docker_command: "docker build -t myapp:ci ."
pnpm#
Workflow: .github/workflows/pnpm.yml
Defaults:
node_version:22pnpm_version:latestlint_command:pnpm run --if-present linttest_command:pnpm run testbuild_command:pnpm run build
Dependencies are installed once, by the preset itself, with
pnpm install --frozen-lockfile, and pnpm-lock.yaml must be committed and in
step with package.json.
A repository without one does not reach the install: the cache step goes first and stops there,
Dependencies lock file is not found in /home/runner/work/<repo>/<repo>.
Supported file patterns: pnpm-lock.yaml
and had it got that far, pnpm install --frozen-lockfile would refuse in its
own words:
Both are the right answer for CI: an install that quietly writes a lockfile is an install that can drift from what was reviewed.
The cache is keyed on ${{ inputs.working_directory }}/pnpm-lock.yaml, so a
package inside a monorepo caches on its own lockfile. Keyed at the repository
root -- which is setup-node's default -- every such consumer fails at that
same step.
Optional test result upload:
upload_test_results:false— set totrueto upload JUnit XML viadorny/test-reportertest_results_path:test-results/**/*.xml— glob for JUnit files; configure your test runner to emit XML hereartifact_suffix:""— appended to thejunit-xml-pnpmartifact name and theTestscheck run; set to e.g.-node20when invoking this preset multiple times in a matrix to avoid name collisions
Notes:
- Works with Turborepo monorepos —
pnpm run testcan delegate toturbo run test. - To enable test result annotations in GitHub Actions UI, set
upload_test_results: trueand configure Vitest to emit JUnit XML:
// vitest.config.ts
export default defineConfig({
test: {
reporters: ['default', 'junit'],
outputFile: { junit: 'test-results/results.xml' },
},
})
Example:
jobs:
ci:
uses: nikolareljin/ci-helpers/.github/workflows/pnpm.yml@production
with:
node_version: "22"
upload_test_results: true
pnpm + Playwright#
Workflow: .github/workflows/pnpm-playwright.yml
Defaults:
node_version:22pnpm_version:latestlint_command:""— lint is disabled by default; set to e.g.pnpm run lintto enabletest_command:pnpm run testbuild_command:pnpm run builde2e_command:pnpm exec playwright install --with-deps && pnpm dlx start-server-and-test 'pnpm run preview' http://localhost:4173 'pnpm exec playwright test'upload_playwright_report:true— uploadsplaywright-report/as an artifact on every runplaywright_report_path:playwright-report/
Optional test result upload:
upload_test_results:false— set totrueto upload JUnit XML viadorny/test-reportertest_results_path:test-results/**/*.xml— glob for Playwright JUnit outputartifact_suffix:""— appended toplaywright-report,junit-xml-playwright, and theTestscheck name; use in a matrix to avoid collisions
Notes:
- Use for pnpm monorepos. Playwright browsers are installed as part of
e2e_command. - Override
e2e_commandto change the preview server command or port. - For a Turborepo monorepo where the demo app is a workspace, use e.g.
pnpm run previeworpnpm --filter demo preview.
Example:
jobs:
e2e:
uses: nikolareljin/ci-helpers/.github/workflows/pnpm-playwright.yml@production
with:
node_version: "22"
e2e_command: "pnpm exec playwright install --with-deps && pnpm dlx start-server-and-test 'pnpm --filter demo preview' http://localhost:4173 'pnpm exec playwright test'"
Pages (any static site)#
Workflow: .github/workflows/pages.yml
Builds a static site with whatever generator the repository uses and deploys it
to GitHub Pages. Stack-agnostic: MkDocs, Sphinx, Hugo, Astro, or a plain
cp -r. Use pnpm-pages.yml instead when you need pnpm-workspace installs or a
Playwright capture step.
Requires pages: write and id-token: write in the caller even on runs where
deploy is false — GitHub validates a reusable workflow's declared
permissions when the run starts, before any job-level if: is evaluated, so a
caller granting less fails the whole run with startup_failure.
That matters on pull requests, where the build runs third-party code (pip, npm
postinstall, generator plugins) while those write scopes are live and nothing is
being published. If you would rather not grant them there, call
pages-build.yml and pages-deploy.yml as two jobs instead — see
Pages, split below. pages.yml remains the simpler choice for a
repository that publishes from every event it builds on.
Defaults:
runner:ubuntu-latestworking_directory:.concurrency_key:""— defaults togithub.ref. Every deployment to a ref is serialised, because Pages hosts one site per repository and two racing deploys decide the published content by whichever finishes last. Set this only when two workflows genuinely publish different thingsfetch_depth:0— full history, which generators reading git dates or tags need (mkdocs-git-revision-date, Hugo.Lastmod); set1when nothing doespython_version:""— set to install Python before buildingnode_version:""— set to install Node before buildingrequirements_file:""— a pip requirements file relative toworking_directory; when set andinstall_commandis empty it is installed for you and used as the pip cache keyinstall_command:""— overrides therequirements_fileinstallbuild_command:""— the command that writes the site intopages_path. Leave it empty to publish a directory already committed to the repository: a plain HTML site needs no toolchain and no placeholder commandrequire_entry_file:true— fail whenpages_pathhas noindex.htmlorindex.htmat its root. A site without one deploys successfully and then serves 404 at its own address. Setfalsewhen publishing assets that are only ever linked to directlyartifact_retention_days:1— how long the Pages artifact is kept. It exists to hand the site to the deploy job; keeping build output longer stores content nobody readspages_path:site— directory uploaded to Pages, relative toworking_directory. Absolute paths and any..segment are refused. The root ofworking_directoryis allowed — a repository whose root is the site is a normal Pages layout — but warns, because "everything here" is rarely what someone means to publish.upload-pages-artifactexcludes.git,.githuband dotfiles; it does not exclude sourcedeploy:true— setfalseto build without publishingtimeout_minutes:20
Output: page_url, the published address. Empty when deploy is false.
The build fails if pages_path is missing, or contains no files by the time
the site is uploaded — a tree of empty directories is not a site — whether a generator ran and produced nothing, or a deploy-only call
points at a directory that is not there. That check is why build_command does
not need to be mandatory: an empty site cannot replace a working one on a green
run either way. require_entry_file extends the same idea one step — a
directory full of files with no entry document publishes cleanly and then serves
404 to every visitor.
working_directory gets the same treatment: relative, no climbing out of the
checkout, and it must exist. A path that does not exist otherwise fails several
steps later with a message about whichever command happened to run first. Both
inputs are caller-defined workflow configuration, at the same trust level as the
workflow file — these guards catch a typo, not an attacker.
actions/configure-pages runs before the build on deploying runs, so a
generator that needs the site's own address can read it:
| Variable | Example |
|---|---|
PAGES_BASE_URL |
https://owner.github.io/repo |
PAGES_ORIGIN |
https://owner.github.io |
PAGES_HOST |
owner.github.io |
They are empty when deploy is false: a repository validating its site on
pull requests before ever enabling Pages should fail on its own site, not on the
Pages API. The repository must have Settings → Pages → Source: GitHub
Actions for a deploying run to succeed.
Do not set a pages-* concurrency group in the caller. This preset's own
group is ci-helpers-pages-<key>. A caller whose group name collides with the
called workflow's own group leaves the called jobs queued behind the run that
started them — the run waits for a slot it is itself holding. If you are
migrating a hand-rolled Pages workflow, delete its concurrency: block; this
preset already serialises deployments.
Example (MkDocs — build on every pull request, publish only from main):
name: Docs
on:
push:
branches: [main]
paths: [docs/**, mkdocs.yml, requirements-docs.txt]
pull_request:
paths: [docs/**, mkdocs.yml, requirements-docs.txt]
permissions:
contents: read
pages: write
id-token: write
jobs:
pages:
uses: nikolareljin/ci-helpers/.github/workflows/pages.yml@production
permissions:
contents: read
pages: write
id-token: write
with:
python_version: "3.12"
requirements_file: "requirements-docs.txt"
build_command: "mkdocs build --strict"
pages_path: "site"
deploy: ${{ github.event_name != 'pull_request' }}
Example (publish a directory already in the repository — no build, no toolchain):
jobs:
pages:
uses: nikolareljin/ci-helpers/.github/workflows/pages.yml@production
permissions:
contents: read
pages: write
id-token: write
with:
pages_path: "site"
Example (a Node generator):
jobs:
pages:
uses: nikolareljin/ci-helpers/.github/workflows/pages.yml@production
permissions:
contents: read
pages: write
id-token: write
with:
node_version: "22"
install_command: "npm ci"
build_command: "npm run build"
pages_path: "dist"
Pages, split#
Workflows: .github/workflows/pages-build.yml and
.github/workflows/pages-deploy.yml
The same publisher as pages.yml, in two callable halves. Reach for it when a
repository builds its site on pull requests and publishes only from its default
branch, and you do not want the pull-request build holding deploy-capable
scopes. pages.yml calls these two internally, so behaviour is identical — the
only difference is which permissions the caller has to grant on which event.
Minimum grants, both measured against a real run rather than inferred:
| Job | Grant | Measured |
|---|---|---|
pages-build.yml |
contents: read + pages: read |
succeeds |
pages-build.yml |
contents: read alone |
startup_failure — pages: read is genuinely required |
pages-deploy.yml |
pages: write + id-token: write |
succeeds |
pages-build.yml takes every input pages.yml does except deploy and
concurrency_key (runner, working_directory, fetch_depth,
python_version, node_version, requirements_file, install_command,
build_command, pages_path, require_entry_file, artifact_retention_days,
timeout_minutes), plus two of its own:
upload:true— upload the built site as thegithub-pagesartifact for a later deploy job. Setfalseon runs that only check the site builds, so no artifact is stored for a deploy that never happensconfigure_pages:false— runactions/configure-pagesand exportPAGES_BASE_URL,PAGES_ORIGINandPAGES_HOSTtobuild_command. Off by default because it calls the Pages API, which fails on a repository that has not enabled Pages — the common case for a caller that only validates its site. Turn it on for the deploying run of a generator that builds absolute links
pages-deploy.yml takes runner, timeout_minutes and concurrency_key, and
outputs page_url. It checks out nothing and builds nothing: the write scopes
are only ever live in a job that runs no third-party code.
name: Docs
on:
push:
branches: [main]
paths: [docs/**, mkdocs.yml, requirements-docs.txt]
pull_request:
paths: [docs/**, mkdocs.yml, requirements-docs.txt]
permissions:
contents: read
jobs:
build:
uses: nikolareljin/ci-helpers/.github/workflows/pages-build.yml@production
permissions:
contents: read
pages: read
with:
python_version: "3.12"
requirements_file: "requirements-docs.txt"
build_command: "mkdocs build --strict"
pages_path: "site"
upload: ${{ github.ref == 'refs/heads/main' }}
deploy:
needs: build
if: ${{ github.ref == 'refs/heads/main' && github.event_name != 'pull_request' }}
uses: nikolareljin/ci-helpers/.github/workflows/pages-deploy.yml@production
permissions:
pages: write
id-token: write
On a pull request the deploy job is skipped, so its token is never minted and
the run holds no write scope anywhere.
Concurrency: pages-deploy.yml serialises on
ci-helpers-pages-deploy-<key>, which is deliberately not the
ci-helpers-pages-<key> group pages.yml uses. A called workflow's group is
evaluated alongside its caller's, so sharing the name would leave the deploy
queued behind the run that started it. The same rule applies to your own
workflow: do not name a group ci-helpers-pages-deploy-*.
pnpm + Pages#
Workflow: .github/workflows/pnpm-pages.yml
Builds a pnpm monorepo, optionally runs a Playwright capture script to produce
screenshots and videos, runs a static-site generator, and deploys the result to
GitHub Pages. Requires pages: write and id-token: write in the caller.
Defaults:
runner:ubuntu-latestconcurrency_key:""- overridesgithub.refin the concurrency group, for callers publishing several sites from one ref
The workflow keys its own concurrency group as ci-helpers-pnpm-pages-<key>,
so a caller needs no concurrency: block for it. Add one only to serialise
the caller's own runs, and give it a name that is not pages-<ref>. Two callers
publishing different sites from one ref pass distinct concurrency_key
values and stop queueing behind each other.
working_directory:.— set to a subdirectory when the pnpm workspace root is not the repo rootfetch_depth:0— full history; this is the safe default for tag-based tooling (e.g. changeset version, release scripts); set to1for faster shallow clones when no tag/history access is needednode_version:22pnpm_version:latestbuild_command:pnpm build— runs before the capture and generate stepscapture_command:""— optional; set to e.g.node scripts/capture.mjsto run a Playwright-based capture script before site generationgenerate_command:node docs/generate.mjs— generates the static site intopages_pathpages_path:docs/site— directory uploaded to Pages (relative toworking_directory)install_playwright:false— set totrueto install the Playwright browser before captureplaywright_browser:chromium— must be one of:chromium,firefox,webkit,chrome,msedge; validated before install so failures are deterministic
Notes:
pages: writeandid-token: writeare scoped to thedeployjob only; thebuildjob only requirescontents: read, so caller-supplied commands cannot exchange OIDC tokens.playwright_browseris validated against known Playwright browser names before install; an::error::annotation is emitted and the job fails immediately on an unknown value.- The
playwright_browserinput is passed via$PW_BROWSER(not direct interpolation) to avoid shell-injection risk. - All
run:steps useshell: bashwithset -euo pipefail, consistent with other pnpm presets. pages_pathis automatically prefixed withworking_directorywhen not..
Required caller permissions:
Example (with Playwright capture):
jobs:
pages:
uses: nikolareljin/ci-helpers/.github/workflows/pnpm-pages.yml@production
permissions:
contents: read
pages: write
id-token: write
with:
build_command: "pnpm build"
capture_command: "node scripts/capture.mjs"
install_playwright: true
playwright_browser: "chromium"
Example (generate only, no capture):
jobs:
pages:
uses: nikolareljin/ci-helpers/.github/workflows/pnpm-pages.yml@production
permissions:
contents: read
pages: write
id-token: write
with:
build_command: "pnpm build"
generate_command: "node docs/generate.mjs"
pages_path: "docs/site"
pnpm + Cypress#
Workflow: .github/workflows/pnpm-cypress.yml
Defaults:
node_version:22pnpm_version:latestlint_command:""— lint is disabled by default; set to e.g.pnpm run lintto enabletest_command:pnpm run testbuild_command:pnpm run builde2e_command:pnpm exec cypress install && pnpm exec cypress run --componentupload_cypress_artifacts:true— uploads videos and screenshots on failurecypress_videos_path:cypress/videoscypress_screenshots_path:cypress/screenshots
Optional test result upload:
upload_test_results:false— set totrueto upload JUnit XML viadorny/test-reportertest_results_path:test-results/**/*.xml— glob for Cypress JUnit outputartifact_suffix:""— appended tocypress-videos,cypress-screenshots,junit-xml-cypress, and theTestscheck name; use in a matrix to avoid collisions
Notes:
- Default runs Cypress in component test mode (
--component), which bundles and tests components directly — no running server needed. Overridee2e_commandto switch to full E2E mode against a preview server. - For E2E mode:
pnpm exec cypress install && pnpm dlx start-server-and-test 'pnpm run preview' http://localhost:4173 'pnpm exec cypress run'
Example (component tests):
jobs:
cypress:
uses: nikolareljin/ci-helpers/.github/workflows/pnpm-cypress.yml@production
with:
node_version: "22"
Example (E2E against preview server):
jobs:
cypress:
uses: nikolareljin/ci-helpers/.github/workflows/pnpm-cypress.yml@production
with:
node_version: "22"
e2e_command: "pnpm exec cypress install && pnpm dlx start-server-and-test 'pnpm --filter demo preview' http://localhost:4173 'pnpm exec cypress run'"
Playwright (yarn)#
Workflow: .github/workflows/playwright.yml
Defaults:
node_version:22e2e_command:yarn install --frozen-lockfile && npx playwright install --with-deps && npx start-server-and-test 'yarn dev' http://localhost:3000 'npx playwright test'
Notes:
- The tools run through
npx, notyarn dlx.dlxexists only in Yarn Berry, while--frozen-lockfileis Yarn 1 syntax, so a command using both runs on neither line: Yarn 1.22 answerserror Command "dlx" not found.npxships with Node, prefers a binary already innode_modules/.bin, and fetches only when there is none.
Yarn Berry with PnP is the exception. With no node_modules, resolving a
binary needs Yarn's own resolver, so such a repository should override:
e2e_command: "yarn install --immutable && yarn dlx playwright install --with-deps && yarn dlx start-server-and-test 'yarn dev' http://localhost:3000 'yarn playwright test'"
Note --immutable rather than --frozen-lockfile: Berry's own spelling.
- Uses Yarn. For pnpm monorepos use pnpm-playwright.yml instead.
Example:
jobs:
playwright:
uses: nikolareljin/ci-helpers/.github/workflows/playwright.yml@production
with:
node_version: "22"
e2e_command: "npx start-server-and-test 'yarn dev' http://localhost:4173 'npx playwright test'"
Cypress (yarn)#
Workflow: .github/workflows/cypress.yml
Defaults:
node_version:22e2e_command:yarn install --frozen-lockfile && npx cypress install && npx start-server-and-test 'yarn dev' http://localhost:3000 'npx cypress run'
Notes:
- The tools run through
npx, notyarn dlx.dlxexists only in Yarn Berry, while--frozen-lockfileis Yarn 1 syntax, so a command using both runs on neither line: Yarn 1.22 answerserror Command "dlx" not found.npxships with Node, prefers a binary already innode_modules/.bin, and fetches only when there is none.
Yarn Berry with PnP is the exception. With no node_modules, resolving a
binary needs Yarn's own resolver, so such a repository should override:
e2e_command: "yarn install --immutable && yarn dlx cypress install && yarn dlx start-server-and-test 'yarn dev' http://localhost:3000 'yarn cypress test'"
Note --immutable rather than --frozen-lockfile: Berry's own spelling.
- Uses Yarn. For pnpm monorepos use pnpm-cypress.yml instead.
Example:
jobs:
cypress:
uses: nikolareljin/ci-helpers/.github/workflows/cypress.yml@production
with:
node_version: "22"
e2e_command: "npx start-server-and-test 'yarn dev' http://localhost:4173 'npx cypress run'"
Cloudflare Workers#
Workflows: .github/workflows/cloudflare-deploy.yml, .github/workflows/cloudflare-build.yml
Two workflows, not one, and no orchestrator wrapping them. cloudflare-build.yml
declares no secrets at all; cloudflare-deploy.yml declares the token and the
Environment. GitHub validates a called workflow's declared permissions and
secrets when the run starts — before any job-level if: — so a single combined
workflow would put a live deploy credential in scope on pull-request runs, while
npm postinstall scripts and build plugins execute. Splitting them is the only
way a caller can decline that.
This differs from pages.yml, which does ship an orchestrator. There, the
deploy half is useless alone, so an orchestrator earns its place. Here the
deploy workflow is already self-sufficient, and an orchestrator would force
secrets: inherit onto every event it handles.
Before anything deploys#
- Create an API token at https://dash.cloudflare.com/profile/api-tokens with Account → Workers Scripts → Edit on the account that owns the Worker. Add Workers KV Storage → Edit only if your own deploy command writes KV; binding a namespace does not need it.
- Add it as
CLOUDFLARE_API_TOKEN, on the Environment you are deploying to. - Add
CLOUDFLARE_ACCOUNT_IDas a repository or environment variable. It is an identifier, not a credential, and a readable one in the log is what tells you a deploy landed on the wrong account. A secret of the same name also works. - Add a
BASE_URLvariable on that Environment — the host the smoke test checks.smoke_pathdefaults to/health, so without this the deploy succeeds and the run then goes red at the smoke step. Setbase_url:instead if you would rather pass it as an input, orsmoke_path: ""to turn the check off (and lose the only thing that proves the deploy took effect). - Set the repository variable
CLOUDFLARE_DEPLOY_ENABLEDtotrue. Until you do, every run is a visible no-op that explains itself in the job summary. - On a production Environment, add a required reviewer. The workflow puts the Environment on its own deploy job, which is what makes the reviewer apply.
1. A release tag deploys production#
name: deploy
on:
push:
tags: ['[0-9]+.[0-9]+.[0-9]+']
jobs:
deploy:
uses: nikolareljin/ci-helpers/.github/workflows/cloudflare-deploy.yml@production
secrets: inherit
permissions:
contents: read
with:
enabled: ${{ vars.CLOUDFLARE_DEPLOY_ENABLED }}
node_version: "22"
build_command: "npm run build"
config_glob: "dist/*/wrangler.json"
smoke_version_path: /api/status
No environment: is needed: a bare-SemVer tag push resolves to
production_environment, which defaults to production. An rc tag does not:
1.2.3-rc1 is rejected by the same check, so a release candidate cannot reach
production by accident.
Keep the tag filter above. A tag that is not bare SemVer resolves to no
environment, and that is a hard error — the run goes red, it is not skipped.
That is the right behaviour for a caller who forgot environment:, but with
tags: ['*'] it means every rc tag reports a failure. Either filter the trigger
as shown, or pass environment: explicitly.
2. A dispatch picks its environment#
name: deploy
on:
workflow_dispatch:
inputs:
environment:
type: choice
options: [staging, production]
jobs:
deploy:
uses: nikolareljin/ci-helpers/.github/workflows/cloudflare-deploy.yml@production
secrets: inherit
permissions:
contents: read
with:
enabled: ${{ vars.CLOUDFLARE_DEPLOY_ENABLED }}
environment: ${{ inputs.environment }}
node_version: "22"
build_command: "npm run build"
config_glob: "dist/*/wrangler.json"
3. Validate on pull requests, deploy from tags#
The shape that keeps the token out of pull-request runs entirely.
name: worker
on:
pull_request:
push:
tags: ['[0-9]+.[0-9]+.[0-9]+']
jobs:
build:
if: ${{ github.event_name == 'pull_request' }}
uses: nikolareljin/ci-helpers/.github/workflows/cloudflare-build.yml@production
permissions:
contents: read
with:
node_version: "22"
build_command: "npm run build"
config_glob: "dist/*/wrangler.json"
deploy:
if: ${{ github.event_name == 'push' }}
uses: nikolareljin/ci-helpers/.github/workflows/cloudflare-deploy.yml@production
secrets: inherit
permissions:
contents: read
with:
enabled: ${{ vars.CLOUDFLARE_DEPLOY_ENABLED }}
node_version: "22"
build_command: "npm run build"
config_glob: "dist/*/wrangler.json"
smoke_version_path: /api/status
The pull-request run writes no secrets: line, so no Cloudflare credential is
in scope for it.
4. Keep one deploy definition, shared with your laptop#
If the repository already has a deploy script — a ./dev deploy, say — point
deploy_command at it. Every gate still runs: the kill switch, the Environment
and its reviewer, the credential preflight, the version string, the smoke test
and the summary. Only the mechanism becomes yours.
The resolved values reach the command as environment variables:
| Variable | Meaning |
|---|---|
CLOUDFLARE_API_TOKEN |
the secret |
CLOUDFLARE_ACCOUNT_ID |
variable first, secret second, already proven non-empty |
CLOUDFLARE_ENV |
the resolved environment — this is wrangler's own environment selector, so a command that runs wrangler inherits it |
CF_DEPLOY_ENV |
the same value, under a name wrangler does not read |
CF_DEPLOY_COMMAND |
deploy / versions upload / pages deploy |
CF_DEPLOY_VERSION |
the version string |
CF_DEPLOY_REF |
the checked-out ref |
CF_DEPLOY_BASE_URL |
the resolved smoke base |
CF_DEPLOY_ARGS |
deploy_args, verbatim |
This is what stops the version string being defined twice. A script that reads
CF_DEPLOY_VERSION when it is set, and computes its own only when it is not,
has one live definition per run — CI's in CI, its own on a laptop.
script-helpers' cloudflare module does exactly that.
Cloudflare Pages#
Pages is reachable as an input combination, not a separate code path. The Workers lane is the one that has been exercised end to end; this is documented so it can be used, not claimed to be at parity.
with:
enabled: ${{ vars.CLOUDFLARE_DEPLOY_ENABLED }}
environment: staging
command: "pages deploy"
wrangler_env: none # `wrangler pages deploy` rejects --env
version_var: "" # and it has no --var either
build_command: "npm run build"
deploy_args: "dist --project-name example-site"
The token needs Account → Cloudflare Pages → Edit instead of the Workers scope.
Pushing KV entries or seed data#
This is deliberately not automated, and there is no input for it. If a
deploy needs to write KV entries, seed a namespace, or upload data alongside the
Worker, do it from your own deploy_command. Read this section first — the
failure modes here are quieter than the ones in a deploy.
Why it is not built in:
- It needs a wider token. Deploying a Worker needs Workers Scripts → Edit. Writing KV needs Workers KV Storage → Edit as well; binding a namespace does not. Folding a data push into a shared workflow would push every consumer toward minting the broader token whether or not they write data.
- It is not one operation. "Push the data" means something different per
project — a whole namespace replaced, a few keys upserted, a file uploaded, a
migration applied. Any input surface general enough to cover that is a shell
command with extra steps, which is what
deploy_commandalready is. - It is the step that is hardest to undo. A Worker deploy is replaced by the next deploy. Overwritten data is gone.
Four things to get right, in the order they bite:
- Guard it with a string comparison, never a bare truthiness test. A job
output is a string, so
if: needs.resolve.outputs.push_datais true even when that output is the literal"false"— every non-empty string is truthy. A data push guarded that way runs on every deploy, tag-driven production ones included, and the symptom is silent data loss on a green run. Write== 'true', and prefer an enum over a boolean where you can. - Make it idempotent, or make it refuse. Deploys get re-run: a retried job, a re-pushed tag, someone clicking Re-run all jobs. A push that appends or overwrites unconditionally is a different outcome each time. If it cannot be idempotent, have it detect existing data and stop rather than clobber.
- It is not part of the deploy's atomicity. wrangler deploying and your data landing are two operations with no shared transaction. Decide which order fails better for your service — data first means the new Worker meets data it understands; Worker first means old code may meet new data — and write the answer down next to the command.
- Keep it out of the pull-request lane.
cloudflare-build.ymldeclares no secrets precisely so pull requests hold no credential. Do not reach for a data push there.
Where it goes:
with:
enabled: ${{ vars.CLOUDFLARE_DEPLOY_ENABLED }}
environment: production
# One command, owned by your repository. Everything the workflow resolved
# is already in the environment: CLOUDFLARE_API_TOKEN,
# CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_ENV, CF_DEPLOY_VERSION,
# CF_DEPLOY_CONFIG, CF_DEPLOY_REF.
deploy_command: ./scripts/deploy-with-data.sh
The kill switch, the Environment and its required reviewer, the credential preflight, the version string, the smoke test and the summary all still run. Only the mechanism is yours — which is the point: the part that can destroy data stays in the repository that owns the data, where it is reviewed against that project's rules rather than a shared workflow's defaults.
Things worth knowing before you rely on this#
deployedis a string.if: needs.deploy.outputs.deployedis truthy for"false"too — every non-empty string is. Compare it against'true'.--dry-runproves less than it looks. It validates the config and bundles the Worker. It does not check bindings, routes, or account access.npx --yes wrangler@<version>fetches from npm at deploy time, which is an unpinned-integrity dependency inside an otherwise SHA-pinned toolchain. For anything you care about, setwrangler_command: "pnpm exec wrangler"so your lockfile pins it. The default is convenience, and says so.- Set
smoke_version_path. Without it the smoke test proves something answered, which passes just as happily against the release that was already live. The summary says so rather than reporting a clean pass.
Overriding defaults#
Presets forward the pass-through inputs listed at the top of this page, and most accept the engine's command inputs too. For example, to add Docker and E2E in the Node preset: