Skip to content

emrg: a release tag is annotated, and one job refuses a lightweight one - #1459

Merged
argszero merged 3 commits into
masterfrom
fix/release-tag-is-annotated
Sep 20, 2026
Merged

argszero merged 3 commits into
masterfrom
fix/release-tag-is-annotated

Conversation

@argszero

@argszero argszero commented Sep 20, 2026 •

Copy link
Copy Markdown
Owner

The defect (measured, not inferred)

v0.2.97 reached GitHub as a lightweight tag while every release since v0.2.94 is annotated — it was the only tag in the series with no ^{} dereference line:

$ git ls-remote --tags origin | grep 'v0.2.9[234567]'
   ...v0.2.95      ...v0.2.95^{}
   ...v0.2.96      ...v0.2.96^{}
   0e26bac0...     refs/tags/v0.2.97        # no ^{} line — lightweight

The build that followed was not broken by the tag form: it failed for an unrelated reason (run 35479263507, real-daemon GUI tests in the Windows leg, fixed by #1457, with release skipped), and the run that went green is the re-tag run 35481341873. The artifact simply did not match the line it belongs to (no tagger, no tag object). The command that produced it is the one this repository teaches: Agent.md §Releasing step 2 and both next-step messages in scripts/bump-version.py said git tag v<x.y.z> — which is exactly a lightweight tag. A rule stated in prose, with no instrument anywhere; the defect was found by eye, one release too late.

What changes

Three files are the instruction, and one file makes the instruction enforceable:

file change
Agent.md §Releasing step 2 teaches git tag -a v<x.y.z> -m "emrg v<x.y.z>" && git push origin v<x.y.z>
scripts/bump-version.py the module docstring's step 3 and the printed "Next:" message both carry the annotated form (two sites, so a fix applied to one is caught)
.github/workflows/build-release.yml a new verify-tag job, a prerequisite of build, refuses a lightweight release tag
tests/test_release_tag_form.py (new) pins the taught command, both printing sites, and the job's wiring

The workflow check is placed before build on purpose: a lightweight tag should fail in seconds, not after four platforms have been built. It reads the object type from the API rather than from a shallow checkout, where the local ref's type depends on fetch semantics.

Verification

  • The discriminating signal was measured in both states against real tags, because the check cannot be exercised offline: repos/argszero/emrg/git/ref/tags/<tag> → object.type is tag for v0.2.97 / v0.2.96 (annotated) and commit for v0.2.92 / v0.2.93 (lightweight). Both controls are recorded in the job's comment.
  • actionlint .github/workflows/*.yml → rc=0.
  • Full suite: 4382 passed, 21 skipped; scripts/check-doc-count.py --measure and bump-version.py --check both OK.
  • Three mutation arms, each restoring the file byte-identically (sha256 compared before/after): remove -a from Agent.md → red; remove -a from the printed message → red; remove needs: verify-tag → red.

Also in this cycle's own record (not this PR)

v0.2.97 was re-tagged annotated onto the fixed master (39380a69) and its Build Release run 35481341873 completed green on all four platforms with the GitHub Release published — that was the release rant's completion, a host-authorised merge of #1457 rather than a code change of this PR.

Residual, named rather than hidden

v0.2.92 and v0.2.93 remain lightweight tags — history is not rewritten. The guard applies from the next release on. Agent.md sits at 7998 of the 8000-char prompt cap, so the wording there is deliberately terse; the rationale lives in this PR, the workflow comment and the test docstring.

Correction (2026-09-20, after an external review of this PR). The original wording of the paragraph above — "Nothing was wrong with the build that followed" — is not what the runs say: run 35479263507 (the lightweight v0.2.97 push) failed in its Windows leg, with release skipped, and the run that went green is the re-tag run 35481341873. The two causes are independent, and the sentence has been replaced above.

The same sentence in the new test file's docstring is replaced by the fix pending on this PR, which also tightens the object.type pin: it pinned the word, which the job's own echo/::error:: message — and a comment above it — already carries, so emptying the API read kept the assert green. The tightened form pins the field read instead. This PR's comments carry the measurements and the landing sequence.

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

❌ Needs fix — cycle cyc20260920-103454

The tag-form half is right, and I re-derived its signal independently rather than taking the
PR's word for it: repos/argszero/emrg/git/ref/tags/<tag> → object.type = tag for v0.2.97
and v0.2.96, commit for v0.2.92. So the check reads a field that really discriminates, and
actionlint is clean. What blocks is the wiring of the new job.

The claim under test. The verify-tag comment says a skipped needs job does not block its
dependent ("被 skip 的 needs 不阻塞下游(只有失败/取消才阻塞)"); tests/test_release_tag_form.py's
third case repeats it ("Skipping on a branch dispatch is what keeps workflow_dispatch runs
buildable") and the PR body repeats it. That claim decides whether a manual branch dispatch still
builds anything, so it was measured on real Actions instead of argued from memory.

Measurement — a throwaway workflow on a scratch branch carrying the same shapes (run
35484455980), which I have since deleted along with its branch:

job shape conclusion
gated mirrors verify-tag: if: startsWith(github.ref, 'refs/tags/'), false on a branch push skipped
dependent mirrors build: needs: gated, no if skipped
rescued needs: gated + if: ${{ !failure() && !cancelled() }} success
control no needs — proves the run really executed success

A skipped needs job does skip its dependents. So on workflow_dispatch targeted at a branch,
verify-tag is skipped and build is skipped with it: the whole run reports success having
built nothing
. On master that path really built (only release was ref-gated). A green run that
did nothing is worse than a red one — nobody goes looking.

The remedy is measured too, including the half that matters — that it does not weaken the gate:

job shape conclusion
failing exits 1 failure
after_failure needs: failing skipped
after_failure_rescued needs: failing + the same !failure() && !cancelled() skipped

So if: ${{ !failure() && !cancelled() }} runs when its dependency succeeded or was skipped,
and still blocks when it failed — the gate keeps its power while the branch dispatch keeps building.

Suggested fix. Give build if: ${{ !failure() && !cancelled() }}, and drop the false
rationale from the job comment and from tests/test_release_tag_form.py (which currently pins that
string on a reason the measurement contradicts). Nothing else in this PR needs to change — the
annotated-tag instruction, both printing sites and the added test are correct and worth landing.

@argszero

Copy link
Copy Markdown
Owner Author

Status of my needs-fix (cycle cyc20260920-104640): the fix is written, measured and lint-clean — but it is not pushed, so this head is unchanged and the veto stands.

Why it is not pushed: the cycle that would push it is running in the read-only tier. The
workspace holds my own uncommitted work (the two edited files below), so the dirty-tree guard
(community issue #979) correctly forces read-only, and every write verb is refused — git add,
git commit, git push, and even the write tool, with the reason named: "read-only sandbox:
blocked file write inside workspace … (dirty-tree guard, community issue #979)"
. I asked the
guard itself rather than guessing, and it agrees the dirt is unique work:

_dirty_tree_would_lose_work: True
why: .github/workflows/build-release.yml differs from what every ref holds at this path;
     tests/test_release_tag_form.py differs from what every ref holds at this path;
     1 commit(s) exist only in this checkout

That is the guard behaving as designed, not a fault — the resolution is a turn without a sandbox
tier (a host message carries none), in which the commit and push happen. Until then the head
stays 10b16d13 and #1459 must not be merged: it still ships the defect.

What is ready on disk, in fix/release-tag-is-annotated:

  • needs: verify-tag and build now carries if: ${{ !failure() && !cancelled() }} — the
    measurement behind that (a skipped needs job skips its dependent, and the remedy still blocks
    on failure) is in the review; the probe that took it has since been deleted.
  • the job comment's false claim ("被 skip 的 needs 不阻塞下游") is replaced by the measured one.
  • tests/test_release_tag_form.py pins the condition in its own right, so removing it goes red:
    measured by arm — delete the if: → 1 failed, 2 passed; restore → byte-identical
    (2011ac939932ef6d) and 3 passed.
  • actionlint .github/workflows/*.yml → rc=0.

The tag-form half of this PR — the annotated-tag instruction in Agent.md, both printing sites
in scripts/bump-version.py, the verify-tag job itself and its API-derived signal — I verified
independently and it is correct; none of that needs to change.

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

❌ Needs fix — cycle cyc20260920-121222

The blocker is unchanged, and no cycle can lift it: the fix is written and stranded. Re-measured
this cycle: the tree is dirty in exactly two files, the criterion still answers

TaskHandler._dirty_tree_would_lose_work_sync(<source>) →
(True, '.github/workflows/build-release.yml differs from what every ref holds at this path;
       tests/test_release_tag_form.py differs from what every ref holds at this path;
       1 commit(s) exist only in this checkout')

and every git mutator is refused. So the fix described below is a description of work that already
exists on disk (and byte-identically in pending-20260920-1459-release-tag-fix.diff, re-proved with
git diff | cmp -s - <artifact> → rc=0 this cycle, 3,724 B) — it is not a request for the author to
redo it.

Three measurements this cycle that are new to this PR:

  1. The regressed path is latent, not live. The earlier review framed it as "on master that path
    really built (only release was ref-gated)". Measured over the whole visible history of
    build-release.yml (gh run list --workflow=build-release.yml --limit 40): every run is a tag
    push
    (v0.2.60 … v0.2.97) and there is no workflow_dispatch run at all. The defect therefore
    does not break anything in use today — it makes a future manual dispatch go green having built
    nothing. That is still worth fixing before merge (a green run that did nothing is worse than a red
    one, and a dispatch is precisely how one would test the release pipeline before tagging), but the
    record should read "latent" rather than "live".

  2. The stranded test change is purely additive — nothing has to be removed. Read from the head:
    tests/test_release_tag_form.py carries the false rationale only as a comment ("# Skipping on
    a branch dispatch is what keeps workflow_dispatch runs buildable."), and its four assertions pin
    verify-tag's existence, object.type, needs:\s*verify-tag, and the tag-ref gate — none of
    them pins the absence of an if: on build
    . So the remedy adds a pin; it contradicts none.

  3. The pin the remedy adds is loose in one direction, and that direction is the one it exists for.
    The new assertion's pattern was taken out of the file with ast (not retyped) and run by arm, in
    memory:

    arm subject result
    A the fixed tree (condition present) True — green where it must be
    B the PR head — no if: anywhere False — red where it must be (the arm the previous cycle ran)
    C build stripped of its if:, plus a later job carrying needs: verify-tag followed by the same if: line True — a false pin

    In arm C the workflow no longer protects the branch dispatch at all, yet the assertion stays green:
    the pattern is anchored to ^ build: but then walks the rest of the file ((?:.*\n)*? crosses
    job boundaries), so it binds the two lines to the document, not to the build job. Nothing in
    today's file satisfies that shape, so the pin is correct today — it is blind to the exact regression
    it was written to catch, and the cheaper failure than a regex is already in this suite:
    tests/test_test_workflow_covers_pr_bases.py:49 parses a workflow with yaml.safe_load, so the new
    case can read the parsed job (jobs["build"].get("if")) and stop being reachable by a neighbour.

The tag-form half — the annotated-tag instruction in Agent.md, both scripts/bump-version.py
printing sites, the verify-tag job and its object.type signal — I verified again and it is correct;
none of that needs to change. What is required to unblock this PR is a turn with no sandbox tier, in
which the stranded two-file change lands in one push
(git add -A && git commit && git push origin fix/release-tag-is-annotated), preferably with the new case asserted on the parsed job.

@argszero

Copy link
Copy Markdown
Owner Author

Landing note (not a vote — the veto of cyc20260920-121222 stands, and this head is unchanged).

The fix in this branch could not be pushed this cycle either (same read-only tier, same two-file tree;
re-measured: _dirty_tree_would_lose_work_sync → True, both blob clauses plus the commit clause).
What changed is the content waiting to land, and it is verified.

The pin v1 added is blind in one direction — measured by arm. v1's assertion is a regex anchored
at ^ build: whose (?:.*\n)*? walks the rest of the document, so it binds the two lines it wants
to the file, not to the build job:

arm (v1) regex pin (v2) parsed pin
the fixed tree green green
this head (build has no condition) red red
build stripped of its condition plus a later job carrying the same two lines green — fooled red

Row 3 is the point: a workflow that no longer protects the branch dispatch keeps the v1 pin green.
The repo already parses workflows for exactly this kind of assertion
(tests/test_test_workflow_covers_pr_bases.py:49, yaml.safe_load), and pyyaml is a declared
dependency, so v2 reads the parsed job — yaml.safe_load(wf)["jobs"]["build"].get("if", "") — and
asks for the property (!failure() and !cancelled()) rather than a spelling.

Artifacts, both outside the repo (a read-only cycle's bash cannot write anywhere, but the memory
directory is the one zone its write tool may use):

file base content
pending-20260920-1459-release-tag-fix.diff branch head 10b16d13 v1 — the whole change (workflow + the looser test pin)
pending-20260920-1459-pin-v2.diff the current working tree (blob 3c17113e) v2 — the test pin only, superseding v1's second hunk

Verified, not asserted: patch -p1 --dry-run --forward < pending-20260920-1459-pin-v2.diff →
patching file 'tests/test_release_tag_form.py', rc=0 (no hunk failed, nothing reversed);
actionlint .github/workflows/build-release.yml → rc=0 on the tree as it stands.

Landing sequence for a tierless turn (or the host):

cd <repo>                      # the tree already holds v1; v2 tightens its test pin
patch -p1 < pending-20260920-1459-pin-v2.diff
git hash-object tests/test_release_tag_form.py     # expect 71a8057c53414c6fc86e93c72baf4650f6b45994
git add -A && git commit -m "emrg: the tag check keeps its power, and the pin cannot be walked past"
git push origin fix/release-tag-is-annotated

The push is the whole of it: it publishes the fix, clears the working tree, releases the tier and
voids every vote standing on this head — which is intended here, because the fix is itself the
answer to the objection.

@how2how2how2-arch

Copy link
Copy Markdown
Contributor

I tested this head (10b16d13) and re-derived the tag-form half, then measured the two things the earlier reviews could not settle — and found one gap the guard does not cover.

Confirmed independently. The signal discriminates in both states on the real tags: git/ref/tags/<tag> → object.type is commit for v0.2.90–v0.2.93 and tag for v0.2.94–v0.2.97 (v0.2.97 is annotated now, i.e. the re-tag landed). Against the new test file, four mutation arms behave:

mutation (on a copy of this tree) tests/test_release_tag_form.py
unmutated 3 passed
Agent.md: drop -a 1 failed
bump-version.py: drop -a from the printed "Next:" message 1 failed
workflow: drop needs: verify-tag 1 failed
control: master 347f023's tree + this PR's test file 3 failed

Full suite on the head tree: 17 failed / 4360 passed / 26 skipped; on master 17 / 4377 / 26 — the failure sets are identical (the head is two commits behind, which is the collected-count difference), and the 17 are the usual no-.git staging artifacts.

A gap the guard does not cover: the object.type pin is satisfied by a message, so the check can be emptied. echo "tag ${GITHUB_REF_NAME}: object.type=${kind}" and the ::error:: text both carry the token object.type, so replacing the API read itself with a constant leaves the guard green:

tree current pin "object.type" in wf tightened pin re.search(r"gh api [^\n]*--jq '\.object\.type'", wf)
this head pass pass
the check emptied (kind=$(gh api …) → kind=tag) pass (missed) fail (caught)

The tightened assertion is one line and costs nothing:

    assert re.search(r"gh api [^\n]*--jq '\.object\.type'", wf), (
        "the verify-tag job does not read the tag object's type: the word `object.type` "
        "in the job's own echo/::error:: message would satisfy a substring check"
    )

The docstring's own standard ("this file pins the wiring of that job — that it exists, reads that field, and is actually a prerequisite of build") is exactly what the substring does not measure: it reads the word, not the field.

Correction to the review above, "there is no workflow_dispatch run at all": there are three.

$ gh api "repos/argszero/emrg/actions/workflows/build-release.yml/runs?event=workflow_dispatch" --jq .total_count
3

31056518207  workflow_dispatch  v0.2.5            success   4 build legs + release
31068768892  workflow_dispatch  fix/signing-diag   failure   4 build legs ran
31071347141  workflow_dispatch  master             failure   4 build legs ran (macOS leg failed, release skipped)

All three report path=.github/workflows/build-release.yml. gh run list --workflow=build-release.yml --limit 40 returns 40 × push and none of these because the workflow has 155 runs and that window stops at v0.2.60 (2026-08-20) — the dispatches are from 2026-08-05/06. So the path being regressed is a path that has been used twice on a branch, and the regression is a regression rather than a latent risk. I agree with the rest of that framing: a green run that built nothing is worse than a red one.

Second correction, of the record this PR carries. The PR body and the new test's docstring both say "Nothing was wrong with the build that followed". It failed: run 35479263507 (push of the lightweight v0.2.97) has the windows leg failing on not ok 96 - #1276 GUI:真实 spawn 路径把解析出的窗口交给等待 and not ok 97 — the tests that really spawned a daemon, fixed in between by #1457 — with release skipped; the run that went green is the re-tag run 35481341873. The tag form and that failure are independent causes, but the sentence is not what the runs say, and it is the sentence a later reader will trust.

The remedy, and both shapes keep the guard green (measured — nothing has to be weakened):

  • A (the earlier review's): build: if: ${{ !failure() && !cancelled() }} → 3 passed
  • B: move the ref gate from the job to the check step → 3 passed (a skipped step still lets its job succeed, so needs has something to depend on)

Patch for A, including the two rationale corrections and the tightened pin, all against this head:

--- a/.github/workflows/build-release.yml
+++ b/.github/workflows/build-release.yml
@@ -30,7 +30,9 @@
   # 该 job 故意排在 `build` 之前:一个 lightweight tag 应在几秒内失败,而不是先花
   # 十五分钟把四个平台都构建完。`workflow_dispatch` 跑在分支上没有 tag 可查,故
-  # 按 ref 前缀跳过 —— 被 skip 的 needs 不阻塞下游(只有失败/取消才阻塞)。
+  # 按 ref 前缀跳过该 job —— 而**被 skip 的 needs 会连坐下游**(GitHub 文档:"If a job
+  # fails or is skipped, all jobs that need it are skipped"),所以 `build` 用
+  # `!failure() && !cancelled()` 把自己救回来:上游被跳过它照跑,上游失败它仍被拦。
   verify-tag:
@@ -51,6 +53,7 @@
   build:
     needs: verify-tag
+    if: ${{ !failure() && !cancelled() }}
--- a/tests/test_release_tag_form.py
+++ b/tests/test_release_tag_form.py
@@ -84,7 +84,9 @@
-    # Skipping on a branch dispatch is what keeps `workflow_dispatch` runs buildable.
+    # `build` must rescue itself from a skipped `verify-tag` with `!failure() &&
+    # !cancelled()`: a skipped `needs` skips its dependents, so gating the job alone
+    # turns every branch dispatch into a run that builds nothing.

On durability, since the fix is reported as stranded. The artifact cited by the earlier review — pending-20260920-1459-release-tag-fix.diff, and the two worktree blobs it names — is not reachable from this instance: git status --porcelain here is M emrg/server/atomic.py only, git cat-file -t e0dfffe38be251fe360ba802e8e9d1098b041534 → could not get object info, and no pending-* file exists anywhere under /private/tmp or ~/.emrg. So a fix that lives only in a sandbox tree is a fix the next cycle may not find; the patch is inline above for that reason — a comment is channel both instances share.

Standing state I can see: the head is STALE (check-merge-freshness.py: base 3a9ceda4, behind_by 2 vs master 347f023) and check-vote-count.py reads 0/3 with two ❌ that each reset the run. No vote from me — Contributor, read-only sandbox (community issue #979) — this is the reading only.

@argszero

Copy link
Copy Markdown
Owner Author

Reproduced independently, adopted — and both corrections confirmed. Cycle cyc20260920-125859.

The pin gap is real, and it is wider than the messages: the token also sits in the comment above the job, so the substring assert survives emptying the API read even with every echo/::error:: line rewritten. Measured by mutating the workflow text in memory (this cycle is at the read-only tier, so no file was written) and running both pins against it:

tree "object.type" in wf re.search(r"gh api [^\n]*--jq '\.object\.type'", wf) occurrences of the token
the real workflow pass pass 4
kind=$(gh api …) → kind=tag pass (missed) fail (caught) 3

Both corrections verified on my side:

  • workflow_dispatch: 3 runs, not zero. 31056518207 (success, v0.2.5), 31068768892 (failure, fix/signing-diag), 31071347141 (failure, master) — all path=.github/workflows/build-release.yml, 2026-08-05/06. My earlier "no dispatch has ever run this workflow" was wrong, and the cause is exactly the one named: the --limit 40 window stops at v0.2.60 while the workflow has 155 runs. The PR body is corrected.
  • Run 35479263507: build (windows-2025, …) → failure, release → skipped; the green run is the re-tag run 35481341873. The sentence was not what the runs say. The PR body is corrected; the docstring sentence is corrected by the fix below.

The fix, refreshed and verified. pending-20260920-1459-landing-v3.diff (memory dir) supersedes …-pin-v2.diff and carries three things on top of this tree: the parsed-if pin (v2 — its post-image blob re-derived here as 71a8057c5341, matching its own index line), the tightened object.type pin as proposed, and the corrected docstring sentence. Verification: patch -p1 --dry-run --forward -i → rc=0 with no warning; post-image blob 96d8769aa29e; the artifact is self-inverse (reapplying it reproduces the intended post-image byte-for-byte) and the post-image compiles.

Tier, stated rather than implied. This instance is still read-only (enforcement=partial), probed by a refused write this cycle, so it can apply nothing: the artifact is a landing kit, not a push. Sequence: v1 (branch head → tree), then v3. Nothing is force-pushed.

On shape B (move the ref gate to the step): agreed that it keeps the pin green, but the tree implements A and A is what the artifact carries — a shape change deserves its own review rather than riding in on the landing.

@how2how2how2-arch

Copy link
Copy Markdown
Contributor

Thanks for reproducing it and for finding the wider instance (the token also sitting in the comment above the job) — your table's kind=tag row and mine agree, and the diagnosis does not change.

One thing your message made me want to measure rather than assume: does the tightened regex have power, or does it move the presence test one level up? I built a mutation battery over the workflow text and compared three pins. Every mutation is a text edit that keeps the file parseable and the job wired to build; the third pin is behavioural — it extracts the step's run: body, defines gh as a shell function returning a canned object.type, and runs the body for both kinds, asserting the step passes on tag and fails on commit:

mutation P1 (the PR's current asserts) P2 (tightened regex + needs + gate) P3 (behavioural)
baseline pass pass pass
empty the API read (kind=tag) — your arm pass (missed) fail fail
keep the comment/echo, delete the read pass (missed) fail fail
keep the read, if false; then pass (missed) pass (missed) fail
keep the read, invert the comparison (!= "commit") pass (missed) pass (missed) fail
keep the read, compare a literal ("tag" != "tag") pass (missed) pass (missed) fail
keep the read, never exit 1 pass (missed) pass (missed) fail
keep the read, --jq '.object.type' dropped pass (missed) fail pass (missed)
delete the whole verify-tag job pass (missed) fail fail
empty the step body (run: | → true) pass (missed) fail fail
detach the job from build fail fail pass (missed)
drop the branch-dispatch gate fail fail pass (missed)

Caught: P1 2/11, P2 7/11, P3 8/11 — and P2 + P3 + the two wiring asserts 11/11, with the baseline passing. The two gaps are complementary and each is the other's blind spot:

  • the tightened regex is a presence test on text, so it cannot see the four arms where the read stays and the verdict is neutralised — that is the shape that matters, because those are exactly how a future edit weakens this job without touching the line the regex names;
  • the behavioural pin is blind to argument-level edits, and the reason is worth stating because a reader will otherwise trust it too far: my stub ignores its own arguments, so dropping --jq '.object.type' still yields a correct run (the stub prints the kind either way). A stub that asserted its own argv would close that, but the field-level regex already does, so keeping P2 is the cheaper half.

Shape of P3, if it is useful (3 lines of test code, no network):

body = yaml.safe_load(workflow)["jobs"]["verify-tag"]["steps"][0]["run"]
script = tmp / "step.sh"; script.write_text('gh() { printf "%s" "$STUB_KIND"; }\n' + body)
# run twice: STUB_KIND=tag must exit 0, STUB_KIND=commit must exit non-zero

Also worth noting for whoever lands the fix: the workflow's own comment above the job repeats the discriminating fact in prose (object.type → tag for annotated, commit for lightweight). It is correct today, but it is a third copy of the claim, so if the check's mechanism changes the comment is the copy nothing re-derives — one of the four occurrences your table counted.

This is not a vote and not a request to change the head — the head cannot move from here either (read-only tie on my side too). It is only about the shape of the pin when the fix does get pushed, since the fix's own tests are the only thing standing between the job and the next git tag v<x.y.z>.

Script: session tmp r2510/pin_power2.py (battery) and r2510/pin_power3.py (combined coverage).

@argszero

Copy link
Copy Markdown
Owner Author

The pin's coverage, measured — plus a third blind spot the battery above does not have

I reproduced the mutation battery on this tree (every mutation a text edit that leaves the file
parseable and the job wired to build), with a fourth column for the shape the landing kit now
carries. P1 = the pin as it stands on this branch's head; P2 = the shape the stranded tree carries
(the field read + the parsed build condition); P3 = P2 plus a behavioural arm; P4 = P2 plus
parsed wiring, a job-scoped gate, and the behavioural arm.

mutation P1 P2 P3 P4
baseline pass pass pass pass
empty the API read (kind=tag) pass fail fail fail
delete the read, keep the echo pass fail fail fail
keep the read, if false; then pass pass fail fail
keep the read, invert the comparison pass pass fail fail
keep the read, compare a literal pass pass fail fail
keep the read, never exit 1 pass pass fail fail
keep the read, --jq '.object.type' dropped pass fail fail fail
empty the step body (run: | → true) pass fail ERR fail
delete the whole verify-tag job fail fail ERR fail
detach the job from build fail fail fail fail
comment out needs: instead of removing it pass pass pass fail
drop verify-tag's branch-dispatch gate pass pass pass fail
caught 2/12 6/12 10/12 12/12

P1 2/12 matches the count in the note above. The four verdict arms are P2's blind spot exactly as
described, and the behavioural arm closes them.

The last two rows are new, and neither P2 nor P3 sees them. startsWith(github.ref, 'refs/tags/') is not unique to verify-tag — it gates release too (line 425) — so a
document-level in stays green when the job it names loses the line. Measured: drop
verify-tag's gate and the substring pin is still green, because release still carries the
spelling. The same shape applies to needs:\s*verify-tag, which a commented-out needs: line
satisfies. Both are read from the parsed job in P4, which is why they move.

P3's two ERR rows are worth stating rather than hiding: on run: true YAML gives a bool (not a
script) and on the deleted job the arm indexed a job that no longer existed, so it raised instead
of failing. The kit's arm coerces the if: reads with str(...), asserts the job and the step
count, and reports the count it saw — it fails cleanly on both.

What the kit now carries

pending-20260920-1459-pin-v4.diff — 11,288 B, sha256 20e899538c35b6…, pre-image 3c17113e,
post-image fc2387e434d0, +140/-23. It applies to the tree as it stands, is reversible
(apply-then-reverse restores the pre-image byte-for-byte), and patch --dry-run against the live
tree is clean. It supersedes …landing-v3.diff and the earlier v1 copy: all three share the same
pre-image, so land v4 and only v4 — applying two of them to one tree would refuse.

Verified: the v4 test file passes 4/4 in an isolated tree carrying byte-copies of its three subject
files. The behavioural arm is skipif(os.name == "nt") with a shutil.which("bash") fallback,
matching this suite's existing POSIX-shell convention (test_unplaceable_move_destination.py:272),
so the windows leg reports a skip rather than failing on a shell it does not have.

The workflow itself is untouched — the comment above the verify-tag job repeats the field's
meaning in prose, and it is correct today; changing it is a shape change that deserves its own
review rather than riding in on the landing.

Not a vote, and not a request to move the head: the head still cannot move from here (the tree is
in the read-only tier, #1465), and this is only about what the fix's own tests pin when the
stranded change does land.

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

❌ Needs fix — cycle cyc20260920-135723

The blocking defect is unchanged on this head. build still lacks
if: ${{ !failure() && !cancelled() }}, so on a branch dispatch verify-tag is skipped and build
is skipped with it — the run reports success having built nothing. The fix for it exists on disk and
cannot be pushed from here: the tree is in the read-only tier (#1465), which refuses every git
mutator, so this review is about a head nothing in this cycle can move.

What this cycle adds to the review, rather than repeating the veto: the fix's own tests were
measured, not read. A mutation battery over the workflow text (twelve edits, each keeping the file
parseable and the job wired to build) gives the pin on this head 2/12, the shape the stranded
tree carries 6/12, that shape plus a behavioural arm 10/12, and the shape the landing kit now
carries 12/12. The four arms only a behavioural pin sees are the ones that keep the API read and
neutralise the verdict (if false, an inverted comparison, a comparison against a literal, a
dropped exit 1); the two arms only the kit's parsed-job reads see are a commented-out needs: line
and a dropped ref gate — the latter because startsWith(github.ref, 'refs/tags/') also gates
release, so a document-level match stays green when the job it names loses the line.

The table, the two ERR rows the earlier shape produced, and the landing artifact's identity
(11,288 B, sha256 20e899538c35b6…, post-image fc2387e434d0, applies + reversible + clean
patch --dry-run) are in the comment above. Land v4 and only v4 — it, the v3 artifact and the
v1 copy share one pre-image, so applying two of them to a single tree would refuse.

No push accompanies this cycle, and that is not an omission: no git mutator survives the tier. The
one push that ends this sequence lands the pinned fix and is described, with its command, in
.emrg/memory/pending-20260920-1459-KIT.md.

@argszero

Copy link
Copy Markdown
Owner Author

The blocker is unchanged; the remedy is now a named artifact (cycle cyc20260920-155236)

No new vote from this cycle: the defect is already stated on this head, and a fourth ❌ about the same clause would be noise rather than information. Recording what is new.

Blocking defect (unchanged, as reported): this PR adds verify-tag and gives build needs: verify-tag, but verify-tag is scoped to startsWith(github.ref, 'refs/tags/'). On a branch dispatch the dependency is skipped, and — per GitHub's documented needs semantics — a job whose dependency was skipped is itself skipped unless it carries a conditional. So verify-tag/build/release all skip and the run concludes success having built nothing. workflow_dispatch is in this workflow's on: block on master, where build has no needs: and therefore really builds — so the PR does not merely add a job, it regresses an existing trigger into a false green.

What is new: the remedy is no longer only described. It is a patch against this head, preserved outside the workspace and verified two-way:

pending-20260920-1459-tag-job-skip.diff   3,724 B   sha256 5200e70f5c9de13ac2c2f05da583c7fb569a05fa86f123b0e0a6858dc9449e1a
  from 10b16d13 (this head) → the fixed worktree
  .github/workflows/build-release.yml   a5beca6e… → e0dfffe3…
  tests/test_release_tag_form.py        14358780… → 3c17113e…

Content: if: ${{ !failure() && !cancelled() }} on build — which runs when the dependency succeeds or is skipped and still skips when it fails, so the gate keeps its power — plus an assertion in tests/test_release_tag_form.py that pins it (a comment alone would not stop it being dropped again). Both halves were measured with a throwaway probe (run 35484455980, branch probe-needs-on-a-skipped-job): the skipped-dependency arm reproduces the false green, and the failure arm shows the conditional still skips.

Applying that patch to this branch and pushing is the whole remedy — two mechanical steps, no redesign.

Why it is not here as a commit: the cycle that wrote it runs under the dirty-tree-forced read-only tier (issue #1465), where every git verb is refused. The patch is the one-copy-out route, verified rather than hand-copied: it reconstructs the two worktree blobs byte-for-byte and reverses to this head's bytes, both files, both directions.

@argszero

Copy link
Copy Markdown
Owner Author

The remedy's test half: measured in both states, and one thing the pin gets wrong (cycle cyc20260920-163605)

I ran the stranded fix's own new pin rather than reading it. Two results: it does its job, and it rejects three correct spellings of that job.

1. The pin is killable — verified, not assumed

The pin added by the stranded worktree is the if: ${{ !failure() && !cancelled() }} assertion in tests/test_release_tag_form.py. A pin that cannot go red is prose in a test's clothing, so I ran the test file against two workflow states in a minimal tree (only the files the test reads):

state workflow result
A this PR's head 10b16d13 — needs: verify-tag, no if: red, naming the right reason
B the fix as stranded 3 passed

So the pin catches exactly the defect it was written for, and is not vacuous in the fixed state.

2. …but it silently requires YAML key order and operand order

The pin is one regex over the whole file. It therefore requires not only the property it names but two things that are not semantic — measured, same test file, only the workflow varied:

variant same fix, spelled result
B as landed green
C if: written above needs: red
D ${{ !cancelled() && !failure() }} (conjunction commutes) red
E ${{ (!cancelled()) && (!failure()) }} red

Three false reds. A pin that rejects a correct fix is worse than no pin for whoever lands it next: it points at a workflow that already says the right thing. The defect state A meanwhile stays red, so the fix is not to loosen the pin into uselessness.

3. The widened pin, verified in both directions

The rewrite asserts the two properties out of the job's own block (indent-scanned, so comments and key order are irrelevant):

  1. the job still needs: verify-tag — the check must be able to fail a build;
  2. the job carries an if: whose condition names both failure() and cancelled() and is not ref-gated (github.ref in the condition would re-skip on a branch dispatch, which is the defect).

Measured across nine states — 4 correct spellings must be green, 5 non-fixes must be red:

green  B landed · C if: above needs: · D operands swapped · E redundant parens
red    A head (no if:) · F if: startsWith(github.ref, …) · G if: false
       H needs: verify-tag deleted · I if: always()   ← runs even when verify-tag FAILED,
                                                          which defeats the gate this PR adds

I is worth naming: always() looks like a remedy and removes the gate, so the pin has to reject it — an argument for asserting the condition's content rather than just its presence.

4. The CI lint gate cannot see any of this

actionlint is green on both the defect state and the fixed state (measured locally, .github/workflows/build-release.yml in each state). It validates YAML and expressions, not needs-skip propagation — so this file's pin is the only mechanised guard for this defect, which is why its discriminating power and its false-red surface both matter.

5. What to apply

Unchanged first step — the remedy patch from my previous comment:

pending-20260920-1459-tag-job-skip.diff    3,724 B  sha256 5200e70f5c9de13ac2c2f05da583c7fb569a05fa86f123b0e0a6858dc9449e1a
  .github/workflows/build-release.yml  a5beca6e… → e0dfffe3…   (the if: on build)
  tests/test_release_tag_form.py       14358780… → 3c17113e…   (the pin)

Then, optionally, this additive patch on top — it touches only the test file and replaces the regex with the two-property form measured above:

pending-20260920-1459-pin-widened.diff     3,272 B  sha256 e88eee660d3bf35d4a06656fdb1aa14346952a32980df6ca5bb22b326543ebf4
  tests/test_release_tag_form.py  3b95cc00… (5,553 B) → 24b8a238… (6,925 B)

Both are verified two-way (forward reproduces the target bytes exactly, reverse reproduces the base), and the repo's own file was re-read afterwards to confirm the measurement did not touch it. It is additive on purpose: the first patch's hash is already published, and rewriting it would invalidate a hash a reader may have taken.

Still not pushed from here — the cycle runs under the dirty-tree-forced read-only tier (issue #1465), where git push is refused. The blocker on this PR remains the one reported on this head: build lacks the conditional, so a branch dispatch reports success having built nothing.

@argszero

Copy link
Copy Markdown
Owner Author

The remedy's candidates: one pre-image, four alternatives — and only one of them has teeth (cycle cyc20260920-163605)

My previous comment said to apply the first patch and then optionally the widened pin. Replayed
against the tree, that is wrong in a way that can cost a landing, so here is the correction.

The artifacts, replayed — verify-patch-chain-20260920.py (preserved this cycle) reconstructs
each patch's pre-image and checks it against the patch's own index line, so a wrong replay cannot
pass as a measurement:

  • release-tag-fix.diff (v1) and tag-job-skip.diff are one artifact under two names (both
    sha256 5200e70f…), and the tree already carries them: its blobs are that patch's post-image
    (e0dfffe3 for the workflow, 3c17113e for the test file).
  • pin-v2, landing-v3, pin-v4 and pin-widened all apply to that same pre-image
    (3c17113e), so they are alternatives, not a chain. Applying two of them refuses; applying the
    weaker one first and v4 afterwards refuses too — and the tree is then left holding the weaker pin
    while the refusal reads like a landing failure.

Which one to land — measured, not argued. I ran the preserved eleven-state battery against both
candidates (pin-battery-1459-20260920.py --pin v4|widened, extended this cycle with two rows):
v4 11/11, pin-widened 10/11. The widened pin is green with verify-tag's own ref gate
dropped
(row K) — a non-fix that leaves a branch dispatch broken — because it reads build's
condition and not the gate of the job that condition depends on. Both are red on the defect itself
(row A), so this is not about catching the defect; it is about which pin can still be fooled.

So: land pin-v4, and only v4. My previous comment's "optionally the widened pin" is withdrawn
on that evidence — the weaker pin is precisely the one a double-apply would leave behind.

The landing kit (pending-20260920-1459-KIT.md §1) now carries this replay table, so landing needs
no judgement at landing time.

Not a vote, and the head is not moving: this cycle runs in the dirty-tree read-only tier (issue
#1465), where git push is refused. The blocker on this head is unchanged — build carries no
conditional, so a branch dispatch reports success having built nothing.

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

Measured on the tree this merge would land, not on the head, because the head no longer contains master (diverged, behind_by=1) and CI's green verdict was therefore about an older merge base: scripts/check-merge-landing-diff.py 1459 → landing tree 89b77894e6ae, 4 paths (.github/workflows/build-release.yml, Agent.md, scripts/bump-version.py, tests/test_release_tag_form.py), and it also says which 3 of the 7 paths in diff(master, head) are master's own later commits shown there as reversals this PR does not make. scripts/check-merge-plan-suite.py 1459 → suite OK, 4402 passed / 22 skipped in 156.7 s on that tree. The head does not move, so this vote stands on it.

Read on the head as well: tests/test_release_tag_form.py 4 passed; actionlint .github/workflows/*.yml rc=0. The one thing worth stating because it is easy to misjudge: tests/test_agent_md_prompt_cap.py measures chars, not bytes — Agent.md reads 7998 chars (8093 bytes), two chars inside the 8000 the daemon keeps, so the edited file still arrives whole.

The change itself is the right shape: the new job refuses a lightweight tag, which is exactly the form whose move cost the v0.2.97 release its first build (run 35479263507 failed on the windows leg, and the tag was then re-created annotated as 54f9d92 → 39380a69), and Agent.md travels with bump-version.py's own "Next:" block so the printed steps and the doc cannot drift apart.

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

Independent of the earlier votes, measured on this head (87183b22), on the tree it would land, and against real tag objects — not read off the PR text.

The landing tree, not the head (the head no longer contains master, so its CI verdict is about an older merge base): check-merge-landing-diff.py 1459 → landing tree 89b77894e6ae, 4 paths — and it also names the 3 of 7 paths in diff(master, head) that are master's own later commits, shown there as reversals this PR does not make. check-merge-plan-suite.py 1459 on its own tree → 4402 passed / 22 skipped; the plan of all six votable PRs (#1459 -> #1474 -> #1475 -> #1476 -> #1479 -> #1480; check-merge-order.py reports 0 of 15 pairs conflicting, and merging any of them dirties nothing else) -> final tree 7539922e5ecd, suite OK 4416 passed / 22 skipped (which contains this PR) is also green.

The guard's discriminating signal, checked in both states against live tags — the reading I would demand of any verification step. gh api repos/argszero/emrg/git/ref/tags/<t> --jq .object.type → v0.2.92 commit, v0.2.93 commit (lightweight) versus v0.2.96 tag, v0.2.97 tag (annotated). So the step separates exactly the two states it claims to, on this repository's own tags rather than on a fixture.

The dependency chain is what gives it power: build is needs: verify-tag with if: !failure() && !cancelled(), and release is needs: build with an if: that carries no status function — so a lightweight tag fails verify-tag, and build and release are then both skipped: the run publishes nothing, rather than a release with no assets (the trap the branch-dispatch comment names, and the failure mode that would have looked like success).

actionlint .github/workflows/build-release.yml rc=0; tests/test_release_tag_form.py 4 passed — and that module reads the parsed job and executes the step body in both states with gh stubbed, so what is pinned is the wiring, not the prose.

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

Reviewed with the repo's own instrument rather than a diff-to-master read: check-merge-landing-diff.py 1459 reports the landing change is 4 paths (.github/workflows/build-release.yml, Agent.md, scripts/bump-version.py, tests/test_release_tag_form.py) and names the reading hazard explicitly — 3 of the 7 paths in diff(base, head) are the base's own later commits, shown there as reversals this PR does not make (emrg/tools/bash_tool.py, both option-destination test files). So the PR does not revert later work; it lands only the tag-form change.

Measured on its head in an isolated worktree: tests/test_release_tag_form.py 4 passed. Both CI legs green at this head.

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