ref:4a083f2a9502fb72c765f27737d22ffae2a7b34a

Merge pull request #52 from feat/cross-repo-grant-pin-bump into main

SHA: 4a083f2a9502fb72c765f27737d22ffae2a7b34a
Author: Anvil <noreply@anvil.fangorn.io>
Date: 2026-08-31 03:59
3 files changed +146 -37
Type
.anvil.yml +48 −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: |
@@ -89,6 +102,22 @@
git config --global --add safe.directory /workspace
export MIX_HOME=/workspace/.mix
# Never release from a tag-triggered run. This step ENDS by pushing a
# tag, and that push starts another run — one whose HEAD is still the
# main commit, so the SHA gate below passes and it releases again, tags
# again, and so on. It is a self-feeding loop, and it ran: 2026.7.6,
# 2026.7.7 and 2026.7.8 are three releases of the identical commit
# a7e0745, stopped only by an unrelated 403 breaking the chain.
#
# The SHA gate cannot catch this on its own — a tag on the main commit
# is indistinguishable from main by SHA. Only the ref tells them apart.
case "${ANVIL_BRANCH:-}" in
refs/tags/*)
echo "Skipping release: tag-triggered run ($ANVIL_BRANCH)"
exit 0
;;
esac
# Only release from main branch
# CI checks out a detached HEAD so git branch name is always "HEAD".
# Compare the checked-out SHA against origin/main instead.
@@ -171,6 +200,17 @@
git config --global --add safe.directory /workspace
export MIX_HOME=/workspace/.mix
# A tag-triggered run is not a merge to main, and its HEAD is the main
# commit, so the SHA gate below would wave it through and open a second
# identical pin-bump PR for every tag `release` pushes. Same reasoning
# as the guard in `release` — see the comment there.
case "${ANVIL_BRANCH:-}" in
refs/tags/*)
echo "Skipping pin bump: tag-triggered run ($ANVIL_BRANCH)"
exit 0
;;
esac
# Same gate as `release` above: CI checks out a detached HEAD, so the
# branch name is always "HEAD". Compare the checked-out SHA against
# origin/main instead. Deliberately not a `branch contains 'main'`
@@ -184,11 +224,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