Reusable GitHub Actions composite actions + reusable workflows for the Tinyland CI house style.
⚠️ Pin to an immutable release tag such as@v2.0.0.@mainis the develop branch and may break without notice. SeeRELEASING.mdfor the SemVer contract.
Spokes spawned from tinyland-inc/site.scaffold consume this repo for:
- Spoke CI (lint, type-check, build, test, Bazel graph, optional
Playwright) via
spoke-ci.ymlreusable workflow. - Per-PR ephemeral env lifecycle (build image, dispatch to Blahaj,
reap on close) via
spoke-lane-env.yml. - Static projection snapshot refresh via
spoke-pulse-ingest.yml. - GloriousFlywheel REAPI binding via the
flywheel-bazelcomposite action. - Scaffold AX/skills inheritance via
inherit-scaffold-skills, which pullsplugins/scaffold-corefromtinyland-inc/site.scaffoldat a pinned tag. - Repo-shape manifest validation via
repo-manifest-validate. - Schema-validated
lanes.jsonloading vialanes-load. - Blahaj
repository_dispatchpayload construction vialane-dispatch/lane-reap. - Public client preview dispatch via
public-preview-dispatch; Blahaj owns Cloudflare DNS, Access, Tunnel ingress, and cleanup. - Scheduled expired-lane cleanup dispatch via
lane-ttl-reap. - GloriousFlywheel proof dispatch via
flywheel-reapi-proof. - Per-lane GitHub commit status checks via
lane-status-check.
The full contract spokes conform to is
tinyland-inc/site.scaffold/docs/CI-SCHEMA.md.
# .github/workflows/ci.yml
jobs:
ci:
uses: tinyland-inc/ci-templates/.github/workflows/spoke-ci.yml@v2.0.0
with:
flywheel_config: flywheel-executor
playwright_enabled: true
secrets: inherit# .github/workflows/lane-env.yml
on:
pull_request:
types: [opened, synchronize, reopened, closed]
jobs:
lane-env:
uses: tinyland-inc/ci-templates/.github/workflows/spoke-lane-env.yml@v2.0.0
with:
spoke: my-spoke
enable_tailnet_qa: false
secrets:
BLAHAJ_DISPATCH_TOKEN: ${{ secrets.BLAHAJ_DISPATCH_TOKEN }}Your spoke needs .github/lanes.json validating against
schemas/lanes.schema.json. New spokes should
also include tinyland.repo.json validating against
schemas/tinyland-repo-manifest.schema.json.
To inherit the canonical scaffold agent skills into a spoke:
steps:
- uses: actions/checkout@v6
- uses: tinyland-inc/ci-templates/.github/actions/inherit-scaffold-skills@v2.0.0
with:
scaffold_ref: v2026.05.19scaffold_ref must be a pinned scaffold tag, refs/tags/*, or a full commit
SHA. Branch refs such as main are rejected by default.
Use the same house-style entrypoint as consuming repos:
just check
nix develop --command just checkThe check parses all workflow/action YAML, parses vendored JSON schemas,
validates tinyland.repo.json, verifies v2 internal action refs resolve to
checked-in sibling actions, asserts bazelrc/flywheel.bazelrc and
bazelrc/ci-cached.bazelrc remain endpoint-free, asserts the cache_backed
opt-in lane stays default-off and cache-first, and runs the canonical Tinyland
gitleaks working-tree scan.
| Action | Purpose |
|---|---|
nix-setup |
Configure Nix + cache hints. Does not invent Bazel endpoints. |
nix-build |
Run nix build with Attic binary cache. |
greedy-cache |
Start Attic watch-store daemon for concurrent push. |
secrets-scan |
TruffleHog + Gitleaks. |
inherit-scaffold-skills |
Pull plugins/scaffold-core from site.scaffold at a pinned ref and materialize .agents/skills + .claude/skills. |
repo-manifest-validate |
Validate tinyland.repo.json and optionally require repo roles such as static-spoke. |
flywheel-bazel |
bazelisk wrapper with endpoint-free --config=flywheel[-executor]. Supplies cache/executor endpoints from runtime env or inputs. Refuses executor on non-cluster runners. |
lanes-load |
Validate + load .github/lanes.json. Outputs matrix-ready lanes_json. |
lane-dispatch |
Emit Blahaj <spoke>-lane-env provision payload. Supports dry_run. |
lane-reap |
Emit Blahaj destroy payload. Idempotent. |
lane-ttl-reap |
Emit Blahaj expired-lane sweep payload for scheduled TTL backstops. |
public-preview-dispatch |
Emit Blahaj public/client preview payload with Cloudflare Access allowlist. |
flywheel-reapi-proof |
Dispatch and optionally await a GloriousFlywheel executor-backed proof run, correlated by a unique request id. |
lane-status-check |
Post per-lane ci/lane/<name> GitHub commit status. |
pulse-ingest-validate |
Validate a Pulse / static projection snapshot. |
See per-action action.yml files for full input/output documentation.
| Workflow | Purpose |
|---|---|
js-bazel-package.yml |
Pre-existing: JS/TS packages built by Bazel and published to GitHub Packages, with npmjs required/optional/disabled by package policy. Supports an opt-in, default-off cache_backed shared-cache Bazel validation lane (cache-first; see below). |
npm-publish.yml |
Pre-existing: hosted-only Node package build + publish. |
spoke-ci.yml |
Canonical spoke CI: secrets-scan, lanes-load, per-lane flywheel-bazel build/test, bazel-graph, optional Playwright. |
spoke-lane-env.yml |
Canonical PR-env workflow. Replaces hand-rolled pr-env-lanes.yml. |
spoke-lane-ttl-reap.yml |
Reusable scheduled TTL backstop dispatcher for Blahaj lane cleanup. |
spoke-public-preview.yml |
Reusable public/client preview dispatcher for Cloudflare Access-gated aliases. |
spoke-pulse-ingest.yml |
Snapshot-refresh PR opener. |
spoke-deploy-cloudflare-pages.yml |
Sanctioned opt-in Cloudflare Pages deploy lane. Builds the adapter-static build/ via nix develop --command just setup/check/build, then wrangler pages deploy build. Credential-skips when the org CF secrets are absent; PR events build only. Does not replace the scaffold default GitHub-Pages lane. |
spoke-deploy-cloudflare-pages.yml DRYs the hand-rolled CF-Pages publisher that
was copied into multiple spokes (GFTB greatfallstoolbus.org,
transscendsurvival.org, and the site.scaffold
docs/deploy/cloudflare-pages.md template block). GitHub Pages remains the
scaffold default deploy lane (deploy-pages.yml); this is the sanctioned
CF-Pages opt-in, now reusable.
A spoke's thin .github/workflows/deploy-pages.yml becomes a wrapper:
# .github/workflows/deploy-pages.yml
name: Deploy to Cloudflare Pages
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
deployments: write
jobs:
deploy:
permissions:
contents: read
deployments: write
uses: tinyland-inc/ci-templates/.github/workflows/spoke-deploy-cloudflare-pages.yml@v2.10.0
# Pass the CF secrets EXPLICITLY. `secrets: inherit` does not reliably
# deliver repo secrets when the calling repo lives in a DIFFERENT org than
# ci-templates (observed 2026-07-04: Great-Falls-Tool-Bus spoke built green
# while the deploy step credential-skipped on every push). Explicit mapping
# works in both same-org and cross-org callers.
secrets:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}project_name defaults to the slugified repository name (dots/underscores →
hyphens); override it with the project_name input when the CF project name
differs. The deploy step skips with a ::notice:: when
CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID are absent, so the wrapper
merges safely before the org token is minted (personal-account spokes never
hold CF creds). Use secrets: inherit so org/repo-provisioned secrets are
available by name without forcing absent optional secrets to exist. Do not
copy a cloudflare-pages-${{ github.ref }} concurrency group into the caller:
the reusable workflow already owns that group, and duplicating it deadlocks the
caller against the called deploy job. PR events build only — they never deploy
and never mutate repo state. Pin @vX.Y.Z to your intended release; the example
above assumes the first release that ships this lane.
schemas/tinyland-repo-manifest.schema.json, schemas/lanes.schema.json,
schemas/blahaj-dispatch.schema.json, schemas/lane-ttl-reap-dispatch.schema.json, and
schemas/public-preview-dispatch.schema.json
are vendored from tinyland-inc/site.scaffold/docs/schemas/. The
schema-doc repo is the source of truth; this repo vendors at known
stable paths so composite actions can jsonschema against them.
tinyland-repo-manifest.schema.json carries first-class, validated enrollment
fields (TIN-2109): enrollment.forgeScope, enrollment.operatorOverlay,
enrollment.executionPool, and enrollment.substrateMode
(compatibility-local-only | shared-cache-backed | executor-backed). The
object is additive and optional — existing manifests without it still validate —
and substrateMode is the authoritative expected mode the cache-backed gate
enforces.
bazelrc/flywheel.bazelrc is endpoint-free. It defines safe behavior for
--config=flywheel and --config=flywheel-executor, but does not hard-code
remote_cache, remote_executor, credentials, headers, or upload authority.
The flywheel-bazel composite installs it at runtime and supplies
--remote_cache from BAZEL_REMOTE_CACHE; executor mode additionally requires
BAZEL_REMOTE_EXECUTOR. Pull requests default to read-only cache use unless a
trusted lane sets GF_BAZEL_REMOTE_UPLOAD=true.
bazelrc/ci-cached.bazelrc is the consumer-naming counterpart for the
cache-first lane. It defines endpoint-free --config=ci-cached,
--config=cache-readonly, and --config=no-remote-cache behavior that spoke
.bazelrc files reference. It is read-only by default (no upload) and never
selects a remote executor. scripts/cache-attachment-contract.sh is the
fail-closed checker that gates cache-backed work (--strict requires a real
BAZEL_REMOTE_CACHE; rejects unexpanded ${...} placeholders, non-grpc/http
endpoints, and localhost without explicit proof).
js-bazel-package.yml exposes an opt-in, default-off cache_backed input.
When unset, the Bazel validation runs the existing
bazelisk build … --verbose_failures path byte-identically — zero impact on
non-opted consumers. When cache_backed: true, the workflow runs the fail-closed
cache-attachment contract and then validates with
--config=ci-cached --remote_cache=$BAZEL_REMOTE_CACHE --remote_upload_local_results=false, reading the shared Bazel cache. This lane is
cache-first only (TIN-1997 Option D / GF#889); it never wires a remote executor.
On self-hosted Tinyland cluster runners, nix-setup exports BAZEL_REMOTE_CACHE
from cluster DNS, so attach needs no new secret or infrastructure.
The cache-backed lane is hardened for deterministic, fail-closed enrollment
(TIN-2109): it validates the consumer's tinyland.repo.json against the schema,
reads enrollment.substrateMode as the authoritative expected mode (a
declared-vs-actual mismatch fails closed), rejects hosted / non-cluster runner
fallback (no silent degrade to a GitHub-hosted build), and pins the contract-script
fetch fallback to an immutable releasing tag. It also exports
GF_FLYWHEEL_PROFILE_STATE from the resolved substrate mode so consumer
flywheel-doctor / flywheel-verify tooling sees the same machine-readable
attachment state as CI. Copy the single lace-up pattern in
AGENTS.md to enroll. See
docs/js-bazel-package.md (cache_backed,
substrate_mode) for the consumer-facing details.
spoke-ci.yml exposes the same opt-in, default-off enrollment (TIN-2119)
via cache_backed + substrate_mode (and cache_backed_targets for the
SvelteKit flywheel-eligible CAS surface). When set, the flywheel-build and
bazel-graph jobs switch from setup-nix@v2 (install-only) to nix-setup@v2
(which exports BAZEL_REMOTE_CACHE from cluster DNS — the spoke wiring fix),
export GF_FLYWHEEL_PROFILE_STATE from the manifest-driven substrate mode, run
the identical fail-closed contract, and execute a cache-backed Bazel build of
the flywheel-eligible targets reading the shared cache. The default path is
byte-identical for the ~34 non-opted spoke consumers. An opted spoke must also
set flywheel_config: flywheel so flywheel-bazel forwards the remote cache.
See RELEASING.md for the release flow and SemVer
policy. Each PR must amend ## [Unreleased] in CHANGELOG.md. Internal
composite-to-composite refs must use the current floating major tag, not
@main or an older major.
See docs/migration-v0-to-v1.md and
docs/migration-v1-to-v2.md.