fix(ci): read Tailscale credentials from one scope, fail fast, validate server-side - #23
Merged
Merged
Conversation
…te server-side The CI `validate` job read the repository-scoped TAILSCALE_API_KEY while the CD `deploy` job, declaring `environment: production`, read the environment-scoped secret of the same name. Two different values behind one name, with nothing to show they differ: a red validate said nothing about the deploy path, and a rotation applied to one scope silently left the other dead. cd.yml also referenced `vars.TS_OAUTH_CLIENT_ID`, which is set in neither scope (repo variables and production environment variables are both empty), so any swap of the secret to an OAuth client secret would have failed the push with a RuntimeError from ts_auth.resolve_bearer. ci.yml never mapped the variable at all, so the OAuth path was unreachable from the pull request side. - Both jobs now declare `environment: production` and map TAILSCALE_API_KEY and TS_OAUTH_CLIENT_ID exactly once, at job level. A green validate is now evidence about the credential the deploy will use. - scripts/ci_preflight.py runs before the Nix install and fails with a message naming the exact secret or variable and the scope it must live in. It ends with a live auth probe, so an expired key reports as "rotate this, here" rather than an opaque `API error 401` several minutes into the job. - scripts/acl_validate.py adds the missing pre-merge gate: Tailscale type-checks the built policy via POST /acl/validate, which nothing did before (push.py --dry-run only diffs local against live). That endpoint reports policy errors with HTTP 200, so --prove first submits a policy the server must reject and refuses to claim a pass if it comes back clean. - docs/ci-credentials.md records the two-scope trap, both credential kinds and the rotation runbook for each. XDG_CACHE_HOME moves to a `Configure cache dir` step because the `runner` context is not available in job-level `env` (caught by actionlint). No policy change: no .dhall file and no grants.json entry is touched.
Jesssullivan
had a problem deploying
to
production
August 22, 2026 20:15 — with
GitHub Actions
Failure
The first run of this branch proved the preflight works -- and exposed a
wart. `Diff summary` and `Comment on PR` carried a bare `if: always()`, so when
the preflight fails before the Nix install they still ran, and posted a PR
comment whose entire content was:
/home/runner/work/_temp/....sh: line 2: nix: command not found
Gate both on `steps.build.outcome == 'success'`. Fast-failing on a dead
credential now leaves the PR clean instead of adding noise that reads like a
policy problem.
Pinned by a contract test that also refuses a bare `if: always()` in ci.yml.
Jesssullivan
had a problem deploying
to
production
August 22, 2026 20:17 — with
GitHub Actions
Failure
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The two-scope trap
TAILSCALE_API_KEYexists twice, under one name, in two scopes:validateinci.ymlproductionenvironment secretdeployincd.yml(it declaresenvironment: production)GitHub resolves
secrets.Xfrom the job's environment first and silently fallsback to the repository scope. Nothing in the logs distinguishes the two. The
consequences, observed rather than theorised:
32587939240
on feat(acl): grant node_exporter egress proxies scrape access (TIN-3970) #22:
API error 401: {"message":"API token invalid"}.30311167443, success) and had not been exercised since.validatewas never evidence about the deploy path, and a rotationapplied to one scope leaves the other dead with no signal.
The first run of this branch found something worse
Because
validatenow reads theproductionscope, this PR's own CI is thefirst thing in four weeks to test the deploy credential. It is also dead
(run 32596036086,
step 4, 5 seconds in):
Both scopes are dead. The next merge to
mainwould have failed its CD pushregardless of this PR — the old wiring simply had no way to tell you until after
the merge. That is the whole argument for this change, and it is why the red
check on this PR is the intended result rather than a defect: the failure is
five seconds in, names the secret, the scope and the fix, and skips the Nix
build entirely.
There is a second, latent break.
cd.ymlreferencedvars.TS_OAUTH_CLIENT_ID,but at the time of writing:
It is set nowhere.
scripts/ts_auth.pyraisesRuntimeErrorwhenTAILSCALE_API_KEYholds an OAuth client secret (tskey-client-) and no clientid is present — so the moment anyone swaps the secret to the non-expiring OAuth
form (which #15 and #16 built the support for), the push fails.
ci.ymlnevermapped the variable at all, so the OAuth path was unreachable from the PR side
even in principle.
What this changes
1. One scope, one credential set. Both
validateanddeploynow declareenvironment: productionand map the pair once, at job level:A green
validateis now evidence about the credential the deploy will use,because it is the same credential. Per-step
env:blocks are removed — thatscattering is how the scopes drifted apart, and a contract test now pins the
mapping to exactly one occurrence per workflow.
2. Fail-fast preflight (
scripts/ci_preflight.py), first step in both jobs,before the Nix install so a dead credential costs seconds instead of minutes.
Every failure names the exact secret or variable and the scope it must live
in, so a rotation lands correctly first try. It ends with a live auth probe,
so an expired key reports as "rotate this, here" instead of an
API error 401buried four minutes into a build. It never prints a credential value — only the
kind inferred from the prefix.
All four failure paths were exercised against the real API before opening this
PR:
Tailscale credential is not wired+ which scope to create it intskey-client-…, no client idTailscale OAuth client id is missing+ "variable, not a secret"Tailscale OAuth token exchange failed(real HTTP 401 from the grant)tskey-api-……present but rejected (HTTP 401)+ rotation instructions (real 401)3. Pre-merge server-side policy validation (
scripts/acl_validate.py).Nothing validated policy grammar before merge:
push.py --dry-runonly diffsthe local build against live and never asks whether the result is legal. This
POSTs the built policy to
POST /api/v2/tailnet/{tailnet}/acl/validate, whichtype-checks without applying.
The endpoint has a trap worth stating plainly: it reports policy errors with
HTTP 200 and a body carrying
message/data. A status-code check passeseverything. The implementation follows
cmd/gitops-pushertestNewACLsexactly — non-emptymessageordatais a failure regardless ofstatus, and the same
application/hujsoncontent type.Because "200 means pass" is false here,
--provefirst submits a policyTailscale must reject (unknown action and an undefined group reference).
If that known-bad policy comes back clean, the checker is blind and the step
fails rather than reporting a pass it cannot justify. Graceful gates:
::warning::, skip (can only happen locally; in CI thepreflight has already failed the job);
problem, not reported as a validation pass;
4.
docs/ci-credentials.md— the trap, both credential kinds, the rotationrunbook, the preflight message → fix table, and the caveats of putting
environment:on a PR job.5.
Diff summaryandComment on PRare gated onsteps.build.outcome == 'success'. With a bareif: always()they still ranafter a fast preflight failure and posted a PR comment whose entire content was
nix: command not found— noise that reads like a policy problem. Found by thisbranch's own first run; fixed in the second commit.
What the operator must rotate, and where
This PR does not fix the 401. It makes the 401 legible and puts both jobs
behind one credential. The rotation is yours.
Preferred — OAuth pair (non-expiring)
policy_file(read and write; CD pushes).production:TAILSCALE_API_KEY= the client secret(
tskey-client-…)TS_OAUTH_CLIENT_ID= the client id(a variable, not a secret — the client id is not sensitive)
Interim — direct API key
production→ Environment secrets →TAILSCALE_API_KEY=tskey-api-…. No client id needed. Expires in ≤ 90days, i.e. it schedules the next outage.
Either way
TAILSCALE_API_KEY. After this PR nothingreads it, and leaving it in place preserves the exact trap this PR removes —
a future rotation could land on the dead copy again.
productionenvironment:validatewould then block on every PR. If deployapproval is ever wanted, split the environments and update the
SCOPEstringin
scripts/ci_preflight.pyin the same change.Validation
actionlint .github/workflows/*.ymlenvironment: production, identical env keyspython3 -m unittest discover -s testsacl_validateskip / blind / auth-failure paths.dhall,grants.json,justfile,flake.nixactionlintcaught a real bug during authoring:${{ runner.temp }}is notavailable in job-level
env.XDG_CACHE_HOMEtherefore moves to a one-lineConfigure cache dirstep using$RUNNER_TEMP, which also removes theper-step repetition.
The 20 new tests were mutation-tested — each re-run against a deliberately
broken tree to prove it can fail rather than pass vacuously:
environment: productionfromci.ymlsecrets.TAILSCALE_API_KEYmapping--provefromcd.ymlinterpret()with a status-code-only checkKNOWN_BAD_POLICYvalid--provea no-opsteps.build.outcomegate to bareif: always()Restored, all 24 pass.
Not verified here
The happy path — a valid credential returning HTTP 200 with a clean body —
could not be exercised: no working Tailscale credential is available outside
CI. The first green
validaterun after rotation is the proof, and--proveexists precisely so that run cannot be a vacuous green.
Ordering against #22
#22 is correct on its merits and unrelated to this. But its
validatejob isred for a credential reason, so merging it would push a policy through a lane
nothing has confirmed. Suggested order:
credential is dead and the next CD push would fail without it.
validateshould go green, and that green now means thedeploy credential works.
Not merging
This repo drives the live tailnet. Held for operator review and merge.
Refs: #15, #16, #22