Skip to content

Commit e9b18e5

Browse files
committed
fix(gate): stop observed release plans from clearing the revision under test
`just release-profile nightly code` ran the complete gate, passed every step including `source.verify`, and then refused at `confirm-head`: tested-head is empty; refusing to publish a revision nothing recorded Correct refusal, self-inflicted cause. Four contracts in tests/capsem-release/ run a real release plan against a recording runner to read back its argv, and `RecordHead` wrote that runner's empty capture over the running gate's `tested-head`. This is the same bug as the source state, and this morning's fix for that one did not reach it. That fix taught the filesystem `Action` subclasses to check `context.observing`; `RecordHead` writes through the `write_text` helper, which no `Action` mediates. Claiming the fix covered "every action" was wrong -- it covered the ones that happened to be actions. `RecordHead` now checks the same flag as `RecordSourceState`, and the conftest guard watches the set of files a run records its identity in rather than the single one that had failed by then. Reverting the fix reds the guard on all four contracts, by name, in one second -- which is the point: this cost an hour of gate and one aborted release to find.
1 parent f8401c3 commit e9b18e5

3 files changed

Lines changed: 43 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6767

6868
### Fixed
6969

70+
- `release-profile` reached the step before publishing and refused, because
71+
`tested-head` was empty: four contracts that run a release plan to read back
72+
its argv had overwritten the running gate's record of the revision under
73+
test. Same defect as the source state, and the fix for that one did not
74+
reach here -- it taught the `Action` subclasses to check whether the plan was
75+
being read or run, and `RecordHead` writes through the `write_text` helper
76+
instead. The guard has been widened from one file to the set a run records
77+
its identity in, so the next instance fails by name in seconds rather than
78+
an hour into a release.
79+
7080
- A fresh install materialized one profile's images and reported success. A
7181
channel's profiles own their images, so the release graph gives each its own
7282
asset release and the channel pointer can name at most one of them -- but

src/capsem/gate/releasehead.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ def render(self) -> str:
4646
return f"record the revision under test in {self._target.name}"
4747

4848
def perform(self, context: Context) -> None:
49+
if context.observing:
50+
# Reading a release plan is not running one. A contract that runs
51+
# the plan to read back its argv would otherwise overwrite the
52+
# running gate's `tested-head` with the recording runner's empty
53+
# capture, and `confirm-head` -- one step before publishing, an
54+
# hour later -- refuses to publish a revision nothing recorded.
55+
return
4956
head = context.runner.capture(["git", "rev-parse", "HEAD"])
5057
write_text(self._target, head)
5158
context.journal.note(f"qualifying {head}")

tests/conftest.py

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -577,7 +577,16 @@ def pytest_sessionfinish(session, exitstatus):
577577
# The gate's record of what it is qualifying belongs to the gate
578578
# ---------------------------------------------------------------------------
579579

580-
_SOURCE_STATE = Path(__file__).resolve().parent.parent / "target/gate-source-state.json"
580+
_ROOT = Path(__file__).resolve().parent.parent
581+
#: What a run records about *what it is qualifying*. A suite that overwrites
582+
#: any of these makes the gate around it publish the wrong thing, or refuse to
583+
#: publish at all. Guarded as a set rather than one file, because the first
584+
#: version of this guard watched only the source state and let `tested-head`
585+
#: be clobbered by the same bug an hour later.
586+
_RUN_IDENTITY = (
587+
_ROOT / "target/gate-source-state.json",
588+
_ROOT / "target/release-preflight/tested-head",
589+
)
581590

582591

583592
@pytest.fixture(autouse=True)
@@ -596,17 +605,20 @@ def _the_running_gate_keeps_its_own_source_state(request):
596605
it. Whoever does this next finds out here instead, and the run survives:
597606
the file is put back before the failure propagates.
598607
"""
599-
before = _SOURCE_STATE.read_bytes() if _SOURCE_STATE.exists() else None
608+
read = lambda path: path.read_bytes() if path.exists() else None # noqa: E731
609+
before = {path: read(path) for path in _RUN_IDENTITY}
600610
yield
601-
after = _SOURCE_STATE.read_bytes() if _SOURCE_STATE.exists() else None
602-
if after == before:
603-
return
604-
if before is None:
605-
_SOURCE_STATE.unlink(missing_ok=True)
606-
else:
607-
_SOURCE_STATE.write_bytes(before)
608-
raise AssertionError(
609-
f"{request.node.nodeid} rewrote {_SOURCE_STATE.name}, which belongs to the "
610-
f"gate running this suite. It wrote {after!r}. A test that runs a real "
611-
"plan to read it must pass observing=True; see tests/helpers/gate.py."
612-
)
611+
for path, original in before.items():
612+
after = read(path)
613+
if after == original:
614+
continue
615+
if original is None:
616+
path.unlink(missing_ok=True)
617+
else:
618+
path.write_bytes(original)
619+
raise AssertionError(
620+
f"{request.node.nodeid} rewrote {path.name}, which belongs to the "
621+
f"gate running this suite. It wrote {after!r}. A test that runs a "
622+
"real plan to read it must pass observing=True; see "
623+
"tests/helpers/gate.py."
624+
)

0 commit comments

Comments
 (0)