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.
The retired Blahaj lane-environment workflows were removed from this source line under TIN-2130. Runtime lifecycle remains owner-overlay authority; this repository supplies CI and no receiver, apply, or preview fallback.
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. - Breaking v4 action-fabric CI via
spoke-ci-v4.yml: one action-only plan and one compiled-client dispatch boundary. - 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. - 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: inheritThe GloriousFlywheel v4 action-fabric contract has no caller-selected runner,
execution pool, resolved platform, endpoint, profile, upload authority,
concurrency cap, consumer registry row, or fallback. Its schema-3 carrier is
the ci-templates v5.0.0 release line; the historical v4.0.0 tag carries
schema 2 and is not a compatibility path. .github/lanes.json declares named
Bazel commands, finite targets, one provider-blind capability demand
(rbe-linux-x86_64 or rbe-darwin-aarch64), and a mandatory closed result
disposition. Consumer overlays declare demand; the provider resolves private
supply or fails closed when no admitted supply exists.
spoke-ci-v4.yml is a thin dispatcher. One invocation selects one checked-in
action name, checks out the exact admitted source, and requires the compiled
client at its provider-custodied image path. Plan membership is an
admissibility boundary, not a claim that one invocation executes every member.
For pull_request, the admitted source is
github.event.pull_request.head.sha, matching the owner-overlay and admission
identity rather than GitHub's synthetic merge commit. For push, it is
github.sha. Other event shapes do not enter the action-fabric job:
/usr/local/bin/gf-action-client run \
--plan .github/lanes.json \
--action "$ACTION_NAME" \
--source-sha "$SOURCE_SHA" \
--result-dir "$RUNNER_TEMP/gf-action-result-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${ACTION_NAME}"
The Go client owns OIDC, owner-installation binding, cache/executor resolution,
REAPI action dispatch, and result interpretation. The reusable workflow always
supplies one new job-unique directory beneath RUNNER_TEMP; the checked-in
ActionPlan remains the sole result-disposition authority, and the workflow does
not parse or upload the directory. It does not reproduce client lifecycles in
Bash, Python, proxy composites, OCI helpers, or fallback paths. The fixed files
are a structural handoff and do not convey GF-I09 publication authority.
Consumers needing this caller contract pin the immutable @v5.1.1 release only
after that attended release exists and the provider image accepts
--result-dir; v5.1.0 is never moved or reused. The tag publishes this
workflow contract; it does not by itself prove consumer adoption, provider
convergence, runtime execution, qualification, or publication. Those remain
fail-closed until the signed consumer overlay, verified provider supply,
current binding catalog, and matching provider image are all present.
Existing schema-2 callers must follow
docs/migration-v4-to-v5.md. V3 callers start
with docs/migration-v3-to-v4.md, then complete
the schema-3 migration; neither release is a runtime fallback.
Your spoke needs .github/lanes.json validating against
schemas/lanes.schema.json. New spokes should
also include tinyland.repo.json, which is validated against the schema its own
schema_version names — 1 or 2; see
Manifest schema_version routing.
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 internal action refs resolve to
checked-in sibling actions, recursively proves the restricted workflows' exact
self/third-party dependency closure, 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, guards the finite/native contract
of rust-bazel-application.yml, and runs the canonical Tinyland gitleaks
working-tree scan.
| Action | Purpose |
|---|---|
nix-setup |
Configure Nix + cache hints. Does not invent Bazel endpoints. Opt-in attic-public-read: true degrades to a tokenless, read-only public Attic substituter — see below. |
nix-build |
Run nix build with Attic binary cache. |
greedy-cache |
Start Attic watch-store daemon for concurrent push. |
secrets-scan |
TruffleHog + Gitleaks, both installed from checksum-pinned release archives (no Docker, no curl | sh). See Secrets-scan scanner pins below. |
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. |
cache-attachment-validate |
Execute the release-vendored cache attachment contract without a network source fallback. |
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 |
Legacy lane-matrix loader retained for v3 callers. V4 dispatch goes directly through the compiled client. |
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. |
rust-bazel-preflight |
Validate the complete native matrix, owner-group route, and finite target arrays before any dynamic runner is scheduled or caller source is checked out. |
rust-bazel-binary-custody |
Validate the operator-projected, root-owned raw Bazelisk Nix-store path before caller checkout; never use caller input or PATH to select Bazelisk. |
rust-bazel-contract |
Fail-closed lane validation for native platform identity, tracked Bazel 9 pin/Bzlmod lock, finite exact targets, and protected-ref cache-write admission. No build, endpoint, credential, or publication authority. |
See per-action action.yml files for full input/output documentation.
secrets-scan downloads each scanner as an exact release archive and verifies a
source-pinned SHA-256 before extraction. The restricted closure check
(just restricted-workflow-contract-check) asserts these defaults, so a bump is
a reviewed, two-file change.
| Scanner | Version input | Default | Archive |
|---|---|---|---|
| TruffleHog | trufflehog-version / trufflehog-sha256 |
3.95.3 |
trufflehog_<v>_linux_amd64.tar.gz |
| Gitleaks | gitleaks-version / gitleaks-sha256 |
8.30.1 |
gitleaks_<v>_linux_x64.tar.gz |
The pin was 8.21.2 through ci-templates v2.14.0. Gitleaks grew the plural
[[allowlists]] table in 8.25.0; 8.21.x parses a config containing it
without error and then silently ignores every entry. Every Tinyland
.gitleaks.toml (ci-templates, site.scaffold, and the spokes spawned from it)
uses [[allowlists]] exclusively, so none of those allowlists were in force in
CI — spokes were passing on the default ruleset alone, and any finding an
allowlist was meant to suppress had to be worked around with .gitleaksignore
fingerprints instead.
From 8.30.1 the allowlists apply. Two things spokes should check:
- Do not mix singular and plural.
8.30.xfails closed on a config that declares both[allowlist]and[[allowlists]]:Failed to load config error="[allowlist] is deprecated, it cannot be used alongside [[allowlists]]". Convert a lone[allowlist]to a single[[allowlists]]entry; never keep both. - Re-check
.gitleaksignore. Fingerprints added to work around allowlists that 8.21.2 was ignoring are now redundant. They are harmless, but prune them when the allowlist covers the same path so the ignore file keeps documenting only real, reviewed exceptions.
nix-setup, nix-build, and greedy-cache accept an attic-public-read
input, default "false". With it left at its default, behavior is
byte-identical to before this feature existed: no fallback endpoint, no
extra nix.conf writes, no new env exports — rule 2 of AGENTS.md.
Set attic-public-read: "true" on any of the three to opt in. What flips:
- If no
attic-serverinput and no auto-detectedATTIC_SERVER(self-hosted org overlay / fleet env) resolves anything,nix-setupfalls back to the tinyland-inc public-readmaincache (https://nix-cache.tinyland.dev,main:eaUydxuDu7xBoy5cCo3MdknYAkVyTIASQ7DGuwxa+XA=) and configures it as an anonymous, tokenless, read-only Nix substituter. This fallback never exportsATTIC_SERVERinto the job environment —nix-build's push step is gated onenv.ATTIC_SERVER != '' && env.ATTIC_TOKEN != '', so a spoke that happens to haveATTIC_TOKENset but never configured a push destination cannot be silently flipped into pushing. - If an explicit/auto-detected
attic-serverDID resolve to a real (tenant) cache, the same opt-in also wires up a read-only substituter for that server, but only when the caller explicitly suppliesattic-public-keyfor it (or a token is already in scope, in which case the authenticated path —attic-actionlogin innix-build, orgreedy-cache's own tiered substituter step — already establishes trust and this is skipped to avoid duplicatenix.conflines). The tinyland-inc key is never baked in for an arbitrary/tenant server — only for the tinyland-inc public default itself. Precedence for the trusted key is:attic-public-keyinput, then anATTIC_PUBLIC_KEYalready exported in the runner environment, then — only for the tinyland-inc default — the baked key. ATTIC_TOKENcontinues to gate the authenticated/push half exactly as before, innix-buildandgreedy-cache: present and valid is unchanged (nix-buildrunsryanccn/attic-actionto log in and push;greedy-cachelogs in and starts thewatch-storepush daemon). Absent, withattic-public-read: "true"and a read-only substituter actually configured, both actions emit a loud::warning::Attic token absent — anonymous public read only, pushes disabledinstead of a hard failure or a silent no-op (skipped if the caller explicitly passedpush-cache: false, since there's no degrade to warn about). Absent, withattic-public-readleft at its default"false", behavior is unchanged: no substituter, no warning beyond the pre-existing "Attic not configured" notice.
| 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: Node package build + publish, callable only (no local tag/manual trigger). Ran GitHub-hosted until TIN-3914 moved all three jobs to tinyland-nix. |
rust-bazel-application.yml |
Opt-in/default-off native Darwin/Linux Rust application validation with Bazel-only rustfmt, clippy, build, unit, integration, and package targets; cache reads are runtime-attached and writes require an explicitly enabled protected push ref. |
spoke-ci.yml |
Canonical spoke CI: secrets-scan, lanes-load, per-lane flywheel-bazel build/test, bazel-graph, optional Playwright. |
spoke-ci-v4.yml |
Thin v4 action dispatcher: one checked-in action name, exact checkout, and one compiled gf-action-client invocation. Provider lifecycle and supply stay behind the client boundary. |
spoke-ci-restricted.yml |
Explicit private-repo opt-in variant of spoke-ci.yml; every job requires an owner -infra runner group plus its reviewed capability label. |
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. |
The restricted variants are a separate, default-off source surface. Their
v2.12.1 release closes the transitive graph with exact self-release refs,
full third-party commit SHAs, and checksum-verified scanner archives. Existing
spoke-ci.yml callers do not enter a private runner
group by upgrading their pin. Adoption is deliberately sequenced: the owner
-infra overlay first creates or adopts the selected private group and proves
its repository selection; ci-templates then publishes an immutable release;
finally the private app repo pins that release and passes the exact group plus
capability inputs. Workflow source is not proof that the runner group exists,
has the intended repository selection, or has live capacity. See
docs/restricted-private-runners.md.
This release admits only the reviewed tinyland-infra group and exact Tinyland
capability values; adding another owner group requires a reviewed source change
and immutable release, not a caller-selected fallback.
Operator ruling, 2026-08-19: "we should NEVER have gh ubuntu runners in place
ever, we ONLY use GF infra cache fronted runners." As of v3.0.0 every
runs-on in this repository names a self-hosted org capability-class label.
There is no opt-out input — an estate-wide prohibition with a "keep using
hosted" knob would not be a prohibition — which is why this is a MAJOR release
rather than a default-off addition (AGENTS.md rule 2). Consumers are
unaffected until they bump their pin; the full mechanical migration, the
capacity numbers, and the explicit MAJOR-vs-MINOR argument are in
docs/migration-v2-to-v3.md.
What moved:
| Workflow | Job(s) | Before | After |
|---|---|---|---|
spoke-ci.yml |
secrets-scan, lanes-load, repo-manifest |
ubuntu-latest |
default_runner_class, group-routed on opt-in |
js-bazel-package.yml |
resolve-runner |
ubuntu-latest |
tinyland-nix |
npm-publish.yml |
build-and-test, publish-gpr, publish-npm |
ubuntu-latest |
tinyland-nix |
rust-bazel-application.yml |
trust-gate |
ubuntu-24.04 |
tinyland-nix |
spoke-deploy-cloudflare-pages.yml |
build |
ubuntu-latest |
tinyland-nix |
Two js-bazel-package.yml input values are retired and now rejected with a
migration error rather than silently re-routed: runner_mode: hosted and
publish_mode: hosted_exception. Retiring the publish exception has one
consequence worth reading before you bump: publishes are now always self-hosted,
and the pre-existing provenance guard only requests npm publish --provenance
off self-hosted runners, so npm provenance is no longer requested and
npm_publish_provenance is inert (the job says so with a ::warning::).
scripts/lint-runs-on.rb enforces the rule at author time. A GitHub-hosted
label is a FAIL, not a warning, wherever it can be read statically: a bare
scalar, any element of a label array, a literal arm of a ${{ … }} ternary, a
fromJSON(vars.X || '[…]') fallback array, a resolved matrix value, and
either arm of a static or runtime-composed {group, labels} mapping. The
previously-blessed "graceful degradation to hosted when cluster labels are not
reachable" fromJSON shape is now a failure — there is nothing left to degrade
to. An expression that resolves only some of its arms — one good literal plus an
opaque vars.* fallback — is floored at WARN rather than passing: the audit
is incomplete, and that shape is the easy one to write. It is not a FAIL,
because this guard's core promise is that it never fails a runs-on it cannot
statically resolve.
Third-party managed fleets (blacksmith-*, depot-*, namespace-profile-*, …)
are neither GitHub's infrastructure nor GF cache-fronted, and the ruling named
GitHub runners: they WARN, surfacing for a deliberate decision instead of
passing silently. Both gates agree on that — just no-hosted-runners-check is
label-aware and case-insensitive, so blacksmith-4vcpu-ubuntu-2204 and
namespace-profile-default get the same verdict. Its predecessor was a
substring grep, which failed the first (it embeds ubuntu-2) and passed the
second, and let Ubuntu-Latest through entirely — an effective policy decided
by spelling.
| Gate | Reads | Catches |
|---|---|---|
just lint-runs-on-check |
runs-on values, structurally |
the routing itself; 75-case oracle, this repo dogfoods at 0 FAIL |
just no-hosted-runners-check |
every schedulable surface, textually | a label hiding in an input default, a fromJSON fallback, an env value, or a JSON schema |
Both are in just check; the v4 lanes schema is vendored byte-for-byte from
site.scaffold and its provenance is checked there too.
.github/actionlint.yaml declares the six capability labels so actionlint stops
reporting them as unknown. It is a declaration, not a suppression: a typo'd or
repo-shaped label still reports, and scripts/lint-runs-on.rb remains the
authority on which labels are admissible.
spoke-ci.yml's repo-manifest job asserts that the calling repo's
taxonomy.primary_role is one this template is for. Until v3.1.0 that
allowlist was hardcoded — at two independent sites: the repo-manifest job
and, again, the cache_backed lane's manifest gate inside flywheel-build. A
spoke could satisfy one and fail the other, and a fix to either would have
looked complete. allowed_repo_roles replaces both, and the matching pair in
spoke-ci-restricted.yml.
with:
# comma-separated or a JSON array — both normalize identically
allowed_repo_roles: static-spoke,static-spoke-scaffold,app-stateful-spoke- Both spellings work at the ref you pin. The JSON→comma normalization is
done by the workflow (
startsWith/fromJSON/join), not by the composite action. That placement is the point: auses:step resolves the action at its own ref, so a rule written action-side only reaches you once a release moves that ref — and never, for the restricted variant, whose closure is pinned to an exact release by contract. The workflow file is the thing you pin, so the rule ships with the version you name. - Default
"static-spoke,static-spoke-scaffold"= today's exact literal. Every consumer that does not opt in renders byte-identically (AGENTS.mdrule 2), proved site by site — and the site count pinned — byjust repo-role-census-contract-check. app-stateful-spokeis ratified but not defaulted. It is in the vendored schema's$defs.repoRole, so it is a valid role; it is not in this default, because ratifying a role is not ratifying a template binding. The schema's ownallOfblock constrainsstatic-spoke/static-spoke-scaffoldtoowns_runtime_backend/owns_auth/owns_payments/…== falseand deliberately omitsapp-stateful-spoke— the families are materially different, and this census is the only place in ci-templates whereprimary_roleis enforced at all. Opting in is one line; widening the default for ~190 consumers would be a MAJOR to undo. Full argument in thev3.1.0CHANGELOG entry.- A census failure names its remedy — the error prints the exact
allowed_repo_roles:line that would admit the rejected role. This one is action-side, so it reachesspoke-ci.ymlconsumers fromv3.1.0(its internal action refs now track@v3); the restricted variant will show it when its pinned closure is next advanced.
GFTB spokes: one edit covers all three. A Great-Falls-Tool-Bus spoke moving
to a private runner group, an org capability class, and a non-static role does
it in a single with: block plus the pin bump:
uses: tinyland-inc/ci-templates/.github/workflows/spoke-ci.yml@v3.1.0
with:
default_runner_class: great-falls-tool-bus-nix
heavy_runner_class: great-falls-tool-bus-nix
kvm_runner_class: great-falls-tool-bus-nix
runner_group: great-falls-tool-bus-infra
allowed_repo_roles: static-spoke,static-spoke-scaffold,app-stateful-spoke
secrets: inheritspoke-ci.yml takes an optional runner_group input. Default "" = today's
label-only routing, byte-identical for every non-opted consumer. Set it and
each self-hosted job routes with GitHub's structured form instead:
jobs:
ci:
# Pin the exact release that carries `runner_group`: the input does not
# exist in v2.14.0 or earlier; it ships in v3.0.0. Bumping the pin is
# REQUIRED alongside the input — a caller that adds `runner_group:` while
# pinned to an older release fails at workflow start with an unknown input.
# v3.0.0 also retires GitHub-hosted runners; read docs/migration-v2-to-v3.md
# before bumping.
uses: tinyland-inc/ci-templates/.github/workflows/spoke-ci.yml@v3.0.0
with:
default_runner_class: tinyland-nix
heavy_runner_class: tinyland-nix
kvm_runner_class: tinyland-nix
runner_group: great-falls-tool-bus-infra # <- the only new line
secrets: inherit| Job | runner_group unset |
runner_group set |
|---|---|---|
secrets-scan, lanes-load, repo-manifest |
default_runner_class |
{ group: <runner_group>, labels: default_runner_class } |
flywheel-build, flywheel-test |
runner_labels_json → matrix.lane.runner_class → default_runner_class |
{ group: <runner_group>, labels: <same value> } |
bazel-graph |
heavy_runner_class |
{ group: <runner_group>, labels: heavy_runner_class } |
playwright |
kvm_runner_class |
{ group: <runner_group>, labels: kvm_runner_class } |
- Label resolution is untouched. The group mapping carries the same value
the job resolves today, including the string-vs-array shape of
runner_labels_json. The input adds a group; it never re-picks a label. - The hosted class is gone, and the group now covers everything. TIN-3914
moved
secrets-scan,lanes-load, andrepo-manifestoffubuntu-latestontodefault_runner_class, so all seven jobs are group-routed on opt-in. The gate still derives the never-group-routed set from "literalruns-onin the pinned baseline" rather than naming jobs, so that class is currently empty and a future literal-runs-onjob lands on an already-tested rule; the self-test keeps that branch executable against a synthetic baseline. - A group narrows, it does not widen. GitHub schedules onto a runner that is in that group and carries the labels. A group whose runners lack the capability label queues forever — which is why this is opt-in per spoke.
- Workflow source is not proof. The org-level group must already exist, have
the calling repository in its selection, and serve the capability label. The
runs-on linter rejects generic groups (
Default,shared*, GitHub-hosted) and any GitHub-hosted label in either arm of the mapping. - This is routing, not trust. It does not add the fork/pre-scheduling trust
gate; a private repo that needs a fail-closed group+capability contract still
uses
spoke-ci-restricted.yml, whererunner_groupis required. - YAML cannot express a conditional mapping, so the workflow composes the
mapping at runtime via
fromJSON(format(...)).just runner-group-contract-checkrenders both paths over a scenario grid and fails if the default path ever stops being byte-identical.
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/tinyland-repo-manifest.v2.schema.json, and schemas/lanes.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.json carries an integer schema_version, and each published
value has its own vendored schema: 1 →
schemas/tinyland-repo-manifest.schema.json,
2 →
schemas/tinyland-repo-manifest.v2.schema.json.
SCHEMA_BY_VERSION in
scripts/manifest-schema-validate.py is
the only place that mapping lives. The repo-manifest-validate composite passes
--schemas-dir schemas and lets the validator route; it does not name a schema
file. validate-ci-templates.py cache-backed-optin-contract fails if the action
starts resolving one itself, or if a mapped version has no vendored schema.
The mapping is total. A schema_version that is absent, denotes no integer,
or is an integer with no vendored schema exits 3 and names the value it saw;
it is never routed to v1 as a fallback. "Denotes an integer" is JSON's
definition, not Python's: 2.0 routes to v2, because JSON Schema 2020-12 counts
a number with zero fractional part as an integer and the v2 schema's
{"const": 2} accepts it — a router that exited 3 there would be refusing to
route a document the schema it would have routed to accepts. true does not
route to v1, even though bool subclasses int in Python. Both rules live in
_as_schema_version(). Before this routing existed, the composite
hardcoded the v1 path, so a consumer on the published schema_version 2 failed
the gate with a wall of Additional properties are not allowed ending in at /schema_version: 1 was expected — the gate blaming the manifest for a branch
the gate did not have. Exit 4 is separate again, for a version that routes to
a schema absent from the ci-templates checkout: that means nothing validated the
manifest, which is a broken release rather than a consumer problem.
The validator imports jsonschema or exits 5 naming the dependency. There is
no fallback, deliberately (TIN-4132/TIN-4192).
5 is its own code and not shared with 2. The refusal and every usage/IO
error answered 2 together in the first draft, and the composite's 2) arm —
written for the refusal — therefore greeted an unparseable tinyland.repo.json
with "this is a RUNNER problem, not a manifest problem". The caller went
looking at their runner image for a defect that was a comma in their own file.
2 now means only what it says: the CLI was called wrongly, or the manifest or
a schema could not be read.
A dependency-free stdlib validator used to stand in whenever the package was
unimportable. It implemented a subset of JSON Schema and, for months, never
evaluated not — so every prohibition passed unconditionally. The v2 schema
expresses its role discipline negatively (not ×17, contains ×13, anyOf
×4), so under the subset the overlay branches were unsatisfiable, which was
misread as schema over-reach for weeks. Widening it and adding a coverage guard
made it honest about its gaps; it was still a second engine kept in step with
the first by a hand-written harness. A gate that reads as coverage while
enforcing less than the schema says is worse than no gate.
Its reason for existing is gone as well. It guarded a cold nix develop
failing on a nix-store lock (TIN-2109) — a failure class of the shared-store
host-runner generation. Current ARC pods mount per-pod ephemeral nix stores, so
that contention is structurally gone.
Supplying the dependency is the calling workflow's job, and no ci-templates
composite does it for you. An earlier draft of this section said jsonschema
was "supplied by the nix-setup composite, cache-hot from the in-cluster
Attic". It is not, and never was: grep nix-setup/action.yml for
python|pip|jsonschema|nix develop and the only line that matches is set -euo pipefail — it detects Attic and Bazel cache endpoints. setup-nix installs Nix
and starts its daemon; it installs no python package either. Put a step before
repo-manifest-validate that makes python3 able to import it (nix profile install nixpkgs#python3Packages.jsonschema, or a devshell the job runs inside).
repo-manifest-validate deliberately does not shell out to nix develop
itself — just cache-backed-optin-contract-check still asserts it must not, and
making the gate's own bootstrap a second failure surface is not the fix.
scripts/manifest-schema-validate-selftest.sh (via just manifest-validate-selftest) covers routing and validation against that one
engine, plus a refusal contract: with jsonschema hidden behind an
ImportError shim, the validator must exit 5 and name the dependency, and
must not return a verdict of any kind — including on an invalid manifest,
where answering 1 would mean something still validated it. Every one of those
cases returned 0 or 1 off the subset before it was deleted, so they are live
guards against a fallback being reintroduced rather than tautologies. A second
lane pins the other half of the split — with the engine present, a malformed
or absent manifest must be 2, so the two meanings cannot silently recombine —
and two cases assert the refusal names a remedy that exists and says which
composites cannot help. It also
carries an engine-identity control: a dependentRequired violation must be
reported, which is a verdict only the real engine can produce. On a host that
cannot import jsonschema the harness refuses (exit 2) rather than printing
"0 failed" over an engine it never ran.
schemas/VENDORED.json records the source revision and per-file sha256 of
every schema in schemas/, and just vendored-schema-provenance-check
asserts the copies still match. Before it, there was no lock and no gate, and
the v1 copy had silently diverged from its source in both directions with
nothing comparing them.
Scope is the whole directory on purpose. The first draft globbed
tinyland-repo-manifest*.json and reported "all 2 vendored schemas match" over
a directory holding four — including load-bearing lanes.schema.json, which
backs the lanes-load action that consumer CI runs. A gate that reads as
coverage while enforcing less than it appears to is the defect this whole
section is about.
Each entry carries a state, and the state is asserted, not just recorded:
identical requires upstream_sha256 to equal the vendored digest, drifted
requires one that differs, and unsourced requires none at all. Before that,
an entry could claim identical beside an upstream_sha256 of 64 zeros and
pass — unread metadata in the exact field an operator reads to sequence a
de-fork.
unsourced is a real class here, not a placeholder:
blahaj-dispatch.schema.json carries a $id naming a site.scaffold path that
404s at the recorded revision. Its digest is still pinned, so a hand-edit is
caught; what cannot be claimed is
provenance. That also means the "$id names the authority" heuristic this file
rests on is not by itself reliable, which is worth knowing before trusting it
elsewhere.
The gate is deliberately hermetic: recorded digests only, no network. A
network call would make every consumer's CI depend on another repository being
reachable. It catches what a lock can catch offline — a hand-edit that never
went through a re-vendor — plus a schema that no entry records, a malformed
entry (named, not a traceback), and a record with no entries at all. Upstream
freshness, and source_revision itself, stay separate non-blocking
questions: no offline check can prove that string names a real commit. An entry
marked drifted is reported, never failed.
The lanes.schema.json bytes match signed site.scaffold #163 commit
0a46c06b3415ba0b9dc4e8ff98173a6087d0ba68. The vendoring record names that
exact revision, records SHA-256
4fef58645b8cd367a4336a66eaee629388c8a949a06d85becc97cfc1be82e3b8 on both
sides, and classifies the file as identical; this provenance edge is closed
for the ActionPlan/v4 schema-3 projection. The repository-manifest entries are
independently drifted or unsourced as recorded; this release does not hide
or silently reconcile those separate migrations.
Known drift, not closed here: the vendored
tinyland-repo-manifest.schema.json (v1) and site.scaffold's copy have diverged
in both directions. Measured by normalising both documents before diffing, so
indentation and key order cannot inflate the count (a raw diff -u of the two
files reports 136 changed lines, almost all of it formatting):
$ norm() { python3 -c 'import json,sys; print(json.dumps(json.load(open(sys.argv[1])),indent=2,sort_keys=True))' "$1"; }
$ diff <(norm ../site.scaffold/docs/schemas/tinyland-repo-manifest.schema.json) \
<(norm schemas/tinyland-repo-manifest.schema.json) | grep -c '^[<>]'
2222 differing lines (13 only upstream, 9 only here), which resolve to two
constraint-bearing differences, one in each direction — this repo carries an
authorities.artifact_registry property site.scaffold lacks; site.scaffold
carries an authorities not/required: [gitops_receiver] constraint this copy
lacks — plus five annotation-only differences (four reworded descriptions
and one description dropped from a $ref). Pinned to blobs so the figure stays
checkable: this copy is c724d1bf, site.scaffold's is 981427d8 at
site.scaffold 6c58bb6. Nothing compares them automatically. The v2 schema
vendored alongside is byte-identical to site.scaffold's (both blob
74c13a7a, last changed there by 8659dcd), and reconciling the v1 copies is a
separate change to the vendored file, not to this router.
schemas/blahaj-dispatch.schema.json is a retired-era historical artifact:
the merged scaffold #119 recut deleted its upstream source
from site.scaffold/docs/schemas/, so it no longer has a source of
truth there. The vendored copy remains only because the deprecated
Blahaj-dispatch composite still validates against it; do not treat it as
current contract surface.
(schemas/lane-ttl-reap-dispatch.schema.json was removed with the
lane-ttl-reap composite, its only consumer, on 2026-08-07.)
tinyland-repo-manifest.schema.json carries first-class, validated enrollment
fields (TIN-2109): enrollment.forgeScope, enrollment.operatorOverlay, 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.
rust-bazel-application.yml is a separate opt-in/default-off workflow for
Bazel-authoritative Rust applications. It takes an exact caller-owned native
Darwin/Linux matrix, owner-overlay runner group, and finite target arrays. A
hosted, no-checkout/no-secret admission job rejects public, fork, and
pull_request_target events before any private native runner is assigned.
Each admitted runner must project one immutable raw Bazelisk Nix-store path;
the workflow validates it before checkout and never executes PATH Bazelisk.
Pull-request cache use receives only a distinct server-enforced read
credential. A write credential is materialized only after a caller opts in and
GitHub marks the pushed main branch or release tag protected; remote execution
is always disabled. See
docs/rust-bazel-application.md.
See RELEASING.md for the release flow and SemVer
policy. Each PR must amend ## [Unreleased] in CHANGELOG.md. Restricted
workflow self-references must use an exact immutable release and all
third-party Actions in that closure must use full commit SHAs; @main and the
floating major are rejected by the closure validator.
See docs/migration-v0-to-v1.md and
docs/migration-v1-to-v2.md. Note the v0→v1
guide recommends spoke-lane-env.yml; that recommendation is superseded by
the Superseded note at the top of this README.