Compare commits

...
1 Commits
Author SHA1 Message Date
Nick 87bce8cfa3 fix(release): repair reporting delivery and evidence honesty (#2582)
deploy / deploy (push) Skipped
CI Pipeline / go-surface (push) Canceled after 0s
CI Pipeline / rust-surface (push) Canceled after 0s
CI Pipeline / canary (push) Canceled after 0s
CI Pipeline / best-practices (push) Canceled after 0s
CI Pipeline / documentation (push) Canceled after 0s
CI Pipeline / type-check (push) Canceled after 0s
CI Pipeline / gitea-offline (push) Canceled after 0s
CI Pipeline / required-checks (push) Canceled after 0s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Canceled after 0s
CI Pipeline / lint (push) Canceled after 26s
CI Pipeline / unit-tests (push) Canceled after 26s
Auto-label / auto-label (pull_request) Successful in 1s
Spec gate (headless) / spec-gate (pull_request) Successful in 22s
Local-Model First-Pass Review (#131) / first-pass-review (pull_request) Successful in 23s
CI Pipeline / lint (pull_request) Failing after 43s
CI Pipeline / go-surface (pull_request) Failing after 22s
CI Pipeline / rust-surface (pull_request) Failing after 7s
CI Pipeline / canary (pull_request) Failing after 1m49s
CI Pipeline / best-practices (pull_request) Successful in 5s
CI Pipeline / documentation (pull_request) Successful in 3s
CI Pipeline / type-check (pull_request) Failing after 36s
CI Pipeline / gitea-offline (pull_request) Successful in 14s
CI Pipeline / unit-tests (pull_request) Failing after 26m54s
CI Pipeline / required-checks (pull_request) Failing after 1s
CI Pipeline / CI Pipeline / notify-event-bridge (pull_request) Failing after 5s
2026-10-25 05:39:15 +00:00
11 changed files with 491 additions and 59 deletions

No files matched your search

+48 -39
View File
@@ -150,23 +150,30 @@ jobs:
\ live on master; TUI footer will render v{NEW}\")\nwith open(os.environ[\"\
GITHUB_ENV\"], \"a\") as env:\n env.write(f\"RELEASE_VERSION={NEW}\\n\"\
)\n env.write(\"AUTO_BUMPED=true\\n\")\n env.write(f\"BUMP_COMMIT_SHA={commit_sha}\\n\")\nPYBUMP\n"
- name: Capture immutable release range
run: |
set -euo pipefail
git fetch --tags origin master
RANGE=$(python3 scripts/tag_repair.py --release-range "$RELEASE_VERSION" --bump-sha "${BUMP_COMMIT_SHA:-}")
export RANGE
python3 - <<'PYRANGE'
import json, os
value = json.loads(os.environ["RANGE"])
with open(os.environ["GITHUB_ENV"], "a") as env:
print("RELEASE_SINCE=" + value["since"], file=env)
print("RELEASE_TO=" + value["to"], file=env)
PYRANGE
- name: Create evidence-derived release (SCAFFOLD-TRANCHE-009B)
env:
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }}
RELEASE_SERVER_URL: ${{ gitea.server_url }}
RELEASE_REPO: ${{ gitea.repository }}
run: "# ONE note-generation authority: the tested release_notes module.\n# The\
\ old PR-title/body paste path (which produced empty '##'\n# bodies on direct-push\
\ releases) is REMOVED \u2014 governed evidence\n# from the scaffold store\
\ is the source, not PR prose.\n# EXIT-NOTE TEMPLATE (Nick 2026-10-08): plain-English\
\ summary of\n# what was worked on and submitted \u2014 the version-history\
\ reader's\n# counterpart of the issue-entry template. Appended to the\n#\
\ evidence-derived body so governed facts AND human narrative\n# both appear.\n\
LAST_TAG=$(python3 scripts/tag_repair.py --latest-tag --namespace main)\nEXIT_NOTE=$(python3 scripts/release_exit_note.py\
\ --since \"$LAST_TAG\" --to HEAD) || EXIT_NOTE=\"\"\npython3 scripts/release_history_cli.py\
\ init \\\n --version \"$RELEASE_VERSION\" \\\n --server \"$RELEASE_SERVER_URL\"\
\ --repo \"$RELEASE_REPO\" \\\n --store store/scaffold/current.json \\\n\
\ ${EXIT_NOTE:+--exit-note \"$EXIT_NOTE\"}"
run: |
EXIT_NOTE=$(python3 scripts/release_exit_note.py --since "$RELEASE_SINCE" --to "$RELEASE_TO")
python3 scripts/release_history_cli.py init \
--version "$RELEASE_VERSION" --release-sha "$RELEASE_TO" \
--server "$RELEASE_SERVER_URL" --repo "$RELEASE_REPO" \
--store store/scaffold/current.json --exit-note "$EXIT_NOTE"
- name: Plain-English narrative at the top of the release body
env:
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }}
@@ -180,24 +187,26 @@ jobs:
# Python. Idempotent: a body already carrying the section is left
# alone. Additive and non-blocking: a missing toolchain or a failed
# prepend must never fail a release.
LAST_TAG=$(python3 scripts/tag_repair.py --latest-tag --namespace main)
if ! command -v go >/dev/null 2>&1; then
echo "::warning::go toolchain unavailable — release published without the plain-English narrative"
exit 0
fi
(cd src/go/release-notes && go build -o /tmp/rune-release-notes .) || {
BUILD_DIR=$(mktemp -d)
trap 'rm -rf "$BUILD_DIR"' EXIT
(cd src/go/release-notes && go build -o "$BUILD_DIR/release-notes" .) || {
echo "::warning::release-notes build failed — narrative skipped"; exit 0; }
# rebuild-body (#2582): the FULL plain-english template — proper
# markdown (one H1 title, sections in reading order, capped bullets,
# the no-changes contradiction scrubbed, evidence tails preserved).
(cd src/go/release-notes && go build -o /tmp/rune-release-notes .) || true
/tmp/rune-release-notes rebuild-body \
"$BUILD_DIR/release-notes" rebuild-body \
--server "$RELEASE_SERVER_URL" --repo "$RELEASE_REPO" \
--tag "v${RELEASE_VERSION}" --since "$LAST_TAG" || {
--tag "v${RELEASE_VERSION}" --since "$RELEASE_SINCE" --to "$RELEASE_TO" || {
echo "::warning::body rebuild failed — release body left unchanged"; exit 0; }
- name: Sync the committed changelog from the version tags
env:
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }}
RELEASE_SERVER_URL: ${{ gitea.server_url }}
RELEASE_REPO: ${{ gitea.repository }}
run: |
# CHANGELOG LAW (#2582): every release syncs the committed
# CHANGELOG.md (deterministic regeneration from the tags). CI's lint
@@ -205,28 +214,26 @@ jobs:
# skipped its sync turns the gate red on the next PR. The sync
# commit is chore(changelog) (= releases nothing; the convergence
# classifier's "other" bucket), so it earns no version.
(cd src/go/release-notes && go build -o /tmp/rn-changelog .) || {
echo "::warning::changelog sync skipped — go toolchain unavailable"; exit 0; }
/tmp/rn-changelog changelog
set -euo pipefail
BUILD_DIR=$(mktemp -d)
trap 'rm -rf "$BUILD_DIR"' EXIT
(cd src/go/release-notes && go build -o "$BUILD_DIR/release-notes" .)
PYTHONPATH=scripts python3 - <<'PYPUSH'
from pathlib import Path
import tag_repair
error = tag_repair._configure_http_auth(Path.cwd())
if error:
raise SystemExit(error)
PYPUSH
git fetch --tags origin master
git checkout -B master FETCH_HEAD
"$BUILD_DIR/release-notes" changelog
if ! git diff --quiet CHANGELOG.md; then
git config user.name "release-bot"
git config user.email "release-bot@rune.dev"
git add CHANGELOG.md
# push auth = tag_repair's tested extraheader mechanism
python3 - <<'PYPUSH'
import tag_repair
import os, subprocess
repo = os.getcwd()
try:
err = tag_repair._configure_http_auth(repo)
except Exception as e:
err = str(e)
if err:
print("::warning::changelog push auth unavailable — next release retries:", str(err)[:120])
else:
r = subprocess.run(["git", "push", "gitea", "master"], capture_output=True, text=True)
print("changelog push:", r.returncode, (r.stdout or r.stderr)[:200])
PYPUSH
git commit -m "chore(changelog): sync release history for v${RELEASE_VERSION}"
git push origin HEAD:refs/heads/master
else
echo "changelog already current"
fi
@@ -248,7 +255,9 @@ jobs:
echo "::warning::go toolchain unavailable — README highlights not refreshed"
exit 0
fi
(cd src/go/release-notes && go build -o /tmp/rune-release-notes .) || {
BUILD_DIR=$(mktemp -d)
trap 'rm -rf "$BUILD_DIR"' EXIT
(cd src/go/release-notes && go build -o "$BUILD_DIR/release-notes" .) || {
echo "::warning::release-notes build failed — highlights refresh skipped"; exit 0; }
# The bump step edits README's version strings server-side (API
# commit) and never touches this checkout — re-point master at the
@@ -258,7 +267,7 @@ jobs:
echo "::warning::git fetch failed — highlights refresh skipped"; exit 0; }
git checkout -B master FETCH_HEAD || {
echo "::warning::master re-point failed — highlights refresh skipped"; exit 0; }
ACTION=$(/tmp/rune-release-notes refresh-highlights \
ACTION=$("$BUILD_DIR/release-notes" refresh-highlights \
--file README.md --server "$RELEASE_SERVER_URL" --repo "$RELEASE_REPO") || {
echo "::warning::refresh-highlights failed — README left unchanged"; exit 0; }
echo "refresh-highlights: $ACTION"
@@ -326,5 +335,5 @@ jobs:
python3 scripts/tag_repair.py --ensure "${RELEASE_VERSION}" \
--bump-sha "${BUMP_COMMIT_SHA:-}" --push
concurrency:
group: auto-version-and-release-${ github.ref }
cancel-in-progress: true
group: auto-version-and-release-${{ github.ref }}
cancel-in-progress: false
+34
View File
@@ -0,0 +1,34 @@
# Release reporting repair implementation plan
**Goal:** Finish the remaining #2582 release delivery defects without claiming unsupported verification.
**Architecture:** Keep the existing Go report generator and Python release integration. Capture one immutable release range before publication, use an isolated executable, and exercise the actual workflow shell in disposable repositories.
**Constraints:** Preserve the owner changelog edit; no new Python product helpers; no master push, protection bypass, or live backfill. Normal hooks and required CI remain mandatory. Two authorized successful delivery cycles are required before completion.
Admission: `ADMIT-release-final-2582-20261024`, fresh classify → activate → admit, phase building. Base fetched and advanced to `d58ba57f32ff609c968351f3c2e0aa9ea63a27b2`. Owner patch preserved at `/tmp/rune-2582-changelog-preserved.patch` and in the worktree.
## Execution
- [ ] Add failing parsed-workflow syntax and cancellation tests; fix shell and concurrency.
- [ ] Add disposable origin-only changelog execution tests for commit, push, repeat execution and rejection; repair the real step.
- [ ] Add initial/repeated range tests with mixed namespaces and unrelated newer tags; capture and reuse pinned endpoints.
- [ ] Add missing/failed-evidence tests; replace unsupported generic success claims.
- [ ] Verify unique build paths, run focused suites and mutation checks, record finding → test → commit.
- [ ] Commit with normal hooks, push only feature branch, open PR referencing #2582, collect required CI and authorized delivery evidence.
## Discharge evidence
The implementation commit carrying this record discharges these observed failures:
| Finding | Red-first regression | Observed failure |
| --- | --- | --- |
| Invalid embedded shell | `test_auto_version_shell_steps_parse` | Unterminated PYPUSH here-document, bash exit 2 |
| Parent cancellation | `test_release_does_not_cancel_its_parent` | cancel-in-progress was true |
| No changelog commit / ignored push failure | `test_changelog_step_commits_pushes_and_repeats` | No chore(changelog) commit; rejection returned success |
| Shared build file / duplicate compilation | `test_release_builds_are_private_and_not_duplicated` | Fixed /tmp path; actual integration also saw permission denied hidden as success |
| Repeated latest-tag lookup | `test_release_consumers_share_captured_range`, `test_release_range_pins_predecessor_and_target_across_reruns` | Independent lookup; range CLI absent |
| First release omitted | `test_first_release_range_includes_repository_root`, `TestFirstReleaseIncludesRoot`, `test_exit_note_first_release_includes_root` | Range rejected or root commit omitted |
| Invented verification success | `TestRebuildMissingEvidenceIsHonest`, `test_exit_note_does_not_invent_verification` | Generic claims emitted without any results |
Local verification: 73 Python tests pass across the three changed test modules; full release-notes Go suite passes; go vet passes; focused Ruff and git diff --check pass. Owner changelog diff byte-compares equal to the preserved patch. Mutation spot-check: removing the actual changelog commit from the parsed workflow causes the real-step test to fail. Existing highlights assertion updated only to the private binary path; non-blocking assertions preserved.
Not complete / not merge-ready: independent reviewer identified mutable all-tags changelog input, misleading historical first-release rendering, and reader-facing jargon/advice; these remain outstanding. No full repository pipeline, independent post-change approval, or two authorized delivery cycles has been established. Failed supplied evidence preservation also needs a dedicated regression. Do not backfill releases or close #2582 based on these local results.
+4 -10
View File
@@ -63,7 +63,7 @@ _NOISE_RE = re.compile(r"^chore\(release\): auto-bump|Merge (remote|pull)|^sync:
def commit_range(since, to="HEAD"):
r = subprocess.run(
["git", "log", f"{since}..{to}", "--format=%s", "--no-merges"],
["git", "log", to if since == "ROOT" else f"{since}..{to}", "--format=%s", "--no-merges"],
capture_output=True, text=True, cwd=REPO,
)
return [s.strip() for s in r.stdout.splitlines() if s.strip() and not _NOISE_RE.match(s)]
@@ -111,9 +111,7 @@ def render(since, to="HEAD"):
counts = ", ".join(f"{v} {k}" for k, v in sorted(by_kind.items(), key=lambda x: -x[1]))
headline = (
f"{len(subjects)} landed change{'s' if len(subjects) != 1 else ''} "
f"({counts}), each under a construction contract with a frozen "
f"truth battery generated from the live Python oracle, gated by CI "
f"(go vet/gofmt/race/fuzz, cargo clippy/fmt/test, ruff ratchet)."
f"({counts})."
)
return {
"version_range": f"{since}..{to}",
@@ -133,12 +131,8 @@ def render_markdown(note):
lines.append("")
lines.append("### How it was verified")
lines.append("")
lines.append("- Every change: contract opened via construction_interface "
"before commit, walked to closed with evidence")
lines.append("- Every port: truth battery generated from the live Python "
"oracle, replayed as a hard CI gate")
lines.append("- Pipeline: ruff + silent-handler ratchet (Python), go "
"vet/gofmt/race/fuzz (Go), cargo clippy/fmt/test (Rust)")
lines.append("- Verification evidence unavailable: commit summaries do not "
"establish which checks ran or passed for this version.")
return "\n".join(lines)
+23
View File
@@ -40,6 +40,7 @@ import argparse
import base64
import json
import os
import re
import subprocess
import sys
import urllib.request
@@ -267,6 +268,8 @@ def _configure_http_auth(
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Release tag convergence repair")
parser.add_argument("--release-range", metavar="VERSION",
help="print pinned predecessor and target commits as JSON")
parser.add_argument("--latest-tag", action="store_true",
help="print the highest release tag (SIGPIPE-free) and exit")
parser.add_argument("--namespace", default="main", choices=("main", "tui", "any"),
@@ -285,6 +288,26 @@ def main(argv: list[str] | None = None) -> int:
args = parser.parse_args(argv)
repo = args.repo or Path.cwd()
if args.release_range:
version = args.release_range.removeprefix("v")
if not re.fullmatch(r"[0-9]+[.][0-9]+[.][0-9]+", version):
parser.error("release range requires a numeric main version")
tag = f"v{version}"
target = f"refs/tags/{tag}" if tag_exists(tag, repo) else (args.bump_sha or "HEAD")
end = _git(["rev-parse", "--verify", f"{target}^{{commit}}"], repo).stdout.strip()
tags = _git(["for-each-ref", "--sort=-v:refname", "--format=%(refname:short)",
f"--merged={end}", "refs/tags/v*"], repo).stdout.splitlines()
for previous in tags:
if not re.fullmatch(r"v[0-9]+[.][0-9]+[.][0-9]+", previous):
continue
if previous == tag or not _semver_lte(previous[1:], version):
continue
start = _git(["rev-parse", f"refs/tags/{previous}^{{commit}}"], repo).stdout.strip()
if start != end:
print(json.dumps({"previous_tag": previous, "since": start, "to": end}))
return 0
print(json.dumps({"previous_tag": "", "since": "ROOT", "to": end}))
return 0
if args.latest_tag:
print(latest_release_tag(repo, namespace=args.namespace) or "")
return 0
+11 -8
View File
@@ -259,7 +259,11 @@ func GitChangedPaths(since, to string) ([]string, error) {
if to == "" {
to = "HEAD"
}
out, err := exec.Command("git", "log", "--name-only", "--format=", "--no-merges", since+".."+to).Output()
revision := since + ".." + to
if since == "ROOT" {
revision = to
}
out, err := exec.Command("git", "log", "--name-only", "--format=", "--no-merges", revision).Output()
if err != nil {
return nil, err
}
@@ -280,7 +284,11 @@ func GitLogSubjects(since, to string) ([]string, error) {
if to == "" {
to = "HEAD"
}
out, err := exec.Command("git", "log", "--format=%s", "--no-merges", since+".."+to).Output()
revision := since + ".." + to
if since == "ROOT" {
revision = to
}
out, err := exec.Command("git", "log", "--format=%s", "--no-merges", revision).Output()
if err != nil {
return nil, err
}
@@ -469,13 +477,8 @@ func RebuildReleaseBody(tag string, evidenceBody, narrative, tryList string) str
}
}
if len(verified) == 0 && narrative != "" {
// The recursion strip may consume the verification boilerplate that
// earlier rebuilds had eaten into their own sections — the standard
// gates ride every release regardless.
verified = []string{
"Every change: contract opened via construction_interface before commit, walked to closed with evidence",
"Every port: truth battery generated from the live Python oracle, replayed as a hard CI gate",
"Pipeline: ruff + silent-handler ratchet (Python), go vet/gofmt/race/fuzz (Go), cargo clippy/fmt/test (Rust)",
"Verification evidence unavailable: no recorded check results were supplied for this version.",
}
}
if len(verified) > 0 {
+35
View File
@@ -6,10 +6,45 @@
package main
import (
"os"
"os/exec"
"strings"
"testing"
)
func TestFirstReleaseIncludesRoot(t *testing.T) {
dir := t.TempDir()
old, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
defer os.Chdir(old)
for _, args := range [][]string{{"init", "-q"}, {"-c", "user.name=test", "-c", "user.email=test@example.invalid", "commit", "--allow-empty", "-m", "feat: first release"}} {
if out, err := exec.Command("git", args...).CombinedOutput(); err != nil {
t.Fatalf("%s: %v", out, err)
}
}
subjects, err := GitLogSubjects("ROOT", "HEAD")
if err != nil || len(subjects) != 1 || subjects[0] != "feat: first release" {
t.Fatalf("root omitted: %v %v", subjects, err)
}
}
func TestRebuildMissingEvidenceIsHonest(t *testing.T) {
body := RebuildReleaseBody("v1.0.0", "", "A release change", "")
if !strings.Contains(body, "Verification evidence unavailable") {
t.Fatalf("missing evidence must be explicit: %s", body)
}
for _, claim := range []string{"Every change:", "Every port:", "Pipeline:"} {
if strings.Contains(body, claim) {
t.Fatalf("invented claim %q", claim)
}
}
}
func TestNarrativeTranslationMapHits(t *testing.T) {
got := PlainEnglishNarrative([]string{
"feat(tui): osc 8 hyperlink support in the viewport",
@@ -0,0 +1,183 @@
{
"checkpoints": [
{
"at": "2026-10-25T00:40:05Z",
"evidence": "predicted-frontier",
"note": "{\"level0_direct\": [], \"level1_obligations\": [], \"contracts\": [], \"level2_verification\": []}",
"phase": "draft"
},
{
"at": "2026-10-25T00:40:36Z",
"evidence": "user: finish unfinished #2582 release work end-to-end bounded repair and normal feature PR",
"note": "",
"phase": "approved"
},
{
"at": "2026-10-25T00:41:05Z",
"evidence": "builder:release-final-2582",
"note": "",
"phase": "building"
}
],
"evidence_refs": [
"contract-created:ADMIT-release-final-2582-20261024"
],
"ext": {
"activation": {
"activated": true,
"builder": "release-final-2582",
"evidence": [
"affected surface known: ['.gitea/workflows/auto-version.yml', 'scripts/tag_repair.py']",
"change scoped to an existing project/target",
"target project: rune"
],
"lifecycle": "proportional",
"mode": "EXISTING_PROJECT_CHANGE",
"note": "attached to existing project authority; Need/completeness/patterns/contract flow governs proportionally",
"project_authority": "rune_internal",
"target_project": "rune"
},
"affected_paths": [
"plans/2026-10-24-2582-release-final.md",
"scripts/release_exit_note.py",
"scripts/tag_repair.py",
"src/go/release-notes/narrative.go",
"src/go/release-notes/narrative_test.go",
"tests/test_release_exit_note.py",
"tests/test_tag_repair.py",
"tests/test_workflow_script_contract.py"
],
"affected_regions": [],
"affected_ui_surfaces": [],
"expected_runtime_effects": [],
"predicted_frontier": {
"change_kind": "implementation_change",
"contextual_not_obligations": [],
"contracts": [],
"level0_direct": [],
"level1_obligations": [],
"level2_verification": [],
"max_level": 2,
"traversal_scope": "spine-structural-only"
},
"scope_provenance": {
"caller_added": [],
"generated_from_affected_surface": [
".gitea/workflows/auto-version.yml",
"plans/2026-10-24-2582-release-final.md",
"scripts/release_exit_note.py",
"scripts/tag_repair.py",
"src/go/release-notes/narrative.go",
"src/go/release-notes/narrative_test.go",
"tests/test_release_exit_note.py",
"tests/test_tag_repair.py",
"tests/test_workflow_script_contract.py"
]
},
"task_ref": "#2582",
"unresolved_surfaces": [
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "plans/2026-10-24-2582-release-final.md"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "scripts/release_exit_note.py"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "scripts/tag_repair.py"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "src/go/release-notes/narrative.go"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "src/go/release-notes/narrative_test.go"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "tests/test_release_exit_note.py"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "tests/test_tag_repair.py"
},
{
"kind": "source_path",
"reason": "no authority records this path as an entity (path remains file-scope only)",
"surface": "tests/test_workflow_script_contract.py"
},
{
"kind": "workflow_config",
"reason": "kind workflow_config has no deterministic resolver",
"surface": ".gitea/workflows/auto-version.yml"
}
],
"workflow_config_surfaces": [
".gitea/workflows/auto-version.yml"
]
},
"id": "contract:ADMIT-release-final-2582-20261024",
"identities": {
"acceptance": [
"bounded repair verified: #2582 bounded repair of release workflow, pinned ranges, changelog push and honest evidence"
],
"affected_entities": [],
"expected_consumers": [],
"expected_new_entities": [
"plans/2026-10-24-2582-release-final.md",
"test:release_exit_note",
"test:tag_repair",
"test:workflow_script_contract",
"tests/test_release_exit_note.py"
],
"gap_evidence": "change scoped to an existing project/target; target project: rune; affected surface known: ['.gitea/workflows/auto-version.yml', 'scripts/tag_repair.py']",
"migration_obligations": [],
"need": "#2582 bounded repair of release workflow, pinned ranges, changelog push and honest evidence",
"next_action": "complete implementation, then verify",
"non_goals": [],
"origin": "worker",
"regression_obligations": [
"Preserve dirty changelog; no live backfill or master push"
],
"requirement": "headless admission (classified: EXISTING_PROJECT_CHANGE)",
"research_refs": [
"tests/test_workflow_script_contract.py"
],
"resolution": "",
"rollback_ref": "git revert",
"scope": [
".gitea/workflows/auto-version.yml",
"plans/2026-10-24-2582-release-final.md",
"scripts/release_exit_note.py",
"scripts/tag_repair.py",
"src/go/release-notes/narrative.go",
"src/go/release-notes/narrative_test.go",
"tests/test_release_exit_note.py",
"tests/test_tag_repair.py",
"tests/test_workflow_script_contract.py"
],
"toolchain": {},
"verification_obligations": [
"Tests first; normal CI; two authorized delivery cycles before completion",
"pytest tests/test_release_exit_note.py",
"pytest tests/test_tag_repair.py",
"pytest tests/test_workflow_script_contract.py"
]
},
"kind": "construction_contract",
"maturity": "DECLARED",
"observed_at": "2026-10-25T00:41:05Z",
"phase": "building",
"status": "open",
"tags": []
}
@@ -7434,3 +7434,5 @@
{"builder": "release-reporting", "detail": "ADMIT-release-reporting \u2192 building", "kind": "builder_session", "mode": "external_project", "observed_at": "2026-10-24T11:27:06Z", "operation": "transition"}
{"builder": "release-reporting", "detail": "ADMIT-release-reporting \u2192 approved", "kind": "builder_session", "mode": "external_project", "observed_at": "2026-10-24T11:43:26Z", "operation": "transition"}
{"builder": "release-reporting", "detail": "ADMIT-release-reporting \u2192 building", "kind": "builder_session", "mode": "external_project", "observed_at": "2026-10-24T11:43:52Z", "operation": "transition"}
{"builder": "release-final-2582", "detail": "ADMIT-release-final-2582-20261024 \u2192 approved", "kind": "builder_session", "mode": "external_project", "observed_at": "2026-10-25T00:40:36Z", "operation": "transition"}
{"builder": "release-final-2582", "detail": "ADMIT-release-final-2582-20261024 \u2192 building", "kind": "builder_session", "mode": "external_project", "observed_at": "2026-10-25T00:41:05Z", "operation": "transition"}
+28
View File
@@ -0,0 +1,28 @@
import importlib.util
import subprocess
from pathlib import Path
def test_exit_note_first_release_includes_root(tmp_path, monkeypatch):
path = Path(__file__).resolve().parents[1] / "scripts/release_exit_note.py"
spec = importlib.util.spec_from_file_location("release_exit_note", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
monkeypatch.setattr(module, "REPO", tmp_path)
subprocess.run(["git", "init", "-q", str(tmp_path)], check=True)
subprocess.run(["git", "-c", "user.name=test", "-c", "user.email=test@example.invalid",
"commit", "--allow-empty", "-m", "feat: first release"], cwd=tmp_path, check=True)
assert module.commit_range("ROOT", "HEAD") == ["feat: first release"]
def test_exit_note_does_not_invent_verification(monkeypatch):
path = Path(__file__).resolve().parents[1] / "scripts/release_exit_note.py"
spec = importlib.util.spec_from_file_location("release_exit_note", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
monkeypatch.setattr(module, "commit_range", lambda *args: ["fix: repair release delivery"])
text = module.render_markdown(module.render("previous", "target"))
assert "Verification evidence unavailable" in text
assert "Every change:" not in text
assert "Every port:" not in text
assert "gated by CI" not in text
+33
View File
@@ -8,6 +8,7 @@ minutes before) and every wave of work vanished from the release body
"""
import subprocess
import json
import sys
from pathlib import Path
@@ -19,6 +20,38 @@ sys.path.insert(0, str(ROOT / "scripts"))
from tag_repair import latest_release_tag # noqa: E402
def test_first_release_range_includes_repository_root(tmp_path):
repo = _repo_with_tags(tmp_path, [])
result = _run_cli(["--release-range", "1.0.0"], repo)
assert result.returncode == 0, result.stderr
value = json.loads(result.stdout)
assert value["since"] == "ROOT"
assert value["previous_tag"] == ""
assert value["to"] == subprocess.check_output(
["git", "-C", str(repo), "rev-parse", "HEAD"], text=True).strip()
def test_release_range_pins_predecessor_and_target_across_reruns(tmp_path):
repo = _repo_with_tags(tmp_path, ["v1.0.0", "tui-v9.0.0"])
def git(*args):
return subprocess.check_output(["git", "-C", str(repo), *args], text=True).strip()
start = git("rev-parse", "HEAD")
git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "--allow-empty", "-m", "fix: next")
end = git("rev-parse", "HEAD")
args = ["--release-range", "1.1.0", "--bump-sha", end]
first = _run_cli(args, repo)
assert first.returncode == 0, first.stderr
assert json.loads(first.stdout) == {"previous_tag": "v1.0.0", "since": start, "to": end}
git("tag", "v1.1.0")
git("checkout", "--orphan", "unrelated")
git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "--allow-empty", "-m", "feat: unrelated")
git("tag", "v1.0.9")
git("tag", "v99.0.0")
repeat = _run_cli(["--release-range", "1.1.0"], repo)
assert repeat.returncode == 0, repeat.stderr
assert json.loads(repeat.stdout) == json.loads(first.stdout)
def _repo_with_tags(tmp_path, tags):
repo = tmp_path / "repo"
repo.mkdir()
+90 -2
View File
@@ -20,6 +20,9 @@ RELEASE_SERVER_URL/RELEASE_REPO — KeyError at runtime on both release paths.
from __future__ import annotations
import re
import subprocess
import os
import shutil
from pathlib import Path
from typing import Any, Iterator
@@ -32,6 +35,91 @@ SCRIPTS_DIR = ROOT / "scripts"
WORKFLOWS = ("auto-version.yml", "maintenance.yml", "tui-release.yml")
def test_auto_version_shell_steps_parse():
workflow = _load_workflow("auto-version.yml")
for step in workflow["jobs"]["release"]["steps"]:
if "run" not in step:
continue
script = re.sub(r"[$][{][{].*?[}][}]", "expression", step["run"])
result = subprocess.run(["bash", "-n"], input=script, text=True, capture_output=True)
assert result.returncode == 0, f"{step.get('name')}: {result.stderr}"
def test_release_does_not_cancel_its_parent():
concurrency = _load_workflow("auto-version.yml")["concurrency"]
assert concurrency["cancel-in-progress"] is False
assert "${{" in concurrency["group"]
def test_release_builds_are_private_and_not_duplicated():
steps = _load_workflow("auto-version.yml")["jobs"]["release"]["steps"]
for step in steps:
script = step.get("run", "")
if "go build" in script:
assert "/tmp/rune-release-notes" not in script
assert "/tmp/rn-changelog" not in script
assert "mktemp -d" in script
assert script.count("go build") == 1
assert "trap " in script
def test_release_consumers_share_captured_range():
steps = _load_workflow("auto-version.yml")["jobs"]["release"]["steps"]
for name in ("Create evidence-derived release", "Plain-English narrative"):
script = next(s["run"] for s in steps if s.get("name", "").startswith(name))
assert "--latest-tag" not in script
assert '--since "$RELEASE_SINCE"' in script
assert '--to "$RELEASE_TO"' in script
@pytest.mark.parametrize("reject_push", [False, True])
def test_changelog_step_commits_pushes_and_repeats(tmp_path, reject_push):
def git(cwd, *args):
return subprocess.check_output(["git", *args], cwd=cwd, text=True).strip()
remote = tmp_path / "origin.git"
git(tmp_path, "init", "--bare", str(remote))
repo = tmp_path / "checkout"
git(tmp_path, "clone", str(remote), str(repo))
git(repo, "checkout", "-b", "master")
git(repo, "config", "user.name", "test")
git(repo, "config", "user.email", "test@example.invalid")
shutil.copytree(ROOT / "src/go/release-notes", repo / "src/go/release-notes")
(repo / "scripts").mkdir()
shutil.copy2(ROOT / "scripts/tag_repair.py", repo / "scripts/tag_repair.py")
shutil.copy2(ROOT / ".gitignore", repo / ".gitignore")
(repo / "CHANGELOG.md").write_text("old changelog")
git(repo, "add", ".")
git(repo, "commit", "-m", "feat: first release")
git(repo, "tag", "v1.0.0")
git(repo, "commit", "--allow-empty", "-m", "fix: second release")
git(repo, "tag", "v1.1.0")
git(repo, "push", "--tags", "origin", "master")
if reject_push:
hook = remote / "hooks/pre-receive"
hook.write_text("#!/bin/sh\nexit 1\n")
hook.chmod(0o755)
steps = _load_workflow("auto-version.yml")["jobs"]["release"]["steps"]
step = next(s for s in steps if s.get("name", "").startswith("Sync the committed changelog"))
env = dict(os.environ, GITEA_TOKEN="test-only", RELEASE_SERVER_URL="http://127.0.0.1:1",
RELEASE_REPO="test/repo", RELEASE_VERSION="1.1.0")
result = subprocess.run(["bash", "-e", "-o", "pipefail", "-c", step["run"]],
cwd=repo, env=env, text=True, capture_output=True)
if reject_push:
assert result.returncode != 0
assert "pre-receive hook declined" in result.stderr
return
assert result.returncode == 0, result.stdout + result.stderr
head = git(repo, "rev-parse", "HEAD")
assert git(remote, "rev-parse", "master") == head
assert git(repo, "log", "-1", "--format=%s").startswith("chore(changelog):")
assert git(repo, "status", "--porcelain") == ""
result = subprocess.run(["bash", "-e", "-o", "pipefail", "-c", step["run"]],
cwd=repo, env=env, text=True, capture_output=True)
assert result.returncode == 0, result.stdout + result.stderr
assert git(repo, "rev-parse", "HEAD") == head
# Hard env reads: os.environ["KEY"] / os.environ['KEY'] (KeyError risk).
REQUIRED_ENV_RE = re.compile(r"""os\.environ\[["']([A-Za-z_][A-Za-z0-9_]*)["']\]""")
# Bare subscript of an allowlisted derived key — the derivation must be real.
@@ -336,14 +424,14 @@ def test_highlights_refresh_step_is_non_blocking() -> None:
"missing-toolchain guard required (sibling narrative step pattern)"
)
for i, line in enumerate(lines):
if "refresh-highlights" in line and "/tmp/rune-release-notes" in line:
if "refresh-highlights" in line and "$BUILD_DIR/release-notes" in line:
assert "exit 0" in "\n".join(lines[i:i + 4]), (
"the refresh-highlights invocation must be failure-guarded "
"with a ::warning:: + exit 0 (non-blocking discipline)"
)
break
else:
assert False, "no /tmp/rune-release-notes refresh-highlights invocation found"
assert False, "no private release-notes refresh-highlights invocation found"
def test_highlights_refresh_step_is_idempotent() -> None: