ref:aa652cbbee354082e2ef6e087e0068d3ec0b0ae2

feat(ci): bump Anvil's pin with the job token, not a provisioned secret

Requires fangorn/anvil#245. DO NOT MERGE before that deploys — the currently deployed parser rejects an unknown top-level `permissions:` key, which would fail validation on every run in this repository. Verified against origin/main's parser: `["top-level: unknown key 'permissions'"]`. `bump-anvil-pin` needs write access to fangorn/anvil, which an Anvil job token could not have: it is scoped to the repository that dispatched it. The stopgap was ANVIL_PIN_BUMP_TOKEN, a separately provisioned CI secret — a long-lived credential carrying a human's full authority across every repo they can reach, with nothing recording what it was for. It was never provisioned, so the step has been failing loudly since it landed (#51). Anvil now supports cross-repo grants. The top-level `permissions:` block asks for contents/pull_requests on fangorn/anvil, an admin there approves the request once from the repo's settings, and the ordinary injected ANVIL_TOKEN carries those scopes for the life of the job. Nothing to provision, nothing to rotate, and the credential dies with the job that used it. Asking is not receiving. The block here is a request that can only narrow what the grant allows, and with no grant it yields nothing — anyone who can open a pull request against this repo can edit that file, so it cannot be the authority. Because Anvil reports every cross-repo refusal identically and without a reason — deliberately, so a token cannot enumerate which repos have grants — an unapproved request is indistinguishable from an ordinary auth failure at the call site. `abort/1` therefore appends the approval instructions whenever a failure looks like one, rather than guessing which it was. REQ-CI-003 is updated to match: the property it asserts (the permission is explicit, and a refusal fails with an actionable message rather than skipping silently) is unchanged; only the mechanism moved from a secret to a grant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SHA: aa652cbbee354082e2ef6e087e0068d3ec0b0ae2
Author: Cole Christensen <cole.christensen@gmail.com>
Date: 2026-08-05 02:42
Parents: abf04d1
3 files changed +119 -37
Type
.anvil.yml +21 −6
@@ -12,6 +12,19 @@
- unzip -q -o /tmp/elixir.zip -d /usr/local && rm /tmp/elixir.zip
- mix local.hex --force && mix local.rebar --force
# Scopes this pipeline asks for on OTHER repositories, so `bump-anvil-pin` can
# open its pull request with the ordinary injected ANVIL_TOKEN instead of a
# separately provisioned secret (fangorn/anvil#245).
#
# This block is only a request. The grant lives on fangorn/anvil and only its
# admins can create it — anyone able to open a pull request here can edit this
# file, so it can only ever narrow what has already been granted, never widen
# it. With no grant approved, these lines do nothing at all.
permissions:
fangorn/anvil:
contents: write
pull_requests: write
steps:
- name: deps
run: |
@@ -184,11 +197,13 @@
exit 0
fi
# No secret needed: the top-level `permissions:` block asks for
# The runner-injected ANVIL_TOKEN is scoped to THIS repository and
# cannot write to fangorn/anvil (fangorn/anvil#390), so the bump needs a
# separately provisioned cross-repo credential. Its absence fails the
# job loudly — a silent skip would leave Anvil pinned to an old commit
# with nothing to show anything was missed. The script prints exactly
# what an admin has to provision.
# contents/pull_requests on fangorn/anvil, an admin there approves the
# request once, and the injected ANVIL_TOKEN carries those scopes for
# the life of this job (fangorn/anvil#245).
#
# A refused or unapproved request fails the job loudly — a silent skip
# would leave Anvil pinned to an old commit with nothing to show
# anything was missed. The script prints which approval is missing.
curl -sL "https://anvil.fangorn.io/runner/download?os=$(uname -s)&arch=$(uname -m)" -o /usr/local/bin/anvil
chmod +x /usr/local/bin/anvil
ci/pin_bump.exs +78 −19
@@ -34,6 +34,20 @@
whether an open PR already exists, reading the credential, and rendering the
body.
## The credential is the ordinary job token
This used to need a separately provisioned CI secret, because an Anvil job
token could only act on the repository that dispatched it. Anvil now
supports cross-repo grants (fangorn/anvil#245), so the `permissions:` block
in `.anvil.yml` asks for `contents: write` and `pull_requests: write` on
`fangorn/anvil`, an admin there approves the request once, and the injected
`ANVIL_TOKEN` carries those scopes for the life of the job.
Asking is not receiving: the block in this repository is a request that can
only narrow what the grant allows, and with no grant it yields nothing. That
asymmetry is the point — anyone who can open a pull request here can edit
that file.
## The pin lives in two places
`mix.exs` carries `ref: "<sha>"` on the dependency and `mix.lock` carries the
@@ -52,9 +66,13 @@
# from it, instead of accumulating a pile of them.
@branch "chore/bump-ex-git-objectstore"
# Deliberately not `ANVIL_TOKEN`. CI secrets are merged *over* the job
# environment, so a secret by that name would silently replace the injected
# per-job token for every other step in the job.
# The token the runner already injects. This used to be a separately
# provisioned CI secret, because a job token could only ever act on its own
# repository. Anvil now supports cross-repo grants (fangorn/anvil#245): the
# `permissions:` block in `.anvil.yml` asks for scopes on `fangorn/anvil`,
# an admin there approves the request, and the ordinary per-job token
# carries them. No secret to provision, none to rotate, and the credential
# dies with the job that used it.
@token_var "ANVIL_TOKEN"
@token_var "ANVIL_PIN_BUMP_TOKEN"
@sha_pattern "[0-9a-f]{40}"
@@ -161,12 +179,16 @@
# ── Credential ──────────────────────────────────────────────────────────
@doc """
Reads the job token, or explains precisely what is missing.
Reads the cross-repo credential, or explains precisely what is missing.
The runner's injected `ANVIL_TOKEN` is scoped to the dispatching repository
and cannot write to Anvil (fangorn/anvil#390), so this needs a separately
provisioned secret. Its absence is a hard failure — skipping silently would
leave the pin quietly unbumped, which is the bug this exists to fix.
Whether that token can actually write to Anvil is not decidable here — it
depends on a grant published on `#{@anvil_repo}`, which this side can only
request. So absence of the variable is the only thing checked locally; a
present-but-ungranted token surfaces as a 403 from the CLI, and
`insufficient_grant_message/0` explains that case.
Either way the failure is hard. Skipping silently would leave the pin
quietly unbumped, which is the bug this exists to fix.
"""
@spec fetch_token(map()) :: {:ok, String.t()} | {:error, String.t()}
def fetch_token(env) do
@@ -181,18 +203,42 @@
"""
#{@token_var} is not set, so the #{@dep_name} pin on #{@anvil_repo} cannot be bumped.
The runner injects #{@token_var} into every CI job automatically, so an
empty one means the job environment is not what this step expects — check
that the step is running under a real Anvil runner rather than locally.
This step deliberately fails rather than skipping: a silent skip would
leave Anvil pinned to an old commit with nothing to show that anything
was missed.
"""
end
The runner injects ANVIL_TOKEN scoped to this repository only; it cannot
write to #{@anvil_repo}. This step needs a separate credential, provisioned
once by an admin as a CI secret on this repository:
name: #{@token_var}
value: an Anvil token that can push a branch to #{@anvil_repo}
and open a pull request on it (contents: write)
@doc """
What to print when the token exists but Anvil refuses the cross-repo action.
Until that secret exists this step will keep failing, which is deliberate:
a silent skip would leave Anvil pinned to an old commit with nothing to
show that anything was missed.
The grant lives on `#{@anvil_repo}` and only its admins can create it, so
there is nothing to fix in this repository — the message names the approval
that is missing rather than pointing at a secret to provision.
"""
def insufficient_grant_message do
"""
#{@anvil_repo} refused this token's cross-repo request.
The `permissions:` block in this repository's .anvil.yml asks for scopes on
#{@anvil_repo}, but asking grants nothing on its own. An admin of
#{@anvil_repo} has to approve it, once:
#{@anvil_repo} → Settings → Cross-repo Grants → Approve
The request appears there after this pipeline has run. It needs:
contents: write push the bump branch
pull_requests: write open the pull request
A grant applies to this repository's default branch only, which is where
this step runs. See docs/guides/cross-repo-ci-permissions.md in
#{@anvil_repo}.
"""
end
# ── PR content ──────────────────────────────────────────────────────────
@@ -274,8 +320,21 @@
end
defp abort(message) do
IO.puts(:stderr, "\n" <> message)
IO.puts(:stderr, "\n" <> message <> grant_hint(message))
System.halt(1)
end
# Anvil reports every cross-repo refusal identically and without a reason,
# deliberately — the error must not let a token enumerate which repos have
# grants. That makes it indistinguishable from an ordinary auth failure at
# the call site, so when a failure *looks* like one, append the explanation
# rather than guessing which it was.
defp grant_hint(message) do
if Regex.match?(~r/403|forbidden|unauthorized|authentication failed/i, message) do
"\n" <> insufficient_grant_message()
else
""
end
end
defp resolve_sha(env) do
test/ci/pin_bump_test.exs +20 −12
@@ -224,26 +224,34 @@
describe "the cross-repo credential" do
@tag requirements: ["REQ-CI-003"]
test "is read from its own variable, not the injected job token" do
refute PinBump.token_var() == "ANVIL_TOKEN",
"CI secrets merge over the job environment, so reusing ANVIL_TOKEN " <>
"would replace the injected per-job token for the whole job"
assert {:ok, "tok"} = PinBump.fetch_token(%{PinBump.token_var() => "tok"})
test "is the ordinary injected job token, not a provisioned secret" do
# The separate ANVIL_PIN_BUMP_TOKEN secret is gone: cross-repo grants
# (fangorn/anvil#245) attach the scopes to the per-job token, which is
# minted at dispatch and dies with the job.
assert PinBump.token_var() == "ANVIL_TOKEN"
assert {:ok, "tok"} = PinBump.fetch_token(%{"ANVIL_TOKEN" => "tok"})
end
@tag requirements: ["REQ-CI-003"]
test "absence is an error naming the secret, the repo and the permission" do
for env <- [%{}, %{PinBump.token_var() => ""}, %{PinBump.token_var() => " "}] do
test "absence is an error rather than a silent skip" do
for env <- [%{}, %{"ANVIL_TOKEN" => ""}, %{"ANVIL_TOKEN" => " "}] do
assert {:error, message} = PinBump.fetch_token(env)
assert message =~ PinBump.token_var()
assert message =~ "ANVIL_TOKEN"
assert message =~ PinBump.anvil_repo()
assert message =~ "contents: write"
end
end
@tag requirements: ["REQ-CI-003"]
test "the injected job token alone is not enough" do
assert {:error, _} = PinBump.fetch_token(%{"ANVIL_TOKEN" => "job-scoped"})
test "a refusal names the approval that is missing, not a secret to provision" do
# Whether the token may act on Anvil is not decidable here — it depends
# on a grant published over there. The message has to send the reader to
# the approval, since there is nothing to fix in this repository.
message = PinBump.insufficient_grant_message()
assert message =~ PinBump.anvil_repo()
assert message =~ "Cross-repo Grants"
assert message =~ "contents: write"
assert message =~ "pull_requests: write"
refute message =~ "ANVIL_PIN_BUMP_TOKEN"
end
end