From 9ea4794763bb006b9c416f3ee9162f52b6dbc80b Mon Sep 17 00:00:00 2001 From: SUaDtL Date: Tue, 4 Aug 2026 07:58:50 -0400 Subject: [PATCH 1/2] fix(release): close the blind-run 16-18 gaps -- back-fill floor, run-pre-tag diagnosis, apply-bump - _tree_state()'s internal probe now carries the same audit-scratch exemption (:/ + ,top exclusions for gate-events.log and .markers/) the release skill already requires everywhere else, so a mid-window log append is no longer read as a mutating pre-tag command (#584 MEDIUM-1). - run-pre-tag now distinguishes four outcomes instead of two: exit 5 (a command RAN and reported drift), exit 6 (a command MUTATED the tree), new exit 7 (a command's interpreter/program could not be located at all -- not drift, no discard needed), and new exit 8 (the tree-state probe itself failed -- no verdict about any command exists) (#585 MEDIUM-2, #584 MEDIUM-1/MEDIUM-3). - Every declared pre-tag command now runs with PY set to this process's own resolved interpreter, so a row may portably spell "$PY" instead of a hardcoded interpreter (#583 MEDIUM-2, #584 MEDIUM-3). - New apply-bump subcommand mechanizes the version-bump arithmetic that was the release lane's last by-eye step; refuses "none" and any non-major/minor/patch word (#585 MEDIUM-3). - Pinned first_release_baseline's existing oldest-of-several behavior with an additional CLI-level two-file test. - SKILL.md: floors the first-release footer check on the earliest addition of CONTEXT.md OR release-targets.md, so a Back-fill consumer's own first release can clear Phase 1 step 3 (#585 HIGH); checks the $REBUILD subshell's own exit code before trusting any freshness assertion (#585 MEDIUM); documents exit 7/8 in the 6d and Phase-1-gate exit-code prose (#585 MEDIUM-2, #584 MEDIUM-3); states the declared-command PY portability contract in Targets (#583 MEDIUM-2, #584 MEDIUM-3); rekeys the CONTEXT.md default-branch fallback on the fact being unresolvable rather than the file being absent (#584 MEDIUM-2); hardens the python3 App Execution Alias probe to actually run it, not just resolve it (#584 LOW); adds git fetch --tags before LAST_TAG resolution (#585 LOW); spells the one sanctioned multi-valued-field split (#585 LOW); fixes a stale $REBUILD cross-reference and two LOW wording items (#585 LOW, #583 LOW). - Version bumps for the payload changes above: ca 2.11.2, ca-codex 0.4.1, ca-pi 0.2.1 (root package.json regenerated via ca-pi's declared generate command). README badge and ca-codex-version line, ca and ca-pi CHANGELOG top sections updated to match. Part 3 (rewriting .codearbiter/release-targets.md's hardcoded python3 to "$PY") is deliberately NOT included -- see PR body for the measured reason. Closes #585, closes #584, closes #583. CHANGELOG: release lane: back-fill first releases clear the footer check, run-pre-tag distinguishes could-not-run from drift, and the version bump is mechanized via apply-bump Claude-Session: https://claude.ai/code/session_01QjJeSbcwPHwMmd6CEZeagB --- .github/scripts/test_release_lib.py | 183 ++++++++++++++++- CHANGELOG.md | 23 +++ README.md | 4 +- core/pysrc/_releaselib.py | 225 +++++++++++++++++++-- core/surface/skills/release/SKILL.md | 39 +++- package.json | 2 +- plugins/ca-codex/.codex-plugin/plugin.json | 2 +- plugins/ca-codex/hooks/_releaselib.py | 225 +++++++++++++++++++-- plugins/ca-codex/routines/release/SKILL.md | 39 +++- plugins/ca-pi/CHANGELOG.md | 11 + plugins/ca-pi/hooks/_releaselib.py | 225 +++++++++++++++++++-- plugins/ca-pi/package.json | 2 +- plugins/ca-pi/routines/release/SKILL.md | 39 +++- plugins/ca/.claude-plugin/plugin.json | 2 +- plugins/ca/hooks/_releaselib.py | 225 +++++++++++++++++++-- plugins/ca/skills/release/SKILL.md | 39 +++- 16 files changed, 1173 insertions(+), 112 deletions(-) diff --git a/.github/scripts/test_release_lib.py b/.github/scripts/test_release_lib.py index a9e50799..36b8b738 100644 --- a/.github/scripts/test_release_lib.py +++ b/.github/scripts/test_release_lib.py @@ -3293,6 +3293,31 @@ def test_adoption_commit_cli_exits_0_with_no_output_when_never_adopted(self): self.assertEqual(proc.returncode, 0) self.assertEqual(proc.stdout.strip(), "") + def test_adoption_commit_cli_two_file_pathspec_prints_the_oldest_addition(self): + # #585 (2a): the Back-fill lane's own first release floors the + # window from BOTH candidate adoption files (CONTEXT.md and + # release-targets.md), fed to `adoption-commit` as ONE combined + # `git log --diff-filter=A --format=%H -- CONTEXT.md release- + # targets.md` pipeline. This function does not care how many + # pathspecs produced its stdin -- it always takes the LAST line, + # since `git log` prints newest-first -- so a synthetic two-file + # addition history (interleaved, as a real multi-pathspec `git + # log` would emit) must still resolve to the OLDEST line. + # `test_first_release_baseline_takes_the_EARLIEST_of_several` + # above already pins this at the function level with a bare + # two-line fixture; this extends the same proof to the actual CLI + # entry point with a shape that looks like real two-file output. + env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1") + # Newest-first, as `git log` emits: the release-targets.md + # addition (newer, e.g. a Back-fill run today) came after the + # CONTEXT.md addition (older, e.g. full onboarding months ago). + two_file_log = "bbbbbbb\naaaaaaa\n" + proc = subprocess.run( + [sys.executable, _CORE_RELEASELIB_PATH, "adoption-commit"], + input=two_file_log, capture_output=True, text=True, env=env) + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertEqual(proc.stdout.strip(), "aaaaaaa") + def test_run_pre_tag_catches_a_mutation_of_an_ALREADY_MODIFIED_file(self): # HIGH, run 10. The assertion was a set difference over porcelain # LINES, so a command mutating a file that was already ` M` @@ -3344,6 +3369,160 @@ def test_run_pre_tag_runs_commands_in_the_project_root(self): self.assertEqual(proc.returncode, 0, proc.stderr) self.assertIn("1.1.0", proc.stdout) + # ---- #584 MEDIUM-1: the tree-state probe exempts the audit scratch ---- + def _pretag_repo_with_gate_log(self, tmp, commands): + """Like `_pretag_repo`, but with `.codearbiter/gate-events.log` + created and COMMITTED up front, so a declared command that appends + to it mid-window is appending to a TRACKED file -- the shape the + hooks actually produce, not an untracked one.""" + root = os.path.join(tmp, "consumer") + os.makedirs(os.path.join(root, ".codearbiter")) + with open(os.path.join(root, "package.json"), "w") as fh: + fh.write('{"version": "1.0.0"}\n') + with open(os.path.join(root, "CHANGELOG.md"), "w") as fh: + fh.write("# Changelog\n") + with open(os.path.join(root, ".codearbiter", "gate-events.log"), "w") as fh: + fh.write("existing-line\n") + with open(os.path.join(root, ".codearbiter", "release-targets.md"), "w") as fh: + fh.write("\n[app]\nprefix: v\n" + "changelog: CHANGELOG.md\npayload: .\n" + + "".join(f"pre-tag: {c}\n" for c in commands) + + "\n") + env = dict(os.environ, + GIT_CONFIG_GLOBAL=os.devnull, GIT_CONFIG_SYSTEM=os.devnull, + GIT_AUTHOR_NAME="t", GIT_AUTHOR_EMAIL="t@t", + GIT_COMMITTER_NAME="t", GIT_COMMITTER_EMAIL="t@t") + base = ["git", "-c", "commit.gpgsign=false", "-c", "core.hooksPath=", + "-c", "init.defaultBranch=main"] + for argv in (["init", "-q"], + ["add", "package.json", "CHANGELOG.md", + ".codearbiter/release-targets.md", + ".codearbiter/gate-events.log"], + ["commit", "-q", "-m", "init", "--no-verify"]): + proc = subprocess.run(base + argv, cwd=root, env=env, + capture_output=True, text=True) + self.assertEqual( + proc.returncode, 0, + f"fixture git {argv[0]} failed: {proc.stderr.strip()}") + return root + + def test_run_pre_tag_exempts_a_mid_window_gate_events_log_append(self): + # #584 MEDIUM-1: the hooks append to `.codearbiter/gate-events.log` + # on essentially every command, INCLUDING the commands this lane + # runs -- so a pre-tag command that (like a real hook append) + # writes to the log between the baseline probe and the + # post-command probe must not read as a mutation. Before the fix, + # this exact shape returned exit 6, whose stated remedy is "fix the + # declaration or remove the row entry" -- deleting a release gate + # over a blameless audit-log append. + appender = (f'"{sys.executable}" -c ' + '"open(\'.codearbiter/gate-events.log\',\'a\')' + '.write(chr(10))"') + with tempfile.TemporaryDirectory() as tmp: + root = self._pretag_repo_with_gate_log(tmp, [appender]) + proc = self._run_pre_tag(root) + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + + # ---- #583 MEDIUM-2 / #584 MEDIUM-3: PY exported to declared commands ---- + def test_run_pre_tag_exports_PY_to_declared_commands(self): + check = ('python -c "import os,sys;' + 'sys.exit(0 if os.environ.get(\'PY\') else 1)"') + with tempfile.TemporaryDirectory() as tmp: + root = self._pretag_repo(tmp, [check]) + proc = self._run_pre_tag(root) + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + + # ---- #585 MEDIUM-2 / #584 MEDIUM-1: could-not-run vs. drift ---- + def test_could_not_run_recognizes_only_the_documented_codes(self): + # Platform-independent proof of the exit-code MAPPING itself + # (distinct from the end-to-end test below, whose real-world + # trigger is platform-dependent -- see that test's own docstring). + for code in (126, 127, 9009): + with self.subTest(code=code): + self.assertTrue(core_releaselib._could_not_run(code)) + for code in (0, 1, 2, 5, 6, 8, 128, -1): + with self.subTest(code=code): + self.assertFalse(core_releaselib._could_not_run(code)) + + def test_run_pre_tag_exits_7_when_a_declared_command_could_not_run(self): + # A row's command exiting one of the documented could-not-run codes + # (127: POSIX "command not found") must be diagnosed as "could not + # run", never folded into exit 5's "ran and disagreed" (#585 + # MEDIUM-2 / #584 MEDIUM-1). Spelled as an explicit `sys.exit(127)` + # rather than relying on a genuinely-missing PATH entry: a real + # "not found" failure's exit code is platform-dependent -- POSIX + # shells report 127, but `cmd.exe`'s own "not recognized" error was + # MEASURED on a Windows 11 host to report 1, not one of the + # documented could-not-run codes (a residual gap, noted in + # `_could_not_run`'s own docstring) -- so this test pins the + # DIAGNOSIS given a 127, which is the exact code the real-world + # motivating case (a hardcoded `python3` absent from PATH) reports + # on a POSIX host, portably across every platform this suite runs + # on. + cmd = f'"{sys.executable}" -c "import sys; sys.exit(127)"' + with tempfile.TemporaryDirectory() as tmp: + root = self._pretag_repo(tmp, [cmd]) + proc = self._run_pre_tag(root) + self.assertEqual(proc.returncode, 7, proc.stdout + proc.stderr) + self.assertIn("COULD NOT RUN", proc.stderr) + self.assertIn("NOT drift", proc.stderr) + + # ---- #584 residual: exit 8, the probe itself failing ---- + def test_run_pre_tag_exits_8_when_the_tree_state_probe_fails(self): + # A `_tree_state()` probe failure (here: CLAUDE_PROJECT_DIR points + # at a directory that was never `git init`ed, so `git status` + # itself exits non-zero) must be diagnosed as "the probe failed, no + # verdict exists" -- exit 8 -- never exit 6, which names a + # DECLARED COMMAND as the fault and tells the operator to fix or + # remove the row. No command has even run yet on this path. + with tempfile.TemporaryDirectory() as tmp: + root = os.path.join(tmp, "not-a-git-repo") + os.makedirs(os.path.join(root, ".codearbiter")) + with open(os.path.join(root, ".codearbiter", "release-targets.md"), + "w") as fh: + fh.write("\n[app]\nprefix: v\n" + "changelog: CHANGELOG.md\npayload: .\n" + "\n") + proc = self._run_pre_tag(root) + self.assertEqual(proc.returncode, 8, proc.stdout + proc.stderr) + self.assertIn("PROBE itself failed", proc.stderr) + self.assertNotIn("MUTATED", proc.stderr) + + # ---- #585 MEDIUM-3: apply-bump mechanizes the version arithmetic ---- + def test_apply_bump_patch_minor_major(self): + self.assertEqual(core_releaselib.apply_bump("2.3.4", "patch"), "2.3.5") + self.assertEqual(core_releaselib.apply_bump("2.3.4", "minor"), "2.4.0") + self.assertEqual(core_releaselib.apply_bump("2.3.4", "major"), "3.0.0") + + def test_apply_bump_refuses_none_and_nonsense_words(self): + # `none` is a deliberate, explicit refusal -- not folded into the + # generic "unrecognised word" case -- because a caller must NEVER + # apply a non-bump; echoing `base` back unchanged would look like a + # successful (if inert) bump rather than the caller's own bug. + self.assertIsNone(core_releaselib.apply_bump("2.3.4", "none")) + self.assertIsNone(core_releaselib.apply_bump("2.3.4", "nonsense")) + + def test_apply_bump_refuses_a_non_semver_base(self): + self.assertIsNone(core_releaselib.apply_bump("not-a-version", "patch")) + + def test_apply_bump_cli_prints_exactly_the_version(self): + for word, expected in (("patch", "2.3.5"), ("minor", "2.4.0"), + ("major", "3.0.0")): + with self.subTest(word=word): + out = self._run_core("apply-bump", "2.3.4", word) + self.assertEqual(out.returncode, 0, out.stderr) + self.assertEqual(out.stdout.strip(), expected) + # stdout is EXACTLY the version -- no decoration, no label. + self.assertEqual(out.stdout, expected + "\n") + + def test_apply_bump_cli_exits_2_on_none_nonsense_or_bad_base(self): + for base, word in (("2.3.4", "none"), ("2.3.4", "nonsense"), + ("not-a-version", "patch")): + with self.subTest(base=base, word=word): + out = self._run_core("apply-bump", base, word) + self.assertEqual(out.returncode, 2, out.stdout) + self.assertEqual(out.stdout, "") + def test_semver_greater_bad_invocation_exits_2(self): import io, contextlib err = io.StringIO() @@ -3363,8 +3542,8 @@ def test_usage_banner_names_every_implemented_subcommand(self): core_releaselib.main([]) banner = err.getvalue() for name in ("tag-prefix", "list-targets", "last-tag", "notes-match", - "dates-match", "semver-greater", "classify", "peel-tag", - "backfill-detect"): + "dates-match", "semver-greater", "apply-bump", "classify", + "peel-tag", "backfill-detect"): self.assertIn( name, banner, f"{name!r} is implemented but missing from the usage banner") diff --git a/CHANGELOG.md b/CHANGELOG.md index 7933577b..f7cca054 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,29 @@ predate the plugin rewrite and are grouped by date. ## [Unreleased] +## [2.11.2] — 2026-08-04 + +### Fixed + +- Gave the release lane's internal tree-state probe the same audit-scratch + exemption the skill already requires everywhere else, so a mid-window + `gate-events.log` append is no longer misdiagnosed as a mutating pre-tag + command. +- Split `run-pre-tag`'s failure diagnosis into four distinct exit codes: a + declared command that ran and reported drift, one that mutated the tree, + one whose interpreter or program could not be located at all, and a + tree-state probe failure — each with its own remedy, so "could not run" + is never reported as "ran and disagreed". +- Exported the resolved interpreter to every declared release-lane command + via a `PY` environment variable, so a declared row can portably spell + `"$PY"` instead of a hardcoded interpreter. +- Mechanized the release lane's version-bump arithmetic behind a new + `apply-bump` subcommand, closing the one step still left to hand + derivation. +- Floored the release lane's first-release footer check on the earliest + addition of either `CONTEXT.md` or `release-targets.md`, so a Back-fill + consumer's own first release can clear Phase 1 step 3. + ## [2.11.1] — 2026-08-03 ### Fixed diff --git a/README.md b/README.md index 8ea04715..57a9ddcf 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ project context. You decide. codeArbiter enforces. Claude Code plugin Codex plugin Pi Feature Forge preview -version 2.11.1 +version 2.11.2 commands skills agents @@ -119,7 +119,7 @@ Approve the normal plugin trust prompt, open the target repository, and continue ### Codex CLI -The public GitHub-slug flow is **available now**. The repository currently ships `ca-codex 0.4.0`; +The public GitHub-slug flow is **available now**. The repository currently ships `ca-codex 0.4.1`; the dated end-to-end public-install record discovered `ca-codex 0.2.4` from release `v2.8.13`. Current packaging and shared-core parity are continuously verified, while that dated live-install record stays labeled rather than being silently promoted to evidence for a newer adapter: diff --git a/core/pysrc/_releaselib.py b/core/pysrc/_releaselib.py index 42818419..165d5df8 100644 --- a/core/pysrc/_releaselib.py +++ b/core/pysrc/_releaselib.py @@ -34,6 +34,7 @@ # Public API: # semver_key(value) -> tuple | None # semver_greater(current, base) -> bool +# apply_bump(base, word) -> str | None # last_tag_select(tags, prefix) -> str # notes_heading_matches(notes_text, tag) -> bool # release_dates_consistent(changelog_section, tag_message) -> bool @@ -953,6 +954,71 @@ def peel_tag(ls_remote_text, tag): return peeled or direct +_PLAIN_SEMVER_RE = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") +_BUMP_WORDS = ("major", "minor", "patch") + + +def apply_bump(base, word): + """Apply a classify-window bump `word` to `base`, a PLAIN SemVer string + (`MAJOR.MINOR.PATCH`, no pre-release or build metadata), and return the + bumped SemVer string. `major`: `X+1.0.0`. `minor`: `X.Y+1.0`. `patch`: + `X.Y.Z+1`. + + Returns `None` -- never raises, per this module's mechanism-function + invariant -- when `base` is not plain SemVer (reusing the same + no-leading-zero grammar `SEMVER` anchors its own major/minor/patch + groups with) or `word` is not exactly one of the three bumping words. + This deliberately EXCLUDES `word == "none"`: a caller must never apply a + non-bump, and passing `none` here is refused rather than echoing `base` + back unchanged, which would silently look like a successful (if inert) + bump. + + #585 MEDIUM-3: the bump arithmetic was the single unmechanized judgment + left in the release lane -- step 4 had the operator "apply the step-2 + bump to `$BASE_VERSION`" by hand, and nothing downstream re-derived it, + so the hard rule "a `feat` in the window cannot ship as a `patch`" had + no enforcement anywhere. `apply-bump` (the CLI subcommand below) is the + sanctioned way to do this arithmetic instead of by eye. + """ + if not isinstance(base, str) or not isinstance(word, str): + return None + match = _PLAIN_SEMVER_RE.fullmatch(base.strip()) + if match is None or word not in _BUMP_WORDS: + return None + major, minor, patch = (int(g) for g in match.groups()) + if word == "major": + return f"{major + 1}.0.0" + if word == "minor": + return f"{major}.{minor + 1}.0" + return f"{major}.{minor}.{patch + 1}" + + +# Exit codes a POSIX shell uses to report that a command's INTERPRETER OR +# PROGRAM ITSELF could not be located or executed, as distinct from the +# program running and reporting a failure: 127 is POSIX "command not +# found", 126 is POSIX "found but not executable" (e.g. missing execute +# bit, or a script with a shebang naming an interpreter that itself is +# missing), and 9009 is the code Windows' `cmd.exe` is documented to report +# for an unresolvable command in several invocation shapes (batch-file/npm +# wrapper contexts). #585 MEDIUM-2 / #584 MEDIUM-1: this is a BEST-EFFORT, +# POSIX-reliable signal -- a bare `subprocess.run(cmd, shell=True)` "is not +# recognized" error under a raw `cmd.exe /c` was measured on a Windows 11 +# host to return 1, indistinguishable there from an ordinary command +# failure, so this set does not catch every Windows "not found" shape. It +# reliably catches the POSIX shape this campaign's issues were filed +# against (a row hardcoding `python3` on a host that has only `python`), +# and any Windows invocation that does surface 9009. +_COULD_NOT_RUN_CODES = frozenset({126, 127, 9009}) + + +def _could_not_run(returncode): + """True iff `returncode` is one of the exit codes above that means the + declared command's interpreter or program itself was never located -- + "could not run", never "ran and disagreed" (house rule: the two must + never be folded together).""" + return returncode in _COULD_NOT_RUN_CODES + + # --------------------------------------------------------------------------- # # Declared-target-file parser. Grammar: per-target `[name]` sub-blocks of # `key: value` lines inside the HTML-comment delimiter convention this @@ -1558,10 +1624,24 @@ def main(argv): DECLARED ORDER, stopping at the first non-zero exit, and asserts a clean tree after each (DECISION-0034: check-only, never a - fixer). exit 0 all passed - 5 a command - failed - 6 a command mutated the tree (or the - tree was already dirty) - 2 bad invocation / - unknown target - 3/4 declared-file states. + fixer). Each declared command's environment + carries `PY=`, so a row may portably spell + `"$PY"` instead of a hardcoded interpreter + (#583 MEDIUM-2 / #584 MEDIUM-3). exit 0 all + passed - 5 a command RAN and reported drift + - 6 a command exited 0 but MUTATED the tree + (or the tree was already dirty on a probe + failure path that predates this fix) - 7 a + command's interpreter or program itself + could not be located/executed at all -- + NOT drift, no release-edit discard needed + (#585 MEDIUM-2 / #584 MEDIUM-1) - 8 the + tree-state PROBE itself failed, so no + verdict about the declared commands exists + (distinct from 6, which names a command as + the fault) - 2 bad invocation / unknown + target - 3/4 declared-file states. semver-greater exit 0 iff `candidate` is STRICTLY greater than `floor`; 1 when equal or lesser; 2 when @@ -1570,6 +1650,14 @@ def main(argv): including the manifest FLOOR check -- both were hand-done against a hard rule saying the version MUST NOT be guessed. + apply-bump prints the SemVer `base` bumped by `word` + (`major`/`minor`/`patch`; `none` and any + other value are refused). exit 0 with the + bumped version on stdout - 2 when `base` is + not plain SemVer or `word` is not one of + the three bumping words. The sanctioned way + to apply the bump `classify-window` + derived, instead of by eye (#585 MEDIUM-3). dates-match exit 0 iff the changelog section's heading date equals the `Released-at:` date in the @@ -1620,8 +1708,9 @@ def main(argv): sys.stderr.write( "usage: _releaselib.py {tag-prefix|list-targets|show-row|" "payload-pathspec|last-tag|notes-match|dates-match|" - "semver-greater|classify|peel-tag|run-pre-tag|adoption-commit|" - "classify-window|check-manifests|backfill-detect} ...\n") + "semver-greater|apply-bump|classify|peel-tag|run-pre-tag|" + "adoption-commit|classify-window|check-manifests|" + "backfill-detect} ...\n") return 2 cmd, rest = argv[0], list(argv[1:]) @@ -1912,9 +2001,21 @@ def _flatten(value): # command. Ordering the lane correctly is the caller's job; making # it impossible to conflate the two is this command's. # - # Exit codes: 0 all passed - 5 a command exited non-zero - 6 a - # command left the tree dirty - 2 bad invocation or unknown target - # - 3/4 the declared-file states, unchanged. + # Exit codes: 0 all passed - 5 a command RAN and reported drift + # (non-zero, not one of the could-not-run codes below) - 6 a + # command exited 0 but MUTATED the tree - 7 a command's interpreter + # or program itself could not be located/executed at all (#585 + # MEDIUM-2 / #584 MEDIUM-1: "could not run" is never "ran and + # disagreed") - 8 the tree-state PROBE itself failed, so no verdict + # about the declared commands exists at all (distinct from 6, which + # means a command mutated the tree -- a probe failure means this + # subcommand never got far enough to know) - 2 bad invocation or + # unknown target - 3/4 the declared-file states, unchanged. + # + # PY env-var contract: every declared command below runs with + # `PY` set in its environment to THIS process's own interpreter + # (`sys.executable`), so a row may portably spell `"$PY"` instead + # of a hardcoded `python3`/`python` (#583 MEDIUM-2 / #584 MEDIUM-3). try: rows = load_targets(default_targets_path()) except ReleaseTargetsError as exc: @@ -1960,8 +2061,21 @@ def _tree_state(): # prepend to) would otherwise silently run the wrong binary or # none at all. Enforced by test_pi_package's # `test_shared_python_contains_no_direct_bare_git_subprocess`. + # `--` plus the same `:/` + `,top`-exclusion pathspec the + # release skill's own Pre-flight and step-7 clean-tree checks + # are required to spell (#584 MEDIUM-1): the hooks append to + # `gate-events.log` on essentially every command, INCLUDING the + # commands this probe's own caller (`run-pre-tag`) runs, so a + # mid-window append between the baseline snapshot and a + # post-command probe put a blameless audit log in the changed + # set -- and exit 6's remedy tells the operator to permanently + # delete a release gate for it. `.markers/` is exempted for the + # same reason the skill exempts it: a per-machine confirmation + # marker minted by this same run is not a release surface. probe = subprocess.run( - [git_executable(), "status", "--porcelain"], + [git_executable(), "status", "--porcelain", "--", ":/", + ":(exclude,top).codearbiter/gate-events.log", + ":(exclude,top).codearbiter/.markers/"], capture_output=True, text=True, cwd=project_root) if probe.returncode != 0: return None, (probe.stderr.strip() or "git status failed") @@ -1999,8 +2113,22 @@ def _tree_state(): # through untouched. baseline, failure = _tree_state() if failure is not None: - sys.stderr.write(f"run-pre-tag: cannot read the tree state: {failure}\n") - return 6 + # Exit 8, never 6 (#584 residual / house rule: "could not run" + # is never folded into "ran and disagreed", and the same holds + # one level up for a PROBE that could not run at all). Exit 6 + # is a specific, actionable diagnosis -- "a command mutated the + # tree" -- and this is not that: the probe failed before any + # declared command even ran, so there is no verdict about the + # commands to report at all, and exit 6's "fix the declaration" + # remedy would misdirect an operator at the wrong problem. + sys.stderr.write( + f"run-pre-tag: the tree-state PROBE itself failed: {failure}\n" + " This is not a verdict about any declared command -- no " + "command has run yet, so nothing has been checked or " + "mutated. Investigate why `git status` failed in this tree " + "(not a git repository, no readable .git, etc.) before " + "re-running.\n") + return 8 for command in (row.get("pre_tag") or []): # flush=True: the subprocess writes to the same fds directly and @@ -2009,7 +2137,41 @@ def _tree_state(): # produced what -- actively misleading in the one report an # operator reads to decide whether a release is safe. print(f"pre-tag: {command}", flush=True) - proc = subprocess.run(command, shell=True, cwd=project_root) + # `PY` exported to the child's environment (#583 MEDIUM-2 / #584 + # MEDIUM-3): the interpreter-resolution convention the release + # skill establishes for its OWN invocations stopped at the + # skill's own commands -- a declared row is operator shell this + # lane EXECUTES exactly like any other step, so a row hardcoding + # `python3` fails on exactly the host the convention exists for. + # `sys.executable` is THIS process's own resolved interpreter, + # so a row may portably spell `"$PY"` instead. + proc = subprocess.run( + command, shell=True, cwd=project_root, + env={**os.environ, "PY": sys.executable}) + if _could_not_run(proc.returncode): + # Exit 7, never 5 (#585 MEDIUM-2 / #584 MEDIUM-1): a command + # whose interpreter or program itself could not be located + # was never actually RUN, so nothing was checked and there + # is no drift to reconcile -- the exit-5 remedy below is the + # wrong diagnosis for this case and its "discard this run's + # uncommitted release edits" step is unnecessary busywork, + # since nothing the row asserts was ever evaluated. + sys.stderr.write( + f"run-pre-tag: COULD NOT RUN -- {command!r} exited " + f"{proc.returncode} (interpreter or command not " + "found).\n" + " This is NOT drift. The command's interpreter or " + "program itself could not be located, so it never ran " + "and nothing was checked -- do not reconcile it as a " + "check failure.\n" + " Remedy: fix the command's availability on this host " + "(a common cause is a row hardcoding a specific " + "interpreter, e.g. `python3`, on a host that has only " + "`python` -- spell it `\"$PY\"` instead, per the " + "declared-command interpreter convention), then " + "re-run. No release-edit discard is needed: nothing " + "was checked, so there is nothing to undo.\n") + return 7 if proc.returncode != 0: sys.stderr.write( f"run-pre-tag: BLOCK -- {command!r} exited " @@ -2028,8 +2190,14 @@ def _tree_state(): return 5 current, failure = _tree_state() if failure is not None: - sys.stderr.write(f"run-pre-tag: cannot read the tree state: {failure}\n") - return 6 + sys.stderr.write( + f"run-pre-tag: the tree-state PROBE itself failed after " + f"{command!r} ran: {failure}\n" + " This is not a verdict about the command that just " + "ran -- the probe that would confirm or refute a " + "mutation could not complete, so no verdict about it " + "exists.\n") + return 8 # The UNION of both key sets, not `current` alone. A path git # reported as changed at baseline and no longer reports has been # REVERTED by the command -- which is a mutation of the tree in @@ -2096,6 +2264,33 @@ def _tree_state(): return 2 return 0 if semver_greater(rest[0], rest[1]) else 1 + if cmd == "apply-bump" and len(rest) == 2: + # #585 MEDIUM-3: the one number that mattered was the only step + # left to the eye. Step 4 had the operator "apply the step-2 bump + # to `$BASE_VERSION`" by hand -- none of the other fifteen + # subcommands does this arithmetic, and nothing downstream + # re-derives it (a minor window mis-applied as a patch still passes + # `semver-greater` and `check-manifests`, because both compare + # against the same wrong value this step just wrote). This is the + # sanctioned way to do it instead: exit 0 with the bumped version + # on stdout, or exit 2 with nothing to stdout when `base` is not + # plain SemVer or `word` is not exactly one of `major`/`minor`/ + # `patch` -- `none` included, deliberately: a caller must never + # apply a non-bump, and this refuses rather than echoing `base` + # back unchanged. + base, word = rest + result = apply_bump(base, word) + if result is None: + sys.stderr.write( + f"apply-bump: cannot apply bump {word!r} to base {base!r}. " + "`base` must be plain MAJOR.MINOR.PATCH SemVer (no " + "pre-release or build metadata) and `word` must be exactly " + "'major', 'minor', or 'patch' -- 'none' is deliberately " + "refused here, since a caller must never apply a non-bump.\n") + return 2 + print(result) + return 0 + if cmd == "dates-match" and len(rest) == 2: # MEDIUM (adversarial review 2026-07-31, run 4): Phase 1 step 5 and # Phase 2 step 1 both name `release_dates_consistent`, and Phase 2 diff --git a/core/surface/skills/release/SKILL.md b/core/surface/skills/release/SKILL.md index 5e441c39..a5c9a6cb 100644 --- a/core/surface/skills/release/SKILL.md +++ b/core/surface/skills/release/SKILL.md @@ -16,9 +16,11 @@ Every phase below is written once, against that row. Nothing in this skill is pe **Resolve the interpreter ONCE, by presence, before the first invocation:** ```sh -PY=python3; command -v python3 >/dev/null 2>&1 || PY=python +PY=python3; { command -v python3 >/dev/null 2>&1 && python3 --version >/dev/null 2>&1; } || PY=python ``` +**`command -v` alone is not enough** (LOW, #584): a Windows host commonly ships a `python3` *App Execution Alias* stub at `%LOCALAPPDATA%\Microsoft\WindowsApps\python3` that satisfies `command -v python3` with no Python actually installed — running it opens the Microsoft Store and exits non-zero. Only actually RUNNING it (`python3 --version`) tells the truth; `command -v` merely tells you a name resolves on `PATH`. `python3` wins whenever both it and `python` are genuinely present — the resolve-once order above tries it first and only falls back to `python` when it is absent or the stub — so a host with both interpreters gets the one this convention exists to prefer, not an arbitrary pick. + Every helper invocation below is then spelled `"$PY" "{{PLUGIN_ROOT}}/hooks/