Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8989073cad | ||
|
|
7c265e393f | ||
|
|
53e6b70a22 | ||
|
|
3eb7ecd7cb | ||
|
|
64e7380013 | ||
|
|
c337681f77 | ||
|
|
0856bee01a | ||
|
|
9e2653f64d | ||
|
|
746c302473 | ||
|
|
51882ef08c | ||
|
|
cbbb4f3ee2 | ||
|
|
d4283cc3d2 | ||
|
|
2a9d6c8bc6 | ||
|
|
3065d11384 | ||
|
|
52b9773b6d |
No files matched your search
@@ -43,30 +43,16 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
if [ "$LATEST" != "$RELEASE_VERSION" ]; then
|
||||
echo "New version $RELEASE_VERSION (last tag v$LATEST) — bump rode the PR"
|
||||
exit 0
|
||||
if [ "$(printf '%s\n%s\n' "$LATEST" "$RELEASE_VERSION" | sort -V | head -1)" = "$RELEASE_VERSION" ]; then
|
||||
echo "::warning::VERSION-BEHIND-TAG ANOMALY (#192): master=$RELEASE_VERSION < latest tag v$LATEST — bumping from master version; reconcile the tag lineage"
|
||||
else
|
||||
echo "Version $RELEASE_VERSION > last tag v$LATEST — bump rode the PR"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
NEW=$(python3 -c "
|
||||
import os, re
|
||||
version = os.environ['RELEASE_VERSION']
|
||||
title = os.environ.get('PR_TITLE', '')
|
||||
# Conventional-commits semver: feat! / BREAKING CHANGE -> major, feat -> minor, else patch
|
||||
if re.match(r'^\s*\w+(\([^)]*\))?!:', title) or 'BREAKING CHANGE' in title:
|
||||
kind = 'major'
|
||||
elif re.match(r'^\s*feat(\([^)]*\))?:', title):
|
||||
kind = 'minor'
|
||||
else:
|
||||
kind = 'patch'
|
||||
major, minor, patch = version.split('.')
|
||||
# 0.x semver convention: breaking changes bump the MINOR until 1.0.0
|
||||
result = (
|
||||
f'{major}.{int(minor)+1}.0' if kind == 'major' and int(major) == 0
|
||||
else f'{int(major)+1}.0.0' if kind == 'major'
|
||||
else f'{major}.{int(minor)+1}.0' if kind == 'minor'
|
||||
else f'{major}.{minor}.{int(patch)+1}'
|
||||
)
|
||||
print(result)
|
||||
")
|
||||
# semver from PR title — logic lives in scripts/semver_bump.py
|
||||
# (#190 lesson: inline python in YAML scalars rots unlinted)
|
||||
NEW=$(python3 scripts/semver_bump.py "$RELEASE_VERSION" "$PR_TITLE")
|
||||
export NEW_VERSION="$NEW"
|
||||
echo "Auto-bump: $RELEASE_VERSION -> $NEW (via protected-branch-safe bump PR)"
|
||||
python3 - <<'PYBUMP'
|
||||
@@ -116,6 +102,9 @@ print(result)
|
||||
"README.md": [
|
||||
(f"v{VERSION}", f"v{NEW}"),
|
||||
],
|
||||
"pyproject.toml": [
|
||||
(f'version = "{VERSION}"', f'version = "{NEW}"'),
|
||||
],
|
||||
}
|
||||
for path, pairs in surfaces.items():
|
||||
status, cur = api(f"/contents/{path}?ref={BRANCH}")
|
||||
@@ -149,6 +138,13 @@ print(result)
|
||||
head_sha = pr["head"]["sha"]
|
||||
print(f" bump PR #{pr_number} head {head_sha[:12]}")
|
||||
|
||||
# 3b. DISPATCH CI on the bump branch — API-created PRs do not receive
|
||||
# pull_request events on this Gitea (observed live: #219 sat with zero
|
||||
# statuses). workflow_dispatch runs ci.yml on the branch ref; the
|
||||
# required-checks statuses attach to the head sha the loop polls.
|
||||
disp = api("/actions/workflows/ci.yml/dispatches", "POST", {"ref": BRANCH})
|
||||
print(f" CI dispatched on {BRANCH}: {disp[0]}")
|
||||
|
||||
# 4. wait for the protected check on the bump PR (same gate as everything else)
|
||||
deadline = time.time() + 900 # 15 min ceiling
|
||||
state = None
|
||||
|
||||
@@ -220,7 +220,11 @@ jobs:
|
||||
bash -n scripts/gitea-backup.sh scripts/gitea-restore-verify.sh
|
||||
|
||||
required-checks:
|
||||
if: gitea.event_name == 'pull_request'
|
||||
# pull_request AND workflow_dispatch: the auto-bump path creates its PR via
|
||||
# API (no pull_request events on this Gitea) and dispatches ci.yml on the
|
||||
# bump branch — without dispatch, required-checks never publishes and the
|
||||
# bump PR can never merge (#219, observed live).
|
||||
if: gitea.event_name == 'pull_request' || gitea.event_name == 'workflow_dispatch'
|
||||
runs-on: rune-ci-desktop
|
||||
# #148: type-check MUST gate the protected status — a red type-check job
|
||||
# previously left the protected merge gate green (dependency-chain hole).
|
||||
|
||||
@@ -87,13 +87,33 @@ def has_research_round(comments: list[dict]) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def open_pr_issue_refs(api) -> set[int]:
|
||||
"""Issue numbers referenced by OPEN PRs (closes/fixes/resolves #N in the
|
||||
body) — those issues are in-flight; research is premature."""
|
||||
refs: set[int] = set()
|
||||
page = 1
|
||||
ref_re = re.compile(r"(?:closes|fixes|resolves)\s+#(\d+)", re.IGNORECASE)
|
||||
while True:
|
||||
status, batch = api.get(f"/pulls?state=open&limit=50&page={page}")
|
||||
if not isinstance(batch, list):
|
||||
break
|
||||
for pr in batch:
|
||||
for m in ref_re.finditer(pr.get("body") or ""):
|
||||
refs.add(int(m.group(1)))
|
||||
if len(batch) < 50:
|
||||
return refs
|
||||
page += 1
|
||||
|
||||
|
||||
def compute_targets(issues: list[dict], api) -> list[dict]:
|
||||
"""Eligible issues for research-pending labeling.
|
||||
|
||||
`api` is a ForgeAdapter-like object exposing .get(path) -> (status, body).
|
||||
Gate semantics: open, not blocked, not already labeled, no existing
|
||||
research round, all deps server-verified closed (missing = unsatisfied).
|
||||
research round, all deps server-verified closed (missing = unsatisfied),
|
||||
and not referenced by an open PR (in-flight work doesn't need research).
|
||||
"""
|
||||
in_flight = open_pr_issue_refs(api)
|
||||
targets = []
|
||||
for i in issues:
|
||||
if i.get("pull_request") or i.get("state") != "open":
|
||||
@@ -101,6 +121,8 @@ def compute_targets(issues: list[dict], api) -> list[dict]:
|
||||
labels = {l["name"] for l in i.get("labels", [])}
|
||||
if "blocked" in labels or "research-pending" in labels:
|
||||
continue
|
||||
if i["number"] in in_flight:
|
||||
continue
|
||||
deps = [int(m) for m in DEP_RE.findall(i.get("body") or "")]
|
||||
unsatisfied = False
|
||||
for d in deps:
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python3
|
||||
"""semver_bump.py — compute the next version from a merged PR title.
|
||||
|
||||
Extracted from auto-version.yml after the inline python (pasted into a YAML
|
||||
double-quoted scalar with unescaped quotes and column-0 lines) made the whole
|
||||
workflow unparseable — silently disabling every post-merge version bump since
|
||||
#190 (2026-09-06 15:01). Bug-fix contract: logic in a testable script, not a
|
||||
heredoc nobody can lint.
|
||||
|
||||
Conventions (0.x semver until 1.0.0):
|
||||
- breaking (feat!:/fix!:/BREAKING CHANGE) -> minor bump (major+1 . 0 . 0 only at 1.x)
|
||||
- feat:/feat(scope): -> minor bump (x . minor+1 . 0)
|
||||
- anything else -> patch bump
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
|
||||
BREAKING_RE = re.compile(r"^\s*\w+(\([^)]*\))?!:")
|
||||
FEAT_RE = re.compile(r"^\s*feat(\([^)]*\))?")
|
||||
|
||||
|
||||
def next_version(version: str, title: str) -> str:
|
||||
if BREAKING_RE.match(title) or "BREAKING CHANGE" in title:
|
||||
kind = "major"
|
||||
elif FEAT_RE.match(title):
|
||||
kind = "minor"
|
||||
else:
|
||||
kind = "patch"
|
||||
major, minor, patch = (int(x) for x in version.split("."))
|
||||
if kind == "major":
|
||||
return f"{major}.{minor + 1}.0" if major == 0 else f"{major + 1}.0.0"
|
||||
if kind == "minor":
|
||||
return f"{major}.{minor + 1}.0"
|
||||
return f"{major}.{minor}.{patch + 1}"
|
||||
|
||||
|
||||
def main_with(argv: list[str]) -> int:
|
||||
if len(argv) != 3:
|
||||
print("usage: semver_bump.py <current-version> <pr-title>", file=sys.stderr)
|
||||
return 2
|
||||
print(next_version(argv[1], argv[2]))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main_with(sys.argv))
|
||||
@@ -3,4 +3,4 @@
|
||||
This is the main entry point for the Rune program.
|
||||
"""
|
||||
|
||||
__version__ = "0.54.0"
|
||||
__version__ = "0.54.1"
|
||||
@@ -252,7 +252,7 @@ def main() -> int:
|
||||
)
|
||||
check(
|
||||
"Rune minor version advertises provisioning and restore verification",
|
||||
'__version__ = "0.54.0"' in rune_version,
|
||||
'__version__ = "0.54.1"' in rune_version,
|
||||
failures,
|
||||
)
|
||||
# #169 policy update: auto-bump on merge is sanctioned (Nick directive:
|
||||
|
||||
@@ -7,7 +7,7 @@ mode system, subject header, shared memory and identity.
|
||||
|
||||
This is not an interface. This is how I see, speak, and interact with the world.
|
||||
|
||||
Version: v0.54.0
|
||||
Version: v0.54.1
|
||||
Features: 41 (24 original + 17 new native commands)
|
||||
|
||||
Usage:
|
||||
@@ -1923,7 +1923,7 @@ class RuneApp(App):
|
||||
# Show version info
|
||||
self.chat_channel.add_message(
|
||||
"system",
|
||||
f"[dim]Rune v0.54.0 | Textual TUI | {len(loaded)} native modules[/dim]",
|
||||
f"[dim]Rune v0.54.1 | Textual TUI | {len(loaded)} native modules[/dim]",
|
||||
)
|
||||
|
||||
# Auto-show night mode suggestion if evening/night hours
|
||||
|
||||
@@ -46,14 +46,17 @@ RESEARCH_COMMENT = "## INWARD\n... ## OUTWARD\n... ## INNOVATION\n... ## SYNTHES
|
||||
class FakeForge:
|
||||
"""Simulates the SERVER contract: issues list + per-issue fetch + comments."""
|
||||
|
||||
def __init__(self, issues, comments=None):
|
||||
def __init__(self, issues, comments=None, open_prs=None):
|
||||
self.issues = issues
|
||||
self.open_prs = open_prs or []
|
||||
self.comments = comments or {} # issue_number -> [comment bodies]
|
||||
self.added_labels: list[tuple[int, str]] = []
|
||||
|
||||
def get(self, path):
|
||||
if path == "/issues?state=open&type=issues&limit=50&page=1":
|
||||
return 200, [i for i in self.issues if not i.get("pull_request")]
|
||||
if path.startswith("/pulls?state=open"):
|
||||
return 200, self.open_prs
|
||||
if path.startswith("/issues/") and path.endswith("/comments"):
|
||||
n = int(path.split("/")[2])
|
||||
return 200, self.comments.get(n, [])
|
||||
@@ -162,3 +165,12 @@ def test_main_apply_writes(forge, monkeypatch, capsys):
|
||||
)
|
||||
assert rc == 0
|
||||
assert forge.added_labels == [(23, "research-pending")]
|
||||
|
||||
|
||||
def test_skips_issues_with_open_pr(forge, monkeypatch):
|
||||
"""In-flight issues (open PR references them) are not research targets —
|
||||
research is premature while implementation is already under review."""
|
||||
forge.issues = [_issue(30), _issue(31)]
|
||||
monkeypatch.setattr(rs, "open_pr_issue_refs", lambda api: {30})
|
||||
targets = rs.compute_targets(forge.issues, forge)
|
||||
assert [t["number"] for t in targets] == [31]
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Red-first tests for scripts/semver_bump.py — discharge of the auto-version
|
||||
outage (#190 made the workflow YAML unparseable; no bump ran since 2026-09-06).
|
||||
|
||||
Also freezes the #192 designed anomaly: version-behind-tag must be LOUD,
|
||||
never a silent skip (the workflow's old `exit 0` on LATEST != RELEASE_VERSION
|
||||
silently skipped every bump while tags ran ahead of master during testing).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SPEC = importlib.util.spec_from_file_location("semver_bump", ROOT / "scripts" / "semver_bump.py")
|
||||
sb = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(sb)
|
||||
|
||||
|
||||
def test_fix_bumps_patch():
|
||||
assert sb.next_version("0.54.0", "fix(pipeline): merge-robot auth") == "0.54.1"
|
||||
|
||||
|
||||
def test_feat_bumps_minor():
|
||||
assert sb.next_version("0.54.0", "feat(pipeline): research stage") == "0.55.0"
|
||||
|
||||
|
||||
def test_breaking_bumps_minor_at_0x():
|
||||
assert sb.next_version("0.54.0", "feat(pipeline)!: redesign gates") == "0.55.0"
|
||||
|
||||
|
||||
def test_breaking_bumps_major_at_1x():
|
||||
assert sb.next_version("1.2.3", "feat!: redesign gates") == "2.0.0"
|
||||
|
||||
|
||||
def test_breaking_change_keyword():
|
||||
assert sb.next_version("0.54.0", "chore: stuff\n\nBREAKING CHANGE: api") == "0.55.0"
|
||||
|
||||
|
||||
def test_chore_bumps_patch():
|
||||
assert sb.next_version("0.54.0", "chore(release): auto-bump") == "0.54.1"
|
||||
|
||||
|
||||
def test_scoped_feat_with_parenthetical():
|
||||
assert sb.next_version("0.54.0", "feat(memory): hippocampal index") == "0.55.0"
|
||||
|
||||
|
||||
def test_version_behind_tag_is_anomaly_not_skip():
|
||||
"""#192: master version behind latest tag = loud anomaly. The workflow
|
||||
must pass the version through to the bump logic (bump from CURRENT master
|
||||
version), never silently exit because tags diverged."""
|
||||
# 0.49.0 on master, tags at 0.54.0: bump STILL computes from 0.49.0 ->
|
||||
# the anomaly is surfaced by the loud log line + the bump PR itself.
|
||||
assert sb.next_version("0.49.0", "fix: thing") == "0.49.1"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"workflow",
|
||||
[
|
||||
".gitea/workflows/auto-version.yml",
|
||||
".gitea/workflows/merge-robot.yml",
|
||||
".gitea/workflows/ci.yml",
|
||||
".gitea/workflows/pipeline-gate.yml",
|
||||
".gitea/workflows/research-stage-headless.yml",
|
||||
".gitea/workflows/research-backlog-sweep.yml",
|
||||
".gitea/workflows/spec-gate-headless.yml",
|
||||
],
|
||||
)
|
||||
def test_all_workflows_are_valid_yaml(workflow):
|
||||
"""The missing gate that let #190 rot silently: every workflow file on
|
||||
master MUST parse. This test IS the lint gate."""
|
||||
import yaml
|
||||
|
||||
src = (ROOT / workflow).read_text()
|
||||
# Gitea parses with its own loader; 'on' must survive — use BaseLoader-free
|
||||
# safe_load and accept the YAML-1.1 on→bool quirk (Gitea normalizes too).
|
||||
data = yaml.safe_load(src)
|
||||
assert isinstance(data, dict), f"{workflow} must parse to a mapping"
|
||||
assert any(k in data for k in ("on", True)), f"{workflow} missing trigger block"
|
||||
Reference in new issue
Block a user