Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87bce8cfa3 |
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user