fangorn/ex_git_objectstore
public
ref:main
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule ExGitObjectstore.CI.PinBump do
@moduledoc """
Opens a pull request on `fangorn/anvil` moving its pinned
`ex_git_objectstore` reference to a new commit (#81).
Anvil consumes this library as a git dependency pinned by SHA. Nothing moved
that pin, so a fix could land on `main` and sit unconsumed indefinitely —
which is what happened to #78 and #75.
This is CI tooling, not library code, so it lives in `ci/` and is not in
`elixirc_paths`: it never ships in the published package. It is loaded
explicitly, by the CI step that runs it and by its test.
## Everything decidable is a pure function
The I/O — cloning Anvil, running git, calling the `anvil` CLI — is confined
to `main/1` and the handful of functions it calls. The parts with actual
logic take strings and return strings, so they are tested directly rather
than through a subprocess: rewriting the two pinned references, deciding
whether an open PR already exists, reading the credential, and rendering the
body.
## The pin lives in two places
`mix.exs` carries `ref: "<sha>"` on the dependency and `mix.lock` carries the
resolved SHA. **`mix.exs` is authoritative** — Mix treats a lock that
disagrees with it as stale and re-resolves from the dependency — so a bump
that edits only the lock does nothing at all. Both are rewritten, and a file
that does not contain the reference in the shape expected is an error rather
than something to write over.
"""
@anvil_repo "fangorn/anvil"
@dep_name "ex_git_objectstore"
# One fixed branch, reused. This is what makes the whole thing idempotent:
# repeated merges force-push the same branch and update the one PR opened
# 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.
@token_var "ANVIL_PIN_BUMP_TOKEN"
@sha_pattern "[0-9a-f]{40}"
def branch, do: @branch
def token_var, do: @token_var
def anvil_repo, do: @anvil_repo
# ── Pinned reference rewriting ──────────────────────────────────────────
@doc """
The SHA `mix.exs` currently pins, or an error if the dependency is not
declared in the shape this understands.
"""
@spec current_pin(String.t()) :: {:ok, String.t()} | {:error, String.t()}
def current_pin(mix_exs) do
case Regex.run(~r/ref:\s*"(#{@sha_pattern})"/, mix_exs, capture: :all_but_first) do
[sha] -> {:ok, sha}
_ -> {:error, "mix.exs has no `ref: \"<40-hex>\"` for #{@dep_name}"}
end
end
@doc """
Rewrites the `ref:` in `mix.exs`.
Exactly one occurrence must match. Zero means the dependency is not declared
as expected; more than one means the file changed shape and a blind rewrite
could move an unrelated pin.
"""
@spec bump_mix_exs(String.t(), String.t()) :: {:ok, String.t()} | {:error, String.t()}
def bump_mix_exs(mix_exs, new_sha) do
with :ok <- validate_sha(new_sha) do
pattern = ~r/ref:\s*"#{@sha_pattern}"/
case Regex.scan(pattern, mix_exs) do
[_one] -> {:ok, Regex.replace(pattern, mix_exs, ~s(ref: "#{new_sha}"))}
[] -> {:error, "mix.exs has no `ref: \"<40-hex>\"` to bump"}
many -> {:error, "mix.exs has #{length(many)} `ref:` pins; refusing to guess"}
end
end
end
@doc """
Rewrites the `#{@dep_name}` line in `mix.lock`.
The entry carries the SHA twice — once as the resolved commit and once in
the `ref:` options — and both move. Only that line is touched.
"""
@spec bump_mix_lock(String.t(), String.t()) :: {:ok, String.t()} | {:error, String.t()}
def bump_mix_lock(mix_lock, new_sha) do
with :ok <- validate_sha(new_sha) do
pattern =
~r/^(\s*"#{@dep_name}":\s*\{:git,\s*"[^"]+",\s*")#{@sha_pattern}(",\s*\[ref:\s*")#{@sha_pattern}("\]\},?)$/m
case Regex.scan(pattern, mix_lock) do
[_one] ->
# `\g{1}` rather than `\1`: a SHA beginning with a digit would other-
# wise be read as part of the group number — `\1` followed by "5..."
# parses as group 15, silently eating the first character of the SHA
# and writing a corrupt lock.
replacement = "\\g{1}#{new_sha}\\g{2}#{new_sha}\\g{3}"
{:ok, Regex.replace(pattern, mix_lock, replacement)}
[] ->
{:error, "mix.lock has no git entry for #{@dep_name} in the expected shape"}
many ->
{:error, "mix.lock has #{length(many)} entries for #{@dep_name}; refusing to guess"}
end
end
end
defp validate_sha(sha) do
if Regex.match?(~r/^#{@sha_pattern}$/, sha || "") do
:ok
else
{:error, "#{inspect(sha)} is not a 40-character hex SHA"}
end
end
# ── Idempotency ─────────────────────────────────────────────────────────
@doc """
Whether to update an existing pin-bump PR or open a new one.
Takes the PRs the CLI reports as open, as `%{"number" => n, "head_branch" =>
b}` maps. Only a PR opened from our own branch counts — an unrelated open PR
must never be rewritten. If several match, the lowest number wins so the
choice is stable across runs rather than depending on listing order.
"""
@spec pr_action([map()]) :: {:update, integer()} | :create
def pr_action(open_prs) do
open_prs
|> Enum.filter(&(Map.get(&1, "head_branch") == @branch))
|> Enum.map(&Map.get(&1, "number"))
|> Enum.reject(&is_nil/1)
|> Enum.sort()
|> case do
[number | _] -> {:update, number}
[] -> :create
end
end
# ── Credential ──────────────────────────────────────────────────────────
@doc """
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.
"""
@spec fetch_token(map()) :: {:ok, String.t()} | {:error, String.t()}
def fetch_token(env) do
case env |> Map.get(@token_var) |> to_string() |> String.trim() do
"" -> {:error, missing_token_message()}
token -> {:ok, token}
end
end
@doc false
def missing_token_message do
"""
#{@token_var} is not set, so the #{@dep_name} pin on #{@anvil_repo} cannot be bumped.
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)
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.
"""
end
# ── PR content ──────────────────────────────────────────────────────────
@doc """
The PR body: what moved, and which commits it brings in.
A reviewer approving a dependency bump needs the commits, not a pair of
SHAs — the whole point of routing this through review is that someone can
see what they are taking.
"""
@spec pr_body(String.t(), String.t(), [String.t()]) :: String.t()
def pr_body(old_sha, new_sha, commit_lines) do
commits =
case commit_lines do
[] -> "_(no commits listed — the range was empty or could not be read)_"
lines -> Enum.map_join(lines, "\n", &"- #{&1}")
end
"""
Moves Anvil's pinned `#{@dep_name}` reference to the current `main`.
| | |
|---|---|
| from | `#{old_sha}` |
| to | `#{new_sha}` |
## Commits being pulled in
#{commits}
---
Opened automatically when a commit landed on `#{@dep_name}` `main`
(ex_git_objectstore#81). Both places the reference is pinned move together:
the `ref:` on the dependency in `mix.exs`, which is authoritative, and the
resolved SHA in `mix.lock`.
This PR is opened, never merged — Anvil's CI is the gate and a human
merges. Repeated merges to `#{@dep_name}` `main` update this same PR rather
than opening more.
"""
end
@doc """
The PR title, carrying the short SHA so the branch's history is legible.
"""
@spec pr_title(String.t()) :: String.t()
def pr_title(new_sha), do: "chore(deps): bump #{@dep_name} to #{String.slice(new_sha, 0, 8)}"
# ── The I/O edge ────────────────────────────────────────────────────────
#
# Everything above is pure and tested directly. What follows is the part
# that talks to git, the filesystem and the `anvil` CLI, so it is kept as
# thin as possible: it decides nothing that is not decided above.
@doc """
Clones Anvil, rewrites both pinned references, and opens or updates the PR.
Reads its inputs from the environment so the CI step passes nothing
positionally:
* `#{@token_var}` — the cross-repo credential (required)
* `ANVIL_SERVER_URL` — injected by the runner
* `PIN_BUMP_SHA` — the commit to pin (defaults to `HEAD`)
* `PIN_BUMP_DRY_RUN` — when set, does everything except push and call the
CLI, so the rewrite can be exercised without a credential
"""
def main(env \\ System.get_env()) do
with {:ok, token} <- fetch_token(env),
{:ok, new_sha} <- resolve_sha(env),
{:ok, workdir} <- clone_anvil(env, token),
{:ok, old_sha} <- rewrite_pins(workdir, new_sha) do
commits = commit_lines(old_sha, new_sha)
publish(env, workdir, token, old_sha, new_sha, commits)
else
{:error, message} -> abort(message)
end
end
defp abort(message) do
IO.puts(:stderr, "\n" <> message)
System.halt(1)
end
defp resolve_sha(env) do
case env |> Map.get("PIN_BUMP_SHA") |> to_string() |> String.trim() do
"" -> git(["rev-parse", "HEAD"], ".")
sha -> {:ok, sha}
end
end
defp clone_anvil(env, token) do
server =
env
|> Map.get("ANVIL_SERVER_URL", "https://anvil.fangorn.io")
|> String.replace_prefix("https://", "")
|> String.replace_prefix("http://", "")
|> String.trim_trailing("/")
workdir = Path.join(System.tmp_dir!(), "pin-bump-#{System.unique_integer([:positive])}")
url = "https://x-token:#{token}@#{server}/#{@anvil_repo}.git"
case git(["clone", "--depth", "50", url, workdir], ".") do
{:ok, _} -> {:ok, workdir}
{:error, reason} -> {:error, "could not clone #{@anvil_repo}: #{reason}"}
end
end
defp rewrite_pins(workdir, new_sha) do
exs_path = Path.join(workdir, "mix.exs")
lock_path = Path.join(workdir, "mix.lock")
with {:ok, exs} <- read(exs_path),
{:ok, lock} <- read(lock_path),
{:ok, old_sha} <- current_pin(exs),
{:ok, new_exs} <- bump_mix_exs(exs, new_sha),
{:ok, new_lock} <- bump_mix_lock(lock, new_sha) do
File.write!(exs_path, new_exs)
File.write!(lock_path, new_lock)
{:ok, old_sha}
end
end
defp read(path) do
case File.read(path) do
{:ok, contents} -> {:ok, contents}
{:error, reason} -> {:error, "could not read #{path}: #{inspect(reason)}"}
end
end
# Best-effort: the log is for the reviewer's benefit, so a range that cannot
# be read produces an empty list rather than failing the bump.
defp commit_lines(old_sha, new_sha) do
case git(["log", "--oneline", "--no-decorate", "#{old_sha}..#{new_sha}"], ".") do
{:ok, ""} -> []
{:ok, out} -> String.split(out, "\n", trim: true)
{:error, _} -> []
end
end
defp publish(env, workdir, token, old_sha, new_sha, commits) do
title = pr_title(new_sha)
body = pr_body(old_sha, new_sha, commits)
if Map.get(env, "PIN_BUMP_DRY_RUN") in [nil, ""] do
do_publish(workdir, token, title, body)
else
IO.puts("PIN_BUMP_DRY_RUN set — not pushing. Would open:\n\n#{title}\n\n#{body}")
:ok
end
end
defp do_publish(workdir, token, title, body) do
with {:ok, _} <- git(["checkout", "-B", @branch], workdir),
{:ok, _} <- git(["add", "mix.exs", "mix.lock"], workdir),
{:ok, _} <- commit(workdir, title),
# `--no-thin`: Anvil's receive-pack cannot resolve thin-pack
# REF_DELTAs against packed objects (ex_git_objectstore#78).
{:ok, _} <- git(["push", "--force", "--no-thin", "origin", @branch], workdir),
{:ok, _} <- open_or_update(token, title, body) do
IO.puts("Pin bump published on #{@branch}.")
:ok
else
{:error, reason} -> abort("failed to publish the pin bump: #{reason}")
end
end
defp commit(workdir, title) do
_ = git(["config", "user.email", "ci@anvil.fangorn.io"], workdir)
_ = git(["config", "user.name", "Anvil CI"], workdir)
case git(["diff", "--cached", "--quiet"], workdir) do
# Nothing staged: the pin already points at this SHA.
{:ok, _} -> {:error, "the pin is already at this SHA; nothing to do"}
{:error, _} -> git(["commit", "-m", title], workdir)
end
end
defp open_or_update(token, title, body) do
case pr_action(list_open_prs(token)) do
{:update, number} ->
anvil_cli(token, [
"pr",
"edit",
to_string(number),
"--repo",
@anvil_repo,
"--title",
title,
"--body",
body
])
:create ->
anvil_cli(token, [
"pr",
"create",
"--repo",
@anvil_repo,
"--base",
"main",
"--head",
@branch,
"--title",
title,
"--body",
body
])
end
end
# `--limit` is raised well past the default 30: the listing is paginated,
# and an existing pin-bump PR sitting past the first page would read as "no
# PR open" and get a second one created — which is exactly the pile-up this
# is supposed to prevent.
@pr_list_limit 200
defp list_open_prs(token) do
args = [
"pr",
"list",
@anvil_repo,
"--state",
"open",
"--limit",
to_string(@pr_list_limit),
"--json"
]
case anvil_cli(token, args) do
{:ok, out} -> decode_prs(out)
{:error, reason} -> abort("could not list open PRs on #{@anvil_repo}: #{reason}")
end
end
@doc """
Pulls the PR list out of the CLI's response.
The payload is an object carrying `pull_requests` alongside pagination, not
a bare array. A shape that cannot be read is an error rather than an empty
list: treating "I could not tell" as "there is no open PR" would open a
duplicate every run.
"""
@spec decode_prs(String.t()) :: [map()]
def decode_prs(json) do
case JSON.decode(json) do
{:ok, %{"pull_requests" => prs}} when is_list(prs) -> prs
{:ok, prs} when is_list(prs) -> prs
_ -> abort("could not parse the PR listing from #{@anvil_repo}:\n#{json}")
end
end
defp anvil_cli(token, args) do
run("anvil", args, ".", [{~c"ANVIL_TOKEN", String.to_charlist(token)}])
end
defp git(args, dir), do: run("git", args, dir, [])
defp run(cmd, args, dir, env) do
opts = [stderr_to_stdout: true, cd: dir]
opts =
if env == [],
do: opts,
else: [{:env, Enum.map(env, fn {k, v} -> {to_string(k), to_string(v)} end)} | opts]
case System.cmd(cmd, args, opts) do
{out, 0} ->
{:ok, String.trim(out)}
{out, code} ->
{:error, "#{cmd} #{Enum.join(args, " ")} exited #{code}: #{String.trim(out)}"}
end
end
end