Skip to content

feat(nix-setup)!: default-on, fail-closed pooled Nix cache read edge (TIN-4299) - #166

Draft
Jess Sullivan (Jesssullivan) wants to merge 2 commits into
mainfrom
feat/tin-4299-attic-public-read-fail-closed
Draft

feat(nix-setup)!: default-on, fail-closed pooled Nix cache read edge (TIN-4299)#166
Jess Sullivan (Jesssullivan) wants to merge 2 commits into
mainfrom
feat/tin-4299-attic-public-read-fail-closed

Conversation

@Jesssullivan

@Jesssullivan Jess Sullivan (Jesssullivan) commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Carrier: TIN-4246/comment/c2093793 (steamroll audit, amend-class)

Amendment 2026-09-05 — steamroll audit c2093793 (amend-class, in place)

The TIN-4246 steamroll audit (Linear comment c2093793-8ddf-4531-8c40-0b224fbe79be, 2026-09-05T18:36Z) found two defects in this PR as opened. Both are corrected on this carrier in commit 25c0ffa; no parallel carrier.

  1. Default-on but fail-open (missed). nix-build and greedy-cache passed the flipped attic-public-read: "true" through to floating tinyland-inc/ci-templates/.github/actions/nix-setup@v3. uses: resolves the nested composite at its own ref, so the pass-throughs reached the v3-line opt-in nix-setup (feat(attic): opt-in tokenless read degrade (TIN-3836) #134 warn-and-degrade), never the fail-closed one this PR ships. Fix: both pass-throughs self-pin nix-setup@v6.0.0, the MAJOR the [Unreleased] BREAKING entry lands in. That is the only pin form the repo's own contract admits and resolves to the fail-closed action:
    • scripts/validate-ci-templates.py check_internal_refs (just internal-refs-check) accepts an exact vX.Y.Z or the current line @v3; a 40-char commit SHA fails it — measured 2026-09-05T21:54:08Z, rc=1 for nix-setup@acfa448f…, rc=0 for @v6.0.0.
    • A consumer-relative ./.github/actions/nix-setup is named inadmissible by RELEASING.md "Composite-action internal refs" (:112-114) and rejected as "not release-vendored" by validate-ci-templates.py:854-856; at runtime it would resolve against the consumer's workspace, not this repo.
    • RELEASING.md step 3 (:27-33): self-references must name the exact version before the release commit lands. The v6.0.0 release commit MUST retarget both pins if it names a different version.
  2. Uncited prior. This PR restores the operator's TIN-3836 ruling 3 (2026-08-17, TIN-3836 description §Rulings item 3: "ci-templates reader: GF-parity degrade — public substituter + ATTIC_PUBLIC_KEY unconditional; token gates netrc/push only; loud warning on degrade"), which feat(attic): opt-in tokenless read degrade (TIN-3836) #134 shipped under AGENTS.md rule 2 as the opt-in "false" default. The CHANGELOG entry and the AGENTS.md rule-2 exception now cite it.

Hold: converted to Draft. ci-templates merges at 0 approvals and cuts tags from origin/main, so "opened unarmed" was no hold here; Draft is the only hold this repo has. Not armed.

Receipts (amendment commit 25c0ffaf2244c9d242653b66a016f91eb0244f6f, GPG-signed, verified Good signature):

$ python3 scripts/validate-ci-templates.py internal-refs      # @v6.0.0 as committed
internal action refs resolve and track their release line (or an exact release pin)   rc=0
$ …same with nix-setup@acfa448f08fdf0cf5950c612d347e016ec0b9cb2                       rc=1  (not an exact release pin)
$ …same with nix-setup@v5                                                            rc=1  (not on the current release line @v3)
MEASURED@2026-09-05T21:54:08Z

$ nix develop --command just check      # worktree at 25c0ffa
lint-runs-on: 27 runs-on checked, 0 FAIL, 19 WARN across 8 workflow file(s)
no-hosted-runners: 0 GitHub-hosted labels, 0 third-party-fleet WARN across 38 scanned file(s)
ci-templates checks passed.
EXIT=0 END 2026-09-05T21:55:17Z

Ruling executed

TIN-4299 ruling 4 / lane F2-ii (operator-approved 2026-09-04): the pooled Nix cache consumer edge becomes default-on and fail-closed. BREAKING — lands in the next MAJOR cut, not in a v5.x patch. Opened unarmed for that reason.

Rule-2 exception (AGENTS.md)

Rule 2 forbids a default flip unless it is a standing exception with a MAJOR bump (precedent TIN-3914). This PR adds the third standing exception to rule 2: a consumer that never declared trust must not silently build cache-less or substitute from an unverified cache, so the default flips and the edge hard-fails; attic-public-read: "false" stays the explicit, byte-identical opt-out for lanes that never touch Nix.

Behavior (.github/actions/nix-setup/action.yml)

Default path (attic-public-read: "true"), in order:

  1. Endpoint: attic-server input > ATTIC_SERVER (org overlay / fleet env) > last-resort tinyland-inc public-read main cache. An explicit/auto-detected server whose DNS does not resolve is now a hard failure (was warning + cache-less).
  2. Trusted key: attic-public-key input > ATTIC_PUBLIC_KEY env > (default server only) baked key. Non-default server with no key = hard failure (was silent "no substituter"). Key must be <cache>:<base64>, decode to 32 bytes (ed25519), and name the substituted cache; else hard failure.
  3. Reachability: GET <server>/<cache>/nix-cache-info (10s) must succeed and contain StoreDir: /nix/store. DNS alone is not reachability. attic_reachable reports the probe.
  4. Substituter: only then append extra-substituters / extra-trusted-public-keys to ~/.config/nix/nix.conf, export ATTIC_PUBLIC_KEY, emit attic_public_key (always emitted on the default path).
  5. ATTIC_TOKEN in scope: the authenticated caller owns nix.conf trust (::notice, never a silent skip); the probe still runs — with the token, so a private cache answers — and still fails closed.

Unchanged: ATTIC_SERVER is never exported from the last-resort default; no push path is enabled here; nix-build / greedy-cache token-absent warning gates work as before.

nix-build and greedy-cache pass the value through explicitly, so their attic-public-read defaults flip to "true" too (leaving them at "false" would silently opt every caller out).

Decision D3 — baked literal

https://nix-cache.tinyland.dev + main:eaUy… are kept only as the last-resort default (docker/dind runner types never receive the fleet env). The literal is a consumer surface naming provider placement; its retirement is the TIN-4299 packet's ADD-TYPE item (NixCacheSupply: endpoint CLASS + trust-root digest). Stated in the action comment, README, and CHANGELOG.

Pin insulation

spoke-ci.yml and js-bazel-package.yml reference nix-setup@v3; nix-build and greedy-cache self-pin nix-setup@v6.0.0 (amended 2026-09-05, see top — floating @v3 there was default-on but fail-open); spoke-ci-restricted.yml pins @v2.12.1; spoke-ci-v4.yml calls no Nix action at all. No consumer changes behavior until it moves pins to the MAJOR that carries this. The release cut should add docs/migration-v5-to-v6.md (not in this PR).

Receipts

$ nix develop --command just check
lint-runs-on: 27 runs-on checked, 0 FAIL, 19 WARN across 8 workflow file(s)
no-hosted-runners: 0 GitHub-hosted labels, 0 third-party-fleet WARN across 38 scanned file(s)
ci-templates checks passed.
just check exit=0

Offline harness (extracts the detect step, stubs getent/curl, fake HOME/GITHUB_ENV/GITHUB_OUTPUT, operator env scrubbed):

default-lastresort-happy      rc=0  nix.conf: extra-substituters = https://nix-cache.tinyland.dev/main ; ATTIC_PUBLIC_KEY exported ; attic_reachable=true
default-unreachable           rc=1  ::error … pooled Nix cache unreachable: GET …/main/nix-cache-info failed (curl: (7) …)
default-not-a-cache           rc=1  ::error … did not answer as a Nix binary cache (no 'StoreDir: /nix/store')
tenant-absent-key             rc=1  ::error … no trusted public key for https://cache.tenant.example
tenant-key-cache-mismatch     rc=1  ::error … key names cache 'other' but the substituter is …/tenant
tenant-key-bad-length         rc=1  ::error … does not decode to a 32-byte ed25519 key
tenant-key-not-shaped         rc=1  ::error … not <cache>:<base64> shaped
tenant-happy                  rc=0  nix.conf written for …/tenant ; ATTIC_SERVER exported (DNS ok)
tenant-dns-unresolvable       rc=1  ::error … DNS unresolvable (cache.tenant.example)
token-in-scope-probe-ok       rc=0  no nix.conf ; attic_public_key= ; probe sent Authorization bearer
token-in-scope-probe-fail     rc=1  ::error … unreachable
optout-no-endpoint            rc=0  old ::warning ; no probe ; no nix.conf ; no attic_public_key output
optout-dns-unresolvable       rc=0  old ::warning ; no probe
optout-tenant-dns-ok          rc=0  old ::notice ; ATTIC_SERVER exported ; no probe

Byte-identical opt-out proof: for the three optout-* cases, stdout, GITHUB_ENV, GITHUB_OUTPUT, and the curl log are cmp-identical between origin/main's nix-setup/action.yml and this branch's, with no nix.conf written by either.

Files: .github/actions/nix-setup/action.yml, .github/actions/nix-build/action.yml, .github/actions/greedy-cache/action.yml, README.md (§ Attic tokenless read), AGENTS.md (rule 2, third exception; TIN-3836 R3 citation), CHANGELOG.md ([Unreleased]### Changed — BREAKING (next MAJOR); TIN-3836 R3 citation + pass-through pin).

@linear-code

linear-code Bot commented Sep 5, 2026

Copy link
Copy Markdown

TIN-4299

…(TIN-4299)

BREAKING CHANGE: attic-public-read defaults to "true" on nix-setup,
nix-build, and greedy-cache; lands in the next MAJOR cut.

TIN-4299 ruling 4 (2026-09-04, operator-approved): a consumer that
never declared trust must not silently build cache-less or substitute
from an unverified cache. The opt-in attic-public-read edge had exactly
that fail-open shape: default "false" meant most lanes ran with no
substituter at all, and even opted-in lanes treated DNS resolution as
reachability, silently skipped a tenant server with no key, and never
checked that the key named the cache being substituted.

nix-setup default path now: resolve the endpoint (attic-server input >
ATTIC_SERVER from the org overlay / fleet env > last-resort tinyland-inc
public-read "main" cache), resolve the trusted key (input > env > baked
key for the default server only; a non-default server with no key is a
hard failure), require the key to be <cache>:<base64>, decode to a
32-byte ed25519 key, and name the substituted cache, then GET
<server>/<cache>/nix-cache-info and require "StoreDir: /nix/store".
Any failure is ::error + exit 1. Only then is nix.conf written and
ATTIC_PUBLIC_KEY exported; attic_public_key is always emitted on the
default path and attic_reachable reports the probe. With ATTIC_TOKEN in
scope the authenticated caller still owns nix.conf trust (::notice,
never a silent skip) and the probe still runs with the token so a
private cache answers. ATTIC_SERVER is never exported from the
last-resort default and no push path is enabled.

attic-public-read: "false" is the explicit opt-out for lanes that never
touch Nix and restores the pre-TIN-4299 path byte-for-byte (offline
harness: stdout, GITHUB_ENV, GITHUB_OUTPUT, curl log cmp-identical to
origin/main for the three opt-out cases; no nix.conf on either).

The baked nix-cache.tinyland.dev server + key stay only as the
last-resort default (docker/dind runner types never receive the fleet
env); that consumer-surface literal is retired by the typed
NixCacheSupply (endpoint class + trust-root digest) in the TIN-4299
packet.

nix-build and greedy-cache pass the value through explicitly, so their
defaults flip too; leaving them at "false" would silently opt every
caller out. Their token-absent warning gates are unchanged.

AGENTS.md rule 2 gains its third standing exception (precedent
TIN-3914); CHANGELOG [Unreleased] carries the entry under "Changed --
BREAKING (next MAJOR)". Callers pinned to @V3 / @v2.12.1 / v5.x are
unaffected until they move pins.

Receipts: nix develop --command just check passed (lint-runs-on 27
checked 0 FAIL; no-hosted-runners 0 hosted labels across 38 files);
offline harness 14/14 cases (absent key, unreachable, not-a-cache,
key/cache mismatch, bad key length, unshaped key, DNS unresolvable,
token-in-scope probe pass/fail, tenant happy, default happy, three
opt-out cases).
… TIN-3836 R3 (TIN-4299)

Root cause: TIN-4246 steamroll audit (comment c2093793, 2026-09-05) found
that #166 flipped `attic-public-read` to default-on in `nix-build` and
`greedy-cache` while both pass-throughs still called floating
`tinyland-inc/ci-templates/.github/actions/nix-setup@v3`. `uses:` resolves
nix-setup at ITS OWN ref, so the flipped default reached the v3-line opt-in
nix-setup (warn-and-degrade), not the fail-closed one this PR ships:
default-ON but fail-OPEN. The same audit found the PR restored the
operator's 2026-08-17 TIN-3836 ruling 3 design without citing it.

Amend, in place on the extant carrier:

- `nix-build` / `greedy-cache`: pin `nix-setup@v6.0.0`, the MAJOR the
  `[Unreleased]` BREAKING entry lands in. The repo's composite-action-pin
  contract (scripts/validate-ci-templates.py check_internal_refs;
  RELEASING.md "Composite-action internal refs") admits only an exact
  `vX.Y.Z` self-release or the current line `@v3`: a commit-SHA ref fails
  `internal-refs-check` (measured), and a consumer-relative `./` ref is
  named as not release-vendored (RELEASING.md; rust-bazel closure check).
  The release commit retargets both pins if it names another version
  (RELEASING.md step 3).
- CHANGELOG `[Unreleased]` entry + AGENTS.md rule-2 exception: cite
  TIN-3836 ruling 3 verbatim ("ci-templates reader: GF-parity degrade —
  public substituter + ATTIC_PUBLIC_KEY unconditional; token gates
  netrc/push only; loud warning on degrade"), note #134 shipped it as the
  opt-in default, and record the pass-through pin.

Carrier: TIN-4246/comment/c2093793 (steamroll audit, amend-class).
@Jesssullivan
Jess Sullivan (Jesssullivan) marked this pull request as draft September 5, 2026 21:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant