The token that cannot see the other repository
A cross-repository step that does nothing is rarely loud. It usually fails as an authentication symptom wearing a not-found costume, and on this fleet there is a specific token that cannot see the other repository — by design, because it is scoped to one repo.
The step that has to reach across
ymrtech/nix-config-private deploys by bumping its own input inside the public
ymrtech/nix-config repository’s flake.lock. Pushing to the private repo alone
changes nothing on any host until that lock moves. So the private repo’s CD chain
has to write a branch and open a pull request in a repository it does not own — and
the workflow token cannot do it. The workflow says so in its own env block:
1
2
3
4
5
6
env:
NIXCFG: ymrtech/nix-config
API: https://git.ymrtech.com/api/v1
# github.token is repo-scoped (cannot write nix-config); use the runner
# user's ~/.netrc (hermes PAT, rendered on every runner host by the public
# repo's modules/clientsv/nix-netrc.nix).
Two facts follow, and both are load-bearing.
A repo-scoped token answers 404 for a repository outside its scope. Not 403.
The API returns the same status it would for a repository that does not exist, so
“you are not allowed” and “there is nothing there” are indistinguishable in the log.
This is not one endpoint’s quirk: on the same forge, the same netrc token produces
nix-config -> 200 ("private":false) and nix-config-private -> 404 (denied).
Private-repo run pages and job logs behave the same way — token auth covers the
public repo’s pages only.
netrc matches by host. The credential file on this runner has exactly one
entry: machine git.ymrtech.com. A lookup with curl -n only finds credentials
when the URL’s hostname matches, which is why the CI comment is explicit about it —
curl’s netrc lookup matches the HOSTNAME, so you always call the public hostname the
netrc knows, never an internal address for the same service.
Why it can exit zero and change nothing
Together those two give you a script with a working read path and a dead write path — the worst combination to debug, because the first half looks like proof that the credentials are fine.
- Public repositories need no read auth at all. Resolving
main’s head, listing open pull requests: all of it succeeds with no token, or with a token that has no permission over that repo. Every step before the write looks healthy. - Then the write 404s. The step that creates the branch or the pull request fails at the one operation that needs scope — and the message is a not-found.
curlexits 0 on an HTTP error unless you ask it not to. Without-f,curlhands the error document tojq, and a pipeline that merely checks the command ran can conclude that it did. A404 page not foundbody is valid input to ajqexpression that then yieldsnull, andnullreads as “no result”, not “no permission”.
So the step runs, prints something plausible, exits zero, and changes nothing. The only symptom is the absence of an effect.
The fix is to assert the effect
Not to widen the token. A token that can write to everything removes the symptom and keeps the bug — the step was never verified, it just stopped failing. Scope the token to what the chain needs, then check the result directly.
This chain does, because it learned the hard way. Its merge step re-reads the artifact after merging and compares it against the exact revision this run pushed:
1
FATAL: PR #$LOCK_PR reported merged but main's flake.lock does not pin clientsv@$MYSHA
The comment above it records the lesson: a 200 from the merge endpoint without the
merge applying was reported as success, because the wait that followed matched a
pre-existing deploy of the old main head — the lock never reached main and nothing
noticed. A status code from the command that claims to have done the work is not the
work. The pull-request check follows the same rule: rather than trusting the POST
that created it, it lists open pull requests and matches .head.ref against the
per-revision branch name it just pushed.
The general form: assert that the pull request exists at the expected head
revision, or that the file at ref=main contains the value you set, or that the
branch resolves. Something that can only be true if the effect happened.
What you can check
- Your netrc’s
machinelines. Every hostname you call must appear, spelled the way you call it. A missing entry is not an error; it is silence. - Whether your log can tell the two 404s apart. It cannot, from the status alone. Read the body, and treat any cross-repo step as unverified until you have checked the artifact.
- Whether the step asserts its effect, or merely reports its exit code. Find the write step and ask what verifies it afterwards.
This is the same class of problem as a green deploy that deployed nothing: a status is an input to a decision, not the decision. That post is here; the layer underneath — the hosts, the runner, and the health gate that verifies a deploy after the fact — is on how the stack is built.