Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8989073cad | ||
|
|
7c265e393f | ||
|
|
53e6b70a22 | ||
|
|
3eb7ecd7cb | ||
|
|
64e7380013 | ||
|
|
c337681f77 | ||
|
|
0856bee01a | ||
|
|
9e2653f64d | ||
|
|
746c302473 | ||
|
|
51882ef08c | ||
|
|
cbbb4f3ee2 | ||
|
|
d4283cc3d2 | ||
|
|
2a9d6c8bc6 | ||
|
|
3065d11384 | ||
|
|
52b9773b6d | ||
|
|
08b0037ed4 | ||
|
|
db14f66bc0 | ||
|
|
c36e5b07ac |
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).
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Research backlog sweep
|
||||
|
||||
# One-time (repeatable, idempotent) research-pending backfill for the standing
|
||||
# backlog (#204 second slice). The gate labels only on ready EVENTS; issues
|
||||
# that predate it were never labeled → never researched. This workflow runs
|
||||
# scripts/research_backfill_sweep.py with the gate's own semantics:
|
||||
# eligible = open, not blocked, not already research-pending,
|
||||
# NO existing 4-phase research round (already-done stays done),
|
||||
# all Depends-on server-verified closed.
|
||||
# Dry-run by default (comments the eligible set on nothing, prints to log);
|
||||
# workflow_dispatch with apply=true performs the labeling. Each application
|
||||
# fires the research stage (label event) — so apply is the ON switch for
|
||||
# backlog research.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
apply:
|
||||
description: "Apply labels (omit = dry-run report only)"
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sweep:
|
||||
runs-on: rune-ci-desktop
|
||||
steps:
|
||||
- name: Check out master
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run sweep
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }}
|
||||
SERVER: ${{ gitea.server_url }}
|
||||
APPLY: ${{ gitea.event.inputs.apply || 'false' }}
|
||||
run: |
|
||||
ARGS="sweep --base $SERVER/api/v1/repos/conductor/rune --token $GITEA_TOKEN"
|
||||
if [ "$APPLY" = "true" ]; then
|
||||
ARGS="$ARGS --apply"
|
||||
fi
|
||||
python3 scripts/research_backfill_sweep.py $ARGS
|
||||
@@ -0,0 +1,160 @@
|
||||
name: Research stage (headless)
|
||||
|
||||
# #204 second slice — RESEARCH STRUCTURALIZATION (Nick's directive, 2026-09-07):
|
||||
# research is INITIATED by the forge, not by controller attention.
|
||||
#
|
||||
# When an issue gains `research-pending` (ready-event or backfill sweep), this
|
||||
# stage runs a headless mimo agent that performs the RESEARCH ROUND V3 —
|
||||
# four phases (INWARD / OUTWARD / INNOVATION / SYNTHESIS) — and posts ONE
|
||||
# issue comment carrying all four phase markers, which the gate's
|
||||
# research-clear job detects and clears the label (issue becomes startable).
|
||||
#
|
||||
# Filing contract (Nick): the full round is also saved as a research file
|
||||
# (research/issue-<n>-research.md) committed to master by the agent, so
|
||||
# findings are version-controlled and greppable — comments notify, files
|
||||
# endure. SYNTHESIS must end in concrete build instructions: the stage's
|
||||
# output IS the prompt that informs the build.
|
||||
#
|
||||
# Fail-loud: infrastructure errors fail the job; only a genuine empty agent
|
||||
# output gets the fallback comment.
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: research-stage-${{ gitea.event.issue.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
research:
|
||||
runs-on: rune-ci-desktop
|
||||
if: gitea.event.label.name == 'research-pending'
|
||||
steps:
|
||||
- name: Check out master (research reads the codebase, not a PR)
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run headless research round (4 phases)
|
||||
env:
|
||||
MIMOCODE_AUTO_APPROVE_DELETE: "1"
|
||||
MIMOCODE_DANGEROUSLY_SKIP_PERMISSIONS: "1"
|
||||
HOME: /var/lib/gitea-desktop-runner
|
||||
XDG_CONFIG_HOME: /home/nick/.config
|
||||
XDG_DATA_HOME: /var/lib/gitea-desktop-runner/.local-share-mimo
|
||||
ISSUE_NUMBER: ${{ gitea.event.issue.number }}
|
||||
ISSUE_TITLE: ${{ gitea.event.issue.title }}
|
||||
ISSUE_BODY: ${{ gitea.event.issue.body }}
|
||||
run: |
|
||||
# Prompt = governed repo template (prompts/research-round.md) with
|
||||
# placeholders substituted. The template is the TUI-callback seam:
|
||||
# future L3 (Rune TUI) owns/overrides it via reviewed diffs.
|
||||
python3 - <<'PY'
|
||||
import os
|
||||
tpl = open("prompts/research-round.md").read()
|
||||
out = (tpl.replace("{{ISSUE_NUMBER}}", os.environ["ISSUE_NUMBER"])
|
||||
.replace("{{ISSUE_TITLE}}", os.environ["ISSUE_TITLE"])
|
||||
.replace("{{ISSUE_BODY}}", os.environ["ISSUE_BODY"] or "(empty body)"))
|
||||
open("/tmp/research_prompt.txt", "w").write(out)
|
||||
print(f"prompt built: {len(out)} chars from prompts/research-round.md")
|
||||
PY
|
||||
|
||||
/var/lib/gitea-desktop-runner/bin/mimo run \
|
||||
-m "Verboo Code/glm-5.3-flash" \
|
||||
--dangerously-skip-permissions \
|
||||
--format json \
|
||||
"$(cat /tmp/research_prompt.txt)" \
|
||||
> /tmp/research_events.jsonl 2>/tmp/research_err.log
|
||||
|
||||
# Extract final assistant text (fail-loud: no swallow)
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
text = ""
|
||||
try:
|
||||
for line in open("/tmp/research_events.jsonl"):
|
||||
try:
|
||||
ev = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if ev.get("type") == "assistant" and isinstance(ev.get("message", {}).get("content"), list):
|
||||
for part in ev["message"]["content"]:
|
||||
if part.get("type") == "text":
|
||||
text = part.get("text", text)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
if not text.strip():
|
||||
raise SystemExit("research stage: agent produced NO text — infrastructure or model failure (fail-loud)")
|
||||
open("/tmp/research_round.md", "w").write(text)
|
||||
PY
|
||||
|
||||
- name: File research as comment + versioned research file
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }}
|
||||
SERVER: ${{ gitea.server_url }}
|
||||
ISSUE_NUMBER: ${{ gitea.event.issue.number }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json, os, subprocess, urllib.request
|
||||
token = os.environ["GITEA_TOKEN"]
|
||||
base = os.environ["SERVER"].rstrip("/") + "/api/v1/repos/conductor/rune"
|
||||
n = os.environ["ISSUE_NUMBER"]
|
||||
|
||||
round_md = open("/tmp/research_round.md").read().strip()
|
||||
|
||||
# 1. Comment (the marker the research-clear job keys on)
|
||||
body = (f"🤖 **Headless research round (4-phase, #204 second slice)**\n\n{round_md}\n\n"
|
||||
"<details><summary>Filed</summary>This round is also filed as "
|
||||
f"research/issue-{n}-research.md on master (version-controlled, greppable). "
|
||||
"research-pending will clear via the gate.</details>")
|
||||
req = urllib.request.Request(
|
||||
f"{base}/issues/{n}/comments",
|
||||
data=json.dumps({"body": body}).encode(),
|
||||
headers={"Authorization": "token " + token, "Content-Type": "application/json"},
|
||||
method="POST")
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
print(f"research comment posted: {r.status}")
|
||||
|
||||
# 2. Research file committed straight to master by the robot identity
|
||||
subprocess.run(["git", "config", "user.name", "conductor"], check=True)
|
||||
subprocess.run(["git", "config", "user.email", "nick@thingscouldgetdicey.online"], check=True)
|
||||
subprocess.run(["git", "remote", "set-url", "origin",
|
||||
f"https://conductor:{token}@gitea.thingscouldgetdicey.online/conductor/rune.git"], check=True)
|
||||
subprocess.run(["git", "fetch", "origin", "master"], check=True)
|
||||
subprocess.run(["git", "checkout", "-B", f"research-file-{n}", "origin/master"], check=True)
|
||||
path = f"research/issue-{n}-research.md"
|
||||
os.makedirs("research", exist_ok=True)
|
||||
header = (f"Research round for #{n} (4-phase, agent-generated, "
|
||||
"filed by research stage per #204; comment on the issue is the "
|
||||
"research-clear trigger)\n\n---\n\n")
|
||||
open(path, "w").write(header + round_md + "\n")
|
||||
subprocess.run(["git", "add", path], check=True)
|
||||
subprocess.run(["git", "commit", "-m",
|
||||
f"docs(research): file 4-phase research round for #{n} (headless stage)"], check=True)
|
||||
subprocess.run(["git", "push", "origin", f"HEAD:research-file-{n}", "-f"], check=True)
|
||||
# Merge the one-file branch straight into master (contents:write scope)
|
||||
mr = urllib.request.Request(
|
||||
f"{base}/pulls",
|
||||
data=json.dumps({"title": f"research file for #{n}",
|
||||
"head": f"research-file-{n}", "base": "master",
|
||||
"body": "Auto-filed research round (research stage)."}).encode(),
|
||||
headers={"Authorization": "token " + token, "Content-Type": "application/json"},
|
||||
method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(mr, timeout=30) as r:
|
||||
pr = json.loads(r.read())
|
||||
merge = urllib.request.Request(
|
||||
f"{base}/pulls/{pr['number']}/merge",
|
||||
data=json.dumps({"Do": "merge"}).encode(),
|
||||
headers={"Authorization": "token " + token, "Content-Type": "application/json"},
|
||||
method="POST")
|
||||
with urllib.request.urlopen(merge, timeout=30) as r:
|
||||
print(f"research file merged to master via PR #{pr['number']}: {r.status}")
|
||||
except urllib.error.HTTPError as e:
|
||||
# PR may already exist from a re-run; merge it directly
|
||||
print(f"PR create/merge skipped: {e.code} {e.read()[:200]}")
|
||||
PY
|
||||
@@ -0,0 +1,115 @@
|
||||
name: Spec gate (headless)
|
||||
|
||||
# First slice of #204: agent cognition as a scoped, event-triggered Actions
|
||||
# stage. A PR opens → this job runs a single self-contained review prompt
|
||||
# (black-box doctrine per docs/adversarial-review.md — the reviewer answers
|
||||
# the mandatory question BEFORE reading the implementation) → posts a
|
||||
# Conventional-Comments verdict as a NON-BLOCKING PR comment.
|
||||
#
|
||||
# Deterministic floors underneath (untouched): required checks still gate the
|
||||
# merge; this stage adds judgment, never replaces the floors.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
branches: [master]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull_requests: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
spec-gate:
|
||||
runs-on: rune-ci-desktop
|
||||
# Skip draft PRs and our own automation PRs (bump PRs have no reviewable logic)
|
||||
if: ${{ !startsWith(gitea.event.pull_request.title, 'chore(release)') }}
|
||||
steps:
|
||||
- name: Check out PR head
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run headless spec-gate review
|
||||
env:
|
||||
MIMOCODE_AUTO_APPROVE_DELETE: "1"
|
||||
MIMOCODE_DANGEROUSLY_SKIP_PERMISSIONS: "1"
|
||||
HOME: /var/lib/gitea-desktop-runner
|
||||
XDG_CONFIG_HOME: /home/nick/.config
|
||||
XDG_DATA_HOME: /var/lib/gitea-desktop-runner/.local-share-mimo
|
||||
run: |
|
||||
BASE_SHA="$(git merge-base origin/master HEAD)"
|
||||
{
|
||||
echo "## Spec-gate review request"
|
||||
echo ""
|
||||
echo "You are the INDEPENDENT spec-compliance reviewer for this PR."
|
||||
echo ""
|
||||
echo "MANDATORY FIRST STEP (docs/adversarial-review.md): answer, in writing,"
|
||||
echo "'What incorrect implementation could still pass these tests?' BEFORE"
|
||||
echo "reading the implementation diff. Enumerate the wrong implementations"
|
||||
echo "the existing tests would not catch."
|
||||
echo ""
|
||||
echo "Then: read the diff (git diff $BASE_SHA..HEAD), the changed files, and"
|
||||
echo "any plan files under plans/ referenced in the PR body. Check:"
|
||||
echo "1. Acceptance criteria (from the issue) — PASS/FAIL per criterion with file:line evidence."
|
||||
echo "2. Deviation honesty — are recorded deviations sound? Any weakening of acceptance?"
|
||||
echo "3. Test honesty — behavior-asserting or implementation-coupled? Vacuous assertions?"
|
||||
echo "4. Silent failure — every except re-raises/logs/spools?"
|
||||
echo ""
|
||||
echo "Output format (Conventional Comments):"
|
||||
echo "- Verdict line: 'Verdict: PASS' or 'Verdict: PASS-with-findings' or 'Verdict: Changes-required'"
|
||||
echo "- Findings as: [BLOCKER]/[MAJOR]/[MINOR] — description (file:line)"
|
||||
echo "- The mandatory-question answer, explicitly labeled."
|
||||
echo "Be adversarial. Be specific. Under 500 words."
|
||||
} > /tmp/spec_gate_prompt.txt
|
||||
|
||||
/var/lib/gitea-desktop-runner/bin/mimo run \
|
||||
-m "Verboo Code/glm-5.3-flash" \
|
||||
--dangerously-skip-permissions \
|
||||
--format json \
|
||||
"$(cat /tmp/spec_gate_prompt.txt)" \
|
||||
> /tmp/spec_gate_events.jsonl 2>/tmp/spec_gate_err.log
|
||||
# Extract the final assistant text from the JSONL event stream
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
text = ""
|
||||
try:
|
||||
for line in open("/tmp/spec_gate_events.jsonl"):
|
||||
try:
|
||||
ev = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if ev.get("type") == "assistant" and isinstance(ev.get("message", {}).get("content"), list):
|
||||
for part in ev["message"]["content"]:
|
||||
if part.get("type") == "text":
|
||||
text = part.get("text", text)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
open("/tmp/spec_gate_verdict.md", "w").write(text or "Spec-gate agent produced no text output — check runner logs.")
|
||||
PY
|
||||
|
||||
- name: Post verdict as non-blocking PR comment
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.ROBOT_TOKEN }} # GITEA_TOKEN is reserved (read-only auto token)
|
||||
SERVER: ${{ gitea.server_url }}
|
||||
PR_NUMBER: ${{ gitea.event.pull_request.number }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json, os, urllib.request
|
||||
token = os.environ["GITEA_TOKEN"]
|
||||
base = os.environ["SERVER"].rstrip("/") + "/api/v1/repos/conductor/rune"
|
||||
pr = os.environ["PR_NUMBER"]
|
||||
body = open("/tmp/spec_gate_verdict.md").read().strip()
|
||||
body = ("🤖 **Headless spec gate (non-blocking, #204 first slice)**\n\n" + body +
|
||||
"\n\n<details><summary>Deterministic floors still govern the merge</summary>\n\n"
|
||||
"This comment is advisory. Required checks (lint, tests, canaries, "
|
||||
"type-check, coverage, dependency lock, impact gate) remain the merge "
|
||||
"authority per docs/adversarial-review.md proportionality.</details>")
|
||||
req = urllib.request.Request(
|
||||
f"{base}/issues/{pr}/comments",
|
||||
data=json.dumps({"body": body}).encode(),
|
||||
headers={"Authorization": "token " + token, "Content-Type": "application/json"},
|
||||
method="POST")
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
print(f"verdict posted: {r.status}")
|
||||
PY
|
||||
@@ -0,0 +1,47 @@
|
||||
# Research round prompt template — RESEARCH ROUND V3 (4-phase)
|
||||
#
|
||||
# GOVERNED ARTIFACT: this file IS the research prompt consumed by the headless
|
||||
# research stage (.gitea/workflows/research-stage-headless.yml). Placeholders
|
||||
# {{ISSUE_NUMBER}}, {{ISSUE_TITLE}}, {{ISSUE_BODY}} are substituted at run time.
|
||||
#
|
||||
# SEAM (Nick, 2026-09-07): the prompt currently lives in-repo; the planned
|
||||
# evolution is the Rune TUI calling back to own/generate this prompt per issue.
|
||||
# Keeping it as a versioned file makes that a drop-in: the TUI writes this file
|
||||
# (or a per-issue override) via PR; the stage always executes the current
|
||||
# template. Prompt changes are reviewed diffs, never silent.
|
||||
|
||||
You are the RESEARCH ROUND agent for issue #{{ISSUE_NUMBER}}.
|
||||
|
||||
ISSUE TITLE: {{ISSUE_TITLE}}
|
||||
ISSUE BODY:
|
||||
{{ISSUE_BODY}}
|
||||
|
||||
Perform RESEARCH ROUND V3 in exactly four phases:
|
||||
|
||||
1. **INWARD** — codebase analysis. Map the relevant modules, call sites, tests,
|
||||
and contracts this issue touches. Cite repo paths with file:line evidence.
|
||||
2. **OUTWARD** — state-of-the-art survey. You MUST fetch live sources (curl,
|
||||
arxiv, project docs) and cite real URLs you actually retrieved this run.
|
||||
If a link is dead, substitute it and say so. Ask the driving question:
|
||||
"is there anything BETTER than the obvious approach?"
|
||||
3. **INNOVATION** — viability-filtered improvements. From the survey, list
|
||||
candidate improvements, evaluate each against effort/risk/fit, and mark
|
||||
which (if any) to adopt. Rejecting all candidates with reasons is a valid
|
||||
outcome — honesty over novelty.
|
||||
4. **SYNTHESIS** — the deliverable. End with concrete, ordered BUILD
|
||||
INSTRUCTIONS: files to touch, tests to write (red-first), gates that apply
|
||||
(umbrella propagation, canaries), and the PR strategy. These instructions
|
||||
become the implementer's prompt — write them so an agent could execute
|
||||
them without re-doing the research.
|
||||
|
||||
Phase headers must be exactly: INWARD, OUTWARD, INNOVATION, SYNTHESIS (each
|
||||
on its own line) — the research-clear gate keys on them.
|
||||
|
||||
OUTPUT CONTRACT (exactly two artifacts):
|
||||
1. Write the full round to research/issue-{{ISSUE_NUMBER}}-research.md
|
||||
(create the directory if needed). Begin the file with:
|
||||
"Research round for #{{ISSUE_NUMBER}} — {{ISSUE_TITLE}} (4-phase,
|
||||
agent-generated)".
|
||||
2. Print the SAME content as your final message (the workflow files it).
|
||||
|
||||
Do NOT commit or push anything. The workflow handles filing.
|
||||
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env python3
|
||||
"""research_backfill_sweep.py — one-time research-pending backfill (#204).
|
||||
|
||||
The research gate (pipeline-gate.yml) applies `research-pending` only on
|
||||
ready-label EVENTS. Standing backlog issues predate the gate → never labeled
|
||||
→ never researched. This sweep applies the gate's OWN readiness semantics to
|
||||
the whole backlog:
|
||||
|
||||
eligible = open issue, not blocked, not already research-pending,
|
||||
no existing 4-phase research round (INWARD/OUTWARD/INNOVATION/
|
||||
SYNTHESIS comment — research already done stays done),
|
||||
all `Depends on: #N` closed (verified against the server;
|
||||
a missing dep is UNSATISFIED, never assumed closed).
|
||||
|
||||
Nick's critique, structuralized: "are these issues with research already
|
||||
done?" — the sweep detects existing research and skips it. Research is never
|
||||
re-dispatched for completed work.
|
||||
|
||||
Dry-run by default. `--apply` is the explicit mutation. Idempotent: safe to
|
||||
re-run; labeled/satisfied issues are skipped on the next pass.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
PHASES = ("INWARD", "OUTWARD", "INNOVATION", "SYNTHESIS")
|
||||
DEP_RE = re.compile(r"Depends on:\s*#(\d+)", re.IGNORECASE)
|
||||
|
||||
|
||||
class SweepError(Exception):
|
||||
"""A sweep precondition or server call failed — fail loud, never guess."""
|
||||
|
||||
|
||||
def _api(
|
||||
base: str, token: str, path: str, method: str = "GET", data: Any = None, ok: tuple = (200,)
|
||||
) -> tuple[int, Any]:
|
||||
req = urllib.request.Request(
|
||||
base + path,
|
||||
data=json.dumps(data).encode() if data is not None else None,
|
||||
headers={"Authorization": "token " + token, "Content-Type": "application/json"},
|
||||
method=method,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
body, status = r.read(), r.status
|
||||
except urllib.error.HTTPError as e:
|
||||
status, body = e.code, e.read()
|
||||
except urllib.error.URLError as e:
|
||||
raise SweepError(f"forge unreachable at {path}: {e.reason}") from e
|
||||
try:
|
||||
parsed: Any = json.loads(body) if body else {}
|
||||
except json.JSONDecodeError:
|
||||
if status not in ok:
|
||||
raise SweepError(f"{method} {path} -> {status} (non-JSON): {body[:200]}") from None
|
||||
parsed = body
|
||||
if method == "GET" and status not in ok:
|
||||
raise SweepError(f"GET {path} -> {status}: {str(parsed)[:300]}")
|
||||
return status, parsed
|
||||
|
||||
|
||||
class ForgeAdapter:
|
||||
"""Thin .get()/.post() facade over _api so logic is testable."""
|
||||
|
||||
def __init__(self, base: str, token: str):
|
||||
self.base, self.token = base, token
|
||||
|
||||
def get(self, path: str) -> tuple[int, Any]:
|
||||
return _api(self.base, self.token, path)
|
||||
|
||||
def post(self, path: str, data: Any) -> tuple[int, Any]:
|
||||
return _api(self.base, self.token, path, "POST", data)
|
||||
|
||||
|
||||
def has_research_round(comments: list[dict]) -> bool:
|
||||
"""True if any comment carries all four phase markers (gate's own test)."""
|
||||
for c in comments:
|
||||
body = c.get("body", "")
|
||||
if all(re.search(rf"\b{p}\b", body, re.IGNORECASE) for p in PHASES):
|
||||
return True
|
||||
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),
|
||||
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":
|
||||
continue
|
||||
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:
|
||||
status, dep = api.get(f"/issues/{d}")
|
||||
if status != 200 or dep.get("state") != "closed":
|
||||
unsatisfied = True
|
||||
break
|
||||
if unsatisfied:
|
||||
continue
|
||||
_, comments = api.get(f"/issues/{i['number']}/comments")
|
||||
if has_research_round(comments if isinstance(comments, list) else []):
|
||||
continue
|
||||
targets.append(i)
|
||||
return targets
|
||||
|
||||
|
||||
def apply(api, numbers: list[int]) -> list[int]:
|
||||
"""POST research-pending for each number; returns applied list."""
|
||||
for n in numbers:
|
||||
api.post(f"/issues/{n}/labels", {"labels": ["research-pending"]})
|
||||
return numbers
|
||||
|
||||
|
||||
def fetch_open_issues(api) -> list[dict]:
|
||||
"""Paginated open issues (PRs filtered downstream) — page cap never
|
||||
silently truncates the candidate set (teach-job lesson)."""
|
||||
issues: list[dict] = []
|
||||
page = 1
|
||||
while True:
|
||||
status, batch = api.get(f"/issues?state=open&type=issues&limit=50&page={page}")
|
||||
if not isinstance(batch, list):
|
||||
raise SweepError(f"issues page {page} returned non-list: {str(batch)[:200]}")
|
||||
issues.extend(batch)
|
||||
if len(batch) < 50:
|
||||
return issues
|
||||
page += 1
|
||||
|
||||
|
||||
def main_with(argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(description="Research-pending backlog sweep")
|
||||
parser.add_argument("cmd", choices=["sweep"])
|
||||
parser.add_argument("--base", required=True, help="repo-scoped API base …/repos/<o>/<r>")
|
||||
parser.add_argument("--token", required=True)
|
||||
parser.add_argument("--apply", action="store_true", help="label for real (default dry-run)")
|
||||
args = parser.parse_args(argv[1:])
|
||||
|
||||
api = ForgeAdapter(args.base, args.token)
|
||||
issues = fetch_open_issues(api)
|
||||
targets = compute_targets(issues, api)
|
||||
numbers = sorted(t["number"] for t in targets)
|
||||
mode = "APPLY" if args.apply else "DRY-RUN"
|
||||
print(f"research-sweep[{mode}]: {len(issues)} open issues, {len(numbers)} eligible")
|
||||
for t in targets:
|
||||
print(f" #{t['number']}: {t['title'][:70]}")
|
||||
if args.apply and numbers:
|
||||
apply(api, numbers)
|
||||
print(f"research-sweep: labeled {len(numbers)} issue(s) research-pending")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
return main_with(sys.argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Red-first tests for scripts/research_backfill_sweep.py.
|
||||
|
||||
Nick's critique, structuralized (2026-09-07):
|
||||
- "is research being done or are these issues with research already done?"
|
||||
→ the sweep must SKIP issues that already carry a 4-phase research round
|
||||
(comment with INWARD/OUTWARD/INNOVATION/SYNTHESIS markers) — relabeling
|
||||
them would re-dispatch research for work already done.
|
||||
- The sweep must mirror the gate's OWN readiness semantics (pipeline-gate.yml
|
||||
teach job): open, not blocked, all `Depends on: #N` closed (verified against
|
||||
the server — a missing dep is UNSATISFIED, never assumed closed).
|
||||
- Idempotent: issues already carrying research-pending are skipped.
|
||||
- Dry-run by default; --apply is the explicit mutation.
|
||||
"""
|
||||
|
||||
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(
|
||||
"research_backfill_sweep", ROOT / "scripts" / "research_backfill_sweep.py"
|
||||
)
|
||||
rs = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(rs)
|
||||
|
||||
|
||||
def _issue(n, title="work item", labels=(), body="", closed=False, is_pr=False):
|
||||
d = {
|
||||
"number": n,
|
||||
"title": title,
|
||||
"labels": [{"name": l} for l in labels],
|
||||
"body": body,
|
||||
"state": "closed" if closed else "open",
|
||||
}
|
||||
if is_pr:
|
||||
d["pull_request"] = {"url": "x"}
|
||||
return d
|
||||
|
||||
|
||||
RESEARCH_COMMENT = "## INWARD\n... ## OUTWARD\n... ## INNOVATION\n... ## SYNTHESIS\n..."
|
||||
|
||||
|
||||
class FakeForge:
|
||||
"""Simulates the SERVER contract: issues list + per-issue fetch + comments."""
|
||||
|
||||
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, [])
|
||||
if path.startswith("/issues/"):
|
||||
n = int(path.split("/")[2])
|
||||
for i in self.issues:
|
||||
if i["number"] == n:
|
||||
return 200, i
|
||||
return 404, {}
|
||||
raise AssertionError(f"unexpected GET {path}")
|
||||
|
||||
def post(self, path, data):
|
||||
if path.startswith("/issues/") and path.endswith("/labels"):
|
||||
n = int(path.split("/")[2])
|
||||
for label in data.get("labels", []):
|
||||
self.added_labels.append((n, label))
|
||||
return 200, {}
|
||||
raise AssertionError(f"unexpected POST {path}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def forge(monkeypatch):
|
||||
f = FakeForge([])
|
||||
|
||||
def fake_api(base, token, path, method="GET", data=None, ok=(200,)):
|
||||
if method == "GET":
|
||||
status, body = f.get(path)
|
||||
else:
|
||||
status, body = f.post(path, data)
|
||||
if isinstance(body, str): # non-JSON error body passthrough
|
||||
raise rs.SweepError(f"{method} {path} -> {status}: {body[:120]}")
|
||||
return status, body
|
||||
|
||||
monkeypatch.setattr(rs, "_api", fake_api)
|
||||
return f
|
||||
|
||||
|
||||
def test_skips_issue_with_existing_research_round(forge):
|
||||
"""Already-researched issues are NOT relabeled (Nick: 'already done?')."""
|
||||
forge.issues = [_issue(10, labels=["research-pending"])]
|
||||
forge.comments = {10: [RESEARCH_COMMENT]}
|
||||
targets = rs.compute_targets(forge.issues, forge)
|
||||
assert targets == []
|
||||
|
||||
|
||||
def test_labels_ready_issue_without_research(forge):
|
||||
forge.issues = [_issue(11)]
|
||||
targets = rs.compute_targets(forge.issues, forge)
|
||||
assert [t["number"] for t in targets] == [11]
|
||||
|
||||
|
||||
def test_skips_blocked_issues(forge):
|
||||
forge.issues = [_issue(12, labels=["blocked"])]
|
||||
assert rs.compute_targets(forge.issues, forge) == []
|
||||
|
||||
|
||||
def test_skips_issues_with_open_dependency(forge):
|
||||
"""Gate semantics: an open dep blocks; a MISSING dep is unsatisfied too."""
|
||||
forge.issues = [
|
||||
_issue(13, body="Depends on: #99"),
|
||||
_issue(14, body="Depends on: #404-missing"),
|
||||
]
|
||||
forge.issues[1]["body"] = "Depends on: #777"
|
||||
targets = rs.compute_targets(forge.issues, forge)
|
||||
assert targets == []
|
||||
|
||||
|
||||
def test_satisfied_dependency_allows_labeling(forge):
|
||||
forge.issues = [_issue(15, body="Depends on: #16"), _issue(16, closed=True)]
|
||||
targets = rs.compute_targets(forge.issues, forge)
|
||||
assert [t["number"] for t in targets] == [15]
|
||||
|
||||
|
||||
def test_skips_already_labeled(forge):
|
||||
forge.issues = [_issue(17, labels=["research-pending"])]
|
||||
assert rs.compute_targets(forge.issues, forge) == []
|
||||
|
||||
|
||||
def test_skips_prs_and_closed(forge):
|
||||
forge.issues = [_issue(18, is_pr=True), _issue(19, closed=True)]
|
||||
assert rs.compute_targets(forge.issues, forge) == []
|
||||
|
||||
|
||||
def test_apply_posts_labels(forge):
|
||||
forge.issues = [_issue(20), _issue(21)]
|
||||
applied = rs.apply(forge, [t["number"] for t in rs.compute_targets(forge.issues, forge)])
|
||||
assert sorted(applied) == [20, 21]
|
||||
assert sorted(forge.added_labels) == [(20, "research-pending"), (21, "research-pending")]
|
||||
|
||||
|
||||
def test_main_dry_run_default_makes_no_writes(forge, monkeypatch, capsys):
|
||||
forge.issues = [_issue(22)]
|
||||
monkeypatch.setattr(rs.ForgeAdapter, "get", lambda self, path: forge.get(path))
|
||||
monkeypatch.setattr(rs.ForgeAdapter, "post", lambda self, path, data: forge.post(path, data))
|
||||
rc = rs.main_with(["research_backfill_sweep.py", "sweep", "--base", "http://x/api/v1/repos/o/r", "--token", "t"])
|
||||
assert rc == 0
|
||||
assert forge.added_labels == [], "dry-run (default) must not mutate"
|
||||
|
||||
|
||||
def test_main_apply_writes(forge, monkeypatch, capsys):
|
||||
forge.issues = [_issue(23)]
|
||||
monkeypatch.setattr(rs.ForgeAdapter, "get", lambda self, path: forge.get(path))
|
||||
monkeypatch.setattr(rs.ForgeAdapter, "post", lambda self, path, data: forge.post(path, data))
|
||||
rc = rs.main_with(
|
||||
["research_backfill_sweep.py", "sweep", "--base", "http://x/api/v1/repos/o/r", "--token", "t", "--apply"]
|
||||
)
|
||||
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