feat: enforce issue-linked branches + versioning/tagging policy (#48) #49
@@ -174,6 +174,24 @@ under `branches/`. The main repository checkout is an orchestration checkout:
|
||||
use it for status checks, issue creation/claiming, and creating worktrees, but
|
||||
do not edit tracked repository files there.
|
||||
|
||||
**Issue → branch → worktree → PR → cleanup.** Every implementation branch is
|
||||
tied to an issue number so the work is traceable end to end:
|
||||
|
||||
| Stage | Form |
|
||||
|-------|------|
|
||||
| Issue | `#123` (claimed with `status:in-progress`) |
|
||||
| Branch | `(fix\|feat\|docs\|chore)/issue-123-<slug>` (review: `review/pr-456-<slug>`) |
|
||||
| Worktree | `branches/fix-issue-123-<slug>` (slashes → hyphens) |
|
||||
| PR | body says `Closes #123` (closes) or `Refs #123` (related) |
|
||||
| Cleanup | remove remote+local branch + worktree folder; drop `status:in-progress` |
|
||||
|
||||
`scripts/worktree-start` **rejects** implementation branches that are not
|
||||
issue-linked (use `--allow-unlinked` only for genuine exceptions). When claiming,
|
||||
post a comment like
|
||||
`Claimed. Branch: fix/issue-123-<slug>. Worktree: branches/fix-issue-123-<slug>.`
|
||||
Gitea has no native issue→branch API field (only a PR's head branch), so this
|
||||
linkage is enforced by branch name + claim comment + PR body + cleanup.
|
||||
|
||||
Branch folders are ignored by git via `branches/`, so dirty work in one issue
|
||||
does not block starting an unrelated issue in a separate branch folder. No LLM
|
||||
may edit another issue's branch folder unless explicitly assigned to that issue.
|
||||
@@ -313,6 +331,32 @@ All mutating attempts — allowed, blocked, failed, or succeeded — are audit-l
|
||||
with the profile and authenticated user when `GITEA_AUDIT_LOG` is set (see
|
||||
[`safety-model.md`](safety-model.md)).
|
||||
|
||||
## Releases and version tags
|
||||
|
||||
Versions follow SemVer — **`vMAJOR.MINOR.PATCH`**, using **`v0.x.y`** while
|
||||
unstable. Pick the bump by the largest change since the last tag:
|
||||
|
||||
- **PATCH** — bug fixes, docs, tests, wrappers, non-breaking workflow polish.
|
||||
- **MINOR** — new MCP tools, new workflow helpers, new config features;
|
||||
backward-compatible behavior.
|
||||
- **MAJOR** — breaking config/schema/API behavior or a changed MCP contract.
|
||||
|
||||
Tags are **annotated** (`git tag -a`), created **only from the exact commit on
|
||||
remote `master`**, **only after the full suite passes**, and carry release notes
|
||||
referencing the merged PRs/issues. **Never tag** feature branches, dirty
|
||||
worktrees, unreviewed or self-authored work, or commits not on remote `master`.
|
||||
|
||||
Release runbook (see [`../skills/llm-project-workflow/templates/release-tag.md`](../skills/llm-project-workflow/templates/release-tag.md)):
|
||||
|
||||
1. `git fetch prgs --prune`.
|
||||
2. Confirm local `master` equals `prgs/master` (`0 0`) and the tree is clean.
|
||||
3. Run the full test suite; stop on failure.
|
||||
4. Review merged issues/PRs since the last tag
|
||||
(`git log --oneline <last-tag>..prgs/master`).
|
||||
5. Choose the version bump.
|
||||
6. `git tag -a <vX.Y.Z> prgs/master -m "<notes referencing #issues / PRs>"`.
|
||||
7. `git push prgs <vX.Y.Z>`; add release notes if the forge supports it.
|
||||
|
||||
## Safety notes
|
||||
|
||||
- Never place raw tokens or passwords in any LLM MCP config; reference secrets
|
||||
|
||||
+37
-6
@@ -3,21 +3,32 @@ set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
usage: scripts/worktree-start [--dry-run] <branch-name> [start-ref]
|
||||
usage: scripts/worktree-start [--dry-run] [--allow-unlinked] <branch-name> [start-ref]
|
||||
|
||||
Create an issue-specific git worktree under branches/<branch-name-with-slashes-replaced>.
|
||||
Create an issue-linked git worktree under branches/<branch-name-with-slashes-replaced>.
|
||||
|
||||
Branch names must be traceable to an issue (or a PR, for review branches):
|
||||
implementation: (fix|feat|docs|chore)/issue-<number>-<short-description>
|
||||
review: review/pr-<number>-<short-description>
|
||||
Use --allow-unlinked to bypass the check (discouraged).
|
||||
|
||||
Examples:
|
||||
scripts/worktree-start fix/issue-123-example
|
||||
scripts/worktree-start --dry-run review/pr-123-scope-check prgs/master
|
||||
scripts/worktree-start --dry-run review/pr-456-scope-check prgs/master
|
||||
EOF
|
||||
}
|
||||
|
||||
dry_run=0
|
||||
if [[ "${1:-}" == "--dry-run" ]]; then
|
||||
dry_run=1
|
||||
allow_unlinked=0
|
||||
while [[ "${1:-}" == --* ]]; do
|
||||
case "$1" in
|
||||
--dry-run) dry_run=1 ;;
|
||||
--allow-unlinked) allow_unlinked=1 ;;
|
||||
--help) usage; exit 0 ;;
|
||||
*) usage >&2; exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $# -lt 1 || $# -gt 2 ]]; then
|
||||
usage >&2
|
||||
@@ -27,6 +38,26 @@ fi
|
||||
branch="$1"
|
||||
start_ref="${2:-prgs/master}"
|
||||
|
||||
# Enforce issue-linked, traceable branch names (issue → branch → worktree → PR).
|
||||
if [[ "$allow_unlinked" -eq 0 ]]; then
|
||||
if [[ "$branch" =~ ^(fix|feat|docs|chore)/issue-[0-9]+-.+ ]] \
|
||||
|| [[ "$branch" =~ ^review/pr-[0-9]+-.+ ]]; then
|
||||
:
|
||||
else
|
||||
cat >&2 <<EOF
|
||||
Untraceable branch name: $branch
|
||||
|
||||
Implementation branches must be issue-linked:
|
||||
(fix|feat|docs|chore)/issue-<number>-<short-description>
|
||||
Review branches:
|
||||
review/pr-<number>-<short-description>
|
||||
|
||||
Fix the branch name, or pass --allow-unlinked to override (discouraged).
|
||||
EOF
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(cd "$script_dir/.." && pwd)"
|
||||
worktree_name="${branch//\//-}"
|
||||
|
||||
@@ -34,16 +34,40 @@ assigned to that issue and cleanup is part of the task.
|
||||
|
||||
## Branch Naming
|
||||
|
||||
Use issue- or PR-scoped names:
|
||||
Every implementation branch **must include its issue number** so it is
|
||||
traceable end to end: **issue → branch → worktree folder → PR → cleanup.**
|
||||
|
||||
Allowed implementation patterns:
|
||||
|
||||
- `fix/issue-123-short-description`
|
||||
- `feat/issue-123-short-description`
|
||||
- `docs/issue-123-short-description`
|
||||
- `review/pr-456-scope-check`
|
||||
- `chore/issue-123-short-description`
|
||||
|
||||
Review-only branches:
|
||||
|
||||
- `review/pr-456-short-description`
|
||||
|
||||
Use a filesystem-safe folder under `branches/` by replacing slashes with
|
||||
hyphens, for example `branches/fix-issue-123-short-description`.
|
||||
|
||||
`scripts/worktree-start` **enforces** this: it rejects an implementation branch
|
||||
that does not match `(fix|feat|docs|chore)/issue-<number>-…` (or a
|
||||
`review/pr-<number>-…` branch), unless `--allow-unlinked` is passed. Traceability
|
||||
is maintained by:
|
||||
|
||||
- the branch name (contains the issue number),
|
||||
- a claim comment on the issue, e.g.
|
||||
`Claimed. Branch: fix/issue-123-short-description. Worktree: branches/fix-issue-123-short-description.`,
|
||||
- the PR body — `Closes #123` when the PR should close the issue, `Refs #123`
|
||||
when related but not closing,
|
||||
- cleanup after merge — remove the remote branch, local branch, and the issue
|
||||
worktree folder, and drop `status:in-progress`.
|
||||
|
||||
Forges like Gitea do not expose a native issue→branch association through the
|
||||
API (only a PR's head branch), so linkage is enforced by the branch name, claim
|
||||
comment, PR body, and cleanup process above — not a dedicated API field.
|
||||
|
||||
## Worktree Creation
|
||||
|
||||
Generic pattern:
|
||||
@@ -221,5 +245,37 @@ Ready-to-copy templates live in `templates/`:
|
||||
- `review-pr.md`
|
||||
- `merge-pr.md`
|
||||
- `recover-dirty-worktree.md`
|
||||
- `release-tag.md`
|
||||
|
||||
Adapt the placeholders to the project before use.
|
||||
|
||||
## Versioning And Tagging
|
||||
|
||||
Releases follow SemVer: **`vMAJOR.MINOR.PATCH`** (use **`v0.x.y`** while
|
||||
unstable). Choose the bump by the largest change since the last tag:
|
||||
|
||||
- **PATCH** — bug fixes, docs, tests, wrappers, non-breaking workflow polish.
|
||||
- **MINOR** — new tools/helpers/config features; backward-compatible behavior.
|
||||
- **MAJOR** — breaking config/schema/API behavior or a changed MCP contract.
|
||||
|
||||
Tags must:
|
||||
|
||||
- be created **only from `master`** (the exact commit on remote `master`),
|
||||
- be created **only after the full test suite passes**,
|
||||
- be **annotated** tags (`git tag -a`), never lightweight,
|
||||
- include release notes / a changelog summary referencing the merged PRs/issues.
|
||||
|
||||
**Never tag** feature branches, dirty worktrees, unreviewed or self-authored
|
||||
work, or commits not present on remote `master`.
|
||||
|
||||
Release process (see [`templates/release-tag.md`](templates/release-tag.md)):
|
||||
|
||||
1. `git fetch <remote> --prune`.
|
||||
2. Verify local `master` equals remote `master` (`0 0`) and the tree is clean.
|
||||
3. Run the full test suite; stop on any failure.
|
||||
4. Inspect merged issues/PRs since the last tag
|
||||
(`git log --oneline <last-tag>..<remote>/master`).
|
||||
5. Choose the version bump.
|
||||
6. Create the annotated tag on remote `master` with release notes.
|
||||
7. Push the tag.
|
||||
8. Create/update release notes if the forge supports it.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Template: cut a release tag
|
||||
|
||||
Copy, fill the `<...>` fields, and paste as the task prompt. Tagging is
|
||||
irreversible-ish and outward-facing — fail closed on any doubt.
|
||||
|
||||
```text
|
||||
Task: cut release <vX.Y.Z> from master.
|
||||
|
||||
Rules (llm-project-workflow — versioning & tagging):
|
||||
- SemVer: vMAJOR.MINOR.PATCH (v0.x.y while unstable).
|
||||
- PATCH: bug fixes, docs, tests, wrappers, non-breaking workflow polish.
|
||||
- MINOR: new tools/helpers/config features, backward-compatible behavior.
|
||||
- MAJOR: breaking config/schema/API or changed MCP contract.
|
||||
- Tag ONLY from clean, tested remote master. Annotated tags only (git tag -a).
|
||||
- NEVER tag: feature branches, dirty worktrees, unreviewed/self-authored work,
|
||||
or commits not present on remote master.
|
||||
|
||||
Steps:
|
||||
1. git fetch <remote> --prune
|
||||
2. Confirm local master == <remote>/master (git rev-list --left-right --count
|
||||
<remote>/master...master → 0 0) and the tree is clean.
|
||||
3. Run the FULL test suite; it must pass. STOP on any failure.
|
||||
4. Inspect merged issues/PRs since the last tag:
|
||||
git log --oneline <last-tag>..<remote>/master
|
||||
5. Choose the bump (PATCH/MINOR/MAJOR) per the rules above; set <vX.Y.Z>.
|
||||
6. Create an ANNOTATED tag on <remote>/master with release notes that reference
|
||||
the merged PRs/issues:
|
||||
git tag -a <vX.Y.Z> <remote>/master -m "<vX.Y.Z>: <summary>
|
||||
|
||||
- #<n> <title> (PR #<pr>)
|
||||
- ..."
|
||||
7. Push the tag: git push <remote> <vX.Y.Z>
|
||||
8. Create/update the release notes / changelog entry if the forge supports it.
|
||||
|
||||
Fail-closed: STOP if tests fail, the tree/worktree is dirty, master != remote,
|
||||
the target commit is not on remote master, or the work was self-authored/
|
||||
unreviewed. Never tag to "fix" a failing state.
|
||||
|
||||
Handoff: version, bump rationale, commit tagged, tests result, tag pushed,
|
||||
release notes link.
|
||||
```
|
||||
+29
-2
@@ -35,16 +35,43 @@ class TestWorktreeStart(unittest.TestCase):
|
||||
self.assertEqual(rc, 2)
|
||||
|
||||
def test_refuses_existing_worktree(self):
|
||||
slug = f"zz-refuse-start-{os.getpid()}"
|
||||
branch = f"fix/issue-999-refuse-{os.getpid()}"
|
||||
slug = branch.replace("/", "-")
|
||||
target = BRANCHES / slug
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
rc, _, err = run("worktree-start", "--dry-run", slug)
|
||||
rc, _, err = run("worktree-start", "--dry-run", branch)
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertIn("Refusing to reuse", err)
|
||||
finally:
|
||||
target.rmdir()
|
||||
|
||||
# -- issue-linked branch validation (#48) --------------------------------
|
||||
|
||||
def test_accepts_issue_linked_impl_branches(self):
|
||||
for branch in ("fix/issue-123-example", "feat/issue-123-example",
|
||||
"docs/issue-123-example", "chore/issue-123-example"):
|
||||
rc, out, err = run("worktree-start", "--dry-run", branch)
|
||||
self.assertEqual(rc, 0, f"{branch}: {err}")
|
||||
self.assertIn(f"branches/{branch.replace('/', '-')}", out)
|
||||
|
||||
def test_accepts_review_branch(self):
|
||||
rc, out, _ = run("worktree-start", "--dry-run", "review/pr-456-example")
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertIn("branches/review-pr-456-example", out)
|
||||
|
||||
def test_rejects_untraceable_branches(self):
|
||||
for branch in ("fix/random-name", "my-branch", "feat/no-issue-here",
|
||||
"fix/issue-abc-x"):
|
||||
rc, _, err = run("worktree-start", "--dry-run", branch)
|
||||
self.assertEqual(rc, 2, branch)
|
||||
self.assertIn("Untraceable branch name", err)
|
||||
|
||||
def test_allow_unlinked_override(self):
|
||||
rc, out, _ = run("worktree-start", "--dry-run", "--allow-unlinked", "my-branch")
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertIn("branches/my-branch", out)
|
||||
|
||||
|
||||
class TestWorktreeReview(unittest.TestCase):
|
||||
|
||||
|
||||
Reference in New Issue
Block a user