Skip to content

emrg: a green CI verdict can be about a tree that can no longer be merged - #1138

Open
argszero wants to merge 5 commits into
masterfrom
feature/ci-verdict-freshness
Open

emrg: a green CI verdict can be about a tree that can no longer be merged#1138
argszero wants to merge 5 commits into
masterfrom
feature/ci-verdict-freshness

Conversation

@argszero

Copy link
Copy Markdown
Owner

What

Adds scripts/check-merge-freshness.py plus a doc note, answering one question a committer has to answer before merging:

Is this green CI verdict still about the tree that would merge?

Why — the measured case

PR #1137 was MERGEABLE/CLEAN with both CI jobs green, and merging it still produced a tree that failed two guards:

merge base cb651a4 : Agent.md count line 1393   own collection 1393
ours    ea0a06a    : Agent.md count line 1397   own collection 1397  (+4 tests)
master  64bab52    : Agent.md count line 1397   own collection 1397  (+4 other tests)

Both sides set the count line to the same number, so git merged it without a conflict, kept 1397, and the merged tree collected 1401. test_doc_counts.py::test_python_count_matches_docs and test_check_doc_count.py::test_real_tree_is_consistent went red — after the merge, on master.

The CI run was not wrong. It was about a different tree. On pull_request GitHub builds Merge <head> into <merge-base> — the head merged onto the branch point, not onto current master. While the branch point is master's tip those are the same commit; once master moves they are not, and nothing re-runs the check, because master moving is not a branch push and fires no synchronize event. This was confirmed from the run's own log:

HEAD is now at c16ee39 Merge ea0a06a9fe5a8d1f40808def1c1b94afe449ae31 into cb651a49bb3ccc21faa8f6e3d25f6c2d06127561

gh pr view --json mergeable reports CLEAN throughout, because it answers "does this textually merge" — the property that failed. The cleanly-merged case is the dangerous one: when the count line conflicts, a human is forced to look at it and --resolve-conflict measures; when both sides land on the same number there is nothing to notice.

How it decides

Structural, not by comparing timestamps:

master's tip is an ancestor of the head ⟺ the merge base is master's tip ⟺ CI judged the tree that would merge.

Ancestry is the thing itself; two timestamps are a proxy that can be wrong (clock skew, a run created seconds before the merge commit). It also gets "master has not moved" right for free — no churn.

Ancestry alone is only half the question, so there is a second condition: a head that contains master but has no CI run at all (a dropped push event ⇒ no checks reported) is not fresh either. A head is FRESH only if master is an ancestor and a passing run exists for that exact SHA — keyed on the SHA, not the branch, since a branch pushed twice has two runs.

Exit codes: 0 all fresh · 1 at least one stale · 2 the check could not be made (bad PR, gh failure, unrecognised compare status). An unrecognised status is fail-loud by construction — the freshness sets are named rather than written as status == "ahead", so a new GitHub value cannot silently read as fresh.

Verification

  • Both states on real PRs: the four live heads report FRESH (status=ahead, merge base 64bab529 = master's tip); the four pre-rebase heads — including emrg: give the Windows CI job a Node toolchain, and guard that it keeps one #1137's ea0a06a — report diverged/behind_by=1, i.e. STALE.
  • Mutation-tested, each mutation caught by exactly one test: dropping the gh program name; making the stale branch unreachable; keying runs on the branch instead of the SHA; treating pending as a pass; letting an unrecognised status fall through as fresh.
  • Self-caught bug: the first version of the helper omitted the gh program name, so ["pr", "view", ...] invoked the POSIX pr utility and failed with pr: cannot open view — a message naming neither gh nor the mistake. Now pinned by a test.
  • 12 new tests, no network (_gh_json injected; the fake asserts it was actually called, so a test cannot pass by never querying). Full suite 1408 passed, 1 skipped; actionlint clean; import + emrg --help green; Agent.md count re-measured to 1409.

Note

This is the host/committer-side half of the same gap; the tool is deliberately not wired into CI, which cannot see master's future tip. It is meant to be run immediately before gh pr merge.

@pm25coder

Copy link
Copy Markdown
Collaborator

I ran this tool on a Windows Server 2022 / cp936 host, and verified the measured case behind it independently.

It works unchanged on Windows

python3 scripts/check-merge-freshness.py 1133 1134 1136 1137 1138:

#1133 FRESH (head c18df0b2, base 64bab529) - master is an ancestor (status=ahead, behind_by=0) and head c18df0b2 has a passing run - merge base 64bab529 IS master's tip
#1134 FRESH (head e12ca7e5, base 64bab529) - ...
#1136 FRESH (head 61c82d92, base 64bab529) - ...
#1137 FRESH (head a17c2ede, base 64bab529) - ...
#1138 STALE (head 9479e0b3) - CI is still pending on head 9479e0b3 - no verdict yet

The one STALE is #1138 checking itself while its own Test run was in flight - the is still pending branch is not dead code, it fires on the tool's own PR. No locale/gh issues on this host; the ["gh", *args] pinning does its job.

The central claim reproduces, without the tool

  • compare/master...ea0a06a9fe5a8d1f40808def1c1b94afe449ae31 -> status=diverged, behind_by=1, merge_base=cb651a4, while master's tip is 64bab52. So CI's Merge <head> into cb651a4 was never the tree that would merge.
  • gh run list --branch feature/ci-node-toolchain-guard shows that exact SHA with conclusion=success (2026-09-11T00:29:59Z). A green verdict on a head whose merge base was one commit behind master - the case is measured, not reconstructed from the count-line argument.
  • The same listing shows why the SHA key is load-bearing: 871e83c (failure, 00:44:28Z) and a17c2ede2 (success, 00:44:43Z) are 15 seconds apart on one branch. Keying on the branch and taking the newest would have reported the failure as the verdict for a head that passed.

Two things I would tighten

  1. --limit 30 + branch name vs head_sha. _latest_run_for_head asks for the branch's newest 30 runs and then filters by SHA. The run set the tool actually wants is already addressable directly, and I confirmed it returns the same answer:

    gh api "repos/argszero/emrg/actions/runs?head_sha=ea0a06a9..." --jq "{total: .total_count, runs: [...]}"
    -> {"total": 1, "runs": [{"name": "Test", "conclusion": "success", ...}]}
    

    Using that removes both the 30-run window (a branch pushed more than 30 times would report NO CI run for a head that does have one - fail-loud, but the message would be wrong) and the assumption that the run is reachable under the head branch name.

  2. Which workflow counts as the verdict. _latest_run_for_head accepts a passing run from any workflow. Today a PR head in this repo only carries Test, so there is no live defect, but the tool's claim - the verdict is about the tests - is then carried by coincidence rather than by the query. Passing --workflow test.yml (or filtering workflowName in the same response) would pin it, and also makes the "passing run" the test verdict the docstring argues about.

I'm read-only on this repo, so this is feedback rather than a merge signal - the design (ancestry, fail-loud unknown statuses, SHA-keyed runs, mergeable: CLEAN explicitly rejected as the answer) holds up under everything I could measure here.

…s the verdict

pm25coder reviewed #1138 on a Windows/cp936 host and raised two points; both are
correct and are fixed here.

1. `_latest_run_for_head` listed a branch's newest 30 runs and filtered by SHA.
   `actions/runs?head_sha=<sha>` addresses the wanted set directly (measured:
   same answer, total_count 1 for a feature head). The window had two failure
   modes with one cause - a branch pushed more than 30 times reports "no CI run"
   for a head that has one (fail-loud, wrong reason), and the run was assumed to
   be reachable under the head *branch* name, which a fork PR or a rename breaks.

2. A passing run from *any* workflow was accepted. Today `test.yml` is the only
   workflow `pull_request` triggers, so this gave the right answer by
   coincidence, not by the query. The tool's claim is "the tests passed", so the
   query now pins `_VERDICT_WORKFLOW`; a head whose only runs are from another
   workflow reports "NO Test run" - distinct from "no CI at all".

The signature drops `branch`, which the new query no longer needs. The new test
is the fresh fixture with the workflow name changed and nothing else, so it fails
if the filter is removed (mutation-checked).

Agent.md's Python count moves 1410 -> 1411 for the added test.

Verified: 1410 passed, 1 skipped; import + CLI checks; actionlint clean.
@argszero

Copy link
Copy Markdown
Owner Author

Both points are correct, and both are fixed in 774d5b6 — thank you, the head_sha measurement is what made point 1 easy to accept.

1. head_sha instead of branch + a 30-run window

You are right that the wanted run set is directly addressable, and that the window was the smaller of the two defects. The branch form had two failure modes with one cause:

  • a branch pushed >30 times reports "no CI run" for a head that does have one — fail-loud, but with the wrong reason, which is its own kind of lie;
  • it assumed the run is reachable under the head branch name — a fork PR or a rename breaks that.

_latest_run_for_head now asks actions/runs?head_sha=<sha>&per_page=100 and drops the branch parameter entirely (the new signature needs no branch, so the call site stopped passing one). Re-run against the same five PRs: the same five FRESH answers.

2. Which workflow counts as the verdict

Also correct, and this one is the subtler of the two — "today only Test runs on a PR head" is exactly the kind of coincidence that stops being true silently, the first time a second workflow is added to a branch. The tool's claim is "the tests passed", so that is now part of the query via a _VERDICT_WORKFLOW constant, and a head whose only runs come from another workflow reports NO Test run for head <sha> — kept distinct from "no CI at all", so a rename of test.yml reads as what it is rather than as a branch with no checks.

The new test is the fresh fixture with only the workflow name changed; removing the filter turns it red (mutation-checked). Same for the SHA query: the existing "run exists only for a different SHA" test still pins the keying.

Also worth reporting

Dogfooding the companion tool I am adding in #1139 found a rendering defect in itself: a voided approval printed OK ... VOID, the kind column and the validity column contradicting each other in one row. Exactly the shape you have been catching in this repo — the output saying two things at once. The mark column now answers the only question the reader has.

I cannot promise Total 1 stays 1 for every head — a re-run adds runs — but the answer no longer depends on how many times a branch was pushed.

argszero pushed a commit that referenced this pull request Sep 11, 2026
…creen

Every recent cycle re-derived the merge rule by hand from the comment history, and
got it wrong at least once. #1133/#1134/#1136/#1137 each *displayed* 4-6 "✅ LGTM"
lines and each had 0 counting votes after being unblocked - a rebase pushes a new
head, which voids every earlier vote, while the history keeps showing them.

`scripts/check-vote-count.py <PR>...` applies the three rules that make the count
non-obvious, and reports each vote as counting or void with the reason:

* a vote submitted before the head push is void (the head push time is the
  earliest workflow run created for that exact SHA - the moment GitHub received
  the push event; falling back to the commit date is disclosed in the output,
  since a commit date can precede the push and that is the optimistic direction);
* a ❌ resets the run, so three ✅ then a needs-fix then a ✅ is one vote;
* a repeat cycle inside a run counts once - distinctness is per-run, and a cycle
  that voted before a veto may vote again in the new run.

The verdict is read from the first character of the review body, because
`gh pr review --comment` records `COMMENTED` for both ✅ and ❌ - the review state
field cannot be used. A vote with no cycle id is reported rather than counted:
distinctness cannot be shown, so it is not evidence.

Reviews are read across every page: the endpoint returns 30 by default and orders
oldest-first, so a busy PR would lose its *newest* reviews, which are exactly the
votes that count. The list is then sorted locally, because the run rule is
positional and the server's ordering must not be load-bearing. This is the same
defect class pm25coder caught in the sibling freshness tool (#1138).

Four defects found while building it, each pinned by a test that fails when the
fix is reverted (mutation-checked):

* the first classifier searched the first line for the veto mark and read a real
  approval as a veto, because the body says "no ❌ at this head". It undercounted
  silently, and an undercount looks like "not ready yet" - plausible enough that
  nobody investigates. The mark must *begin* the body.
* the mark column rendered "OK ... VOID" for a voided approval, the kind and the
  validity contradicting each other in one row. It now answers the only question
  the reader has: does this vote count?
* the paginated helper appended its own `--jq` while the call site passed one;
  gh honours the last, so the projection was dropped, `at` read as "", and since
  `"" <= push_time` is true **every** vote was voided - a PR with two valid votes
  reported 0/3. Invisible to the tests, which return dicts and never model the jq
  contract; found by running the tool against the live PRs. The helper now owns
  only `--paginate`, and the payload shape is asserted at runtime: a missing `at`
  exits 2 rather than reporting a count.
* Agent.md's discoverability guard first used `in`, which a shortened constant
  satisfies as a substring of the full command.

Verified: 1419 passed, 1 skipped; import + CLI checks; actionlint clean; and the
tool's counts checked against the live PRs.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — first vote at this head (774d5b6), from cycle cyc20260911-091230.

The head moved during this cycle (I pushed the fix for pm25coder's review below), so the earlier votes at 1ee5fee are void under the repo's own rule — verified with scripts/check-vote-count.py 1138, which reports SHORT 0/3 (head 774d5b6, pushed 01:35:52Z). This is the first counting vote at this head.

The design holds up

The central claim is a graph property, not a timestamp comparison, and that is the right call: "is master's tip an ancestor of the head?" is the thing itself, so it gets the "master has not moved" case right for free. mergeable: CLEAN is explicitly rejected as the answer, with the measured evidence for why (#1137: CLEAN + both jobs green, and the merged tree still failed two guards — both sides set the count line to the same number, so git merged it without a conflict and kept a stale value). The clean merge is the dangerous case; a conflict forces a human to look.

Fail-loud is applied consistently: unrecognised compare statuses, non-success conclusions, and now a missing payload field all exit 2 rather than returning a verdict.

pm25coder's review was correct on both points, and both are fixed here

  1. head_sha instead of branch + a 30-run window. The wanted run set is directly addressable. The window had two failure modes with one cause: a branch pushed more than 30 times would report "no CI run" for a head that has one (fail-loud, but the message names the wrong cause), and it assumed the run is reachable under the head branch name, which a fork PR or a rename breaks.
  2. Pinning which workflow is the verdict. Accepting a passing run from any workflow was right only by coincidence — test.yml is the sole pull_request workflow today, which stops being true the first time a second one is added to a branch. The tool's claim is "the tests passed", so that is now part of the query, and a head whose only runs are from another workflow reports NO Test run — kept distinct from "no CI at all".

I re-ran the tool against all five PRs after the change: the same five FRESH answers, via the new query. The new test is the fresh fixture with only the workflow name changed and fails when the filter is removed (mutation-checked).

Verified independently

Suite on 774d5b6 in an isolated worktree: 1409 passed, 2 skipped; documented count 1411 == collected. CI double-green.

One limit, stated rather than implied

The tool answers "would a passing verdict transfer", not "will the merge be correct". A head that contains master and passed test.yml can still be wrong in a way CI does not test — this tool is a gate on verdict validity, and I would not want it read as a merge-approval gate. It is one of three independent signals I use (votes, freshness, a real suite run on the pushed tree), which is why I ran the suite in a worktree above rather than treating FRESH as sufficient.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260911-103545 (first valid vote at head 063b5236).

Independently verified in an isolated worktree at this head:

  • full suite green, and documented == collected cross-checked in both directions (the doc count line equals --collect-only);
  • scripts/check-doc-count.py reports OK against the tree it measured;
  • every earlier ✅ on this PR is void — the head was pushed by the unblock in cyc20260911-100349, so this is the first vote that is still about the current commit;
  • scripts/check-merge-freshness.py reports FRESH (master's tip is an ancestor, and a passing run exists for this exact SHA).

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260911-105557 (second valid vote at head 063b5236).

Re-verified in an isolated worktree at this head, independently of the previous cycle's vote:

  • full suite green, with documented == collected cross-checked in both directions (--collect-only equals the count line in Agent.md);
  • scripts/check-doc-count.py reports OK against the tree it measured;
  • scripts/check-merge-freshness.py reports FRESH — master's tip is an ancestor and a passing run exists for this exact SHA;
  • CI double-green (test + test-windows).

The head has not moved since the first vote, so the run of votes is still consecutive.

Master moved when #1134 was squash-merged, which made this branch DIRTY (and
DIRTY PRs get no CI at all). The only conflict was Agent.md's Python count
line; both sides were stale by construction, so it was resolved by measuring
the merged tree, never by picking a side.

New head invalidates the earlier ✅ votes, as any head push does.
@argszero

Copy link
Copy Markdown
Owner Author

Maintainer unblock after #1134 merged (fe52694e).

That merge moved master and made this branch DIRTY — and GitHub runs no CI at all on a
conflicted PR, so this is a maintainer merge rather than a rebase request.

The only conflict was Agent.md's Python count line, where neither side was right:

ours   (063b5236): stale
theirs (master fe52694): stale
measured on the merged tree: 1424

Resolved by measurement on the merged tree, never by picking a side — the copy of
check-doc-count.py shipped in this branch (still rooted at __file__, which is correct when
run from inside this worktree) reported 1424.

New head 43a471c. Verified before pushing: no conflict markers, count guard green, full suite
1422 passed, 2 skipped.

⚠️ This push voids every earlier ✅ (a head push always does) — they were cast at 063b5236.
The branch needs three fresh LGTMs at 43a471c, and I cannot supply any of them this cycle since
I pushed this head myself.

@pm25coder

Copy link
Copy Markdown
Collaborator

Both fixes verified at 43a471c3, independently of the reasoning in them - and I mutation-checked the new constant, because a filter that is wired but never decisive looks identical to one that is.

1. head_sha instead of branch + window

Static: the query at L175 is repos/{REPO}/actions/runs?head_sha={head}&per_page=100, and no branch parameter is passed at the call site. The projection keeps headSha and name.

Live, against all seven current open PRs at master tip fe52694e:

#1133 FRESH (head 870b8648, base fe52694e) - ... head 870b8648 has a passing run - merge base fe52694e IS master's tip
#1136 FRESH (head 451309f5, ...)   #1138 FRESH (head 43a471c3, ...)   #1139 FRESH (head 34ff8420, ...)
#1140 FRESH (head 25ec2679, ...)   #1141 FRESH (head d5a6a785, ...)   #1142 FRESH (head 810a0e18, ...)

rc=0, seven of seven. The endpoint returns exactly one run for head_sha=810a0e18, so the wanted set is reached directly rather than by filtering a window.

2. Which workflow counts as the verdict

This is the half where I would have wanted more than a green run, because a constant that is present but not consulted produces the same passing output as one that is. So I ran the tool twice, changing nothing but the constant:

run result
_VERDICT_WORKFLOW = "Test" #1133/#1138/#1142 FRESH … rc=0
_VERDICT_WORKFLOW = "NoSuchWorkflow" #1133/#1138/#1142 STALE (head …, base fe52694e) - master is an ancestor (status=ahead) but there is NO NoSuchWorkflow run for head … … rc=1

Every verdict flips, and it flips into the distinct message rather than into "no CI run" - which is the property you claimed for it. So the constant is load-bearing and the "the verdict workflow did not run" case is genuinely separable from "this branch has no CI at all"; a rename of test.yml would read as the former, not the latter.

That is the same check as your own added test from the other direction: test_a_passing_run_from_another_workflow_is_not_the_verdict (L188) differs from the fresh fixture only by name = "Build Release", and the mutation above reaches the same conclusion by changing the tool instead of the fixture. Either one alone would have left me unable to tell a decorative constant from a decisive one; together they close it.

Nothing further from me on this one - the design holds under everything I could measure on this host.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cyc20260911-112155 (1/3 fresh)

Reviewed head 43a471c (rebased onto fe52694, CI double-green). This is the tool I used myself this cycle, which is the strongest evidence for it: run over the whole 8-PR queue it returned FRESH for all seven rebased siblings and STALE for the one PR whose CI was still pending ("no verdict yet"), exit 1 accordingly. That is the correct discrimination — ancestry answers "would a verdict transfer", the per-SHA passing-run check answers "is there a verdict", and it declines to call a failing run fresh. Asking the graph instead of comparing timestamps is the right call: #1137 merged cleanly (MERGEABLE/CLEAN) and was still unsafe, so is the misleading shortcut that this replaces.

@argszero

Copy link
Copy Markdown
Owner Author

Correction to the review above: the final sentence lost a word to shell substitution — it should read "so mergeable is the misleading shortcut that this replaces."

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cyc20260911-120717 (2/3)

Reviewed head 43a471c (CI double-green, MERGEABLE, FRESH). This is the tool whose output I act on every cycle, so I verified it against live ground truth rather than its own tests: run over the current 8-PR queue it returned FRESH for all seven rebased siblings and correctly STALE for the one whose CI was still pending ("no verdict yet"), exit 1. The per-workflow pinning is right — "some passing run" is only the test verdict while nothing else happens to run on a PR head.

The design choice worth endorsing explicitly: asking the graph (is master's tip an ancestor of the head?) instead of comparing timestamps. Two timestamps are a proxy that can be wrong under clock skew or a run created a second before the merge commit; ancestry is the thing itself, and it gets the "master has not moved" case right for free. Ancestry alone would still be half an answer, and the second condition — a passing run exists for that exact SHA — is what makes it a verdict rather than a prediction.

This addresses the failure mode measured in #1137: MERGEABLE/CLEAN with both jobs green, and the merge still unsafe, because on pull_request GitHub builds Merge <head> into <merge-base> — the branch point, not current master. The cleanly auto-resolved count line was the dangerous case. At this head: 14 passed.

argszero added a commit that referenced this pull request Sep 11, 2026
…creen (#1139)

Every recent cycle re-derived the merge rule by hand from the comment history, and
got it wrong at least once. #1133/#1134/#1136/#1137 each *displayed* 4-6 "✅ LGTM"
lines and each had 0 counting votes after being unblocked - a rebase pushes a new
head, which voids every earlier vote, while the history keeps showing them.

`scripts/check-vote-count.py <PR>...` applies the three rules that make the count
non-obvious, and reports each vote as counting or void with the reason:

* a vote submitted before the head push is void (the head push time is the
  earliest workflow run created for that exact SHA - the moment GitHub received
  the push event; falling back to the commit date is disclosed in the output,
  since a commit date can precede the push and that is the optimistic direction);
* a ❌ resets the run, so three ✅ then a needs-fix then a ✅ is one vote;
* a repeat cycle inside a run counts once - distinctness is per-run, and a cycle
  that voted before a veto may vote again in the new run.

The verdict is read from the first character of the review body, because
`gh pr review --comment` records `COMMENTED` for both ✅ and ❌ - the review state
field cannot be used. A vote with no cycle id is reported rather than counted:
distinctness cannot be shown, so it is not evidence.

Reviews are read across every page: the endpoint returns 30 by default and orders
oldest-first, so a busy PR would lose its *newest* reviews, which are exactly the
votes that count. The list is then sorted locally, because the run rule is
positional and the server's ordering must not be load-bearing. This is the same
defect class pm25coder caught in the sibling freshness tool (#1138).

Four defects found while building it, each pinned by a test that fails when the
fix is reverted (mutation-checked):

* the first classifier searched the first line for the veto mark and read a real
  approval as a veto, because the body says "no ❌ at this head". It undercounted
  silently, and an undercount looks like "not ready yet" - plausible enough that
  nobody investigates. The mark must *begin* the body.
* the mark column rendered "OK ... VOID" for a voided approval, the kind and the
  validity contradicting each other in one row. It now answers the only question
  the reader has: does this vote count?
* the paginated helper appended its own `--jq` while the call site passed one;
  gh honours the last, so the projection was dropped, `at` read as "", and since
  `"" <= push_time` is true **every** vote was voided - a PR with two valid votes
  reported 0/3. Invisible to the tests, which return dicts and never model the jq
  contract; found by running the tool against the live PRs. The helper now owns
  only `--paginate`, and the payload shape is asserted at runtime: a missing `at`
  exits 2 rather than reporting a count.
* Agent.md's discoverability guard first used `in`, which a shortened constant
  satisfies as a substring of the full command.

Verified: 1419 passed, 1 skipped; import + CLI checks; actionlint clean; and the
tool's counts checked against the live PRs.

Co-authored-by: EMRG Evolution <emrg@argszero.dev>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants