@@ -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