@arbiterforge/ca-pi 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +558 -0
- package/package.json +35 -0
- package/plugins/ca-pi/CHANGELOG.md +1030 -0
- package/plugins/ca-pi/COMMANDS.md +90 -0
- package/plugins/ca-pi/ORCHESTRATOR.md +159 -0
- package/plugins/ca-pi/SKILLS.md +47 -0
- package/plugins/ca-pi/SPRINT.md +142 -0
- package/plugins/ca-pi/agents/INDEX.md +31 -0
- package/plugins/ca-pi/agents/architecture-drift-reviewer.md +86 -0
- package/plugins/ca-pi/agents/auth-crypto-reviewer.md +60 -0
- package/plugins/ca-pi/agents/backend-author.md +60 -0
- package/plugins/ca-pi/agents/checkpoint-aggregator.md +111 -0
- package/plugins/ca-pi/agents/coverage-auditor.md +71 -0
- package/plugins/ca-pi/agents/decision-challenger.md +116 -0
- package/plugins/ca-pi/agents/dependency-reviewer.md +79 -0
- package/plugins/ca-pi/agents/design-quality-reviewer.md +80 -0
- package/plugins/ca-pi/agents/finding-triage.md +86 -0
- package/plugins/ca-pi/agents/frontend-author.md +64 -0
- package/plugins/ca-pi/agents/grader.md +173 -0
- package/plugins/ca-pi/agents/infra-author.md +64 -0
- package/plugins/ca-pi/agents/map-deps.md +35 -0
- package/plugins/ca-pi/agents/map-structure.md +37 -0
- package/plugins/ca-pi/agents/migration-reviewer.md +65 -0
- package/plugins/ca-pi/agents/scout.md +127 -0
- package/plugins/ca-pi/agents/security-reviewer.md +72 -0
- package/plugins/ca-pi/agents/tribunal-lens-reviewer.md +65 -0
- package/plugins/ca-pi/extensions/codearbiter-child.js +1885 -0
- package/plugins/ca-pi/extensions/codearbiter.js +9802 -0
- package/plugins/ca-pi/generated/command-catalog.json +197 -0
- package/plugins/ca-pi/generated/roles.json +213 -0
- package/plugins/ca-pi/helpers/windows-supervisor.js +205 -0
- package/plugins/ca-pi/hooks/_activationlib.py +196 -0
- package/plugins/ca-pi/hooks/_arbiterstatelib.py +208 -0
- package/plugins/ca-pi/hooks/_babysitlib.py +76 -0
- package/plugins/ca-pi/hooks/_bashguardlib.py +1667 -0
- package/plugins/ca-pi/hooks/_boxlib.py +131 -0
- package/plugins/ca-pi/hooks/_colorlib.py +304 -0
- package/plugins/ca-pi/hooks/_durabilitylib.py +186 -0
- package/plugins/ca-pi/hooks/_entrylib.py +41 -0
- package/plugins/ca-pi/hooks/_fmtlib.py +161 -0
- package/plugins/ca-pi/hooks/_gitexec.py +45 -0
- package/plugins/ca-pi/hooks/_githooks.py +920 -0
- package/plugins/ca-pi/hooks/_gitlib.py +110 -0
- package/plugins/ca-pi/hooks/_hooklib.py +595 -0
- package/plugins/ca-pi/hooks/_host.py +115 -0
- package/plugins/ca-pi/hooks/_intentlib.py +242 -0
- package/plugins/ca-pi/hooks/_ledgerlib.py +1035 -0
- package/plugins/ca-pi/hooks/_metricslib.py +709 -0
- package/plugins/ca-pi/hooks/_pathnorm.py +74 -0
- package/plugins/ca-pi/hooks/_planfilelib.py +664 -0
- package/plugins/ca-pi/hooks/_previewlib.py +193 -0
- package/plugins/ca-pi/hooks/_protectedlib.py +312 -0
- package/plugins/ca-pi/hooks/_protectedstatelib.py +411 -0
- package/plugins/ca-pi/hooks/_provenancelib.py +971 -0
- package/plugins/ca-pi/hooks/_prunelib.py +1398 -0
- package/plugins/ca-pi/hooks/_prunepolicy.py +235 -0
- package/plugins/ca-pi/hooks/_readinjectlib.py +1080 -0
- package/plugins/ca-pi/hooks/_releaselib.py +2657 -0
- package/plugins/ca-pi/hooks/_scopelib.py +262 -0
- package/plugins/ca-pi/hooks/_segmentslib.py +278 -0
- package/plugins/ca-pi/hooks/_sensitivelib.py +270 -0
- package/plugins/ca-pi/hooks/_sessionlib.py +78 -0
- package/plugins/ca-pi/hooks/_sloplib.py +244 -0
- package/plugins/ca-pi/hooks/_standuplib.py +214 -0
- package/plugins/ca-pi/hooks/_subagentslib.py +219 -0
- package/plugins/ca-pi/hooks/_taskboardlib.py +1088 -0
- package/plugins/ca-pi/hooks/_updatelib.py +278 -0
- package/plugins/ca-pi/hooks/babysit.py +47 -0
- package/plugins/ca-pi/hooks/boardsync.py +129 -0
- package/plugins/ca-pi/hooks/doctor.py +420 -0
- package/plugins/ca-pi/hooks/git-enforce.py +325 -0
- package/plugins/ca-pi/hooks/hostapi.py +460 -0
- package/plugins/ca-pi/hooks/init-codearbiter.py +225 -0
- package/plugins/ca-pi/hooks/metrics.py +62 -0
- package/plugins/ca-pi/hooks/migration-pass.py +129 -0
- package/plugins/ca-pi/hooks/pi-bridge.py +543 -0
- package/plugins/ca-pi/hooks/post-write-edit.py +231 -0
- package/plugins/ca-pi/hooks/pre-bash.py +90 -0
- package/plugins/ca-pi/hooks/pre-edit.py +284 -0
- package/plugins/ca-pi/hooks/pre-read.py +81 -0
- package/plugins/ca-pi/hooks/pre-write.py +217 -0
- package/plugins/ca-pi/hooks/preview.py +69 -0
- package/plugins/ca-pi/hooks/prune-transcript.py +232 -0
- package/plugins/ca-pi/hooks/releasehash.py +216 -0
- package/plugins/ca-pi/hooks/security-pass.py +139 -0
- package/plugins/ca-pi/hooks/session-start.py +1218 -0
- package/plugins/ca-pi/hooks/statusline.py +736 -0
- package/plugins/ca-pi/hooks/taskwrite.py +351 -0
- package/plugins/ca-pi/hooks/update-refresh.py +51 -0
- package/plugins/ca-pi/hooks/wire-statusline.py +435 -0
- package/plugins/ca-pi/includes/anti-slop-design/INDEX.md +55 -0
- package/plugins/ca-pi/includes/anti-slop-design/color.md +43 -0
- package/plugins/ca-pi/includes/anti-slop-design/core.md +244 -0
- package/plugins/ca-pi/includes/anti-slop-design/images.md +32 -0
- package/plugins/ca-pi/includes/anti-slop-design/layout.md +45 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-cli.md +39 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-dataviz.md +43 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-diagram.md +35 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-documents.md +70 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-slides.md +30 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-web.md +39 -0
- package/plugins/ca-pi/includes/anti-slop-design/typography.md +51 -0
- package/plugins/ca-pi/includes/author-tdd-workflow.md +14 -0
- package/plugins/ca-pi/includes/compaction-charter.md +16 -0
- package/plugins/ca-pi/includes/cut-docs.md +16 -0
- package/plugins/ca-pi/includes/dev-mode.md +30 -0
- package/plugins/ca-pi/includes/farm.md +237 -0
- package/plugins/ca-pi/includes/fresh-verification.md +14 -0
- package/plugins/ca-pi/includes/harvest.md +69 -0
- package/plugins/ca-pi/includes/maturity-coverage.md +102 -0
- package/plugins/ca-pi/includes/pi-host-notes.md +69 -0
- package/plugins/ca-pi/includes/redirect.md +69 -0
- package/plugins/ca-pi/includes/reference-map.md +22 -0
- package/plugins/ca-pi/includes/review-matrix.md +14 -0
- package/plugins/ca-pi/includes/reviewer-contract.md +53 -0
- package/plugins/ca-pi/includes/routing-table.md +47 -0
- package/plugins/ca-pi/includes/security-gate-record.md +22 -0
- package/plugins/ca-pi/includes/smarts/core.md +90 -0
- package/plugins/ca-pi/includes/smarts/decision-log-format.md +56 -0
- package/plugins/ca-pi/routines/INDEX.md +32 -0
- package/plugins/ca-pi/routines/brainstorming/SKILL.md +122 -0
- package/plugins/ca-pi/routines/commit-gate/SKILL.md +151 -0
- package/plugins/ca-pi/routines/context-check/SKILL.md +85 -0
- package/plugins/ca-pi/routines/context-creation/SKILL.md +171 -0
- package/plugins/ca-pi/routines/crypto-compliance/SKILL.md +41 -0
- package/plugins/ca-pi/routines/debug/SKILL.md +99 -0
- package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +104 -0
- package/plugins/ca-pi/routines/decision-lifecycle/references/adr-template.md +74 -0
- package/plugins/ca-pi/routines/decision-variance/SKILL.md +147 -0
- package/plugins/ca-pi/routines/decompose/SKILL.md +168 -0
- package/plugins/ca-pi/routines/dispatching-parallel-agents/SKILL.md +76 -0
- package/plugins/ca-pi/routines/executing-plans/SKILL.md +83 -0
- package/plugins/ca-pi/routines/finishing-a-development-branch/SKILL.md +91 -0
- package/plugins/ca-pi/routines/post-merge-cleanup/SKILL.md +233 -0
- package/plugins/ca-pi/routines/refactor/SKILL.md +91 -0
- package/plugins/ca-pi/routines/release/SKILL.md +315 -0
- package/plugins/ca-pi/routines/secret-handling/SKILL.md +67 -0
- package/plugins/ca-pi/routines/security-architecture/SKILL.md +63 -0
- package/plugins/ca-pi/routines/skill-author/SKILL.md +108 -0
- package/plugins/ca-pi/routines/skill-author/references/skill-template.md +58 -0
- package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +149 -0
- package/plugins/ca-pi/routines/subagent-driven-development/references/farm-dispatch.md +145 -0
- package/plugins/ca-pi/routines/tdd/SKILL.md +139 -0
- package/plugins/ca-pi/routines/tribunal/SKILL.md +109 -0
- package/plugins/ca-pi/routines/tribunal/references/ai-markers.md +29 -0
- package/plugins/ca-pi/routines/tribunal/references/cost-and-models.md +64 -0
- package/plugins/ca-pi/routines/tribunal/references/finding-record.md +27 -0
- package/plugins/ca-pi/routines/tribunal/references/issue-filing.md +47 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/appsec.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/architecture.md +23 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/coverage.md +20 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/infra.md +24 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/migration.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/observability.md +21 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/performance.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/reliability.md +23 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/secrets-supply.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/test-fidelity.md +24 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/typesafety.md +21 -0
- package/plugins/ca-pi/routines/tribunal/references/report.md +19 -0
- package/plugins/ca-pi/routines/tribunal/references/schemas.md +58 -0
- package/plugins/ca-pi/routines/tribunal/references/telemetry.md +28 -0
- package/plugins/ca-pi/routines/tribunal/references/triage.md +53 -0
- package/plugins/ca-pi/routines/using-git-worktrees/SKILL.md +85 -0
- package/plugins/ca-pi/routines/writing-plans/SKILL.md +129 -0
- package/plugins/ca-pi/routines/writing-plans/references/farm-plan.md +50 -0
- package/plugins/ca-pi/skills/ca-add-dep/SKILL.md +88 -0
- package/plugins/ca-pi/skills/ca-adr/SKILL.md +30 -0
- package/plugins/ca-pi/skills/ca-adr-status/SKILL.md +30 -0
- package/plugins/ca-pi/skills/ca-arbiter/SKILL.md +36 -0
- package/plugins/ca-pi/skills/ca-audit/SKILL.md +51 -0
- package/plugins/ca-pi/skills/ca-btw/SKILL.md +23 -0
- package/plugins/ca-pi/skills/ca-checkpoint/SKILL.md +50 -0
- package/plugins/ca-pi/skills/ca-chore/SKILL.md +58 -0
- package/plugins/ca-pi/skills/ca-cleanup/SKILL.md +55 -0
- package/plugins/ca-pi/skills/ca-commands/SKILL.md +21 -0
- package/plugins/ca-pi/skills/ca-commit/SKILL.md +27 -0
- package/plugins/ca-pi/skills/ca-conflict/SKILL.md +61 -0
- package/plugins/ca-pi/skills/ca-context-check/SKILL.md +32 -0
- package/plugins/ca-pi/skills/ca-create-context/SKILL.md +32 -0
- package/plugins/ca-pi/skills/ca-debug/SKILL.md +42 -0
- package/plugins/ca-pi/skills/ca-decompose/SKILL.md +30 -0
- package/plugins/ca-pi/skills/ca-dev/SKILL.md +42 -0
- package/plugins/ca-pi/skills/ca-doctor/SKILL.md +44 -0
- package/plugins/ca-pi/skills/ca-feature/SKILL.md +105 -0
- package/plugins/ca-pi/skills/ca-fix/SKILL.md +42 -0
- package/plugins/ca-pi/skills/ca-init/SKILL.md +56 -0
- package/plugins/ca-pi/skills/ca-metrics/SKILL.md +80 -0
- package/plugins/ca-pi/skills/ca-new-skill/SKILL.md +34 -0
- package/plugins/ca-pi/skills/ca-override/SKILL.md +72 -0
- package/plugins/ca-pi/skills/ca-pr/SKILL.md +61 -0
- package/plugins/ca-pi/skills/ca-preview/SKILL.md +86 -0
- package/plugins/ca-pi/skills/ca-prune/SKILL.md +100 -0
- package/plugins/ca-pi/skills/ca-reconcile/SKILL.md +43 -0
- package/plugins/ca-pi/skills/ca-refactor/SKILL.md +43 -0
- package/plugins/ca-pi/skills/ca-release/SKILL.md +57 -0
- package/plugins/ca-pi/skills/ca-review/SKILL.md +63 -0
- package/plugins/ca-pi/skills/ca-spike/SKILL.md +41 -0
- package/plugins/ca-pi/skills/ca-sprint/SKILL.md +44 -0
- package/plugins/ca-pi/skills/ca-standup/SKILL.md +112 -0
- package/plugins/ca-pi/skills/ca-status/SKILL.md +56 -0
- package/plugins/ca-pi/skills/ca-task/SKILL.md +61 -0
- package/plugins/ca-pi/skills/ca-threat-model/SKILL.md +45 -0
- package/plugins/ca-pi/skills/ca-tribunal/SKILL.md +44 -0
- package/plugins/ca-pi/skills/ca-watch/SKILL.md +78 -0
|
@@ -0,0 +1,2657 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# codeArbiter — portable release-lane MECHANISM (anchored per-series tag
|
|
3
|
+
# selection, semver comparison, publish-state classification, notes-heading
|
|
4
|
+
# matching, date consistency) plus the declared-target-file parser.
|
|
5
|
+
#
|
|
6
|
+
# This module is the PORTABLE half of the release helper split (issue #563).
|
|
7
|
+
# It ships from core/pysrc/ into every governance plugin's hooks/ directory
|
|
8
|
+
# (tools/sync-core.py, CI-enforced byte-identity) and therefore MUST carry no
|
|
9
|
+
# fact about this repository or its CI vocabulary — no plugin name, no path
|
|
10
|
+
# under this repository, no check-run name, no tag-namespace mapping. Every
|
|
11
|
+
# such fact is DATA, supplied by the caller (a required parameter) or read
|
|
12
|
+
# from an operator-declared file via load_targets(). A consuming repository
|
|
13
|
+
# supplies its own facts; this module supplies only the mechanism.
|
|
14
|
+
#
|
|
15
|
+
# Design invariants (mirror the other _*lib helpers):
|
|
16
|
+
# - Stdlib only; zero side effects at import (no git, no file I/O, no
|
|
17
|
+
# argument parsing at import time).
|
|
18
|
+
# - Every mechanism function (semver_key, semver_greater, last_tag_select,
|
|
19
|
+
# notes_heading_matches, release_dates_consistent, classify_publish_state,
|
|
20
|
+
# select_release_target, classify_merge_readiness, peel_tag) is pure over
|
|
21
|
+
# synthetic input and NEVER raises on malformed input — it degrades to the
|
|
22
|
+
# safe/refusing answer, per this codebase's "never raise on malformed user
|
|
23
|
+
# input" rule for hook-adjacent helpers.
|
|
24
|
+
# - The declared-target-file parser (parse_release_targets / load_targets)
|
|
25
|
+
# is the deliberate, documented exception to that rule: its input is not
|
|
26
|
+
# arbitrary user/session data but an OPERATOR-AUTHORED configuration file
|
|
27
|
+
# that a `contents: write` release lane later executes. A malformed
|
|
28
|
+
# declaration is a configuration error that MUST surface loudly to the
|
|
29
|
+
# operator rather than silently defaulting or partially parsing — so every
|
|
30
|
+
# parser-contract violation raises its own distinguishable
|
|
31
|
+
# ReleaseTargetsError subclass instead of returning a degraded value or
|
|
32
|
+
# letting a bare exception escape from deep inside the parser.
|
|
33
|
+
#
|
|
34
|
+
# Public API:
|
|
35
|
+
# git_executable() -> str git resolved through the trusted-path seam
|
|
36
|
+
# semver_key(value) -> tuple | None
|
|
37
|
+
# semver_greater(current, base) -> bool
|
|
38
|
+
# apply_bump(base, word) -> str | None
|
|
39
|
+
# last_tag_select(tags, prefix) -> str
|
|
40
|
+
# notes_heading_matches(notes_text, tag) -> bool
|
|
41
|
+
# release_dates_consistent(changelog_section, tag_message) -> bool
|
|
42
|
+
# classify_publish_state(tag_exists, tag_sha, head_sha, tag_version,
|
|
43
|
+
# manifest_version, release_is_nondraft) -> str
|
|
44
|
+
# select_release_target_by_name(pairs, targets) -> str name-keyed
|
|
45
|
+
# resolver (A-4.2); pairs are `name=value` strings
|
|
46
|
+
# select_release_target(*confirmations, targets) -> str
|
|
47
|
+
# classify_merge_readiness(check_runs, head_sha, check_name) -> str
|
|
48
|
+
# row_assertions(row) -> dict which lane steps a row's declared fields
|
|
49
|
+
# turn on (A-3.1..3.5)
|
|
50
|
+
# window_excludes_payload_paths(paths, payload, payload_exclude)
|
|
51
|
+
# -> list[str] paths filtered to payload, minus
|
|
52
|
+
# any payload-exclude entry (A-3.4)
|
|
53
|
+
# provenance_trigger_paths(rows) -> list[str] every manifest/changelog/
|
|
54
|
+
# generated_manifest/artifacts path a declared row
|
|
55
|
+
# references, sorted and de-duplicated (A-5.6)
|
|
56
|
+
# _manifest_version(path) -> str | None
|
|
57
|
+
# classify_commit(subject, body) -> dict
|
|
58
|
+
# classify_window(commits) -> dict
|
|
59
|
+
# parse_window_log(text) -> list[dict]
|
|
60
|
+
# first_release_baseline(adoption_log_text) -> str
|
|
61
|
+
# peel_tag(ls_remote_text, tag) -> str
|
|
62
|
+
# parse_release_targets(text) -> list[dict]
|
|
63
|
+
# load_targets(path) -> list[dict]
|
|
64
|
+
# default_backfill_root() -> str
|
|
65
|
+
# scan_backfill_candidates(root) -> (list[str], list[str])
|
|
66
|
+
# detect_candidate_target(manifest_candidates, changelog_candidates,
|
|
67
|
+
# target=..., prefix=...) -> dict
|
|
68
|
+
# format_release_targets_block(row) -> str
|
|
69
|
+
# default_targets_path() -> str
|
|
70
|
+
# _targets_error_exit_code(exc) -> int
|
|
71
|
+
# main(argv) -> int
|
|
72
|
+
#
|
|
73
|
+
# CLI (T-41f): every consuming host vendors this file byte-identically into
|
|
74
|
+
# its own `hooks/` directory (`tools/sync-core.py`), and that vendored copy —
|
|
75
|
+
# `${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py` — is what the release skill
|
|
76
|
+
# actually shells out to post-portability (issue #563). Before this, only the
|
|
77
|
+
# permanent `.github/scripts/_releaselib.py` shim (this repo's OWN CI entry
|
|
78
|
+
# point, never replaced by this one) carried a `__main__`; repointing the
|
|
79
|
+
# skill's invocations without this CLI would aim prose at a file nothing
|
|
80
|
+
# could run. See "CLI entry point" below the parser for the subcommands.
|
|
81
|
+
#
|
|
82
|
+
# Declared exceptions (all subclass ReleaseTargetsError):
|
|
83
|
+
# AbsentBlockError — no delimiter block present at all
|
|
84
|
+
# EmptyBlockError — the delimiter block is present but blank
|
|
85
|
+
# MalformedBlockError — bad `[target]` header grammar, a key before the
|
|
86
|
+
# first header, or an unparsable line
|
|
87
|
+
# UnknownKeyError — a key outside the declared grammar
|
|
88
|
+
# DuplicateKeyError — a scalar key repeated within one target block
|
|
89
|
+
# DuplicateTargetError — the same `[target]` header declared twice
|
|
90
|
+
# InvalidBooleanError — a boolean value that is not exactly true/false
|
|
91
|
+
# MultipleBlocksError — more than one delimiter block in the file
|
|
92
|
+
# ValueTooLongError — a declared value exceeds VALUE_MAX_CHARS
|
|
93
|
+
# (A-2.4); a sibling declared-file error, exits 4
|
|
94
|
+
# DelimiterInValueError — a value contains the literal closing delimiter,
|
|
95
|
+
# which would otherwise truncate the block under a
|
|
96
|
+
# naive non-greedy match
|
|
97
|
+
# MissingRequiredKeyError — a target block is missing prefix/changelog/payload
|
|
98
|
+
# BackfillAmbiguousError — the back-fill scan found zero, or more than one,
|
|
99
|
+
# candidate manifest or changelog file (T-49/T-50)
|
|
100
|
+
# FileExistsNoBlockError — the declared-target file EXISTS on disk but
|
|
101
|
+
# carries no delimiter block at all (HIGH-1,
|
|
102
|
+
# adversarial review 2026-07-31). Deliberately a
|
|
103
|
+
# SIBLING of AbsentBlockError, not a subclass of
|
|
104
|
+
# it: raised only by load_targets() (which knows
|
|
105
|
+
# the file was opened successfully), never by
|
|
106
|
+
# parse_release_targets() on synthetic text,
|
|
107
|
+
# and never caught by an `except
|
|
108
|
+
# AbsentBlockError:` clause written for the
|
|
109
|
+
# genuinely-missing-file case — see "Back-fill
|
|
110
|
+
# detection" below for why that distinction is
|
|
111
|
+
# load-bearing.
|
|
112
|
+
# UnreadableTargetsFileError — the declared path EXISTS but `open()` still
|
|
113
|
+
# failed for a reason other than "not found"
|
|
114
|
+
# (permission denied, a directory, ...). A
|
|
115
|
+
# SIBLING of AbsentBlockError, not a subclass,
|
|
116
|
+
# for the same reason FileExistsNoBlockError is:
|
|
117
|
+
# "unreadable" is not "absent"
|
|
118
|
+
# ([[never-fold-unreadable-into-absent]]) and
|
|
119
|
+
# must not silently trigger the Back-fill lane's
|
|
120
|
+
# `except AbsentBlockError:` on a file that is
|
|
121
|
+
# actually there but denied to this process.
|
|
122
|
+
|
|
123
|
+
from __future__ import annotations
|
|
124
|
+
|
|
125
|
+
import hashlib
|
|
126
|
+
import os
|
|
127
|
+
import re
|
|
128
|
+
import shlex
|
|
129
|
+
import shutil
|
|
130
|
+
import subprocess
|
|
131
|
+
import sys
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def git_executable():
|
|
135
|
+
"""Git resolved through the trusted-path seam, never a bare `git`.
|
|
136
|
+
|
|
137
|
+
Resolved LAZILY and imported inside the call rather than at module
|
|
138
|
+
scope, because this module is loaded two different ways and only one of
|
|
139
|
+
them has `_gitexec` importable by name: as an ordinary sibling module
|
|
140
|
+
in a host's hook directory (a plain import works), and by explicit file
|
|
141
|
+
path, where nothing put this directory on `sys.path`. A module-level
|
|
142
|
+
import would break the second, and would also violate the documented
|
|
143
|
+
invariant that importing this mechanism has no side effects beyond the
|
|
144
|
+
load itself.
|
|
145
|
+
|
|
146
|
+
`_gitexec.py` sits beside this file wherever this file lives, so the
|
|
147
|
+
fallback adds only this module's own directory.
|
|
148
|
+
"""
|
|
149
|
+
try:
|
|
150
|
+
from _gitexec import git_executable as resolve
|
|
151
|
+
except ImportError: # loaded by path
|
|
152
|
+
here = os.path.dirname(os.path.abspath(__file__))
|
|
153
|
+
if here not in sys.path:
|
|
154
|
+
sys.path.insert(0, here)
|
|
155
|
+
from _gitexec import git_executable as resolve
|
|
156
|
+
return resolve()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class ReleaseTargetsError(RuntimeError):
|
|
160
|
+
"""Base for every declared release-targets-file parse error. Callers that
|
|
161
|
+
only care that the declaration was bad, not which rule it broke, can catch
|
|
162
|
+
this one type; callers that need to react differently per violation catch
|
|
163
|
+
the specific subclass."""
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class AbsentBlockError(ReleaseTargetsError):
|
|
167
|
+
"""No delimiter block is present in the file at all."""
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class EmptyBlockError(ReleaseTargetsError):
|
|
171
|
+
"""The delimiter block is present but contains no declaration content."""
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class FileExistsNoBlockError(ReleaseTargetsError):
|
|
175
|
+
"""The declared-target file EXISTS on disk (it opened and read
|
|
176
|
+
successfully) but contains no `<!-- release-targets -->` delimiter block
|
|
177
|
+
at all.
|
|
178
|
+
|
|
179
|
+
HIGH-1 (adversarial review 2026-07-31): before this class existed,
|
|
180
|
+
`load_targets` raised the SAME `AbsentBlockError` for this case as it did
|
|
181
|
+
for a genuinely missing file, because it delegated straight to
|
|
182
|
+
`parse_release_targets`, which cannot tell "no text at all" from "text
|
|
183
|
+
with no block in it" apart from "no file" -- it only ever sees text. An
|
|
184
|
+
agent implementing the release skill's Back-fill lane literally as
|
|
185
|
+
written -- catch `AbsentBlockError`, enter back-fill, "write the
|
|
186
|
+
confirmed block verbatim" -- would silently overwrite an operator's
|
|
187
|
+
EXISTING file that merely lacks the block, discarding whatever they
|
|
188
|
+
actually put there. That is exactly the outcome the skill's own prose
|
|
189
|
+
says this lane must never cause.
|
|
190
|
+
|
|
191
|
+
Deliberately a SIBLING of `AbsentBlockError` under `ReleaseTargetsError`,
|
|
192
|
+
not a subclass of it: an `except AbsentBlockError:` clause written for
|
|
193
|
+
"the file is genuinely absent" (the Back-fill lane's ONE sanctioned
|
|
194
|
+
trigger) continues to see only that case, unchanged, and does not
|
|
195
|
+
accidentally widen to catch this one. A broad `except
|
|
196
|
+
ReleaseTargetsError:` clause still catches both, as it always did. Only
|
|
197
|
+
`load_targets` ever raises this -- `parse_release_targets` is pure over
|
|
198
|
+
text and has no way to know whether a file existed, so its own
|
|
199
|
+
AbsentBlockError-on-no-block behavior against synthetic text is
|
|
200
|
+
unchanged."""
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class UnreadableTargetsFileError(ReleaseTargetsError):
|
|
204
|
+
"""`open()` on the declared path failed for a reason OTHER than "not
|
|
205
|
+
found" -- a permissions error, the path naming a directory, or any other
|
|
206
|
+
`OSError` whose `errno` is not `ENOENT`. The path exists (in the
|
|
207
|
+
filesystem sense) but this process could not read it.
|
|
208
|
+
|
|
209
|
+
Deliberately a SIBLING of `AbsentBlockError`, not a subclass of it, for
|
|
210
|
+
the identical reason `FileExistsNoBlockError` is one:
|
|
211
|
+
[[never-fold-unreadable-into-absent]] -- "could not read" is a different
|
|
212
|
+
fact than "is not there", and folding the two together means a
|
|
213
|
+
permissions error on an EXISTING declared-targets file would silently
|
|
214
|
+
satisfy the Back-fill lane's `except AbsentBlockError:` trigger, driving
|
|
215
|
+
it to write a fresh file over one that was never actually missing, only
|
|
216
|
+
denied. Only `load_targets` ever raises this -- `parse_release_targets`
|
|
217
|
+
is pure over text and never touches the filesystem."""
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class MalformedBlockError(ReleaseTargetsError):
|
|
221
|
+
"""A `[target]` header is malformed (empty, or carries a character outside
|
|
222
|
+
`[A-Za-z0-9._-]`), a key line appears before the first header, or a line is
|
|
223
|
+
neither a header nor a `key: value` pair."""
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class UnknownKeyError(ReleaseTargetsError):
|
|
227
|
+
"""A key outside the declared grammar (e.g. a typo) was used."""
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class DuplicateKeyError(ReleaseTargetsError):
|
|
231
|
+
"""A scalar (non-repeating) key was declared twice within one target block."""
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class DuplicateTargetError(ReleaseTargetsError):
|
|
235
|
+
"""The same `[target]` header was declared more than once."""
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class InvalidBooleanError(ReleaseTargetsError):
|
|
239
|
+
"""A boolean-typed value was neither exactly `true` nor exactly `false`."""
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class MultipleBlocksError(ReleaseTargetsError):
|
|
243
|
+
"""More than one delimiter block was found in the file."""
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class ValueTooLongError(ReleaseTargetsError):
|
|
247
|
+
"""A declared value exceeds `VALUE_MAX_CHARS` (A-2.4, ADR-0002's
|
|
248
|
+
precedent). A sibling of every other declared-file error, so it exits 4
|
|
249
|
+
and never 3 -- an over-long value is a malformed declaration, never the
|
|
250
|
+
genuinely-absent state that triggers the Back-fill lane."""
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class DelimiterInValueError(ReleaseTargetsError):
|
|
254
|
+
"""A declared value contains the literal closing-delimiter text, which
|
|
255
|
+
would otherwise silently truncate the block under a naive non-greedy
|
|
256
|
+
match rather than being treated as part of the value."""
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class MissingRequiredKeyError(ReleaseTargetsError):
|
|
260
|
+
"""A target block is missing one of the required keys (prefix, changelog,
|
|
261
|
+
payload)."""
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# A `2.9.1`-style series tag is exactly `<prefix>MAJOR.MINOR.PATCH` — no
|
|
265
|
+
# suffix. The anchored form excludes pre-releases (`2.6.0-beta.1`) outright:
|
|
266
|
+
# the trailing `$` rejects any tag carrying a suffix past MAJOR.MINOR.PATCH,
|
|
267
|
+
# so a pre-release tag never matches this regex at all.
|
|
268
|
+
#
|
|
269
|
+
# Issue #568: this module used to also carry `_PRERELEASE_MARKERS = ("-beta",
|
|
270
|
+
# "-rc", "-alpha")` and a second check in `last_tag_select` re-testing the
|
|
271
|
+
# prefix-stripped version against it, documented as a "second line of
|
|
272
|
+
# defense". That check was proven UNREACHABLE through the public API as
|
|
273
|
+
# shipped — the anchor above already rejects every tag the marker check
|
|
274
|
+
# could have caught, so no tag can both match the regex and carry a marker,
|
|
275
|
+
# and mutating the tuple to `()` changed nothing observable. Deleted rather
|
|
276
|
+
# than kept as unreachable dead code or made load-bearing by relaxing the
|
|
277
|
+
# anchor: relaxing the anchor to admit a suffix would also newly admit
|
|
278
|
+
# tags like `v1.0.0+build.5` and `v1.0.0.1` that this anchor currently
|
|
279
|
+
# rejects wholesale, a behavior change with its own blast radius this
|
|
280
|
+
# bug-fix cluster does not take on. If the anchor is ever relaxed, a second
|
|
281
|
+
# check will need to be RE-ADDED deliberately, not un-deleted from history.
|
|
282
|
+
_RELEASE_RE_CACHE = {}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _release_re(prefix):
|
|
286
|
+
"""The anchored `<prefix>MAJOR.MINOR.PATCH` matcher for one release series."""
|
|
287
|
+
rx = _RELEASE_RE_CACHE.get(prefix)
|
|
288
|
+
if rx is None:
|
|
289
|
+
rx = re.compile(r"^" + re.escape(prefix) + r"(\d+)\.(\d+)\.(\d+)$")
|
|
290
|
+
_RELEASE_RE_CACHE[prefix] = rx
|
|
291
|
+
return rx
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# A changelog section heading, in either the `## vX.Y.Z - DATE` form or the
|
|
295
|
+
# Keep-a-Changelog `## [X.Y.Z] - DATE` bracket form. The capture is the bare
|
|
296
|
+
# `X.Y.Z`; the optional leading `v` and the surrounding brackets sit OUTSIDE
|
|
297
|
+
# the group, so heading comparison is style-agnostic. Any separator is
|
|
298
|
+
# allowed between version and date. Plus the annotated-tag `Released-at:`
|
|
299
|
+
# footer.
|
|
300
|
+
_HEADING_RE = re.compile(r"^##\s+\[?v?(\d+\.\d+\.\d+)\]?", re.MULTILINE)
|
|
301
|
+
_CHANGELOG_DATE_RE = re.compile(
|
|
302
|
+
r"^##\s+\[?v?\d+\.\d+\.\d+\]?\D+(\d{4}-\d{2}-\d{2})", re.MULTILINE)
|
|
303
|
+
_RELEASED_AT_RE = re.compile(r"Released-at:\s*(\d{4}-\d{2}-\d{2})")
|
|
304
|
+
|
|
305
|
+
# Full SemVer, including the pre-release and build-metadata tails a release
|
|
306
|
+
# tag never carries but a version MANIFEST can. The anchored `_release_re`
|
|
307
|
+
# above deliberately rejects those, because it selects a published release
|
|
308
|
+
# series; this one parses a version for ORDERING, which is a different
|
|
309
|
+
# question and needs the tail.
|
|
310
|
+
# A-2.4 / ADR-0002 precedent. Named rather than inlined so the parser, the
|
|
311
|
+
# error message, and the tests all read the same number, and so raising it
|
|
312
|
+
# is one edit rather than three.
|
|
313
|
+
VALUE_MAX_CHARS = 1024
|
|
314
|
+
|
|
315
|
+
SEMVER = re.compile(
|
|
316
|
+
r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
|
|
317
|
+
r"(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?"
|
|
318
|
+
r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def semver_key(value):
|
|
323
|
+
"""`"2.9.1"` -> a sortable key; `None` when `value` is not valid SemVer.
|
|
324
|
+
|
|
325
|
+
Non-raising per this module's mechanism-function invariant. Build
|
|
326
|
+
metadata is parsed and discarded: SemVer §10 says it is not part of
|
|
327
|
+
precedence, so `1.0.0+a` and `1.0.0+b` compare equal.
|
|
328
|
+
"""
|
|
329
|
+
if not isinstance(value, str):
|
|
330
|
+
return None
|
|
331
|
+
match = SEMVER.fullmatch(value)
|
|
332
|
+
if match is None:
|
|
333
|
+
return None
|
|
334
|
+
prerelease = match.group(4)
|
|
335
|
+
if prerelease is None:
|
|
336
|
+
pre_key = None
|
|
337
|
+
else:
|
|
338
|
+
pre_key = tuple(
|
|
339
|
+
(0, int(part)) if part.isdigit() else (1, part)
|
|
340
|
+
for part in prerelease.split(".")
|
|
341
|
+
)
|
|
342
|
+
return int(match.group(1)), int(match.group(2)), int(match.group(3)), pre_key
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def semver_greater(current, base):
|
|
346
|
+
"""True iff `current` is a STRICT SemVer advance over `base`.
|
|
347
|
+
|
|
348
|
+
The single definition of "advance" every payload-version gate shares.
|
|
349
|
+
Degrades to False when either side is unparseable, which refuses the
|
|
350
|
+
gate rather than passing it. Pre-release ordering follows SemVer §11: a
|
|
351
|
+
pre-release is LOWER than its release (`1.0.0-beta` < `1.0.0`), numeric
|
|
352
|
+
identifiers compare numerically and rank below alphanumeric ones.
|
|
353
|
+
"""
|
|
354
|
+
current_key = semver_key(current)
|
|
355
|
+
base_key = semver_key(base)
|
|
356
|
+
if current_key is None or base_key is None:
|
|
357
|
+
return False
|
|
358
|
+
if current_key[:3] != base_key[:3]:
|
|
359
|
+
return current_key[:3] > base_key[:3]
|
|
360
|
+
current_pre, base_pre = current_key[3], base_key[3]
|
|
361
|
+
if current_pre is None:
|
|
362
|
+
return base_pre is not None
|
|
363
|
+
if base_pre is None:
|
|
364
|
+
return False
|
|
365
|
+
return current_pre > base_pre
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _bare_version(tag):
|
|
369
|
+
"""`v2.6.0` / `[2.6.0]` / `2.6.0` / `myapp-v0.1.31` -> the bare SemVer.
|
|
370
|
+
|
|
371
|
+
Lets the heading match compare a tag against a bracket-style changelog
|
|
372
|
+
heading without caring about either spelling.
|
|
373
|
+
|
|
374
|
+
Anchored on the SemVer at the END rather than by stripping a known
|
|
375
|
+
prefix, so a namespaced series' tag (`<prefix>vMAJOR.MINOR.PATCH`) works
|
|
376
|
+
without the prefix being known here — stripping only a LEADING "v" is
|
|
377
|
+
right for a bare `v2.9.1` and wrong for any namespaced series, since
|
|
378
|
+
`"myapp-v0.1.31".lstrip("v")` is unchanged and never equals the `0.1.31`
|
|
379
|
+
parsed out of the heading."""
|
|
380
|
+
if not isinstance(tag, str):
|
|
381
|
+
return tag
|
|
382
|
+
text = tag.strip().strip("[]")
|
|
383
|
+
match = re.search(r"(\d+\.\d+\.\d+.*)$", text)
|
|
384
|
+
return match.group(1) if match else text.lstrip("v")
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def last_tag_select(tags, prefix):
|
|
388
|
+
"""Return the highest SemVer tag in `tags` for ONE release series,
|
|
389
|
+
excluding pre-releases (`-beta`/`-rc`/`-alpha`) via the anchored
|
|
390
|
+
`_release_re` match alone (issue #568) — no separate marker check.
|
|
391
|
+
Returns NONE_SENTINEL when the series has no release tag yet.
|
|
392
|
+
|
|
393
|
+
`prefix` selects the series and is REQUIRED — this repository's default
|
|
394
|
+
was a repo-specific fact (which series is "the" release) and could not
|
|
395
|
+
survive as a module default without smuggling that fact back in. The
|
|
396
|
+
caller supplies the prefix for the series it means, typically a value
|
|
397
|
+
loaded from a declared row (see `load_targets`).
|
|
398
|
+
|
|
399
|
+
This is the single source of `LAST_TAG`, replacing an inline grep
|
|
400
|
+
one-liner: bare `git describe --tags` returns the nearest tag by commit-
|
|
401
|
+
graph ANCESTRY, which in a multi-series repo is routinely another
|
|
402
|
+
series' tag, and silently bases an entire release on the wrong baseline.
|
|
403
|
+
|
|
404
|
+
Series isolation is a property of the ANCHORED match rather than a list
|
|
405
|
+
of exclusions to maintain: `^v` cannot match `myapp-v0.1.30`, and
|
|
406
|
+
`^myapp-v` cannot match `v2.9.1`. A new series therefore cannot leak into
|
|
407
|
+
an existing one by being forgotten in an exclusion list."""
|
|
408
|
+
best = None # ((major, minor, patch), original_tag)
|
|
409
|
+
if not isinstance(tags, (list, tuple)):
|
|
410
|
+
return NONE_SENTINEL
|
|
411
|
+
if not isinstance(prefix, str) or not prefix:
|
|
412
|
+
return NONE_SENTINEL
|
|
413
|
+
matcher = _release_re(prefix)
|
|
414
|
+
for t in tags:
|
|
415
|
+
if not isinstance(t, str):
|
|
416
|
+
continue
|
|
417
|
+
m = matcher.match(t)
|
|
418
|
+
if not m:
|
|
419
|
+
continue
|
|
420
|
+
ver = tuple(int(g) for g in m.groups())
|
|
421
|
+
if best is None or ver > best[0]:
|
|
422
|
+
best = (ver, t)
|
|
423
|
+
return best[1] if best else NONE_SENTINEL
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
NONE_SENTINEL = "<none>"
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def notes_heading_matches(notes_text, tag):
|
|
430
|
+
"""True iff the FIRST changelog heading in `notes_text` (either `## vX.Y.Z`
|
|
431
|
+
or the Keep-a-Changelog `## [X.Y.Z]` form) names the same version as
|
|
432
|
+
`tag`. A stale notes-file (whose first section is an older version)
|
|
433
|
+
returns False, so a release lane cannot publish the wrong changelog
|
|
434
|
+
section under the right tag. Missing heading or non-string input ->
|
|
435
|
+
False."""
|
|
436
|
+
if not isinstance(notes_text, str) or not isinstance(tag, str):
|
|
437
|
+
return False
|
|
438
|
+
m = _HEADING_RE.search(notes_text)
|
|
439
|
+
if not m:
|
|
440
|
+
return False
|
|
441
|
+
return m.group(1) == _bare_version(tag)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def release_dates_consistent(changelog_section, tag_message):
|
|
445
|
+
"""True iff the date in `changelog_section`'s heading (`## vX.Y.Z - DATE`
|
|
446
|
+
or `## [X.Y.Z] - DATE`) equals the `Released-at: DATE` date in
|
|
447
|
+
`tag_message`. Guards against the date being hand-typed inconsistently
|
|
448
|
+
across surfaces. Either date absent, or non-string input -> False."""
|
|
449
|
+
if not isinstance(changelog_section, str) or not isinstance(tag_message, str):
|
|
450
|
+
return False
|
|
451
|
+
cm = _CHANGELOG_DATE_RE.search(changelog_section)
|
|
452
|
+
tm = _RELEASED_AT_RE.search(tag_message)
|
|
453
|
+
if not cm or not tm:
|
|
454
|
+
return False
|
|
455
|
+
return cm.group(1) == tm.group(1)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def changelog_section(changelog_text, version):
|
|
459
|
+
"""Extract the `## [VERSION] ...` (or `## vVERSION ...`) section from
|
|
460
|
+
`changelog_text` VERBATIM -- from its own heading line up to (but not
|
|
461
|
+
including) the next `##` heading, or end of text. Returns the section
|
|
462
|
+
text with exactly one trailing newline, or `None` when no heading in
|
|
463
|
+
`changelog_text` names `version` exactly. Non-string input -> `None`.
|
|
464
|
+
|
|
465
|
+
This is the mechanical replacement for hand-copying a changelog section:
|
|
466
|
+
Phase 3's `resume_publish` path needs the SAME text Phase 1 composed,
|
|
467
|
+
but Phase 1's own scratch copy is explicitly discardable and routinely
|
|
468
|
+
gone by the time a resumed publish runs. Reading it back out of the
|
|
469
|
+
COMMITTED `$CHANGELOG` -- which Phase 1 step 7 commits before any tag
|
|
470
|
+
exists -- is reading the one permanent home of that text, not
|
|
471
|
+
re-deriving or hand-writing new notes (blind exercise run 19, HIGH-2)."""
|
|
472
|
+
if not isinstance(changelog_text, str) or not isinstance(version, str):
|
|
473
|
+
return None
|
|
474
|
+
matches = list(_HEADING_RE.finditer(changelog_text))
|
|
475
|
+
for i, m in enumerate(matches):
|
|
476
|
+
if m.group(1) != version:
|
|
477
|
+
continue
|
|
478
|
+
start = m.start()
|
|
479
|
+
end = matches[i + 1].start() if i + 1 < len(matches) else len(changelog_text)
|
|
480
|
+
return changelog_text[start:end].rstrip("\n") + "\n"
|
|
481
|
+
return None
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def classify_publish_state(tag_exists, tag_sha, head_sha, tag_version,
|
|
485
|
+
manifest_version, release_is_nondraft):
|
|
486
|
+
"""Classify a (re)publish attempt so a release lane can resume a
|
|
487
|
+
half-finished publish instead of dead-ending on 'tag exists -> STOP'.
|
|
488
|
+
Returns one of:
|
|
489
|
+
|
|
490
|
+
publish_fresh - no tag yet; the normal path.
|
|
491
|
+
already_published - the tag is at HEAD and a non-draft release exists.
|
|
492
|
+
resume_publish - tag is at HEAD and its version matches the
|
|
493
|
+
manifest, but no non-draft release exists (tag
|
|
494
|
+
pushed, release never created) -> finish publish.
|
|
495
|
+
abort_mismatch - tag points at a non-HEAD commit, or its version
|
|
496
|
+
disagrees with the manifest -> STOP, never overwrite.
|
|
497
|
+
|
|
498
|
+
Mismatch OUTRANKS publication state. An existing release used to
|
|
499
|
+
short-circuit to `already_published` before the tag was compared to
|
|
500
|
+
HEAD, so a resumed publish could silently accept a release whose tag
|
|
501
|
+
installs a different snapshot. The tag is what consumers actually fetch;
|
|
502
|
+
if it does not name this commit, nothing about the release makes the
|
|
503
|
+
state safe.
|
|
504
|
+
"""
|
|
505
|
+
if not tag_exists:
|
|
506
|
+
return "publish_fresh"
|
|
507
|
+
if tag_sha != head_sha or tag_version != manifest_version:
|
|
508
|
+
return "abort_mismatch"
|
|
509
|
+
if release_is_nondraft:
|
|
510
|
+
return "already_published"
|
|
511
|
+
return "resume_publish"
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def select_release_target_by_name(pairs, targets):
|
|
515
|
+
"""Resolve a release dispatch from NAME-KEYED inputs (A-4.2).
|
|
516
|
+
|
|
517
|
+
`pairs` are `name=value` strings — one per confirmation input, each
|
|
518
|
+
carrying the target it belongs to. `targets` is the declared register.
|
|
519
|
+
Returns the same label vocabulary as the positional resolver:
|
|
520
|
+
|
|
521
|
+
<target> exactly one non-blank value; that target
|
|
522
|
+
none nothing supplied; nothing to publish
|
|
523
|
+
multiple more than one; ambiguous, MUST be refused
|
|
524
|
+
unknown a pair names a target the declared file does not contain
|
|
525
|
+
|
|
526
|
+
WHY NAME-KEYED. The positional form aligns confirmations to `targets`
|
|
527
|
+
by INDEX, so it is correct only while the workflow's input order and
|
|
528
|
+
the declared file's row order agree. Nothing enforced that. Insert a
|
|
529
|
+
row in the middle of the declared file, or reorder the workflow's
|
|
530
|
+
inputs, and every confirmation shifts by one — a dispatch meaning to
|
|
531
|
+
publish the second declared target publishes the first instead, with a
|
|
532
|
+
`contents: write` token, and every downstream check passes because the
|
|
533
|
+
wrong release is internally consistent. Order was load-bearing and
|
|
534
|
+
invisible.
|
|
535
|
+
|
|
536
|
+
`unknown` is deliberately a LABEL, not an exception, matching this
|
|
537
|
+
module's "prints a label and never raises" contract for the release
|
|
538
|
+
lane. It is also deliberately not `none`: a caller's fail-closed
|
|
539
|
+
default arm refuses both, but only one of them means "somebody named a
|
|
540
|
+
target that does not exist", which is a declaration/workflow
|
|
541
|
+
disagreement worth reporting rather than a quiet no-op.
|
|
542
|
+
|
|
543
|
+
Blank-ish values count as not-selected, exactly as in the positional
|
|
544
|
+
form, so a stray space cannot read as a second target. A pair with no
|
|
545
|
+
`=` at all, or whose NAME is blank (e.g. `"=1.2.3"`), is ignored rather
|
|
546
|
+
than fatal — an empty workflow input can arrive as a bare name or a
|
|
547
|
+
stray leading `=` — but a pair whose NAME is non-blank and unknown is
|
|
548
|
+
reported, because that is a real mismatch rather than an empty slot.
|
|
549
|
+
|
|
550
|
+
Pure and non-raising over synthetic input.
|
|
551
|
+
"""
|
|
552
|
+
known = [t for t in targets if isinstance(t, str)] if isinstance(
|
|
553
|
+
targets, (list, tuple)) else []
|
|
554
|
+
selected = []
|
|
555
|
+
for pair in (pairs if isinstance(pairs, (list, tuple)) else []):
|
|
556
|
+
if not isinstance(pair, str) or "=" not in pair:
|
|
557
|
+
continue
|
|
558
|
+
name, _, value = pair.partition("=")
|
|
559
|
+
name = name.strip()
|
|
560
|
+
if not name:
|
|
561
|
+
# A blank name (e.g. "=1.2.3") identifies no target -- ignore
|
|
562
|
+
# it exactly like a pair with no "=" at all, rather than
|
|
563
|
+
# falling through and selecting "" as if it were a target.
|
|
564
|
+
continue
|
|
565
|
+
if name not in known:
|
|
566
|
+
return "unknown"
|
|
567
|
+
if value.strip():
|
|
568
|
+
selected.append(name)
|
|
569
|
+
if not selected:
|
|
570
|
+
return "none"
|
|
571
|
+
if len(set(selected)) > 1 or len(selected) > 1:
|
|
572
|
+
return "multiple"
|
|
573
|
+
return selected[0]
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def select_release_target(*confirmations, targets):
|
|
577
|
+
"""Resolve which single target a release dispatch selected.
|
|
578
|
+
`confirmations` are the per-target version inputs, positionally aligned
|
|
579
|
+
with `targets`. `targets` is REQUIRED — the register of releasable names
|
|
580
|
+
is a repo-specific fact and cannot survive as a module default. Returns
|
|
581
|
+
one of:
|
|
582
|
+
|
|
583
|
+
<target> - exactly one input was supplied; the matching name from
|
|
584
|
+
`targets`.
|
|
585
|
+
none - no input was supplied; there is nothing to publish.
|
|
586
|
+
multiple - more than one; the dispatch is ambiguous and MUST be
|
|
587
|
+
refused.
|
|
588
|
+
arity - `confirmations` and `targets` are not the same length.
|
|
589
|
+
|
|
590
|
+
Selection is one decision, made once, by a caller that holds no write
|
|
591
|
+
token of its own, so a dispatch that supplies more than one confirmation
|
|
592
|
+
can never start two `contents: write` publishers. Blank-ish input
|
|
593
|
+
(whitespace, non-string) counts as "not selected" so a stray space can
|
|
594
|
+
never read as a second target.
|
|
595
|
+
|
|
596
|
+
The count is checked against `targets` rather than zipped-to-shortest on
|
|
597
|
+
purpose: a caller wired for fewer targets than were actually supplied
|
|
598
|
+
would otherwise silently resolve the wrong one. `arity` is not a target
|
|
599
|
+
and is meant to match no dispatch case, so a caller's fail-closed default
|
|
600
|
+
arm refuses it - and, like every other return here, it is a LABEL rather
|
|
601
|
+
than an exception, so a caller's contract of "prints a label and never
|
|
602
|
+
raises" holds."""
|
|
603
|
+
def _selected(value):
|
|
604
|
+
return isinstance(value, str) and value.strip() != ""
|
|
605
|
+
|
|
606
|
+
if not isinstance(targets, (list, tuple)):
|
|
607
|
+
return "arity"
|
|
608
|
+
if len(confirmations) != len(targets):
|
|
609
|
+
return "arity"
|
|
610
|
+
selected = [target for target, value in zip(targets, confirmations)
|
|
611
|
+
if _selected(value)]
|
|
612
|
+
if len(selected) > 1:
|
|
613
|
+
return "multiple"
|
|
614
|
+
if selected:
|
|
615
|
+
return selected[0]
|
|
616
|
+
return "none"
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def classify_merge_readiness(check_runs, head_sha, check_name):
|
|
620
|
+
"""Classify the merge-readiness evidence for ONE exact commit. `check_runs`
|
|
621
|
+
is the `check_runs` array from a commit's check-runs API response.
|
|
622
|
+
`check_name` — the single aggregate check that means "every required job
|
|
623
|
+
for this commit concluded green" — is REQUIRED: its exact text is a
|
|
624
|
+
repo-specific fact (this codebase's own CI vocabulary) and cannot survive
|
|
625
|
+
as a module default. Returns one of:
|
|
626
|
+
|
|
627
|
+
green - the gate ran for this commit, completed, and succeeded.
|
|
628
|
+
missing - no check run by that name is present at all.
|
|
629
|
+
pending - present but not `completed` (queued / in_progress / ...).
|
|
630
|
+
sha_mismatch - a matching run reports a different `head_sha`.
|
|
631
|
+
not_successful - completed with any conclusion other than `success`
|
|
632
|
+
(failure, cancelled, skipped, timed_out, neutral, ...).
|
|
633
|
+
|
|
634
|
+
A hosted release workflow that only proves it was dispatched from a
|
|
635
|
+
protected branch shows how a commit ENTERED that branch, not that
|
|
636
|
+
post-merge evidence exists for the exact commit about to be tagged.
|
|
637
|
+
|
|
638
|
+
Fail-closed throughout: unparseable input is `missing`, and several runs
|
|
639
|
+
share one name only when a re-run is in flight - we cannot tell which
|
|
640
|
+
verdict is authoritative, so EVERY matching run must be green."""
|
|
641
|
+
if not isinstance(check_runs, list):
|
|
642
|
+
return "missing"
|
|
643
|
+
matching = [run for run in check_runs
|
|
644
|
+
if isinstance(run, dict) and run.get("name") == check_name]
|
|
645
|
+
if not matching:
|
|
646
|
+
return "missing"
|
|
647
|
+
if any(run.get("head_sha") != head_sha for run in matching):
|
|
648
|
+
return "sha_mismatch"
|
|
649
|
+
if any(run.get("status") != "completed" for run in matching):
|
|
650
|
+
return "pending"
|
|
651
|
+
if any(run.get("conclusion") != "success" for run in matching):
|
|
652
|
+
return "not_successful"
|
|
653
|
+
return "green"
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
# Conventional-Commits subject grammar: `type(optional-scope)!: subject`.
|
|
657
|
+
# The `!` sits AFTER the closing paren when a scope is present and directly
|
|
658
|
+
# after the type when it is not -- both spellings mark a breaking change,
|
|
659
|
+
# and a hand-rolled `split(':')[0]` that strips `!` before checking for it
|
|
660
|
+
# loses the marker entirely.
|
|
661
|
+
_CC_SUBJECT_RE = re.compile(r"^(?P<type>[a-zA-Z]+)(?:\((?P<scope>[^)]*)\))?(?P<bang>!)?:\s")
|
|
662
|
+
|
|
663
|
+
# `BREAKING CHANGE:` (and the hyphenated spelling the spec also permits) as
|
|
664
|
+
# a FOOTER -- at the start of its own line, never mid-sentence, so prose
|
|
665
|
+
# that merely discusses a breaking change does not silently bump a major.
|
|
666
|
+
_BREAKING_FOOTER_RE = re.compile(r"^BREAKING[ -]CHANGE:", re.MULTILINE)
|
|
667
|
+
_CHANGELOG_FOOTER_RE = re.compile(r"^CHANGELOG:", re.MULTILINE)
|
|
668
|
+
|
|
669
|
+
# Which types bump, and to what. `refactor` bumps patch and IS harvested
|
|
670
|
+
# (see the changelog-grouping rule); `docs`/`chore`/`test`/`ci` bump
|
|
671
|
+
# nothing but may still carry a harvested footer.
|
|
672
|
+
_BUMPING_TYPES = {"feat": "minor", "fix": "patch", "perf": "patch",
|
|
673
|
+
"refactor": "patch"}
|
|
674
|
+
_BUMP_RANK = {"none": 0, "patch": 1, "minor": 2, "major": 3}
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def row_assertions(row):
|
|
678
|
+
"""Which release-lane steps a row's DECLARED FIELDS turn on (A-3.1..3.5).
|
|
679
|
+
|
|
680
|
+
Returns a dict:
|
|
681
|
+
|
|
682
|
+
version_source "manifest" | "tag"
|
|
683
|
+
assert_manifest_equal bool -- A-3.1/3.2
|
|
684
|
+
manifests list -- every declared manifest path
|
|
685
|
+
rebuild str|None
|
|
686
|
+
artifacts list -- asserted clean after `rebuild` (A-3.3)
|
|
687
|
+
payload_exclude list -- removed from the window (A-3.4)
|
|
688
|
+
record_provenance bool -- A-3.5
|
|
689
|
+
skipped list -- steps that do NOT apply, each with a
|
|
690
|
+
reason, so the report can say a step
|
|
691
|
+
was SKIPPED rather than leaving the
|
|
692
|
+
reader to infer it from silence
|
|
693
|
+
|
|
694
|
+
The five criteria are not independent: "declares no manifest" IS "the
|
|
695
|
+
tag is the version source". Deriving them together makes the two
|
|
696
|
+
answers structurally incapable of disagreeing, which two separate
|
|
697
|
+
checks could not promise.
|
|
698
|
+
|
|
699
|
+
`skipped` exists because the skill's own rule is that a skipped step
|
|
700
|
+
and a forgotten step must never look alike. An optional field's absence
|
|
701
|
+
is a decision the operator made; reporting it explicitly is what makes
|
|
702
|
+
the difference visible in the release report.
|
|
703
|
+
|
|
704
|
+
This REPORTS what applies; it performs none of it. `check-manifests`
|
|
705
|
+
does the equality comparison and `run-pre-tag` runs the commands.
|
|
706
|
+
Folding execution in here would make one function both planner and
|
|
707
|
+
actor, and this lane's history is that the planner/actor seam is
|
|
708
|
+
exactly where its defects have lived.
|
|
709
|
+
|
|
710
|
+
Pure and non-raising: a non-dict yields the all-absent shape.
|
|
711
|
+
"""
|
|
712
|
+
if not isinstance(row, dict):
|
|
713
|
+
row = {}
|
|
714
|
+
|
|
715
|
+
def _list(key):
|
|
716
|
+
value = row.get(key)
|
|
717
|
+
if isinstance(value, list):
|
|
718
|
+
return [v for v in value if isinstance(v, str) and v.strip()]
|
|
719
|
+
return [value] if isinstance(value, str) and value.strip() else []
|
|
720
|
+
|
|
721
|
+
manifests = _list("manifest")
|
|
722
|
+
artifacts = _list("artifacts")
|
|
723
|
+
excludes = _list("payload_exclude")
|
|
724
|
+
rebuild = row.get("rebuild")
|
|
725
|
+
rebuild = rebuild if isinstance(rebuild, str) and rebuild.strip() else None
|
|
726
|
+
provenance = row.get("provenance_manifest")
|
|
727
|
+
provenance = provenance if isinstance(provenance, str) and provenance.strip() else None
|
|
728
|
+
|
|
729
|
+
skipped = []
|
|
730
|
+
if not manifests:
|
|
731
|
+
skipped.append(("manifest-equality",
|
|
732
|
+
"the row declares no manifest, so the derived tag is "
|
|
733
|
+
"the version source and there is nothing to compare"))
|
|
734
|
+
if rebuild is None:
|
|
735
|
+
skipped.append(("rebuild",
|
|
736
|
+
"the row declares no rebuild command"))
|
|
737
|
+
if not artifacts:
|
|
738
|
+
skipped.append(("artifacts-clean",
|
|
739
|
+
"the row declares no artifacts to assert clean"))
|
|
740
|
+
if not excludes:
|
|
741
|
+
skipped.append(("payload-exclude",
|
|
742
|
+
"the row excludes nothing from its payload"))
|
|
743
|
+
if provenance is None:
|
|
744
|
+
skipped.append(("provenance-recording",
|
|
745
|
+
"the row declares no provenance-manifest"))
|
|
746
|
+
|
|
747
|
+
return {
|
|
748
|
+
"version_source": "manifest" if manifests else "tag",
|
|
749
|
+
"assert_manifest_equal": bool(manifests),
|
|
750
|
+
"manifests": manifests,
|
|
751
|
+
"rebuild": rebuild,
|
|
752
|
+
"artifacts": artifacts,
|
|
753
|
+
"payload_exclude": excludes,
|
|
754
|
+
"record_provenance": provenance is not None,
|
|
755
|
+
"provenance_manifest": provenance,
|
|
756
|
+
"skipped": skipped,
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def window_excludes_payload_paths(paths, payload, payload_exclude):
|
|
761
|
+
"""`paths` filtered to `payload`, minus anything under an exclude (A-3.4).
|
|
762
|
+
|
|
763
|
+
Directory-prefix semantics on normalized separators, so `tools` never
|
|
764
|
+
matches `toolsmith/` — a substring test would silently drop a sibling
|
|
765
|
+
directory whose name merely starts the same way.
|
|
766
|
+
|
|
767
|
+
Pure over synthetic input; non-raising.
|
|
768
|
+
"""
|
|
769
|
+
def _norm(value):
|
|
770
|
+
return str(value).replace("\\", "/").strip().strip("/") if value else ""
|
|
771
|
+
|
|
772
|
+
scope = _norm(payload)
|
|
773
|
+
excludes = [_norm(e) for e in (payload_exclude or []) if _norm(e)]
|
|
774
|
+
kept = []
|
|
775
|
+
for path in (paths or []):
|
|
776
|
+
rel = _norm(path)
|
|
777
|
+
if not rel:
|
|
778
|
+
continue
|
|
779
|
+
if scope and scope != "." and not (rel == scope or rel.startswith(scope + "/")):
|
|
780
|
+
continue
|
|
781
|
+
if any(rel == ex or rel.startswith(ex + "/") for ex in excludes):
|
|
782
|
+
continue
|
|
783
|
+
kept.append(path)
|
|
784
|
+
return kept
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def provenance_trigger_paths(rows):
|
|
788
|
+
"""Every path a declared row REFERENCES, sorted and de-duplicated:
|
|
789
|
+
each `manifest`, each `changelog`, each `artifacts`, and each
|
|
790
|
+
`generated_manifest` entry (A-5.6).
|
|
791
|
+
|
|
792
|
+
These are the drift triggers for `.codearbiter/.provenance/release-
|
|
793
|
+
targets.json`. The point is that the declaration and the files it names
|
|
794
|
+
move together: if a manifest path is renamed and the row is not
|
|
795
|
+
updated, the row now points at nothing, and the release lane resolves
|
|
796
|
+
it to a missing file at the worst possible moment.
|
|
797
|
+
|
|
798
|
+
Deliberately NOT a CONTEXT.md-Scope trigger. `compute_drift` compares
|
|
799
|
+
whole-file git oids with no section-level machinery, so a Scope trigger
|
|
800
|
+
would fire on an unrelated `stage:` flip AND stay silent on the thing
|
|
801
|
+
that matters -- a manifest path moving. Wrong in both directions.
|
|
802
|
+
|
|
803
|
+
`payload` and `payload-exclude` are excluded on purpose: they are
|
|
804
|
+
directory scopes, not files, and hashing a directory path is not
|
|
805
|
+
something `batch_hash` can do. `pre-tag`/`rebuild`/`generate` are
|
|
806
|
+
excluded too -- they are commands, not paths, and a command string is
|
|
807
|
+
not a file whose oid can drift.
|
|
808
|
+
|
|
809
|
+
Routine per-release version bumps WILL trip these triggers, by design:
|
|
810
|
+
a release edits its manifest, which is the point of watching it.
|
|
811
|
+
`heal_worklist` re-baselines them in the same release commit. That is
|
|
812
|
+
recorded here so a later maintainer reads it as intended behaviour
|
|
813
|
+
rather than deleting the triggers to quiet the noise.
|
|
814
|
+
|
|
815
|
+
Pure and non-raising over synthetic input.
|
|
816
|
+
"""
|
|
817
|
+
paths = set()
|
|
818
|
+
if not isinstance(rows, (list, tuple)):
|
|
819
|
+
return []
|
|
820
|
+
for row in rows:
|
|
821
|
+
if not isinstance(row, dict):
|
|
822
|
+
continue
|
|
823
|
+
for key in ("manifest", "changelog", "artifacts", "generated_manifest"):
|
|
824
|
+
value = row.get(key)
|
|
825
|
+
for item in (value if isinstance(value, list) else [value]):
|
|
826
|
+
if isinstance(item, str) and item.strip():
|
|
827
|
+
paths.add(item.strip())
|
|
828
|
+
return sorted(paths)
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def _manifest_version(path):
|
|
832
|
+
"""The `version` a manifest declares, or `None` when it cannot be read
|
|
833
|
+
or parsed. Dispatches on EXTENSION, because the declared-file grammar
|
|
834
|
+
permits any format and one reader cannot serve them all -- applying a
|
|
835
|
+
JSON parser to a `pyproject.toml` raises rather than answering.
|
|
836
|
+
|
|
837
|
+
`None` means "no comparison happened", which callers MUST keep
|
|
838
|
+
distinct from "the versions differ". Non-raising, per this module's
|
|
839
|
+
mechanism invariant.
|
|
840
|
+
"""
|
|
841
|
+
lower = str(path).lower()
|
|
842
|
+
try:
|
|
843
|
+
if lower.endswith(".json"):
|
|
844
|
+
import json
|
|
845
|
+
with open(path, encoding="utf-8") as fh:
|
|
846
|
+
value = json.load(fh).get("version")
|
|
847
|
+
elif lower.endswith(".toml"):
|
|
848
|
+
try:
|
|
849
|
+
import tomllib
|
|
850
|
+
except ImportError: # pragma: no cover - Python < 3.11
|
|
851
|
+
return None
|
|
852
|
+
with open(path, "rb") as fh:
|
|
853
|
+
data = tomllib.load(fh)
|
|
854
|
+
value = data.get("project", {}).get("version")
|
|
855
|
+
if value is None:
|
|
856
|
+
value = data.get("tool", {}).get("poetry", {}).get("version")
|
|
857
|
+
else:
|
|
858
|
+
return None
|
|
859
|
+
except (OSError, ValueError, AttributeError, TypeError):
|
|
860
|
+
return None
|
|
861
|
+
return value if isinstance(value, str) else None
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def classify_commit(subject, body=""):
|
|
865
|
+
"""One commit -> `{type, scope, breaking, bump, has_changelog_footer}`.
|
|
866
|
+
|
|
867
|
+
Pure and non-raising over synthetic input, per this module's mechanism
|
|
868
|
+
invariant: a subject that is not Conventional-Commits at all yields
|
|
869
|
+
`type=""`, `bump="none"` -- an unparseable subject cannot bump, which
|
|
870
|
+
is the safe direction.
|
|
871
|
+
|
|
872
|
+
Exists because this was the last mechanical step in the release lane
|
|
873
|
+
with no helper behind it, on the check the hard rules mark MUST-level
|
|
874
|
+
(adversarial review run 11). An exercising agent wrote
|
|
875
|
+
`subject.split('(')[0].split(':')[0].rstrip('!')` as its own reading;
|
|
876
|
+
that strips the `!` BEFORE anything checks for it, so `feat!:` and
|
|
877
|
+
`feat(api)!:` both classify as an ordinary `feat` and a major release
|
|
878
|
+
silently becomes a minor one. Two operators writing two parses produce
|
|
879
|
+
two different gates on the check that decides whether a release may
|
|
880
|
+
proceed.
|
|
881
|
+
"""
|
|
882
|
+
if not isinstance(subject, str):
|
|
883
|
+
subject = ""
|
|
884
|
+
if not isinstance(body, str):
|
|
885
|
+
body = ""
|
|
886
|
+
match = _CC_SUBJECT_RE.match(subject)
|
|
887
|
+
if match is None:
|
|
888
|
+
return {"type": "", "scope": "", "breaking": False, "bump": "none",
|
|
889
|
+
"has_changelog_footer": bool(_CHANGELOG_FOOTER_RE.search(body))}
|
|
890
|
+
ctype = match.group("type").lower()
|
|
891
|
+
breaking = bool(match.group("bang")) or bool(_BREAKING_FOOTER_RE.search(body))
|
|
892
|
+
if breaking:
|
|
893
|
+
bump = "major"
|
|
894
|
+
else:
|
|
895
|
+
bump = _BUMPING_TYPES.get(ctype, "none")
|
|
896
|
+
return {
|
|
897
|
+
"type": ctype,
|
|
898
|
+
"scope": match.group("scope") or "",
|
|
899
|
+
"breaking": breaking,
|
|
900
|
+
"bump": bump,
|
|
901
|
+
"has_changelog_footer": bool(_CHANGELOG_FOOTER_RE.search(body)),
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def classify_window(commits):
|
|
906
|
+
"""`[{sha, subject, body}, ...]` -> the whole window's verdict:
|
|
907
|
+
`{bump, commits: [...], missing_footer: [...]}`.
|
|
908
|
+
|
|
909
|
+
`bump` is the highest precedence across the window (`major` > `minor`
|
|
910
|
+
> `patch` > `none`). `missing_footer` lists every BUMPING commit with
|
|
911
|
+
no `CHANGELOG:` footer -- the exact set Phase 1 step 3 turns into
|
|
912
|
+
`[NEEDS-TRIAGE]` lines, in window order, so the report's shape is not
|
|
913
|
+
re-derived per release either.
|
|
914
|
+
|
|
915
|
+
A breaking commit bumps major regardless of type, so a `chore!:` is
|
|
916
|
+
reported as bumping and IS subject to the footer rule -- a hand-rolled
|
|
917
|
+
type-list check misses that, because `chore` is not in the bumping
|
|
918
|
+
list.
|
|
919
|
+
"""
|
|
920
|
+
rows = []
|
|
921
|
+
if not isinstance(commits, (list, tuple)):
|
|
922
|
+
commits = []
|
|
923
|
+
for entry in commits:
|
|
924
|
+
if not isinstance(entry, dict):
|
|
925
|
+
continue
|
|
926
|
+
verdict = classify_commit(entry.get("subject", ""), entry.get("body", ""))
|
|
927
|
+
verdict["sha"] = str(entry.get("sha", ""))
|
|
928
|
+
verdict["subject"] = str(entry.get("subject", ""))
|
|
929
|
+
rows.append(verdict)
|
|
930
|
+
bump = "none"
|
|
931
|
+
for row in rows:
|
|
932
|
+
if _BUMP_RANK[row["bump"]] > _BUMP_RANK[bump]:
|
|
933
|
+
bump = row["bump"]
|
|
934
|
+
missing = [r for r in rows
|
|
935
|
+
if r["bump"] != "none" and not r["has_changelog_footer"]]
|
|
936
|
+
return {"bump": bump, "commits": rows, "missing_footer": missing}
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def parse_window_log(text):
|
|
940
|
+
"""`git log --pretty=format:%H%n%s%n%b%n----` output -> the
|
|
941
|
+
`[{sha, subject, body}]` shape `classify_window` consumes.
|
|
942
|
+
|
|
943
|
+
The separator is the one the release skill already prescribes, so the
|
|
944
|
+
prose and this parser cannot drift apart into two different readings
|
|
945
|
+
of the same command's output.
|
|
946
|
+
"""
|
|
947
|
+
if not isinstance(text, str):
|
|
948
|
+
return []
|
|
949
|
+
entries = []
|
|
950
|
+
for chunk in text.split("\n----"):
|
|
951
|
+
lines = [ln for ln in chunk.split("\n")]
|
|
952
|
+
while lines and not lines[0].strip():
|
|
953
|
+
lines.pop(0)
|
|
954
|
+
if not lines or not lines[0].strip():
|
|
955
|
+
continue
|
|
956
|
+
sha = lines[0].strip()
|
|
957
|
+
subject = lines[1] if len(lines) > 1 else ""
|
|
958
|
+
body = "\n".join(lines[2:])
|
|
959
|
+
entries.append({"sha": sha, "subject": subject, "body": body})
|
|
960
|
+
return entries
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def first_release_baseline(adoption_log_text):
|
|
964
|
+
"""The commit sha that ADOPTED codeArbiter -- the one that added
|
|
965
|
+
`.codearbiter/CONTEXT.md` -- from `git log --diff-filter=A
|
|
966
|
+
--format=%H -- .codearbiter/CONTEXT.md` output. `""` when the file was
|
|
967
|
+
never added (no adoption commit, or a repo that never onboarded).
|
|
968
|
+
|
|
969
|
+
A-5.5. On a project's FIRST release the tag series is empty, so
|
|
970
|
+
`LAST_TAG` is `<none>` and the window is the entire history. Every
|
|
971
|
+
pre-adoption `feat`/`fix`/`perf`/`refactor` commit therefore enters
|
|
972
|
+
the footer-completeness check -- and none of them carries a
|
|
973
|
+
`CHANGELOG:` footer, because they predate the convention entirely. The
|
|
974
|
+
lane would emit one `[NEEDS-TRIAGE]` line per such commit and STOP: a
|
|
975
|
+
repository adopting at its 500th commit gets a 500-line block on a
|
|
976
|
+
release where nothing is actually wrong. That is a hard block on a
|
|
977
|
+
legitimate release, and it lands on precisely the population the
|
|
978
|
+
Back-fill lane exists to serve.
|
|
979
|
+
|
|
980
|
+
The adoption commit is the honest boundary: commits before it were
|
|
981
|
+
authored under no changelog convention and cannot retroactively
|
|
982
|
+
acquire footers, while commits after it were authored under one and
|
|
983
|
+
SHOULD be held to it.
|
|
984
|
+
|
|
985
|
+
Non-raising and pure over text, per this module's mechanism-function
|
|
986
|
+
invariant. Takes the LAST line when several are present: `git log`
|
|
987
|
+
prints newest-first, so the last line is the EARLIEST addition, which
|
|
988
|
+
is the real adoption. (A file added, deleted, and re-added produces
|
|
989
|
+
two entries -- taking the newest would silently treat a re-adoption as
|
|
990
|
+
the boundary and drop every commit between the two, which is the same
|
|
991
|
+
class of quiet history loss this function exists to prevent.)
|
|
992
|
+
"""
|
|
993
|
+
if not isinstance(adoption_log_text, str):
|
|
994
|
+
return ""
|
|
995
|
+
shas = [line.strip().split()[0] for line in adoption_log_text.splitlines()
|
|
996
|
+
if line.strip()]
|
|
997
|
+
if not shas:
|
|
998
|
+
return ""
|
|
999
|
+
candidate = shas[-1]
|
|
1000
|
+
return candidate if re.fullmatch(r"[0-9a-fA-F]{7,64}", candidate) else ""
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def peel_tag(ls_remote_text, tag):
|
|
1004
|
+
"""Resolve the COMMIT a remote tag names, from `git ls-remote --tags`
|
|
1005
|
+
output. Returns "" when the tag is absent.
|
|
1006
|
+
|
|
1007
|
+
An annotated tag's own object id is not the commit it points at; the
|
|
1008
|
+
peeled `refs/tags/<tag>^{}` line is. A workflow that treats any remote
|
|
1009
|
+
hit as a resumable publish without comparing the tag to the current
|
|
1010
|
+
commit can accept a stale tag as a successful rerun and publish for the
|
|
1011
|
+
wrong commit. Matching is exact on the ref name, so `v2.6.0` is never
|
|
1012
|
+
resolved from `v2.6.0-beta.1`."""
|
|
1013
|
+
if not isinstance(ls_remote_text, str) or not isinstance(tag, str):
|
|
1014
|
+
return ""
|
|
1015
|
+
direct = peeled = ""
|
|
1016
|
+
ref = f"refs/tags/{tag}"
|
|
1017
|
+
for line in ls_remote_text.splitlines():
|
|
1018
|
+
parts = line.split()
|
|
1019
|
+
if len(parts) != 2:
|
|
1020
|
+
continue
|
|
1021
|
+
sha, name = parts
|
|
1022
|
+
if name == ref + "^{}":
|
|
1023
|
+
peeled = sha
|
|
1024
|
+
elif name == ref:
|
|
1025
|
+
direct = sha
|
|
1026
|
+
return peeled or direct
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
_PLAIN_SEMVER_RE = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$")
|
|
1030
|
+
_BUMP_WORDS = ("major", "minor", "patch")
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def apply_bump(base, word):
|
|
1034
|
+
"""Apply a classify-window bump `word` to `base`, a PLAIN SemVer string
|
|
1035
|
+
(`MAJOR.MINOR.PATCH`, no pre-release or build metadata), and return the
|
|
1036
|
+
bumped SemVer string. `major`: `X+1.0.0`. `minor`: `X.Y+1.0`. `patch`:
|
|
1037
|
+
`X.Y.Z+1`.
|
|
1038
|
+
|
|
1039
|
+
Returns `None` -- never raises, per this module's mechanism-function
|
|
1040
|
+
invariant -- when `base` is not plain SemVer (reusing the same
|
|
1041
|
+
no-leading-zero grammar `SEMVER` anchors its own major/minor/patch
|
|
1042
|
+
groups with) or `word` is not exactly one of the three bumping words.
|
|
1043
|
+
This deliberately EXCLUDES `word == "none"`: a caller must never apply a
|
|
1044
|
+
non-bump, and passing `none` here is refused rather than echoing `base`
|
|
1045
|
+
back unchanged, which would silently look like a successful (if inert)
|
|
1046
|
+
bump.
|
|
1047
|
+
|
|
1048
|
+
#585 MEDIUM-3: the bump arithmetic was the single unmechanized judgment
|
|
1049
|
+
left in the release lane -- step 4 had the operator "apply the step-2
|
|
1050
|
+
bump to `$BASE_VERSION`" by hand, and nothing downstream re-derived it,
|
|
1051
|
+
so the hard rule "a `feat` in the window cannot ship as a `patch`" had
|
|
1052
|
+
no enforcement anywhere. `apply-bump` (the CLI subcommand below) is the
|
|
1053
|
+
sanctioned way to do this arithmetic instead of by eye.
|
|
1054
|
+
"""
|
|
1055
|
+
if not isinstance(base, str) or not isinstance(word, str):
|
|
1056
|
+
return None
|
|
1057
|
+
match = _PLAIN_SEMVER_RE.fullmatch(base.strip())
|
|
1058
|
+
if match is None or word not in _BUMP_WORDS:
|
|
1059
|
+
return None
|
|
1060
|
+
major, minor, patch = (int(g) for g in match.groups())
|
|
1061
|
+
if word == "major":
|
|
1062
|
+
return f"{major + 1}.0.0"
|
|
1063
|
+
if word == "minor":
|
|
1064
|
+
return f"{major}.{minor + 1}.0"
|
|
1065
|
+
return f"{major}.{minor}.{patch + 1}"
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
# Exit codes a POSIX shell uses to report that a command's INTERPRETER OR
|
|
1069
|
+
# PROGRAM ITSELF could not be located or executed, as distinct from the
|
|
1070
|
+
# program running and reporting a failure: 127 is POSIX "command not
|
|
1071
|
+
# found", 126 is POSIX "found but not executable" (e.g. missing execute
|
|
1072
|
+
# bit, or a script with a shebang naming an interpreter that itself is
|
|
1073
|
+
# missing), and 9009 is the code Windows' `cmd.exe` is documented to report
|
|
1074
|
+
# for an unresolvable command in several invocation shapes (batch-file/npm
|
|
1075
|
+
# wrapper contexts). #585 MEDIUM-2 / #584 MEDIUM-1: this is a BEST-EFFORT,
|
|
1076
|
+
# POSIX-reliable signal -- a bare `subprocess.run(cmd, shell=True)` "is not
|
|
1077
|
+
# recognized" error under a raw `cmd.exe /c` was measured on a Windows 11
|
|
1078
|
+
# host to return 1, indistinguishable there from an ordinary command
|
|
1079
|
+
# failure, so this set does not catch every Windows "not found" shape. It
|
|
1080
|
+
# reliably catches the POSIX shape this campaign's issues were filed
|
|
1081
|
+
# against (a row hardcoding `python3` on a host that has only `python`),
|
|
1082
|
+
# and any Windows invocation that does surface 9009.
|
|
1083
|
+
_COULD_NOT_RUN_CODES = frozenset({126, 127, 9009})
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
def _could_not_run(returncode):
|
|
1087
|
+
"""True iff `returncode` is one of the exit codes above that means the
|
|
1088
|
+
declared command's interpreter or program itself was never located --
|
|
1089
|
+
"could not run", never "ran and disagreed" (house rule: the two must
|
|
1090
|
+
never be folded together)."""
|
|
1091
|
+
return returncode in _COULD_NOT_RUN_CODES
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
# #602: `run-pre-tag` dispatches every declared `pre-tag` row with
|
|
1095
|
+
# `subprocess.run(command, shell=True, …)`. On POSIX that already runs
|
|
1096
|
+
# through a POSIX shell (`/bin/sh`), so a row spelled `"$PY"` (the #601
|
|
1097
|
+
# convention) already expands there. On WINDOWS, `shell=True` unconditionally
|
|
1098
|
+
# dispatches through `cmd.exe` -- a CPython `subprocess` behavior, not host
|
|
1099
|
+
# configuration -- and `cmd.exe` performs no `$VAR` expansion, so `"$PY"`
|
|
1100
|
+
# passes through as the literal, unrecognized token and fails with an exit
|
|
1101
|
+
# code outside the could-not-run set, misdiagnosed as drift (exit 5). This
|
|
1102
|
+
# was MEASURED, not hypothesized (issue #602).
|
|
1103
|
+
#
|
|
1104
|
+
# `_resolve_posix_shell()` finds a POSIX-compatible shell (Git for Windows'
|
|
1105
|
+
# own `bash.exe`, an MSYS2 build that runs Windows-native binaries directly
|
|
1106
|
+
# -- NOT WSL's `bash.exe`, which runs inside a separate Linux filesystem and
|
|
1107
|
+
# cannot see a Windows path like `cwd=project_root` or a Windows `PY`
|
|
1108
|
+
# interpreter path the same way) so the caller can dispatch a declared row
|
|
1109
|
+
# through it instead of `cmd.exe`.
|
|
1110
|
+
def _resolve_posix_shell():
|
|
1111
|
+
"""Resolve an absolute path to a POSIX-compatible shell for dispatching
|
|
1112
|
+
declared `pre-tag` rows, or None if none could be found.
|
|
1113
|
+
|
|
1114
|
+
Returns None unconditionally on a non-Windows host: `shell=True` already
|
|
1115
|
+
dispatches through a POSIX shell there (`/bin/sh`), so no resolution is
|
|
1116
|
+
needed and the caller keeps its existing `shell=True` dispatch unchanged.
|
|
1117
|
+
|
|
1118
|
+
On Windows, resolution order:
|
|
1119
|
+
1. Relative to `git --exec-path` (via the trusted `git_executable()`
|
|
1120
|
+
seam). Git for Windows ships its own `bash.exe` at a fixed position
|
|
1121
|
+
relative to the install root -- `<root>/bin/bash.exe` -- and
|
|
1122
|
+
`git --exec-path` reports a path under
|
|
1123
|
+
`<root>/mingw64/libexec/git-core`, so `../../../bin/bash.exe`
|
|
1124
|
+
relative to that finds it deterministically, tied to the SAME git
|
|
1125
|
+
install this process already trusts, even when bash was never
|
|
1126
|
+
added to PATH (Git for Windows' installer does not add it by
|
|
1127
|
+
default). This is the PRIMARY strategy because it cannot resolve
|
|
1128
|
+
to a different program entirely the way a PATH search can.
|
|
1129
|
+
2. `shutil.which("bash")`, as a fallback for a non-standard Git for
|
|
1130
|
+
Windows layout -- but a hit under `system32` or `WindowsApps` is
|
|
1131
|
+
rejected: both are where Windows' own WSL launcher stub installs a
|
|
1132
|
+
same-named `bash.exe` that runs inside a separate Linux
|
|
1133
|
+
filesystem, not a POSIX-on-Windows shell, and accepting it would
|
|
1134
|
+
swap one silent misdispatch for another.
|
|
1135
|
+
|
|
1136
|
+
Never raises: any resolution failure (git not found, `--exec-path`
|
|
1137
|
+
failing, a filesystem error) is treated as "not found", matching every
|
|
1138
|
+
other mechanism function in this module's "never raise on malformed
|
|
1139
|
+
input" convention -- the CALLER decides what a None means (here: report
|
|
1140
|
+
a distinct could-not-run exit rather than silently falling back to
|
|
1141
|
+
`cmd.exe`, the exact misdiagnosis this fix closes).
|
|
1142
|
+
"""
|
|
1143
|
+
if os.name != "nt":
|
|
1144
|
+
return None
|
|
1145
|
+
try:
|
|
1146
|
+
exec_path = subprocess.run(
|
|
1147
|
+
[git_executable(), "--exec-path"],
|
|
1148
|
+
capture_output=True, text=True).stdout.strip()
|
|
1149
|
+
except OSError:
|
|
1150
|
+
exec_path = ""
|
|
1151
|
+
if exec_path:
|
|
1152
|
+
candidate = os.path.normpath(
|
|
1153
|
+
os.path.join(exec_path, "..", "..", "..", "bin", "bash.exe"))
|
|
1154
|
+
if os.path.isfile(candidate):
|
|
1155
|
+
return candidate
|
|
1156
|
+
found = shutil.which("bash")
|
|
1157
|
+
if found:
|
|
1158
|
+
normalized = os.path.normpath(found).lower()
|
|
1159
|
+
if "\\system32\\" not in normalized and "\\windowsapps\\" not in normalized:
|
|
1160
|
+
return found
|
|
1161
|
+
return None
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
# --------------------------------------------------------------------------- #
|
|
1165
|
+
# Declared-target-file parser. Grammar: per-target `[name]` sub-blocks of
|
|
1166
|
+
# `key: value` lines inside the HTML-comment delimiter convention this
|
|
1167
|
+
# codebase's path-scope reader (`_scopelib.py`) already uses — reused here
|
|
1168
|
+
# rather than inventing a second delimiter syntax.
|
|
1169
|
+
# --------------------------------------------------------------------------- #
|
|
1170
|
+
|
|
1171
|
+
_OPEN_RE = re.compile(r"<!--\s*release-targets\s*-->")
|
|
1172
|
+
_CLOSE_RE = re.compile(r"<!--\s*/release-targets\s*-->")
|
|
1173
|
+
_HEADER_RE = re.compile(r"^\[([A-Za-z0-9._-]+)\]$")
|
|
1174
|
+
|
|
1175
|
+
# A key not in _LIST_KEYS is scalar: exactly one value per target block, a
|
|
1176
|
+
# second occurrence of the same key within one block is a DuplicateKeyError.
|
|
1177
|
+
# List keys repeat by design and preserve declaration order.
|
|
1178
|
+
_LIST_KEYS = frozenset({
|
|
1179
|
+
"manifest", "artifacts", "pre-tag", "payload-exclude", "generated-manifest",
|
|
1180
|
+
})
|
|
1181
|
+
_BOOLEAN_KEYS = frozenset({"latest-eligible"})
|
|
1182
|
+
_REQUIRED_KEYS = ("prefix", "changelog", "payload")
|
|
1183
|
+
|
|
1184
|
+
# Grammar key -> row field name (rows use `_` throughout, the grammar uses
|
|
1185
|
+
# `-`, matching this codebase's `key: value` / `snake_case` convention split).
|
|
1186
|
+
_KEY_FIELD = {
|
|
1187
|
+
"prefix": "prefix",
|
|
1188
|
+
"changelog": "changelog",
|
|
1189
|
+
"payload": "payload",
|
|
1190
|
+
"rebuild": "rebuild",
|
|
1191
|
+
"provenance-manifest": "provenance_manifest",
|
|
1192
|
+
"latest-eligible": "latest_eligible",
|
|
1193
|
+
"manifest": "manifest",
|
|
1194
|
+
"artifacts": "artifacts",
|
|
1195
|
+
"pre-tag": "pre_tag",
|
|
1196
|
+
"payload-exclude": "payload_exclude",
|
|
1197
|
+
# HIGH-3 (adversarial review 2026-07-31): a `manifest` path that is
|
|
1198
|
+
# GENERATED output (regenerated by a build/packaging step, never
|
|
1199
|
+
# hand-written) is otherwise indistinguishable from an ordinary
|
|
1200
|
+
# hand-edited manifest, so nothing stops the generic "update every
|
|
1201
|
+
# manifest path to the derived version" instruction from hand-editing
|
|
1202
|
+
# generated output. `generated-manifest` names the subset of `manifest`
|
|
1203
|
+
# entries that are generated.
|
|
1204
|
+
#
|
|
1205
|
+
# This is TWO keys, not the one-key "marker on the manifest entry" the
|
|
1206
|
+
# finding also offered as an option. `rebuild` already exists as an
|
|
1207
|
+
# operator-authored, mutating command this lane runs, so pairing
|
|
1208
|
+
# `generated-manifest` with its own `generate` command (mirroring the
|
|
1209
|
+
# existing `rebuild`/`artifacts` shape) creates no NEW trust class on
|
|
1210
|
+
# this file — it reuses one already present. `rebuild` itself cannot be
|
|
1211
|
+
# reused for this: its contract is "run it, then `git diff --quiet` the
|
|
1212
|
+
# result" (nothing should have changed beyond what was already
|
|
1213
|
+
# committed), whereas regenerating a version manifest legitimately
|
|
1214
|
+
# CHANGES the file on every release. Folding a version-bump
|
|
1215
|
+
# regeneration into `rebuild`'s clean-tree contract would make every
|
|
1216
|
+
# release's own `generate` step look like build drift. A single-key
|
|
1217
|
+
# marker form (e.g. a `[generated]` suffix on the `manifest:` value
|
|
1218
|
+
# itself) would need its own value-shape parsing distinct from every
|
|
1219
|
+
# other list key's plain-path values for no offsetting benefit, since a
|
|
1220
|
+
# command is exactly what a caller needs to run anyway.
|
|
1221
|
+
#
|
|
1222
|
+
# `generate` widens this file's own executable-input surface by one key
|
|
1223
|
+
# while the H-22 protected-state enrolment of this file (T-33) and its
|
|
1224
|
+
# security-controls.md boundary entry (T-32) are both still PENDING on
|
|
1225
|
+
# this repo's own plan — declared here, not smuggled: see
|
|
1226
|
+
# `.codearbiter/release-targets.md`'s own header note.
|
|
1227
|
+
#
|
|
1228
|
+
# Declarative only — this module does not cross-validate that a
|
|
1229
|
+
# `generated-manifest` entry also appears in `manifest`, the same way it
|
|
1230
|
+
# does not validate `payload-exclude` against `payload`; the release
|
|
1231
|
+
# skill is what acts on the relationship.
|
|
1232
|
+
"generated-manifest": "generated_manifest",
|
|
1233
|
+
"generate": "generate",
|
|
1234
|
+
# M-1 (adversarial review 2026-07-31): the Phase-3 Release title names a
|
|
1235
|
+
# "display name" no grammar key ever supplied, so a consumer had no way
|
|
1236
|
+
# to declare one and this repo's own next release would silently title
|
|
1237
|
+
# itself from `$TARGET` (`ca`) rather than its established display name
|
|
1238
|
+
# (`codeArbiter`). Optional; the skill falls back to `$TARGET` itself
|
|
1239
|
+
# when a row declares none.
|
|
1240
|
+
"display-name": "display_name",
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
def _new_row(name):
|
|
1245
|
+
return {
|
|
1246
|
+
"target": name,
|
|
1247
|
+
"prefix": None,
|
|
1248
|
+
"manifest": [],
|
|
1249
|
+
"changelog": None,
|
|
1250
|
+
"payload": None,
|
|
1251
|
+
"payload_exclude": [],
|
|
1252
|
+
"rebuild": None,
|
|
1253
|
+
"artifacts": [],
|
|
1254
|
+
"provenance_manifest": None,
|
|
1255
|
+
"pre_tag": [],
|
|
1256
|
+
"latest_eligible": False,
|
|
1257
|
+
"generated_manifest": [],
|
|
1258
|
+
"generate": None,
|
|
1259
|
+
"display_name": None,
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
|
|
1263
|
+
def _finish_row(row):
|
|
1264
|
+
# A parsed key line always assigns a string (`.strip()`-ed at read time),
|
|
1265
|
+
# so `prefix:` with no value yields `''`, never `None` — an `is None`
|
|
1266
|
+
# check alone lets that empty declaration pass as "present". Treat a
|
|
1267
|
+
# blank or whitespace-only value as missing too, so a typo'd required key
|
|
1268
|
+
# cannot silently become a first-release baseline downstream (`''` fed to
|
|
1269
|
+
# `last_tag_select` resolves the NONE_SENTINEL).
|
|
1270
|
+
missing = [key for key in _REQUIRED_KEYS
|
|
1271
|
+
if (row[_KEY_FIELD[key]] or "").strip() == ""]
|
|
1272
|
+
if missing:
|
|
1273
|
+
raise MissingRequiredKeyError(
|
|
1274
|
+
f"target {row['target']!r} is missing required key(s): "
|
|
1275
|
+
+ ", ".join(missing)
|
|
1276
|
+
)
|
|
1277
|
+
|
|
1278
|
+
|
|
1279
|
+
def parse_release_targets(text):
|
|
1280
|
+
"""Parse the declared-target-file GRAMMAR from `text` (already-read file
|
|
1281
|
+
content) into a list of row dicts, one per `[target]` block, each
|
|
1282
|
+
carrying: target, prefix, manifest (list), changelog, payload,
|
|
1283
|
+
payload_exclude (list), rebuild, artifacts (list), provenance_manifest,
|
|
1284
|
+
pre_tag (list), latest_eligible (bool), generated_manifest (list),
|
|
1285
|
+
generate, display_name.
|
|
1286
|
+
|
|
1287
|
+
Pure — no file I/O — so it is testable with synthetic input; `load_targets`
|
|
1288
|
+
is the one function that touches the filesystem.
|
|
1289
|
+
|
|
1290
|
+
Every parser-contract violation raises its own ReleaseTargetsError
|
|
1291
|
+
subclass; never a silent default, never a partial parse. See the module
|
|
1292
|
+
docstring for the full list of declared exceptions.
|
|
1293
|
+
|
|
1294
|
+
Cross-platform LF/CRLF editing drift means a value like
|
|
1295
|
+
`latest-eligible: true\\r` must parse as the boolean `true`, not as an
|
|
1296
|
+
unrecognised value that would otherwise silently drop a feature — the
|
|
1297
|
+
exact silent-default failure this module's loud-failure contract
|
|
1298
|
+
forbids. There is no dedicated CRLF-stripping pass: every line is
|
|
1299
|
+
`.strip()`-ed on extraction from the block (`raw_line.strip()` below)
|
|
1300
|
+
and every key/value pair is independently `.strip()`-ed again off the
|
|
1301
|
+
split — Python's `str.strip()` with no argument removes `\\r` along with
|
|
1302
|
+
every other whitespace character, so a trailing `\\r` never survives to
|
|
1303
|
+
a comparison regardless of which layer runs first."""
|
|
1304
|
+
if not isinstance(text, str):
|
|
1305
|
+
# Every declared parser-contract violation raises a ReleaseTargetsError
|
|
1306
|
+
# subclass so a caller can catch one type (see module docstring); a
|
|
1307
|
+
# non-string input must not be the one escape hatch that raises a bare
|
|
1308
|
+
# TypeError instead. There is no block to find in non-text input, so
|
|
1309
|
+
# this is the same declared answer as an absent block.
|
|
1310
|
+
raise AbsentBlockError(
|
|
1311
|
+
"no <!-- release-targets --> block found (input is not text)")
|
|
1312
|
+
normalized = text
|
|
1313
|
+
|
|
1314
|
+
opens = list(_OPEN_RE.finditer(normalized))
|
|
1315
|
+
if not opens:
|
|
1316
|
+
raise AbsentBlockError("no <!-- release-targets --> block found")
|
|
1317
|
+
if len(opens) > 1:
|
|
1318
|
+
raise MultipleBlocksError(
|
|
1319
|
+
f"found {len(opens)} <!-- release-targets --> opening delimiters; "
|
|
1320
|
+
"exactly one is allowed")
|
|
1321
|
+
|
|
1322
|
+
after_open = normalized[opens[0].end():]
|
|
1323
|
+
closes = list(_CLOSE_RE.finditer(after_open))
|
|
1324
|
+
if not closes:
|
|
1325
|
+
raise MalformedBlockError(
|
|
1326
|
+
"<!-- release-targets --> block is never closed")
|
|
1327
|
+
|
|
1328
|
+
# The GENUINE closing delimiter is the first match that sits ALONE on its
|
|
1329
|
+
# line (only whitespace precedes it since the last newline). A match that
|
|
1330
|
+
# is preceded by other content on the same line is embedded inside a
|
|
1331
|
+
# declared value (e.g. `rebuild: echo <!-- /release-targets -->`) and
|
|
1332
|
+
# must error rather than silently become the block boundary — otherwise a
|
|
1333
|
+
# value's embedded delimiter truncates the block and, for a REQUIRED key,
|
|
1334
|
+
# can silently empty it (`payload: <!-- /release-targets -->` would parse
|
|
1335
|
+
# with `payload == ''`). A close match that occurs entirely AFTER the
|
|
1336
|
+
# genuine terminator — a legitimate stray mention of the delimiter text in
|
|
1337
|
+
# prose following the block — is not inspected at all, so it can never be
|
|
1338
|
+
# misdiagnosed as a value violation.
|
|
1339
|
+
genuine = None
|
|
1340
|
+
for m in closes:
|
|
1341
|
+
line_start = after_open.rfind("\n", 0, m.start()) + 1
|
|
1342
|
+
prefix = after_open[line_start:m.start()]
|
|
1343
|
+
if prefix.strip() == "":
|
|
1344
|
+
genuine = m
|
|
1345
|
+
break
|
|
1346
|
+
raise DelimiterInValueError(
|
|
1347
|
+
"a declared value contains the literal closing delimiter "
|
|
1348
|
+
"'<!-- /release-targets -->', which would truncate the block "
|
|
1349
|
+
"under a naive parse instead of being treated as part of the value")
|
|
1350
|
+
|
|
1351
|
+
block = after_open[:genuine.start()]
|
|
1352
|
+
if not block.strip():
|
|
1353
|
+
raise EmptyBlockError("<!-- release-targets --> block is empty")
|
|
1354
|
+
|
|
1355
|
+
rows = []
|
|
1356
|
+
row = None
|
|
1357
|
+
seen_keys = None
|
|
1358
|
+
seen_names = set()
|
|
1359
|
+
|
|
1360
|
+
for raw_line in block.split("\n"):
|
|
1361
|
+
line = raw_line.strip()
|
|
1362
|
+
if not line:
|
|
1363
|
+
continue
|
|
1364
|
+
|
|
1365
|
+
if line.startswith("["):
|
|
1366
|
+
m = _HEADER_RE.match(line)
|
|
1367
|
+
if not m:
|
|
1368
|
+
raise MalformedBlockError(
|
|
1369
|
+
f"malformed target header: {raw_line!r}")
|
|
1370
|
+
name = m.group(1)
|
|
1371
|
+
if name in seen_names:
|
|
1372
|
+
raise DuplicateTargetError(f"duplicate target block: {name!r}")
|
|
1373
|
+
seen_names.add(name)
|
|
1374
|
+
if row is not None:
|
|
1375
|
+
_finish_row(row)
|
|
1376
|
+
rows.append(row)
|
|
1377
|
+
row = _new_row(name)
|
|
1378
|
+
seen_keys = set()
|
|
1379
|
+
continue
|
|
1380
|
+
|
|
1381
|
+
if row is None:
|
|
1382
|
+
raise MalformedBlockError(
|
|
1383
|
+
f"key line before the first [target] header: {raw_line!r}")
|
|
1384
|
+
|
|
1385
|
+
idx = line.find(":")
|
|
1386
|
+
if idx == -1:
|
|
1387
|
+
raise MalformedBlockError(
|
|
1388
|
+
f"malformed line (expected 'key: value'): {raw_line!r}")
|
|
1389
|
+
key = line[:idx].strip()
|
|
1390
|
+
value = line[idx + 1:].strip()
|
|
1391
|
+
|
|
1392
|
+
if key not in _KEY_FIELD:
|
|
1393
|
+
raise UnknownKeyError(
|
|
1394
|
+
f"unknown key {key!r} in target {row['target']!r}")
|
|
1395
|
+
field = _KEY_FIELD[key]
|
|
1396
|
+
|
|
1397
|
+
# A-2.4: a declared value longer than VALUE_MAX_CHARS is rejected,
|
|
1398
|
+
# on ADR-0002's precedent. Checked for EVERY key, not only
|
|
1399
|
+
# `pre-tag`: the cap exists because these values are operator-
|
|
1400
|
+
# authored input a `contents: write` lane later executes or
|
|
1401
|
+
# interpolates, and `rebuild`/`generate` are executed exactly like
|
|
1402
|
+
# `pre-tag` is. Capping only the key that motivated the rule would
|
|
1403
|
+
# leave the same exposure one field over.
|
|
1404
|
+
if len(value) > VALUE_MAX_CHARS:
|
|
1405
|
+
raise ValueTooLongError(
|
|
1406
|
+
f"key {key!r} in target {row['target']!r} declares a value "
|
|
1407
|
+
f"of {len(value)} characters, over the {VALUE_MAX_CHARS}-"
|
|
1408
|
+
"character limit. A declared value this long is far more "
|
|
1409
|
+
"likely to be a smuggled command line than a path or a "
|
|
1410
|
+
"build invocation")
|
|
1411
|
+
|
|
1412
|
+
if key in _LIST_KEYS:
|
|
1413
|
+
row[field].append(value)
|
|
1414
|
+
continue
|
|
1415
|
+
|
|
1416
|
+
if key in seen_keys:
|
|
1417
|
+
raise DuplicateKeyError(
|
|
1418
|
+
f"duplicate key {key!r} in target {row['target']!r}")
|
|
1419
|
+
seen_keys.add(key)
|
|
1420
|
+
|
|
1421
|
+
if key in _BOOLEAN_KEYS:
|
|
1422
|
+
if value == "true":
|
|
1423
|
+
row[field] = True
|
|
1424
|
+
elif value == "false":
|
|
1425
|
+
row[field] = False
|
|
1426
|
+
else:
|
|
1427
|
+
raise InvalidBooleanError(
|
|
1428
|
+
f"key {key!r} in target {row['target']!r} must be "
|
|
1429
|
+
f"exactly 'true' or 'false', got {value!r}")
|
|
1430
|
+
else:
|
|
1431
|
+
row[field] = value
|
|
1432
|
+
|
|
1433
|
+
if row is not None:
|
|
1434
|
+
_finish_row(row)
|
|
1435
|
+
rows.append(row)
|
|
1436
|
+
|
|
1437
|
+
return rows
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
def load_targets(path):
|
|
1441
|
+
"""Read `path` and parse it via `parse_release_targets`. The one function
|
|
1442
|
+
in this module that touches the filesystem — opened with `newline=""` so
|
|
1443
|
+
a `\\r\\n` line ending survives into the parser exactly as it is on disk,
|
|
1444
|
+
rather than being silently normalised away by Python's own text-mode
|
|
1445
|
+
universal-newline translation before this module's own CRLF handling
|
|
1446
|
+
ever runs.
|
|
1447
|
+
|
|
1448
|
+
A genuinely MISSING path (`FileNotFoundError`, i.e. `errno == ENOENT`)
|
|
1449
|
+
raises `AbsentBlockError` rather than a bare `OSError` — the same
|
|
1450
|
+
declared-error contract `parse_release_targets` gives every other
|
|
1451
|
+
violation, so a `contents: write` caller can catch one exception type
|
|
1452
|
+
instead of one type for content problems and another for I/O ones. There
|
|
1453
|
+
is, in the end, no block to find at a path with nothing on it.
|
|
1454
|
+
|
|
1455
|
+
Any OTHER `OSError` (permission denied, the path naming a directory, a
|
|
1456
|
+
transient I/O failure, ...) means the path exists in some form but this
|
|
1457
|
+
process could not read it, and raises `UnreadableTargetsFileError`
|
|
1458
|
+
instead — NOT `AbsentBlockError` — because "unreadable" and "absent" are
|
|
1459
|
+
different facts about the project ([[never-fold-unreadable-into-absent]]):
|
|
1460
|
+
the Back-fill lane's sanctioned trigger is `except AbsentBlockError:`,
|
|
1461
|
+
and a permissions error on an existing declared file must STOP there,
|
|
1462
|
+
never be folded into "nothing here, safe to write a fresh one."
|
|
1463
|
+
|
|
1464
|
+
An EXISTING, readable file that simply carries no delimiter block is a
|
|
1465
|
+
THIRD, different failure (HIGH-1, adversarial review 2026-07-31) and
|
|
1466
|
+
raises `FileExistsNoBlockError` instead — see that class's docstring.
|
|
1467
|
+
This function is the only place any of these three distinctions can be
|
|
1468
|
+
made, since it is the only one that knows whether `open()` actually
|
|
1469
|
+
succeeded and, if not, why."""
|
|
1470
|
+
try:
|
|
1471
|
+
with open(path, encoding="utf-8", newline="") as fh:
|
|
1472
|
+
text = fh.read()
|
|
1473
|
+
except FileNotFoundError as exc:
|
|
1474
|
+
raise AbsentBlockError(
|
|
1475
|
+
f"could not read release-targets file {path!r}: {exc}") from exc
|
|
1476
|
+
except OSError as exc:
|
|
1477
|
+
raise UnreadableTargetsFileError(
|
|
1478
|
+
f"could not read release-targets file {path!r}: {exc}") from exc
|
|
1479
|
+
try:
|
|
1480
|
+
return parse_release_targets(text)
|
|
1481
|
+
except AbsentBlockError as exc:
|
|
1482
|
+
# The open() above already succeeded, so this is NOT "no file" -- it
|
|
1483
|
+
# is "a file that exists and contains no block". Re-raised under the
|
|
1484
|
+
# sibling class so a caller's `except AbsentBlockError:` (the
|
|
1485
|
+
# Back-fill lane's trigger) never mistakes the two states for one
|
|
1486
|
+
# another.
|
|
1487
|
+
raise FileExistsNoBlockError(
|
|
1488
|
+
f"{path!r} exists but contains no <!-- release-targets --> "
|
|
1489
|
+
f"block: {exc}") from exc
|
|
1490
|
+
|
|
1491
|
+
|
|
1492
|
+
# --------------------------------------------------------------------------- #
|
|
1493
|
+
# Back-fill detection (T-49/T-50, issue #563, spec AC-5.3/5.4). Fires only
|
|
1494
|
+
# when a caller has already observed `load_targets` raise `AbsentBlockError`
|
|
1495
|
+
# — a genuinely MISSING declared file. `load_targets` itself is unchanged and
|
|
1496
|
+
# keeps raising on absence; nothing here is a silent default inside the
|
|
1497
|
+
# parser. An unparseable EXISTING file (any other ReleaseTargetsError
|
|
1498
|
+
# subclass) is a different failure and is never routed through this — the
|
|
1499
|
+
# release skill's own "Targets" prose still STOPs outright on that case.
|
|
1500
|
+
#
|
|
1501
|
+
# Detection is honest about ambiguity by construction: it never returns a
|
|
1502
|
+
# single guess unless the scan found EXACTLY one candidate manifest and
|
|
1503
|
+
# EXACTLY one candidate changelog. Zero of either (nothing plausible) or more
|
|
1504
|
+
# than one of either (several plausible candidates, no signal for which one)
|
|
1505
|
+
# both raise `BackfillAmbiguousError` — the caller (the release skill's
|
|
1506
|
+
# back-fill lane) surfaces that as "cannot propose a row, route to full
|
|
1507
|
+
# elicitation instead" rather than inventing a target from a guess.
|
|
1508
|
+
# --------------------------------------------------------------------------- #
|
|
1509
|
+
|
|
1510
|
+
# Generic, ecosystem-level manifest/changelog filenames — not a fact about
|
|
1511
|
+
# any one consuming repository, so these stay clear of the module denylist
|
|
1512
|
+
# (A-1.2) the same way the shared grammar keys already do.
|
|
1513
|
+
BACKFILL_MANIFEST_CANDIDATES = (
|
|
1514
|
+
"package.json", "pyproject.toml", "Cargo.toml", "composer.json",
|
|
1515
|
+
)
|
|
1516
|
+
BACKFILL_CHANGELOG_CANDIDATES = (
|
|
1517
|
+
"CHANGELOG.md", "CHANGES.md", "HISTORY.md",
|
|
1518
|
+
)
|
|
1519
|
+
|
|
1520
|
+
# The generic single-target example name/prefix this module's own docstring
|
|
1521
|
+
# and the release-portable-fixture spec's grammar section both use for a
|
|
1522
|
+
# one-target consumer (`[app]` / `prefix: v`) — reused here as the back-fill
|
|
1523
|
+
# lane's default rather than restated as a second, drifting copy.
|
|
1524
|
+
_BACKFILL_DEFAULT_TARGET = "app"
|
|
1525
|
+
_BACKFILL_DEFAULT_PREFIX = "v"
|
|
1526
|
+
|
|
1527
|
+
|
|
1528
|
+
class BackfillAmbiguousError(ReleaseTargetsError):
|
|
1529
|
+
"""Raised by `detect_candidate_target` when the scan found zero, or more
|
|
1530
|
+
than one, candidate manifest or changelog file. A repo with several
|
|
1531
|
+
plausible manifests (or none) must not receive a confidently-wrong
|
|
1532
|
+
proposal — the never-guess posture this whole module's parser already
|
|
1533
|
+
applies to a malformed declaration applies here too, to an AMBIGUOUS
|
|
1534
|
+
absence rather than a malformed one."""
|
|
1535
|
+
|
|
1536
|
+
|
|
1537
|
+
def scan_backfill_candidates(root):
|
|
1538
|
+
"""The one filesystem reader for back-fill detection: lists `root`'s
|
|
1539
|
+
top-level entries and returns `(manifest_candidates, changelog_candidates)`
|
|
1540
|
+
— the repo-relative names present from `BACKFILL_MANIFEST_CANDIDATES` and
|
|
1541
|
+
`BACKFILL_CHANGELOG_CANDIDATES`, each sorted for determinism. Deliberately
|
|
1542
|
+
a TOP-LEVEL-ONLY scan: this is a first-pass detection that gets PRESENTED
|
|
1543
|
+
to the user for explicit confirmation, never a silent multi-directory
|
|
1544
|
+
guess. An unreadable `root` degrades to "nothing found" (both lists
|
|
1545
|
+
empty) rather than raising — the caller's own ambiguity handling already
|
|
1546
|
+
treats "zero candidates" as a case to surface, so a missing/unreadable
|
|
1547
|
+
root reaches the same honest "cannot propose one" outcome instead of a
|
|
1548
|
+
bare `OSError` escaping a detection helper."""
|
|
1549
|
+
try:
|
|
1550
|
+
entries = set(os.listdir(root))
|
|
1551
|
+
except OSError:
|
|
1552
|
+
entries = set()
|
|
1553
|
+
manifests = sorted(name for name in BACKFILL_MANIFEST_CANDIDATES
|
|
1554
|
+
if name in entries)
|
|
1555
|
+
changelogs = sorted(name for name in BACKFILL_CHANGELOG_CANDIDATES
|
|
1556
|
+
if name in entries)
|
|
1557
|
+
return manifests, changelogs
|
|
1558
|
+
|
|
1559
|
+
|
|
1560
|
+
def detect_candidate_target(manifest_candidates, changelog_candidates,
|
|
1561
|
+
target=_BACKFILL_DEFAULT_TARGET,
|
|
1562
|
+
prefix=_BACKFILL_DEFAULT_PREFIX):
|
|
1563
|
+
"""Pure detection logic over an ALREADY-SCANNED set of candidate names
|
|
1564
|
+
(`scan_backfill_candidates` is the one filesystem reader, kept separate
|
|
1565
|
+
per this module's read-isolation convention). Returns a row dict shaped
|
|
1566
|
+
like one `load_targets` entry (`target`, `prefix`, `manifest`,
|
|
1567
|
+
`changelog`, `payload`, `latest_eligible`) ONLY when exactly one manifest
|
|
1568
|
+
candidate and exactly one changelog candidate were found. Raises
|
|
1569
|
+
`BackfillAmbiguousError` for every other case — zero or multiple of
|
|
1570
|
+
either — naming which side was ambiguous and what was found, so a caller
|
|
1571
|
+
surfacing the error has something concrete to show the user.
|
|
1572
|
+
|
|
1573
|
+
HIGH-2 (adversarial review 2026-07-31): the returned row declares
|
|
1574
|
+
`latest_eligible: True`. This detector can only ever propose ONE row —
|
|
1575
|
+
it fires on a SINGLE candidate manifest and a SINGLE candidate
|
|
1576
|
+
changelog, which is what "back-fill a consumer with no declared file
|
|
1577
|
+
yet" means by construction — so the project it is proposing a row for
|
|
1578
|
+
is, at the moment of detection, a single-target project. The release
|
|
1579
|
+
skill's hard rule ("at most one declared target may set
|
|
1580
|
+
`latest-eligible: true`, and every other target's Phase-3 publish MUST
|
|
1581
|
+
pass `--latest=false` EXPLICITLY") was written to stop one of several
|
|
1582
|
+
SIBLING series stealing the "Latest" badge from another in a
|
|
1583
|
+
multi-target repository; applied unconditionally to a single-target
|
|
1584
|
+
project's own first-ever release, the same rule demoted the one release
|
|
1585
|
+
that exists out of the position every visitor sees, with nothing in the
|
|
1586
|
+
lane prompting the operator to notice or correct it. Declaring the key
|
|
1587
|
+
explicitly here — rather than leaving the rule to somehow infer
|
|
1588
|
+
"solo project" from a file that names only one target — is also the
|
|
1589
|
+
more honest choice for a project that later adds a SECOND target: the
|
|
1590
|
+
Back-fill lane's own "Present, and require explicit confirmation"
|
|
1591
|
+
step already shows this exact printed block to the operator verbatim
|
|
1592
|
+
before anything is written, so `latest-eligible: true` is a line they
|
|
1593
|
+
read and can strike, not a behavior that silently changes the day a
|
|
1594
|
+
second row is declared by hand."""
|
|
1595
|
+
# Plain ASCII throughout this message, deliberately: unlike every
|
|
1596
|
+
# DOCSTRING/comment in this module, this text is actually written to a
|
|
1597
|
+
# CLI's stdout/stderr and captured by a real subprocess call. A child
|
|
1598
|
+
# Python process with no PYTHONIOENCODING/PYTHONUTF8 set encodes its
|
|
1599
|
+
# stdout/stderr using the ambient console codepage on Windows (not
|
|
1600
|
+
# UTF-8), so a non-ASCII character here (an em-dash raised this exact
|
|
1601
|
+
# failure, verified) can produce bytes a UTF-8-decoding parent
|
|
1602
|
+
# (`subprocess.run(..., encoding="utf-8")`) cannot decode at all.
|
|
1603
|
+
if len(manifest_candidates) != 1:
|
|
1604
|
+
raise BackfillAmbiguousError(
|
|
1605
|
+
f"found {len(manifest_candidates)} candidate manifest file(s) "
|
|
1606
|
+
f"({', '.join(manifest_candidates) or 'none'}) - cannot propose "
|
|
1607
|
+
"a single release-targets.md row without asking which one")
|
|
1608
|
+
if len(changelog_candidates) != 1:
|
|
1609
|
+
raise BackfillAmbiguousError(
|
|
1610
|
+
f"found {len(changelog_candidates)} candidate changelog file(s) "
|
|
1611
|
+
f"({', '.join(changelog_candidates) or 'none'}) - cannot propose "
|
|
1612
|
+
"a single release-targets.md row without asking which one")
|
|
1613
|
+
return {
|
|
1614
|
+
"target": target,
|
|
1615
|
+
"prefix": prefix,
|
|
1616
|
+
"manifest": [manifest_candidates[0]],
|
|
1617
|
+
"changelog": changelog_candidates[0],
|
|
1618
|
+
"payload": ".",
|
|
1619
|
+
"latest_eligible": True,
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
|
|
1623
|
+
def format_release_targets_block(row):
|
|
1624
|
+
"""Render one `load_targets`-loadable file body from a row dict shaped
|
|
1625
|
+
like `detect_candidate_target`'s return value (or any dict carrying at
|
|
1626
|
+
least `target`, `prefix`, `changelog`, `payload`, and a `manifest` list).
|
|
1627
|
+
Round-trips through `parse_release_targets` — this is the one function
|
|
1628
|
+
that turns a detected/confirmed candidate into the exact text the
|
|
1629
|
+
back-fill lane persists, so a caller never hand-assembles the delimiter
|
|
1630
|
+
grammar itself.
|
|
1631
|
+
|
|
1632
|
+
Emits a `latest-eligible` line when `row` declares one (HIGH-2,
|
|
1633
|
+
adversarial review 2026-07-31) — `detect_candidate_target` always does,
|
|
1634
|
+
since it can only ever propose a single-target row — rendered as the
|
|
1635
|
+
grammar's own `true`/`false` literal, never a bare Python truthiness
|
|
1636
|
+
string, so the round-trip through `parse_release_targets` parses it back
|
|
1637
|
+
as the same boolean rather than an `InvalidBooleanError`."""
|
|
1638
|
+
lines = ["<!-- release-targets -->", f"[{row['target']}]",
|
|
1639
|
+
f"prefix: {row['prefix']}"]
|
|
1640
|
+
for manifest in row.get("manifest", []):
|
|
1641
|
+
lines.append(f"manifest: {manifest}")
|
|
1642
|
+
lines.append(f"changelog: {row['changelog']}")
|
|
1643
|
+
lines.append(f"payload: {row['payload']}")
|
|
1644
|
+
if "latest_eligible" in row:
|
|
1645
|
+
lines.append(f"latest-eligible: {'true' if row['latest_eligible'] else 'false'}")
|
|
1646
|
+
lines.append("<!-- /release-targets -->")
|
|
1647
|
+
return "\n".join(lines) + "\n"
|
|
1648
|
+
|
|
1649
|
+
|
|
1650
|
+
# --------------------------------------------------------------------------- #
|
|
1651
|
+
# CLI entry point (T-41f, issue #563). Gated behind `if __name__ ==
|
|
1652
|
+
# "__main__":` at the bottom of this file, so nothing here touches the
|
|
1653
|
+
# zero-side-effects-at-import invariant every other section of this module
|
|
1654
|
+
# states — importing this module never parses argv, resolves a path, or
|
|
1655
|
+
# reads a file; only running it as a script does.
|
|
1656
|
+
#
|
|
1657
|
+
# This is the CLI the release skill's helper invocations resolve against
|
|
1658
|
+
# once repointed under `${CLAUDE_PLUGIN_ROOT}/hooks/_releaselib.py` (T-41b).
|
|
1659
|
+
# It is intentionally NOT the same surface as `.github/scripts/_releaselib.py`
|
|
1660
|
+
# (this repo's permanent CI shim, which additionally carries this repo's own
|
|
1661
|
+
# data constants and CI-only subcommands like `select-target` and
|
|
1662
|
+
# `merge-readiness` — those stay CI-internal and are not part of a portable
|
|
1663
|
+
# skill's vocabulary). This CLI supports only the subcommands a release
|
|
1664
|
+
# skill actually needs to shell out to: resolving a target's declared
|
|
1665
|
+
# prefix, selecting the last tag in a series, checking a notes file's
|
|
1666
|
+
# heading, classifying a (re)publish attempt, and peeling a tag to the
|
|
1667
|
+
# commit it names (`peel-tag`, HIGH-1, adversarial review 2026-07-31 — added
|
|
1668
|
+
# here because the CI shim already had it and a consumer had no equivalent).
|
|
1669
|
+
# --------------------------------------------------------------------------- #
|
|
1670
|
+
|
|
1671
|
+
|
|
1672
|
+
def default_targets_path():
|
|
1673
|
+
"""The declared-target file's default location: `.codearbiter/release-
|
|
1674
|
+
targets.md` under the project root. `CLAUDE_PROJECT_DIR` is read first —
|
|
1675
|
+
the same env-first signal every hook in this codebase trusts as the
|
|
1676
|
+
harness's own authoritative project-root pointer (a subprocess is not
|
|
1677
|
+
guaranteed to start with the project directory as its cwd) — falling
|
|
1678
|
+
back to the process's current working directory when the variable is
|
|
1679
|
+
unset (a bare script invocation outside a governed session, e.g. this
|
|
1680
|
+
module's own tests). Deliberately reimplemented here rather than
|
|
1681
|
+
importing `_activationlib.project_root`: this file must stay loadable
|
|
1682
|
+
standalone via `importlib.util.spec_from_file_location` with no sibling
|
|
1683
|
+
module on `sys.path` (the CI shim, and every test that loads this module
|
|
1684
|
+
under a private name, does exactly that), so it cannot depend on another
|
|
1685
|
+
`core/pysrc/` file being importable by plain `import` at CLI time."""
|
|
1686
|
+
root = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
|
|
1687
|
+
return os.path.join(root, ".codearbiter", "release-targets.md")
|
|
1688
|
+
|
|
1689
|
+
|
|
1690
|
+
def default_backfill_root():
|
|
1691
|
+
"""The back-fill scan's default root, mirroring `default_targets_path`'s
|
|
1692
|
+
own env-first precedence exactly: `CLAUDE_PROJECT_DIR` when set, else the
|
|
1693
|
+
process's current working directory. Without this, `backfill-detect`
|
|
1694
|
+
invoked with no positional root (the shape the release skill's own
|
|
1695
|
+
prose uses) would scan whatever directory the CALLER happens to be
|
|
1696
|
+
running in rather than the project root — the same T-41f defect class
|
|
1697
|
+
this module's CLI already guards against for `tag-prefix`."""
|
|
1698
|
+
return os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
|
|
1699
|
+
|
|
1700
|
+
|
|
1701
|
+
def _resolve_target_row(target, targets_file):
|
|
1702
|
+
"""The declared row named `target` in `targets_file`, or `None` if no
|
|
1703
|
+
row of that name is declared. Raises `ReleaseTargetsError` (any
|
|
1704
|
+
subclass) exactly as `load_targets` does when the file itself is
|
|
1705
|
+
absent, empty, or malformed — a caller distinguishes "no such target"
|
|
1706
|
+
from "the declaration is broken" by catching the exception type."""
|
|
1707
|
+
rows = load_targets(targets_file)
|
|
1708
|
+
for row in rows:
|
|
1709
|
+
if row["target"] == target:
|
|
1710
|
+
return row
|
|
1711
|
+
return None
|
|
1712
|
+
|
|
1713
|
+
|
|
1714
|
+
def _targets_error_exit_code(exc):
|
|
1715
|
+
"""The CLI's exit-code discriminator for any `ReleaseTargetsError`
|
|
1716
|
+
surfaced from `load_targets` (HIGH-1, adversarial review 2026-07-31):
|
|
1717
|
+
|
|
1718
|
+
3 - a genuinely ABSENT declared file (`AbsentBlockError`, and nothing
|
|
1719
|
+
else) -- nothing on disk at all. This is the release skill's
|
|
1720
|
+
Back-fill lane's ONE sanctioned trigger.
|
|
1721
|
+
4 - every other case: an EXISTING file that has no block
|
|
1722
|
+
(`FileExistsNoBlockError`), one this process could not read for
|
|
1723
|
+
any other reason such as a permissions error
|
|
1724
|
+
(`UnreadableTargetsFileError`), or is empty, malformed, or
|
|
1725
|
+
otherwise unparseable. All of these STOP outright per the
|
|
1726
|
+
skill's "Targets" section and must never be mistaken for
|
|
1727
|
+
"safe to back-fill".
|
|
1728
|
+
|
|
1729
|
+
Exit code 2 is deliberately NOT reused for either state here: it already
|
|
1730
|
+
means "bad CLI invocation" or "unrecognised target name" elsewhere in
|
|
1731
|
+
this dispatcher, neither of which is a statement about the declared
|
|
1732
|
+
file's own state -- collapsing them together is exactly how the
|
|
1733
|
+
original defect made "absent" and "exists but broken" indistinguishable
|
|
1734
|
+
from the CLI."""
|
|
1735
|
+
return 3 if isinstance(exc, AbsentBlockError) else 4
|
|
1736
|
+
|
|
1737
|
+
|
|
1738
|
+
def main(argv):
|
|
1739
|
+
"""CLI dispatch. Subcommands:
|
|
1740
|
+
|
|
1741
|
+
tag-prefix <target> [--targets-file PATH]
|
|
1742
|
+
prints the target's declared `prefix`.
|
|
1743
|
+
list-targets [--targets-file PATH]
|
|
1744
|
+
prints every declared target's name, one
|
|
1745
|
+
per line, in declaration order -- the
|
|
1746
|
+
sanctioned way to enumerate targets without
|
|
1747
|
+
hand-parsing the file (never guaranteed to
|
|
1748
|
+
be a single name; a multi-target file names
|
|
1749
|
+
more than one).
|
|
1750
|
+
last-tag <prefix> stdin = tags (whitespace/newline separated)
|
|
1751
|
+
-> prints the selected tag or <none>.
|
|
1752
|
+
notes-match <tag> <notes_file>
|
|
1753
|
+
exit 0 iff the notes file's first heading
|
|
1754
|
+
names the same version as `tag`.
|
|
1755
|
+
changelog-section <changelog_file> <version>
|
|
1756
|
+
prints the `## [<version>] ...` section of
|
|
1757
|
+
`<changelog_file>` verbatim, from its
|
|
1758
|
+
heading up to the next `##` heading or EOF.
|
|
1759
|
+
exit 0 with the section on stdout - 1 no
|
|
1760
|
+
heading names `<version>` - 3 the file
|
|
1761
|
+
could not be read (distinct from 1: "could
|
|
1762
|
+
not compare" is never "compared and
|
|
1763
|
+
disagreed"). The sanctioned way for
|
|
1764
|
+
Phase 3's `resume_publish` path to
|
|
1765
|
+
reconstruct release notes when Phase 1's
|
|
1766
|
+
own scratch file did not survive to a
|
|
1767
|
+
later session (blind exercise run 19,
|
|
1768
|
+
HIGH-2).
|
|
1769
|
+
check-manifests <target> <version>
|
|
1770
|
+
asserts EVERY declared `manifest` in the row
|
|
1771
|
+
equals <version>. exit 0 all match - 1 at
|
|
1772
|
+
least one disagrees (each named) - 2 bad
|
|
1773
|
+
invocation or a manifest that cannot be
|
|
1774
|
+
parsed. The lane's only runnable all-paths
|
|
1775
|
+
equality guard: `classify` short-circuits on
|
|
1776
|
+
a fresh publish and never reaches its own
|
|
1777
|
+
version comparison (run 12).
|
|
1778
|
+
classify-window stdin = `git log $WINDOW --pretty=format:
|
|
1779
|
+
%H%n%s%n%b%n---- -- $PAYLOAD` -> prints the
|
|
1780
|
+
derived bump, then one
|
|
1781
|
+
`[NEEDS-TRIAGE] <sha> <subject>` line per
|
|
1782
|
+
BUMPING commit missing a CHANGELOG: footer.
|
|
1783
|
+
exit 0 clean - 1 a footer is missing (the
|
|
1784
|
+
step-3 BLOCK) - 2 the window is non-bumping
|
|
1785
|
+
(the step-2 STOP). Classifies and reports;
|
|
1786
|
+
the skill keeps the decision.
|
|
1787
|
+
adoption-commit stdin = `git log --diff-filter=A --format=%H
|
|
1788
|
+
-- .codearbiter/CONTEXT.md` -> prints the
|
|
1789
|
+
commit that ADOPTED codeArbiter, or nothing
|
|
1790
|
+
when there is none. The honest window floor
|
|
1791
|
+
for a project's FIRST release: without it,
|
|
1792
|
+
every pre-adoption commit enters the
|
|
1793
|
+
footer check and none can pass, blocking a
|
|
1794
|
+
legitimate release once per commit (A-5.5).
|
|
1795
|
+
run-pre-tag <target> runs the row's declared `pre-tag` commands in
|
|
1796
|
+
DECLARED ORDER, stopping at the first
|
|
1797
|
+
non-zero exit, and asserts a clean tree after
|
|
1798
|
+
each (DECISION-0034: check-only, never a
|
|
1799
|
+
fixer). Each declared command's environment
|
|
1800
|
+
carries `PY=<this process's own
|
|
1801
|
+
interpreter>`, so a row may portably spell
|
|
1802
|
+
`"$PY"` instead of a hardcoded interpreter
|
|
1803
|
+
on every platform (#583 MEDIUM-2 / #584
|
|
1804
|
+
MEDIUM-3): on Windows this dispatch resolves
|
|
1805
|
+
a POSIX-compatible shell (Git for Windows'
|
|
1806
|
+
own `bash.exe`) rather than falling through
|
|
1807
|
+
to `shell=True`'s default `cmd.exe`, which
|
|
1808
|
+
cannot expand `$VAR` (#602). exit 0 all
|
|
1809
|
+
passed - 5 a command RAN and reported drift
|
|
1810
|
+
- 6 a command exited 0 but MUTATED the tree
|
|
1811
|
+
(or the tree was already dirty on a probe
|
|
1812
|
+
failure path that predates this fix) - 7 a
|
|
1813
|
+
command's interpreter or program itself
|
|
1814
|
+
could not be located/executed at all --
|
|
1815
|
+
NOT drift, no release-edit discard needed
|
|
1816
|
+
(#585 MEDIUM-2 / #584 MEDIUM-1) - 8 the
|
|
1817
|
+
tree-state PROBE itself failed, so no
|
|
1818
|
+
verdict about the declared commands exists
|
|
1819
|
+
(distinct from 6, which names a command as
|
|
1820
|
+
the fault) - 9 no POSIX-compatible shell
|
|
1821
|
+
could be resolved to dispatch a declared
|
|
1822
|
+
row on Windows, so NO command ran at all --
|
|
1823
|
+
NOT drift, distinct from 7 (#602) - 2 bad
|
|
1824
|
+
invocation / unknown target - 3/4
|
|
1825
|
+
declared-file states.
|
|
1826
|
+
semver-greater <candidate> <floor>
|
|
1827
|
+
exit 0 iff `candidate` is STRICTLY greater
|
|
1828
|
+
than `floor`; 1 when equal or lesser; 2 when
|
|
1829
|
+
either is unparseable. The sanctioned way to
|
|
1830
|
+
run the lane's strictly-greater assertions,
|
|
1831
|
+
including the manifest FLOOR check -- both
|
|
1832
|
+
were hand-done against a hard rule saying
|
|
1833
|
+
the version MUST NOT be guessed.
|
|
1834
|
+
apply-bump <base> <word> prints the SemVer `base` bumped by `word`
|
|
1835
|
+
(`major`/`minor`/`patch`; `none` and any
|
|
1836
|
+
other value are refused). exit 0 with the
|
|
1837
|
+
bumped version on stdout - 2 when `base` is
|
|
1838
|
+
not plain SemVer or `word` is not one of
|
|
1839
|
+
the three bumping words. The sanctioned way
|
|
1840
|
+
to apply the bump `classify-window`
|
|
1841
|
+
derived, instead of by eye (#585 MEDIUM-3).
|
|
1842
|
+
dates-match <changelog_section_file> <tag_message_file>
|
|
1843
|
+
exit 0 iff the changelog section's heading
|
|
1844
|
+
date equals the `Released-at:` date in the
|
|
1845
|
+
tag message. The prose has always REQUIRED
|
|
1846
|
+
this check; until run 4 it had no CLI
|
|
1847
|
+
entry point, so no operator following the
|
|
1848
|
+
skill could actually run it.
|
|
1849
|
+
classify <tag_exists> <tag_sha> <head_sha> <tag_version>
|
|
1850
|
+
<manifest_version> <release_nondraft>
|
|
1851
|
+
prints the publish-state label. Bools are
|
|
1852
|
+
the bare literals `true`/`false` -- NOT
|
|
1853
|
+
`gh`'s JSON. Note `release_nondraft` is
|
|
1854
|
+
the NEGATION of `gh`'s `isDraft`.
|
|
1855
|
+
peel-tag <tag> stdin = `<sha> <ref>` lines in either
|
|
1856
|
+
`git ls-remote --tags` or `git show-ref
|
|
1857
|
+
--tags -d` format -> prints the COMMIT
|
|
1858
|
+
`tag` names (peeled through its `^{}`
|
|
1859
|
+
line when annotated), or "" when `tag`
|
|
1860
|
+
is absent from stdin (HIGH-1, adversarial
|
|
1861
|
+
review 2026-07-31). This is the one
|
|
1862
|
+
sanctioned way to produce `<tag_sha>` for
|
|
1863
|
+
`classify` below: a bare `git rev-parse
|
|
1864
|
+
<tag>` returns an ANNOTATED tag's own
|
|
1865
|
+
object id, not the commit it points at,
|
|
1866
|
+
which would feed `classify` a value that
|
|
1867
|
+
can never equal `<head_sha>` and
|
|
1868
|
+
misclassify a healthy tag as
|
|
1869
|
+
`abort_mismatch`.
|
|
1870
|
+
backfill-detect [root] scans `root` (default: `default_backfill_
|
|
1871
|
+
root()`, i.e. `CLAUDE_PROJECT_DIR` then
|
|
1872
|
+
cwd) for exactly one candidate manifest and
|
|
1873
|
+
one candidate changelog (T-49/T-50); on a
|
|
1874
|
+
single unambiguous candidate of each,
|
|
1875
|
+
prints the exact `release-targets.md`
|
|
1876
|
+
block text and exits 0; on zero or multiple
|
|
1877
|
+
of either, writes the ambiguity to stderr
|
|
1878
|
+
and exits 1 — it never prints a guess.
|
|
1879
|
+
show-row <target> [--field NAME]
|
|
1880
|
+
prints every field the row declares, one
|
|
1881
|
+
`name: value` line each (multi-valued
|
|
1882
|
+
fields comma-separated), or just NAME's raw
|
|
1883
|
+
value with `--field`. Reads via
|
|
1884
|
+
`default_targets_path()` only, no
|
|
1885
|
+
`--targets-file` override.
|
|
1886
|
+
payload-pathspec <target> prints the row's `payload`, minus its
|
|
1887
|
+
`payload-exclude` entries, as verbatim git
|
|
1888
|
+
pathspec arguments (`:(exclude)` forms
|
|
1889
|
+
included) — the sanctioned way to spell a
|
|
1890
|
+
window subtraction plain `git log --
|
|
1891
|
+
<path>` cannot express. Same declared-file
|
|
1892
|
+
resolution as `show-row`.
|
|
1893
|
+
|
|
1894
|
+
`--targets-file PATH` overrides `default_targets_path()` for `tag-prefix`
|
|
1895
|
+
and `list-targets` only; `show-row` and `payload-pathspec` always resolve
|
|
1896
|
+
the declared file through `default_targets_path()` with no override, and
|
|
1897
|
+
every remaining subcommand needs no declared file at all. `tag-prefix`,
|
|
1898
|
+
`list-targets`, `show-row`, and `payload-pathspec` all exit 3 when the
|
|
1899
|
+
declared file is genuinely absent and 4 for every other declared-file
|
|
1900
|
+
error (HIGH-1, `_targets_error_exit_code`); every other subcommand
|
|
1901
|
+
prints a value/label and exits 0, or writes a short cause to stderr and
|
|
1902
|
+
exits non-zero — never a bare traceback, so a caller shelling out to
|
|
1903
|
+
this file gets a diagnosable failure either way. Returns a process exit
|
|
1904
|
+
code."""
|
|
1905
|
+
if not argv:
|
|
1906
|
+
sys.stderr.write(
|
|
1907
|
+
"usage: _releaselib.py {tag-prefix|list-targets|show-row|"
|
|
1908
|
+
"payload-pathspec|last-tag|notes-match|changelog-section|"
|
|
1909
|
+
"dates-match|semver-greater|apply-bump|classify|peel-tag|"
|
|
1910
|
+
"run-pre-tag|adoption-commit|classify-window|check-manifests|"
|
|
1911
|
+
"backfill-detect} ...\n")
|
|
1912
|
+
return 2
|
|
1913
|
+
|
|
1914
|
+
cmd, rest = argv[0], list(argv[1:])
|
|
1915
|
+
|
|
1916
|
+
if cmd == "tag-prefix":
|
|
1917
|
+
# `--targets-file` is stripped HERE, inside the one subcommand that
|
|
1918
|
+
# reads it, rather than unconditionally over every subcommand's
|
|
1919
|
+
# `rest` — `classify`'s six positional arguments are caller-supplied
|
|
1920
|
+
# data (a sha, a version string, a bool) and must never have a
|
|
1921
|
+
# literal substring lexically special-cased out from under them.
|
|
1922
|
+
targets_file = default_targets_path()
|
|
1923
|
+
if "--targets-file" in rest:
|
|
1924
|
+
idx = rest.index("--targets-file")
|
|
1925
|
+
if idx + 1 >= len(rest):
|
|
1926
|
+
sys.stderr.write("--targets-file requires a value\n")
|
|
1927
|
+
return 2
|
|
1928
|
+
targets_file = rest[idx + 1]
|
|
1929
|
+
rest = rest[:idx] + rest[idx + 2:]
|
|
1930
|
+
if len(rest) != 1:
|
|
1931
|
+
sys.stderr.write(f"_releaselib.py: bad invocation: {' '.join(argv)}\n")
|
|
1932
|
+
return 2
|
|
1933
|
+
target = rest[0]
|
|
1934
|
+
try:
|
|
1935
|
+
row = _resolve_target_row(target, targets_file)
|
|
1936
|
+
except ReleaseTargetsError as exc:
|
|
1937
|
+
sys.stderr.write(
|
|
1938
|
+
f"{type(exc).__name__}: could not read declared release "
|
|
1939
|
+
f"targets from {targets_file!r}: {exc}\n")
|
|
1940
|
+
return _targets_error_exit_code(exc)
|
|
1941
|
+
if row is None:
|
|
1942
|
+
sys.stderr.write(f"unknown release target: {target}\n")
|
|
1943
|
+
return 2
|
|
1944
|
+
print(row["prefix"])
|
|
1945
|
+
return 0
|
|
1946
|
+
|
|
1947
|
+
if cmd in ("show-row", "payload-pathspec"):
|
|
1948
|
+
# Blind-exercise HIGH (run 14). The lane's own rule is that the
|
|
1949
|
+
# declared file must be read "through the same tested grammar", not
|
|
1950
|
+
# "by-eye scan of the delimiter block" -- but only `prefix` and the
|
|
1951
|
+
# target names had readers. Nine fields (`manifest`,
|
|
1952
|
+
# `generated-manifest`, `generate`, `changelog`, `payload`,
|
|
1953
|
+
# `payload-exclude`, `artifacts`, `pre-tag`, `provenance-manifest`,
|
|
1954
|
+
# `latest-eligible`) had none, so following the lane REQUIRED doing
|
|
1955
|
+
# the thing it forbids. An exercising agent read all nine by eye and
|
|
1956
|
+
# said so.
|
|
1957
|
+
#
|
|
1958
|
+
# `payload-pathspec` exists separately because `$PAYLOAD` is
|
|
1959
|
+
# documented as "payload, minus payload-exclude" and plain
|
|
1960
|
+
# `git log -- <path>` cannot express subtraction. The exclusion was
|
|
1961
|
+
# therefore unspellable from the prose, and silently absent from any
|
|
1962
|
+
# window for a row that declares one. This prints the pathspec
|
|
1963
|
+
# arguments to pass verbatim, `:(exclude)` forms included.
|
|
1964
|
+
field = None
|
|
1965
|
+
if "--field" in rest:
|
|
1966
|
+
idx = rest.index("--field")
|
|
1967
|
+
if idx + 1 >= len(rest):
|
|
1968
|
+
sys.stderr.write("--field requires a value\n")
|
|
1969
|
+
return 2
|
|
1970
|
+
field = rest[idx + 1]
|
|
1971
|
+
rest = rest[:idx] + rest[idx + 2:]
|
|
1972
|
+
if len(rest) != 1:
|
|
1973
|
+
sys.stderr.write(f"_releaselib.py: bad invocation: {' '.join(argv)}\n")
|
|
1974
|
+
return 2
|
|
1975
|
+
target = rest[0]
|
|
1976
|
+
targets_file = default_targets_path()
|
|
1977
|
+
try:
|
|
1978
|
+
row = _resolve_target_row(target, targets_file)
|
|
1979
|
+
except ReleaseTargetsError as exc:
|
|
1980
|
+
sys.stderr.write(
|
|
1981
|
+
f"{type(exc).__name__}: could not read declared release "
|
|
1982
|
+
f"targets from {targets_file!r}: {exc}\n")
|
|
1983
|
+
return _targets_error_exit_code(exc)
|
|
1984
|
+
if row is None:
|
|
1985
|
+
sys.stderr.write(f"unknown release target: {target}\n")
|
|
1986
|
+
return 2
|
|
1987
|
+
|
|
1988
|
+
if cmd == "payload-pathspec":
|
|
1989
|
+
payload = row.get("payload") or "."
|
|
1990
|
+
parts = [payload] + [f":(exclude){p}"
|
|
1991
|
+
for p in (row.get("payload_exclude") or [])]
|
|
1992
|
+
print(" ".join(parts))
|
|
1993
|
+
return 0
|
|
1994
|
+
|
|
1995
|
+
# Emitted as SHELL-QUOTED `NAME='value'` pairs, and named for the
|
|
1996
|
+
# variables the release skill actually spells (`TAG_PREFIX`, not
|
|
1997
|
+
# `PREFIX`). Both halves are load-bearing, and blind exercise run 15
|
|
1998
|
+
# found the cost of getting either wrong:
|
|
1999
|
+
#
|
|
2000
|
+
# * UNQUOTED output made the documented `eval "$(… show-row …)"`
|
|
2001
|
+
# EXECUTE declared field values. `rebuild: cd x && npm run build`
|
|
2002
|
+
# parsed as the assignment `REBUILD=cd` followed by the command
|
|
2003
|
+
# `x`, with `&& npm run build` next in line -- it was one
|
|
2004
|
+
# successful exit away from running a build nobody asked for, and
|
|
2005
|
+
# `eval` still reported 0 because plain assignments followed. A
|
|
2006
|
+
# declared file's values are operator-authored shell that this
|
|
2007
|
+
# lane executes only AFTER `releasehash` confirms a human read
|
|
2008
|
+
# them; executing a fragment of that at row-read time runs it
|
|
2009
|
+
# BEFORE the gate that exists for it. `shlex.quote` closes it.
|
|
2010
|
+
# * MISNAMED keys silently left `TAG_PREFIX`, `REBUILD` and
|
|
2011
|
+
# `PRE_TAG` unset after the eval -- the three the lane leans on
|
|
2012
|
+
# hardest -- so the mandated reader delivered 10 of 13 fields and
|
|
2013
|
+
# the operator had to read the rest by eye, which is the exact
|
|
2014
|
+
# thing this subcommand was added to prevent.
|
|
2015
|
+
#
|
|
2016
|
+
# `--field NAME` prints ONE raw value with no quoting and no `NAME=`,
|
|
2017
|
+
# for `X=$(… --field payload)` command substitution. That form needs
|
|
2018
|
+
# no `eval` at all and is what the skill now uses.
|
|
2019
|
+
fields = [("TARGET", "target"), ("TAG_PREFIX", "prefix"),
|
|
2020
|
+
("MANIFEST", "manifest"),
|
|
2021
|
+
("GENERATED_MANIFEST", "generated_manifest"),
|
|
2022
|
+
("GENERATE", "generate"), ("CHANGELOG", "changelog"),
|
|
2023
|
+
("PAYLOAD", "payload"),
|
|
2024
|
+
("PAYLOAD_EXCLUDE", "payload_exclude"),
|
|
2025
|
+
("ARTIFACTS", "artifacts"), ("REBUILD", "rebuild"),
|
|
2026
|
+
("PRE_TAG", "pre_tag"),
|
|
2027
|
+
("PROVENANCE_MANIFEST", "provenance_manifest"),
|
|
2028
|
+
("LATEST_ELIGIBLE", "latest_eligible"),
|
|
2029
|
+
("DISPLAY_NAME", "display_name")]
|
|
2030
|
+
|
|
2031
|
+
def _flatten(value):
|
|
2032
|
+
if isinstance(value, (list, tuple)):
|
|
2033
|
+
return ",".join(str(v) for v in value)
|
|
2034
|
+
if isinstance(value, bool):
|
|
2035
|
+
return "true" if value else "false"
|
|
2036
|
+
return "" if value is None else str(value)
|
|
2037
|
+
|
|
2038
|
+
if field is not None:
|
|
2039
|
+
wanted = field.strip().lower().replace("-", "_")
|
|
2040
|
+
by_key = {key: name for name, key in fields}
|
|
2041
|
+
if wanted not in by_key:
|
|
2042
|
+
sys.stderr.write(
|
|
2043
|
+
f"unknown field {field!r}; declared fields are: "
|
|
2044
|
+
+ ", ".join(key for _n, key in fields) + "\n")
|
|
2045
|
+
return 2
|
|
2046
|
+
print(_flatten(row.get(wanted)))
|
|
2047
|
+
return 0
|
|
2048
|
+
|
|
2049
|
+
for name, key in fields:
|
|
2050
|
+
print(f"{name}={shlex.quote(_flatten(row.get(key)))}")
|
|
2051
|
+
return 0
|
|
2052
|
+
|
|
2053
|
+
if cmd == "list-targets":
|
|
2054
|
+
# MEDIUM (adversarial review 2026-07-31): the single-target rule
|
|
2055
|
+
# requires knowing a target's name, but `tag-prefix` takes the name
|
|
2056
|
+
# as INPUT and, before this subcommand existed, nothing enumerated
|
|
2057
|
+
# the declared names -- an agent had no sanctioned way to answer
|
|
2058
|
+
# "what targets exist?" except hand-parsing the file, exactly the
|
|
2059
|
+
# grammar this module exists to be the one tested parser for.
|
|
2060
|
+
targets_file = default_targets_path()
|
|
2061
|
+
if "--targets-file" in rest:
|
|
2062
|
+
idx = rest.index("--targets-file")
|
|
2063
|
+
if idx + 1 >= len(rest):
|
|
2064
|
+
sys.stderr.write("--targets-file requires a value\n")
|
|
2065
|
+
return 2
|
|
2066
|
+
targets_file = rest[idx + 1]
|
|
2067
|
+
rest = rest[:idx] + rest[idx + 2:]
|
|
2068
|
+
if rest:
|
|
2069
|
+
sys.stderr.write(f"_releaselib.py: bad invocation: {' '.join(argv)}\n")
|
|
2070
|
+
return 2
|
|
2071
|
+
try:
|
|
2072
|
+
rows = load_targets(targets_file)
|
|
2073
|
+
except ReleaseTargetsError as exc:
|
|
2074
|
+
sys.stderr.write(
|
|
2075
|
+
f"{type(exc).__name__}: could not read declared release "
|
|
2076
|
+
f"targets from {targets_file!r}: {exc}\n")
|
|
2077
|
+
return _targets_error_exit_code(exc)
|
|
2078
|
+
for row in rows:
|
|
2079
|
+
print(row["target"])
|
|
2080
|
+
return 0
|
|
2081
|
+
|
|
2082
|
+
if cmd == "last-tag" and len(rest) == 1:
|
|
2083
|
+
print(last_tag_select(sys.stdin.read().split(), rest[0]))
|
|
2084
|
+
return 0
|
|
2085
|
+
|
|
2086
|
+
if cmd == "notes-match" and len(rest) == 2:
|
|
2087
|
+
try:
|
|
2088
|
+
with open(rest[1], encoding="utf-8") as fh:
|
|
2089
|
+
notes_text = fh.read()
|
|
2090
|
+
except OSError:
|
|
2091
|
+
notes_text = ""
|
|
2092
|
+
return 0 if notes_heading_matches(notes_text, rest[0]) else 1
|
|
2093
|
+
|
|
2094
|
+
if cmd == "changelog-section" and len(rest) == 2:
|
|
2095
|
+
# Mechanical reconstruction of Phase 1's composed section, for
|
|
2096
|
+
# `resume_publish` -- see `changelog_section`'s docstring. Exit 0
|
|
2097
|
+
# with the section on stdout - 1 the changelog has no heading for
|
|
2098
|
+
# `<version>` (drift between $CHANGELOG and the tag, not a
|
|
2099
|
+
# bad-invocation) - 3 the changelog file could not be read. 3, not
|
|
2100
|
+
# 2, so "unreadable" and "bad invocation" stay distinguishable
|
|
2101
|
+
# (this module's own [never-fold-unreadable-into-absent] rule) --
|
|
2102
|
+
# 2 is reserved for bad invocation across this whole CLI.
|
|
2103
|
+
changelog_path, version = rest
|
|
2104
|
+
try:
|
|
2105
|
+
with open(changelog_path, encoding="utf-8") as fh:
|
|
2106
|
+
changelog_text = fh.read()
|
|
2107
|
+
except OSError as exc:
|
|
2108
|
+
sys.stderr.write(
|
|
2109
|
+
f"changelog-section: cannot read {changelog_path!r}: {exc}\n")
|
|
2110
|
+
return 3
|
|
2111
|
+
section = changelog_section(changelog_text, version)
|
|
2112
|
+
if section is None:
|
|
2113
|
+
sys.stderr.write(
|
|
2114
|
+
f"changelog-section: no '## [{version}]' heading in "
|
|
2115
|
+
f"{changelog_path!r}\n")
|
|
2116
|
+
return 1
|
|
2117
|
+
# `sys.stdout.write` is text-mode: on Windows with no
|
|
2118
|
+
# PYTHONIOENCODING/PYTHONUTF8 set it encodes using the ambient
|
|
2119
|
+
# console codepage (cp1252, not UTF-8) AND translates `\n` to
|
|
2120
|
+
# `\r\n`. A changelog section legitimately contains an em-dash in
|
|
2121
|
+
# every heading (`## [X.Y.Z] — DATE`), so unlike backfill-detect's
|
|
2122
|
+
# deliberately-ASCII-only block (see its own comment on this exact
|
|
2123
|
+
# failure), this output cannot dodge the problem by staying ASCII.
|
|
2124
|
+
# Writing UTF-8 bytes straight to the binary buffer bypasses both
|
|
2125
|
+
# the codepage transcoding and the newline translation. `.buffer`
|
|
2126
|
+
# is absent on an `io.StringIO` (what a direct, in-process
|
|
2127
|
+
# `main(argv)` call under test redirects to) -- that caller already
|
|
2128
|
+
# gets the exact string back with no encoding step in between, so
|
|
2129
|
+
# falling back to plain `.write()` there is not a weaker code path,
|
|
2130
|
+
# it is the correct one for an object that was never bytes.
|
|
2131
|
+
out_buffer = getattr(sys.stdout, "buffer", None)
|
|
2132
|
+
if out_buffer is not None:
|
|
2133
|
+
out_buffer.write(section.encode("utf-8"))
|
|
2134
|
+
else:
|
|
2135
|
+
sys.stdout.write(section)
|
|
2136
|
+
return 0
|
|
2137
|
+
|
|
2138
|
+
if cmd == "check-manifests" and len(rest) == 2:
|
|
2139
|
+
# HIGH (adversarial review 2026-07-31, run 12). A row MAY declare
|
|
2140
|
+
# several manifests, and Phase 1 must bump every one to the derived
|
|
2141
|
+
# version -- but nothing mechanical asserted it. The skill claimed
|
|
2142
|
+
# `classify` would catch a partial bump; it does not on the path
|
|
2143
|
+
# that matters. `classify_publish_state` short-circuits on
|
|
2144
|
+
# `if not tag_exists: return "publish_fresh"` BEFORE comparing
|
|
2145
|
+
# versions, so the catch fires only when a tag already exists (the
|
|
2146
|
+
# resume path). On a FRESH publish -- every ordinary release, and
|
|
2147
|
+
# every first release -- a lagging secondary manifest sails
|
|
2148
|
+
# through, and the Traps section's own named consequence lands: a
|
|
2149
|
+
# tag that installs a version string the tag does not name.
|
|
2150
|
+
#
|
|
2151
|
+
# Exit 0 every declared manifest equals <version> - 1 at least one
|
|
2152
|
+
# disagrees (each named) - 2 bad invocation, unknown target, or a
|
|
2153
|
+
# manifest that cannot be read or parsed. Unparseable is NEVER
|
|
2154
|
+
# folded into "disagrees": one is "I compared and they differ", the
|
|
2155
|
+
# other is "I could not compare", and this lane has already had to
|
|
2156
|
+
# separate those twice.
|
|
2157
|
+
target, expected = rest
|
|
2158
|
+
try:
|
|
2159
|
+
rows = load_targets(default_targets_path())
|
|
2160
|
+
except ReleaseTargetsError as exc:
|
|
2161
|
+
sys.stderr.write(f"{type(exc).__name__}: {exc}\n")
|
|
2162
|
+
return _targets_error_exit_code(exc)
|
|
2163
|
+
row = next((r for r in rows if r["target"] == target), None)
|
|
2164
|
+
if row is None:
|
|
2165
|
+
sys.stderr.write(f"unknown release target: {target}\n")
|
|
2166
|
+
return 2
|
|
2167
|
+
root = os.path.dirname(os.path.dirname(default_targets_path())) or "."
|
|
2168
|
+
mismatched, unreadable = [], []
|
|
2169
|
+
for rel in (row.get("manifest") or []):
|
|
2170
|
+
path = os.path.join(root, *rel.split("/"))
|
|
2171
|
+
found = _manifest_version(path)
|
|
2172
|
+
if found is None:
|
|
2173
|
+
unreadable.append(rel)
|
|
2174
|
+
elif found != expected:
|
|
2175
|
+
mismatched.append((rel, found))
|
|
2176
|
+
for rel in unreadable:
|
|
2177
|
+
sys.stderr.write(
|
|
2178
|
+
f"check-manifests: cannot read a version from {rel!r} -- this "
|
|
2179
|
+
"is NOT the same answer as 'disagrees' (exit 1); no "
|
|
2180
|
+
"comparison happened\n")
|
|
2181
|
+
for rel, found in mismatched:
|
|
2182
|
+
sys.stderr.write(
|
|
2183
|
+
f"check-manifests: {rel} declares {found!r}, expected "
|
|
2184
|
+
f"{expected!r}\n")
|
|
2185
|
+
if unreadable:
|
|
2186
|
+
return 2
|
|
2187
|
+
return 1 if mismatched else 0
|
|
2188
|
+
|
|
2189
|
+
if cmd == "classify-window" and not rest:
|
|
2190
|
+
# stdin = `git log $WINDOW --pretty=format:%H%n%s%n%b%n---- --
|
|
2191
|
+
# $PAYLOAD`. Prints the derived bump on the first line, then one
|
|
2192
|
+
# `[NEEDS-TRIAGE] <short-sha> <subject>` line per BUMPING commit
|
|
2193
|
+
# with no CHANGELOG: footer -- the exact report shape Phase 1
|
|
2194
|
+
# step 3 specifies, so it is not re-derived per release.
|
|
2195
|
+
#
|
|
2196
|
+
# Exit 0 = classified, no missing footers. Exit 1 = at least one
|
|
2197
|
+
# bumping commit lacks a footer (the step-3 BLOCK). Exit 2 = the
|
|
2198
|
+
# window is non-bumping, which is the step-2 STOP.
|
|
2199
|
+
#
|
|
2200
|
+
# It CLASSIFIES and REPORTS; it does not decide the release. The
|
|
2201
|
+
# skill keeps the BLOCK. A helper returning proceed/stop would put
|
|
2202
|
+
# a governance decision inside a library, which is the wrong side
|
|
2203
|
+
# of ADR-0010's cooperative-agent line.
|
|
2204
|
+
window = classify_window(parse_window_log(sys.stdin.read()))
|
|
2205
|
+
print(window["bump"])
|
|
2206
|
+
for row in window["missing_footer"]:
|
|
2207
|
+
print(f"[NEEDS-TRIAGE] {row['sha'][:7]} {row['subject']}")
|
|
2208
|
+
if window["missing_footer"]:
|
|
2209
|
+
return 1
|
|
2210
|
+
return 2 if window["bump"] == "none" else 0
|
|
2211
|
+
|
|
2212
|
+
if cmd == "adoption-commit" and not rest:
|
|
2213
|
+
# A-5.5. stdin = `git log --diff-filter=A --format=%H --
|
|
2214
|
+
# .codearbiter/CONTEXT.md`. Prints the adoption commit, or nothing
|
|
2215
|
+
# at all when the project has no adoption commit.
|
|
2216
|
+
#
|
|
2217
|
+
# Exit 0 either way, deliberately: "this project has no adoption
|
|
2218
|
+
# commit" is a normal answer for a repo that never onboarded, not
|
|
2219
|
+
# an error, and the caller distinguishes the two by empty output
|
|
2220
|
+
# exactly as it already does for `peel-tag`. A non-zero exit here
|
|
2221
|
+
# would break a `set -e` lane on the ordinary path.
|
|
2222
|
+
baseline = first_release_baseline(sys.stdin.read())
|
|
2223
|
+
if baseline:
|
|
2224
|
+
print(baseline)
|
|
2225
|
+
return 0
|
|
2226
|
+
|
|
2227
|
+
if cmd == "run-pre-tag" and len(rest) == 1:
|
|
2228
|
+
# A-2.1/2.2/2.3 (DECISION-0034). Runs the row's declared `pre-tag`
|
|
2229
|
+
# commands IN DECLARED ORDER, stops at the first non-zero exit, and
|
|
2230
|
+
# asserts a clean tree after each one.
|
|
2231
|
+
#
|
|
2232
|
+
# This is a subcommand rather than four prose rules because
|
|
2233
|
+
# operator-declared shell commands are exactly where an
|
|
2234
|
+
# agent-followed procedure is least trustworthy: the clean-tree
|
|
2235
|
+
# assertion is what surfaces a rogue command's writes before
|
|
2236
|
+
# tagging, and an assertion an agent has to remember is one it can
|
|
2237
|
+
# skip. Logged as a SMARTS decision in .codearbiter/sprint-log.md
|
|
2238
|
+
# (2026-07-31), Scalable weighted heavily per the standing steer.
|
|
2239
|
+
#
|
|
2240
|
+
# The clean-tree check runs BEFORE any `rebuild` (2.3): this
|
|
2241
|
+
# subcommand never invokes rebuild at all, so a rebuild's
|
|
2242
|
+
# legitimate bundle rewrite can never be attributed to a pre-tag
|
|
2243
|
+
# command. Ordering the lane correctly is the caller's job; making
|
|
2244
|
+
# it impossible to conflate the two is this command's.
|
|
2245
|
+
#
|
|
2246
|
+
# Exit codes: 0 all passed - 5 a command RAN and reported drift
|
|
2247
|
+
# (non-zero, not one of the could-not-run codes below) - 6 a
|
|
2248
|
+
# command exited 0 but MUTATED the tree - 7 a command's interpreter
|
|
2249
|
+
# or program itself could not be located/executed at all (#585
|
|
2250
|
+
# MEDIUM-2 / #584 MEDIUM-1: "could not run" is never "ran and
|
|
2251
|
+
# disagreed") - 8 the tree-state PROBE itself failed, so no verdict
|
|
2252
|
+
# about the declared commands exists at all (distinct from 6, which
|
|
2253
|
+
# means a command mutated the tree -- a probe failure means this
|
|
2254
|
+
# subcommand never got far enough to know) - 9 no POSIX-compatible
|
|
2255
|
+
# shell could be resolved to dispatch a declared row on Windows, so
|
|
2256
|
+
# NO declared command ran at all (#602: distinct from 7, which names
|
|
2257
|
+
# a specific command's own interpreter as unresolvable -- 9 means
|
|
2258
|
+
# the DISPATCH MECHANISM itself is unavailable, before any command
|
|
2259
|
+
# is even attempted) - 2 bad invocation or unknown target - 3/4 the
|
|
2260
|
+
# declared-file states, unchanged.
|
|
2261
|
+
#
|
|
2262
|
+
# PY env-var contract: every declared command below runs with
|
|
2263
|
+
# `PY` set in its environment to THIS process's own interpreter
|
|
2264
|
+
# (`sys.executable`), so a row may portably spell `"$PY"` instead
|
|
2265
|
+
# of a hardcoded `python3`/`python` (#583 MEDIUM-2 / #584 MEDIUM-3).
|
|
2266
|
+
# This convention now holds on Windows too (#602): the dispatch
|
|
2267
|
+
# below resolves a POSIX-compatible shell there instead of falling
|
|
2268
|
+
# through to `subprocess.run(shell=True)`'s default `cmd.exe`.
|
|
2269
|
+
try:
|
|
2270
|
+
rows = load_targets(default_targets_path())
|
|
2271
|
+
except ReleaseTargetsError as exc:
|
|
2272
|
+
sys.stderr.write(f"{type(exc).__name__}: {exc}\n")
|
|
2273
|
+
return _targets_error_exit_code(exc)
|
|
2274
|
+
row = next((r for r in rows if r["target"] == rest[0]), None)
|
|
2275
|
+
if row is None:
|
|
2276
|
+
sys.stderr.write(f"unknown release target: {rest[0]}\n")
|
|
2277
|
+
return 2
|
|
2278
|
+
|
|
2279
|
+
# Run the declared commands in the PROJECT root, not whatever cwd
|
|
2280
|
+
# this process inherited (MEDIUM, run 9). The declaration is
|
|
2281
|
+
# resolved from CLAUDE_PROJECT_DIR while the commands and the tree
|
|
2282
|
+
# probe used to run wherever the caller happened to be -- so a
|
|
2283
|
+
# declared check could pass having inspected a DIFFERENT
|
|
2284
|
+
# repository's files, and the clean-tree probe could report an
|
|
2285
|
+
# unrelated repo's dirt. Both directions were demonstrated.
|
|
2286
|
+
project_root = os.path.dirname(os.path.dirname(default_targets_path())) or "."
|
|
2287
|
+
|
|
2288
|
+
# #602: resolve the POSIX-shell dispatch ONCE, before any declared
|
|
2289
|
+
# command runs (and before the tree-state baseline below, which a
|
|
2290
|
+
# row with no `pre-tag` commands at all does not even need this
|
|
2291
|
+
# resolution to reach). `_resolve_posix_shell()` itself returns None
|
|
2292
|
+
# unconditionally on a non-Windows host, so `posix_shell` stays None
|
|
2293
|
+
# and the dispatch below is byte-identical to the pre-#602 behavior
|
|
2294
|
+
# there. A row declaring no `pre-tag` commands never needs a shell
|
|
2295
|
+
# at all, so resolution -- and its failure mode -- is skipped
|
|
2296
|
+
# entirely rather than blocking a target that never runs anything.
|
|
2297
|
+
pre_tag_commands = row.get("pre_tag") or []
|
|
2298
|
+
posix_shell = (
|
|
2299
|
+
_resolve_posix_shell() if (os.name == "nt" and pre_tag_commands) else None)
|
|
2300
|
+
if os.name == "nt" and pre_tag_commands and posix_shell is None:
|
|
2301
|
+
# Exit 9, never 7 (which names a specific command's own
|
|
2302
|
+
# interpreter as unresolvable): this is the DISPATCH MECHANISM
|
|
2303
|
+
# itself being unavailable, before any declared command was even
|
|
2304
|
+
# attempted -- "could not run" one level up, the same way exit 8
|
|
2305
|
+
# is "the probe could not run" one level up from exit 6.
|
|
2306
|
+
sys.stderr.write(
|
|
2307
|
+
"run-pre-tag: COULD NOT RUN -- no POSIX-compatible shell "
|
|
2308
|
+
"could be resolved on this Windows host.\n"
|
|
2309
|
+
" This is NOT drift. No declared command has run, so "
|
|
2310
|
+
"nothing was checked -- do not reconcile it as a check "
|
|
2311
|
+
"failure.\n"
|
|
2312
|
+
" `subprocess.run(shell=True)` dispatches via `cmd.exe` on "
|
|
2313
|
+
"Windows, which cannot expand a row's `\"$PY\"` / `$VAR` "
|
|
2314
|
+
"syntax (#602), so this subcommand requires a POSIX shell "
|
|
2315
|
+
"to dispatch declared rows portably.\n"
|
|
2316
|
+
" Remedy: install Git for Windows "
|
|
2317
|
+
"(https://git-scm.com/download/win), which ships "
|
|
2318
|
+
"`bash.exe`, or put an existing Git-for-Windows `bash.exe` "
|
|
2319
|
+
"on PATH. Then re-run. No release-edit discard is needed: "
|
|
2320
|
+
"nothing was checked, so there is nothing to undo.\n")
|
|
2321
|
+
return 9
|
|
2322
|
+
|
|
2323
|
+
def _tree_state():
|
|
2324
|
+
"""(paths -> content digest, failure). Content, not just the
|
|
2325
|
+
porcelain LINE (HIGH, adversarial review run 10).
|
|
2326
|
+
|
|
2327
|
+
A porcelain-line set alone cannot see a command that mutates a
|
|
2328
|
+
file which was ALREADY modified: the line is byte-identical
|
|
2329
|
+
(` M CHANGELOG.md` before and after), so the change falls out
|
|
2330
|
+
of the set difference and the command exits 0. That blind spot
|
|
2331
|
+
covered exactly `$CHANGELOG` and `$MANIFEST` -- the two files
|
|
2332
|
+
Phase 1 touches immediately before this step, and the two an
|
|
2333
|
+
injected line would actually damage, since both ship: one into
|
|
2334
|
+
the tag message and the Release notes, the other as the
|
|
2335
|
+
version the tag claims. Demonstrated with a changed sha256 and
|
|
2336
|
+
an `INJECTED` line surviving to exit 0.
|
|
2337
|
+
|
|
2338
|
+
Digesting every path git reports as changed closes it. Paths
|
|
2339
|
+
git lists but that do not exist (a deletion, or a rename's old
|
|
2340
|
+
side) get a sentinel rather than being skipped, so a delete is
|
|
2341
|
+
a state change like any other.
|
|
2342
|
+
"""
|
|
2343
|
+
# `git_executable()`, not a bare "git": Pi-reachable modules
|
|
2344
|
+
# must resolve git through the trusted-path seam, because a
|
|
2345
|
+
# host that has no `git` on PATH (or a PATH an attacker can
|
|
2346
|
+
# prepend to) would otherwise silently run the wrong binary or
|
|
2347
|
+
# none at all. Enforced by test_pi_package's
|
|
2348
|
+
# `test_shared_python_contains_no_direct_bare_git_subprocess`.
|
|
2349
|
+
# `--` plus the same `:/` + `,top`-exclusion pathspec the
|
|
2350
|
+
# release skill's own Pre-flight and step-7 clean-tree checks
|
|
2351
|
+
# are required to spell (#584 MEDIUM-1): the hooks append to
|
|
2352
|
+
# `gate-events.log` on essentially every command, INCLUDING the
|
|
2353
|
+
# commands this probe's own caller (`run-pre-tag`) runs, so a
|
|
2354
|
+
# mid-window append between the baseline snapshot and a
|
|
2355
|
+
# post-command probe put a blameless audit log in the changed
|
|
2356
|
+
# set -- and exit 6's remedy tells the operator to permanently
|
|
2357
|
+
# delete a release gate for it. `.markers/` is exempted for the
|
|
2358
|
+
# same reason the skill exempts it: a per-machine confirmation
|
|
2359
|
+
# marker minted by this same run is not a release surface.
|
|
2360
|
+
probe = subprocess.run(
|
|
2361
|
+
[git_executable(), "status", "--porcelain", "--", ":/",
|
|
2362
|
+
":(exclude,top).codearbiter/gate-events.log",
|
|
2363
|
+
":(exclude,top).codearbiter/.markers/"],
|
|
2364
|
+
capture_output=True, text=True, cwd=project_root)
|
|
2365
|
+
if probe.returncode != 0:
|
|
2366
|
+
return None, (probe.stderr.strip() or "git status failed")
|
|
2367
|
+
state = {}
|
|
2368
|
+
for line in probe.stdout.splitlines():
|
|
2369
|
+
# Porcelain v1: XY then a space then the path. A rename
|
|
2370
|
+
# carries `old -> new`; take the destination, which is the
|
|
2371
|
+
# path that exists on disk.
|
|
2372
|
+
rel = line[3:].strip().strip('"')
|
|
2373
|
+
if " -> " in rel:
|
|
2374
|
+
rel = rel.split(" -> ", 1)[1].strip().strip('"')
|
|
2375
|
+
if not rel:
|
|
2376
|
+
continue
|
|
2377
|
+
absolute = os.path.join(project_root, *rel.split("/"))
|
|
2378
|
+
try:
|
|
2379
|
+
with open(absolute, "rb") as fh:
|
|
2380
|
+
digest = hashlib.sha256(fh.read()).hexdigest()
|
|
2381
|
+
except OSError:
|
|
2382
|
+
digest = "<absent-or-unreadable>"
|
|
2383
|
+
state[rel] = digest
|
|
2384
|
+
return state, None
|
|
2385
|
+
|
|
2386
|
+
# The assertion is "this command changed NOTHING NEW", not "the
|
|
2387
|
+
# tree is pristine" (HIGH, run 9). The first form is what this
|
|
2388
|
+
# command is for; the second form BLOCKS EVERY RELEASE, because
|
|
2389
|
+
# Phase 1 rolls the changelog and bumps the manifest BEFORE this
|
|
2390
|
+
# step runs -- and it must, since a badge or catalog check compares
|
|
2391
|
+
# a surface against the NEW version and would pass vacuously
|
|
2392
|
+
# against the old one. Requiring a pristine tree here made the lane
|
|
2393
|
+
# exit 6 even for a row declaring no commands at all.
|
|
2394
|
+
#
|
|
2395
|
+
# Snapshotting instead keeps the property that matters: any path a
|
|
2396
|
+
# declared command touches appears as a NEW entry and is reported,
|
|
2397
|
+
# while the operator's own in-flight release edits are carried
|
|
2398
|
+
# through untouched.
|
|
2399
|
+
baseline, failure = _tree_state()
|
|
2400
|
+
if failure is not None:
|
|
2401
|
+
# Exit 8, never 6 (#584 residual / house rule: "could not run"
|
|
2402
|
+
# is never folded into "ran and disagreed", and the same holds
|
|
2403
|
+
# one level up for a PROBE that could not run at all). Exit 6
|
|
2404
|
+
# is a specific, actionable diagnosis -- "a command mutated the
|
|
2405
|
+
# tree" -- and this is not that: the probe failed before any
|
|
2406
|
+
# declared command even ran, so there is no verdict about the
|
|
2407
|
+
# commands to report at all, and exit 6's "fix the declaration"
|
|
2408
|
+
# remedy would misdirect an operator at the wrong problem.
|
|
2409
|
+
sys.stderr.write(
|
|
2410
|
+
f"run-pre-tag: the tree-state PROBE itself failed: {failure}\n"
|
|
2411
|
+
" This is not a verdict about any declared command -- no "
|
|
2412
|
+
"command has run yet, so nothing has been checked or "
|
|
2413
|
+
"mutated. Investigate why `git status` failed in this tree "
|
|
2414
|
+
"(not a git repository, no readable .git, etc.) before "
|
|
2415
|
+
"re-running.\n")
|
|
2416
|
+
return 8
|
|
2417
|
+
|
|
2418
|
+
for command in pre_tag_commands:
|
|
2419
|
+
# flush=True: the subprocess writes to the same fds directly and
|
|
2420
|
+
# is not buffered, so without this the label lands AFTER the
|
|
2421
|
+
# output it labels and the log misattributes which command
|
|
2422
|
+
# produced what -- actively misleading in the one report an
|
|
2423
|
+
# operator reads to decide whether a release is safe.
|
|
2424
|
+
print(f"pre-tag: {command}", flush=True)
|
|
2425
|
+
# `PY` exported to the child's environment (#583 MEDIUM-2 / #584
|
|
2426
|
+
# MEDIUM-3): the interpreter-resolution convention the release
|
|
2427
|
+
# skill establishes for its OWN invocations stopped at the
|
|
2428
|
+
# skill's own commands -- a declared row is operator shell this
|
|
2429
|
+
# lane EXECUTES exactly like any other step, so a row hardcoding
|
|
2430
|
+
# `python3` fails on exactly the host the convention exists for.
|
|
2431
|
+
# `sys.executable` is THIS process's own resolved interpreter,
|
|
2432
|
+
# so a row may portably spell `"$PY"` instead.
|
|
2433
|
+
if posix_shell is not None:
|
|
2434
|
+
# #602: dispatch via the resolved POSIX shell's OWN `-c`
|
|
2435
|
+
# argv form (`shell=False`), never
|
|
2436
|
+
# `shell=True, executable=posix_shell`. CPython's Windows
|
|
2437
|
+
# `shell=True` path unconditionally builds
|
|
2438
|
+
# `<executable> /c "<command>"` -- cmd.exe-flag syntax --
|
|
2439
|
+
# regardless of what `executable` names, so passing
|
|
2440
|
+
# `executable=<bash.exe>` there would invoke
|
|
2441
|
+
# `bash.exe /c "command"`; `/c` is not a bash option (bash
|
|
2442
|
+
# options start with `-`), so bash would try to run a
|
|
2443
|
+
# nonexistent file literally named `/c` and fail with exit
|
|
2444
|
+
# 127 -- misdiagnosing EVERY row as could-not-run regardless
|
|
2445
|
+
# of whether the row itself is valid. Building the argv
|
|
2446
|
+
# ourselves is the only correct way to dispatch a
|
|
2447
|
+
# POSIX-syntax command string through a specific non-default
|
|
2448
|
+
# shell on Windows.
|
|
2449
|
+
proc = subprocess.run(
|
|
2450
|
+
[posix_shell, "-c", command], shell=False, cwd=project_root,
|
|
2451
|
+
env={**os.environ, "PY": sys.executable})
|
|
2452
|
+
else:
|
|
2453
|
+
proc = subprocess.run(
|
|
2454
|
+
command, shell=True, cwd=project_root,
|
|
2455
|
+
env={**os.environ, "PY": sys.executable})
|
|
2456
|
+
if _could_not_run(proc.returncode):
|
|
2457
|
+
# Exit 7, never 5 (#585 MEDIUM-2 / #584 MEDIUM-1): a command
|
|
2458
|
+
# whose interpreter or program itself could not be located
|
|
2459
|
+
# was never actually RUN, so nothing was checked and there
|
|
2460
|
+
# is no drift to reconcile -- the exit-5 remedy below is the
|
|
2461
|
+
# wrong diagnosis for this case and its "discard this run's
|
|
2462
|
+
# uncommitted release edits" step is unnecessary busywork,
|
|
2463
|
+
# since nothing the row asserts was ever evaluated.
|
|
2464
|
+
sys.stderr.write(
|
|
2465
|
+
f"run-pre-tag: COULD NOT RUN -- {command!r} exited "
|
|
2466
|
+
f"{proc.returncode} (interpreter or command not "
|
|
2467
|
+
"found).\n"
|
|
2468
|
+
" This is NOT drift. The command's interpreter or "
|
|
2469
|
+
"program itself could not be located, so it never ran "
|
|
2470
|
+
"and nothing was checked -- do not reconcile it as a "
|
|
2471
|
+
"check failure.\n"
|
|
2472
|
+
" Remedy: fix the interpreter this row names for THIS "
|
|
2473
|
+
"host (a common cause is a row hardcoding a specific "
|
|
2474
|
+
"interpreter, e.g. `python3`, on a host that has only "
|
|
2475
|
+
"`python`) -- `\"$PY\"` (#601/#602) is the portable "
|
|
2476
|
+
"spelling on every platform this dispatch supports. "
|
|
2477
|
+
"Then re-run. No release-edit discard is needed: "
|
|
2478
|
+
"nothing was checked, so there is nothing to undo.\n")
|
|
2479
|
+
return 7
|
|
2480
|
+
if proc.returncode != 0:
|
|
2481
|
+
sys.stderr.write(
|
|
2482
|
+
f"run-pre-tag: BLOCK -- {command!r} exited "
|
|
2483
|
+
f"{proc.returncode}.\n"
|
|
2484
|
+
" A pre-tag command is a check, never a fixer "
|
|
2485
|
+
"(DECISION-0034), so reconcile the drift it reports.\n"
|
|
2486
|
+
" Then DISCARD this run's uncommitted release edits "
|
|
2487
|
+
"(the manifest bump and the composed changelog section) "
|
|
2488
|
+
"before starting over: leaving the bump in place makes "
|
|
2489
|
+
"it the NEXT run's version floor, so the restart derives "
|
|
2490
|
+
"a HIGHER version and strands the section this run "
|
|
2491
|
+
"already wrote for a version that was never tagged "
|
|
2492
|
+
"(HIGH, adversarial review run 9). Commit the "
|
|
2493
|
+
"reconciliation alone, then re-run the release from "
|
|
2494
|
+
"Pre-flight.\n")
|
|
2495
|
+
return 5
|
|
2496
|
+
current, failure = _tree_state()
|
|
2497
|
+
if failure is not None:
|
|
2498
|
+
sys.stderr.write(
|
|
2499
|
+
f"run-pre-tag: the tree-state PROBE itself failed after "
|
|
2500
|
+
f"{command!r} ran: {failure}\n"
|
|
2501
|
+
" This is not a verdict about the command that just "
|
|
2502
|
+
"ran -- the probe that would confirm or refute a "
|
|
2503
|
+
"mutation could not complete, so no verdict about it "
|
|
2504
|
+
"exists.\n")
|
|
2505
|
+
return 8
|
|
2506
|
+
# The UNION of both key sets, not `current` alone. A path git
|
|
2507
|
+
# reported as changed at baseline and no longer reports has been
|
|
2508
|
+
# REVERTED by the command -- which is a mutation of the tree in
|
|
2509
|
+
# exactly the sense this gate exists to catch, and the most
|
|
2510
|
+
# dangerous one: a pre-tag command that quietly undoes the
|
|
2511
|
+
# lane's own manifest bump or changelog section leaves a release
|
|
2512
|
+
# that tags a version the payload never claims. Walking
|
|
2513
|
+
# `current.items()` could not see it, because a reverted path
|
|
2514
|
+
# simply stops appearing.
|
|
2515
|
+
changed = sorted(
|
|
2516
|
+
rel for rel in set(baseline) | set(current)
|
|
2517
|
+
if baseline.get(rel) != current.get(rel))
|
|
2518
|
+
if changed:
|
|
2519
|
+
sys.stderr.write(
|
|
2520
|
+
f"run-pre-tag: BLOCK -- {command!r} exited 0 but MUTATED "
|
|
2521
|
+
"the tree. Declared pre-tag commands are check-only "
|
|
2522
|
+
"(DECISION-0034); reconciliation is a separate action "
|
|
2523
|
+
"the operator commits before releasing.\n"
|
|
2524
|
+
" Changed by this command (added, edited, or reverted):"
|
|
2525
|
+
"\n "
|
|
2526
|
+
+ "\n ".join(changed) + "\n"
|
|
2527
|
+
" FIX THE DECLARATION -- do not simply re-run. This "
|
|
2528
|
+
"command mutates the tree every time it is invoked, so "
|
|
2529
|
+
"reverting and re-running cannot converge; make it "
|
|
2530
|
+
"check-only (assert and exit non-zero on drift) or "
|
|
2531
|
+
"remove the row entry. Only then restart the release "
|
|
2532
|
+
"from Pre-flight.\n"
|
|
2533
|
+
" This run's edits can be discarded wholesale, because "
|
|
2534
|
+
"nothing has been committed or tagged yet: `git "
|
|
2535
|
+
"checkout -- <tracked paths>` for files that existed "
|
|
2536
|
+
"before, and `rm` for any the lane CREATED (a first "
|
|
2537
|
+
"release composes $CHANGELOG from nothing, and `git "
|
|
2538
|
+
"checkout --` errors on a path git has never seen).\n")
|
|
2539
|
+
return 6
|
|
2540
|
+
return 0
|
|
2541
|
+
|
|
2542
|
+
if cmd == "semver-greater" and len(rest) == 2:
|
|
2543
|
+
# MEDIUM (adversarial review 2026-07-31, run 6): the hard rules say
|
|
2544
|
+
# "MUST NOT guess the version", and every other mechanical step in
|
|
2545
|
+
# the lane got a tested helper -- but the bump arithmetic and the
|
|
2546
|
+
# strictly-greater assertion were both hand-done, because
|
|
2547
|
+
# `semver_greater` was public API reachable only by import. This
|
|
2548
|
+
# is also the mechanism the manifest-floor check needs (HIGH, run
|
|
2549
|
+
# 6: a never-tagged project whose manifest already reads 1.4.2 was
|
|
2550
|
+
# released as 0.1.0, walking its own version backward, because the
|
|
2551
|
+
# only greater-than check compared against a `<none>` sentinel).
|
|
2552
|
+
# Exit 0 iff `candidate` is STRICTLY greater than `floor`; 1
|
|
2553
|
+
# otherwise -- including equal, which is the case that matters.
|
|
2554
|
+
# Parseability is checked SEPARATELY and reported as exit 2, never
|
|
2555
|
+
# folded into the exit-1 "not greater" answer. `semver_greater` is
|
|
2556
|
+
# non-raising by this module's mechanism-function invariant, so an
|
|
2557
|
+
# unparseable version returns False -- which is fail-CLOSED for the
|
|
2558
|
+
# floor check (a garbage version cannot clear the floor) but is
|
|
2559
|
+
# indistinguishable from a genuine "not greater". Conflating "I
|
|
2560
|
+
# compared them and the answer is no" with "I could not compare
|
|
2561
|
+
# them" is the exact defect class this lane's exit-3-vs-4 work
|
|
2562
|
+
# already fixed once; it is not reintroduced here.
|
|
2563
|
+
for value in rest:
|
|
2564
|
+
if semver_key(value) is None:
|
|
2565
|
+
sys.stderr.write(
|
|
2566
|
+
f"semver-greater: not valid SemVer: {value!r} -- this is "
|
|
2567
|
+
"NOT the same answer as 'not greater' (exit 1); the "
|
|
2568
|
+
"comparison did not happen\n")
|
|
2569
|
+
return 2
|
|
2570
|
+
return 0 if semver_greater(rest[0], rest[1]) else 1
|
|
2571
|
+
|
|
2572
|
+
if cmd == "apply-bump" and len(rest) == 2:
|
|
2573
|
+
# #585 MEDIUM-3: the one number that mattered was the only step
|
|
2574
|
+
# left to the eye. Step 4 had the operator "apply the step-2 bump
|
|
2575
|
+
# to `$BASE_VERSION`" by hand -- none of the other fifteen
|
|
2576
|
+
# subcommands does this arithmetic, and nothing downstream
|
|
2577
|
+
# re-derives it (a minor window mis-applied as a patch still passes
|
|
2578
|
+
# `semver-greater` and `check-manifests`, because both compare
|
|
2579
|
+
# against the same wrong value this step just wrote). This is the
|
|
2580
|
+
# sanctioned way to do it instead: exit 0 with the bumped version
|
|
2581
|
+
# on stdout, or exit 2 with nothing to stdout when `base` is not
|
|
2582
|
+
# plain SemVer or `word` is not exactly one of `major`/`minor`/
|
|
2583
|
+
# `patch` -- `none` included, deliberately: a caller must never
|
|
2584
|
+
# apply a non-bump, and this refuses rather than echoing `base`
|
|
2585
|
+
# back unchanged.
|
|
2586
|
+
base, word = rest
|
|
2587
|
+
result = apply_bump(base, word)
|
|
2588
|
+
if result is None:
|
|
2589
|
+
sys.stderr.write(
|
|
2590
|
+
f"apply-bump: cannot apply bump {word!r} to base {base!r}. "
|
|
2591
|
+
"`base` must be plain MAJOR.MINOR.PATCH SemVer (no "
|
|
2592
|
+
"pre-release or build metadata) and `word` must be exactly "
|
|
2593
|
+
"'major', 'minor', or 'patch' -- 'none' is deliberately "
|
|
2594
|
+
"refused here, since a caller must never apply a non-bump.\n")
|
|
2595
|
+
return 2
|
|
2596
|
+
print(result)
|
|
2597
|
+
return 0
|
|
2598
|
+
|
|
2599
|
+
if cmd == "dates-match" and len(rest) == 2:
|
|
2600
|
+
# MEDIUM (adversarial review 2026-07-31, run 4): Phase 1 step 5 and
|
|
2601
|
+
# Phase 2 step 1 both name `release_dates_consistent`, and Phase 2
|
|
2602
|
+
# says it "must pass" -- but this CLI exposed no way to run it, so
|
|
2603
|
+
# an operator following the prose could not perform a check the
|
|
2604
|
+
# prose demanded. The exercising agent could only reach it by
|
|
2605
|
+
# importing the module, which the skill never tells anyone to do.
|
|
2606
|
+
# Same read-and-compare shape as `notes-match` above, including its
|
|
2607
|
+
# unreadable-file-is-empty-text behaviour (an unreadable file has
|
|
2608
|
+
# no date, so the comparison is False, so the exit code is 1 --
|
|
2609
|
+
# never a traceback).
|
|
2610
|
+
texts = []
|
|
2611
|
+
for path in rest:
|
|
2612
|
+
try:
|
|
2613
|
+
with open(path, encoding="utf-8") as fh:
|
|
2614
|
+
texts.append(fh.read())
|
|
2615
|
+
except OSError:
|
|
2616
|
+
texts.append("")
|
|
2617
|
+
return 0 if release_dates_consistent(texts[0], texts[1]) else 1
|
|
2618
|
+
|
|
2619
|
+
if cmd == "classify" and len(rest) == 6:
|
|
2620
|
+
as_bool = lambda s: str(s).lower() == "true"
|
|
2621
|
+
print(classify_publish_state(
|
|
2622
|
+
tag_exists=as_bool(rest[0]), tag_sha=rest[1], head_sha=rest[2],
|
|
2623
|
+
tag_version=rest[3], manifest_version=rest[4],
|
|
2624
|
+
release_is_nondraft=as_bool(rest[5])))
|
|
2625
|
+
return 0
|
|
2626
|
+
|
|
2627
|
+
if cmd == "peel-tag" and len(rest) == 1:
|
|
2628
|
+
# HIGH-1 (adversarial review 2026-07-31): this subcommand did not
|
|
2629
|
+
# exist in this module's own CLI before this fix, even though
|
|
2630
|
+
# `peel_tag` was already public API and `.github/scripts/
|
|
2631
|
+
# _releaselib.py` (this repo's OWN, non-portable CI shim) already
|
|
2632
|
+
# exposed it. A consumer shelling out to the VENDORED copy of THIS
|
|
2633
|
+
# file had no sanctioned way to peel an annotated tag to its commit
|
|
2634
|
+
# at all, which left `git rev-parse <tag>` as the only thing a
|
|
2635
|
+
# reader would reach for -- exactly the value that misclassifies a
|
|
2636
|
+
# healthy tag (see the docstring above and the release skill's
|
|
2637
|
+
# Phase 2 step 1).
|
|
2638
|
+
print(peel_tag(sys.stdin.read(), rest[0]))
|
|
2639
|
+
return 0
|
|
2640
|
+
|
|
2641
|
+
if cmd == "backfill-detect" and len(rest) <= 1:
|
|
2642
|
+
root = rest[0] if rest else default_backfill_root()
|
|
2643
|
+
manifests, changelogs = scan_backfill_candidates(root)
|
|
2644
|
+
try:
|
|
2645
|
+
row = detect_candidate_target(manifests, changelogs)
|
|
2646
|
+
except BackfillAmbiguousError as exc:
|
|
2647
|
+
sys.stderr.write(f"{exc}\n")
|
|
2648
|
+
return 1
|
|
2649
|
+
sys.stdout.write(format_release_targets_block(row))
|
|
2650
|
+
return 0
|
|
2651
|
+
|
|
2652
|
+
sys.stderr.write(f"_releaselib.py: bad invocation: {' '.join(argv)}\n")
|
|
2653
|
+
return 2
|
|
2654
|
+
|
|
2655
|
+
|
|
2656
|
+
if __name__ == "__main__":
|
|
2657
|
+
sys.exit(main(sys.argv[1:]))
|