Compare commits
2 Commits
c4e539c7f7
...
4e43347b2d
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e43347b2d | |||
| ec9ddb09a7 |
@@ -299,14 +299,19 @@ touching anything.
|
|||||||
- **Prompt:** `Use any eligible merger profile to merge PR #N if checks pass and
|
- **Prompt:** `Use any eligible merger profile to merge PR #N if checks pass and
|
||||||
it is mergeable. Confirm with "MERGE PR N". Do not force-merge.`
|
it is mergeable. Confirm with "MERGE PR N". Do not force-merge.`
|
||||||
|
|
||||||
### Close the issue after merge
|
### Close the issue after merge / Reconciliation
|
||||||
|
|
||||||
- **Profile:** issue-manager or merger.
|
- **Profile:** issue-manager or merger.
|
||||||
- **Steps:** verify remote `master` actually contains the merge; close the
|
- **Steps:** verify remote `master` actually contains the merge; close the
|
||||||
issue (or rely on a `Closes #N` keyword); release `status:in-progress`;
|
issue; release `status:in-progress` (if it cannot be removed, report why).
|
||||||
clean up merged branches.
|
- **If closed but not merged (`merged=false`):** Stop normal flow. Do not delete worktrees. Compare PR content to remote `master`.
|
||||||
- **Prompt:** `After confirming master contains the merge of PR #N, close issue
|
- **fully landed:** comment it landed, remove `status:in-progress`, clean up.
|
||||||
#M and delete the merged branch.`
|
- **partially landed:** reopen issue, create corrective PR for missing pieces.
|
||||||
|
- **not landed:** reopen issue/PR, do not clean up.
|
||||||
|
- **Direct push to master:** is forbidden except as a documented recovery exception. Final reports must include why, commits, PR metadata, and repaired labels.
|
||||||
|
- **Final reports:** must include both PR metadata (state, merged flag, merge commit) and Git content (remote master hash, expected content present).
|
||||||
|
- **Prompt (normal):** `After confirming master contains the merge of PR #N, close issue #M and delete the merged branch.`
|
||||||
|
- **Prompt (reconcile):** `Reconcile closed-not-merged PR #N by verifying if its content landed on master.`
|
||||||
|
|
||||||
### Stop on blocker
|
### Stop on blocker
|
||||||
|
|
||||||
@@ -363,6 +368,18 @@ Release runbook (see [`../skills/llm-project-workflow/templates/release-tag.md`]
|
|||||||
6. `git tag -a <vX.Y.Z> prgs/master -m "<notes referencing #issues / PRs>"`.
|
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.
|
7. `git push prgs <vX.Y.Z>`; add release notes if the forge supports it.
|
||||||
|
|
||||||
|
`scripts/release-tag` automates steps 1–7 with these gates built in (SemVer
|
||||||
|
check, fetch/prune, on-master, clean tree, local==remote master, HEAD on remote
|
||||||
|
master, no duplicate tag, tests run unless `--skip-tests`, annotated tag only).
|
||||||
|
It is **safe by default** — no push unless `--push`, and `--dry-run` changes
|
||||||
|
nothing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/release-tag --dry-run v0.4.0
|
||||||
|
scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md
|
||||||
|
scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md --push
|
||||||
|
```
|
||||||
|
|
||||||
## Safety notes
|
## Safety notes
|
||||||
|
|
||||||
- Never place raw tokens or passwords in any LLM MCP config; reference secrets
|
- Never place raw tokens or passwords in any LLM MCP config; reference secrets
|
||||||
|
|||||||
Executable
+139
@@ -0,0 +1,139 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# release-tag — create an annotated release tag safely from remote master.
|
||||||
|
# Enforces the documented tagging policy (see docs/llm-workflow-runbooks.md and
|
||||||
|
# skills/llm-project-workflow/SKILL.md). Never pushes unless --push is given.
|
||||||
|
#
|
||||||
|
# Test/CI injection points (env):
|
||||||
|
# RELEASE_TAG_REMOTE git remote name (default: prgs)
|
||||||
|
# RELEASE_TAG_TEST_CMD test command run before tagging
|
||||||
|
# (default: ./venv/bin/python -m pytest tests/ -q)
|
||||||
|
|
||||||
|
REMOTE="${RELEASE_TAG_REMOTE:-prgs}"
|
||||||
|
TEST_CMD="${RELEASE_TAG_TEST_CMD:-./venv/bin/python -m pytest tests/ -q}"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
usage: scripts/release-tag [--dry-run] [--skip-tests] [--push]
|
||||||
|
[--notes-file <path>] <vMAJOR.MINOR.PATCH>
|
||||||
|
|
||||||
|
Create an annotated release tag from remote master, only when the tree/branch
|
||||||
|
are clean and tests pass. Safe by default: no push unless --push; --dry-run
|
||||||
|
changes nothing.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--dry-run Print planned actions; create/push nothing.
|
||||||
|
--skip-tests Skip the test suite (explicit opt-out; prints a warning).
|
||||||
|
--push Push the tag to the remote after creating it.
|
||||||
|
--notes-file <path> Use this file's contents as the annotated-tag message.
|
||||||
|
--help Show this help.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
scripts/release-tag --dry-run v0.4.0
|
||||||
|
scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md
|
||||||
|
scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md --push
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() { printf 'release-tag: %s\n' "$1" >&2; exit "${2:-1}"; }
|
||||||
|
|
||||||
|
dry_run=0
|
||||||
|
skip_tests=0
|
||||||
|
push=0
|
||||||
|
notes_file=""
|
||||||
|
version=""
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--dry-run) dry_run=1 ;;
|
||||||
|
--skip-tests) skip_tests=1 ;;
|
||||||
|
--push) push=1 ;;
|
||||||
|
--notes-file) shift; notes_file="${1:-}"; [[ -n "$notes_file" ]] || fail "--notes-file needs a path" 2 ;;
|
||||||
|
--help) usage; exit 0 ;;
|
||||||
|
-*) usage >&2; exit 2 ;;
|
||||||
|
*) if [[ -z "$version" ]]; then version="$1"; else usage >&2; exit 2; fi ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ -n "$version" ]] || { usage >&2; exit 2; }
|
||||||
|
|
||||||
|
# 1. SemVer validation (before any git/network work).
|
||||||
|
if [[ ! "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
fail "invalid version '$version' (expected vMAJOR.MINOR.PATCH, e.g. v0.4.0)" 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$notes_file" && ! -f "$notes_file" ]]; then
|
||||||
|
fail "notes file not found: $notes_file" 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
repo_root="$(cd "$script_dir/.." && pwd)"
|
||||||
|
git_c=(git -C "$repo_root")
|
||||||
|
|
||||||
|
# 2. Fetch/prune first.
|
||||||
|
"${git_c[@]}" fetch "$REMOTE" --prune
|
||||||
|
|
||||||
|
# 3. Must be on master.
|
||||||
|
current_branch="$("${git_c[@]}" symbolic-ref --quiet --short HEAD || echo DETACHED)"
|
||||||
|
[[ "$current_branch" == "master" ]] || fail "not on master (on '$current_branch'); tag only from master"
|
||||||
|
|
||||||
|
# 4. Clean worktree.
|
||||||
|
[[ -z "$("${git_c[@]}" status --porcelain)" ]] || fail "worktree is dirty; commit/stash before tagging"
|
||||||
|
|
||||||
|
# 5. Local master must equal remote master.
|
||||||
|
local_sha="$("${git_c[@]}" rev-parse master)"
|
||||||
|
remote_sha="$("${git_c[@]}" rev-parse "$REMOTE/master")"
|
||||||
|
[[ "$local_sha" == "$remote_sha" ]] || fail "local master ($local_sha) != $REMOTE/master ($remote_sha)"
|
||||||
|
|
||||||
|
# 6. HEAD must be that same commit (present on remote master).
|
||||||
|
head_sha="$("${git_c[@]}" rev-parse HEAD)"
|
||||||
|
[[ "$head_sha" == "$remote_sha" ]] || fail "HEAD ($head_sha) is not $REMOTE/master; tag only commits on remote master"
|
||||||
|
|
||||||
|
# 7. Tag must not already exist locally or on the remote.
|
||||||
|
if "${git_c[@]}" rev-parse -q --verify "refs/tags/$version" >/dev/null 2>&1; then
|
||||||
|
fail "tag $version already exists locally"
|
||||||
|
fi
|
||||||
|
if "${git_c[@]}" ls-remote --tags "$REMOTE" "refs/tags/$version" | grep -q .; then
|
||||||
|
fail "tag $version already exists on $REMOTE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Annotation message: notes file, or a minimal default.
|
||||||
|
if [[ -n "$notes_file" ]]; then
|
||||||
|
notes_arg=(-F "$notes_file")
|
||||||
|
else
|
||||||
|
notes_arg=(-m "$version")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Tests (default on; explicit --skip-tests warns). Not executed in dry-run.
|
||||||
|
tests_run="no"
|
||||||
|
if [[ "$skip_tests" -eq 1 ]]; then
|
||||||
|
printf 'release-tag: WARNING --skip-tests set; NOT running the test suite before tagging.\n' >&2
|
||||||
|
elif [[ "$dry_run" -eq 1 ]]; then
|
||||||
|
printf 'release-tag: [dry-run] would run tests: %s\n' "$TEST_CMD"
|
||||||
|
else
|
||||||
|
tests_run="yes"
|
||||||
|
( cd "$repo_root" && eval "$TEST_CMD" ) || fail "tests failed; refusing to tag"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create (and optionally push) the annotated tag.
|
||||||
|
tag_created="no"
|
||||||
|
tag_pushed="no"
|
||||||
|
if [[ "$dry_run" -eq 1 ]]; then
|
||||||
|
printf 'release-tag: [dry-run] would create annotated tag %s at %s\n' "$version" "$head_sha"
|
||||||
|
[[ "$push" -eq 1 ]] && printf 'release-tag: [dry-run] would push %s to %s\n' "$version" "$REMOTE"
|
||||||
|
else
|
||||||
|
"${git_c[@]}" tag -a "$version" "$head_sha" "${notes_arg[@]}"
|
||||||
|
tag_created="yes"
|
||||||
|
if [[ "$push" -eq 1 ]]; then
|
||||||
|
"${git_c[@]}" push "$REMOTE" "$version"
|
||||||
|
tag_pushed="yes"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'commit: %s\n' "$head_sha"
|
||||||
|
printf 'tag: %s\n' "$version"
|
||||||
|
printf 'tests_run: %s\n' "$tests_run"
|
||||||
|
printf 'tag_created: %s\n' "$tag_created"
|
||||||
|
printf 'tag_pushed: %s\n' "$tag_pushed"
|
||||||
@@ -19,6 +19,14 @@ identity, and cleaned up only after a real merge.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Definitions
|
||||||
|
|
||||||
|
- **Merged**: Gitea PR metadata says `merged=true`.
|
||||||
|
- **Landed**: Equivalent content is present on remote `master`, but PR metadata may not say merged.
|
||||||
|
- **Closed-not-merged**: PR state is closed and `merged=false`.
|
||||||
|
- **Reconciled**: A human/LLM verified whether closed-not-merged content landed, partially landed, or was lost, and repaired issue/label/tracker state.
|
||||||
|
|
||||||
## A. Issue-first rule
|
## A. Issue-first rule
|
||||||
|
|
||||||
**No repository change without a tracking issue.** This includes creating,
|
**No repository change without a tracking issue.** This includes creating,
|
||||||
@@ -133,6 +141,14 @@ Worktree folder = branch with `/` replaced by `-`
|
|||||||
10. Push the branch.
|
10. Push the branch.
|
||||||
11. Open a PR to `master`.
|
11. Open a PR to `master`.
|
||||||
12. **If you are the author, stop before review/merge.**
|
12. **If you are the author, stop before review/merge.**
|
||||||
|
13. **Normal issue work must not directly push to `master`.** PR content should be merged through the forge PR merge mechanism.
|
||||||
|
14. Direct push to `master` is allowed only as a documented recovery exception. If used, the final report must include:
|
||||||
|
- why the PR merge path could not be used
|
||||||
|
- exact commits pushed
|
||||||
|
- PR metadata state
|
||||||
|
- issue labels/state repaired
|
||||||
|
- whether the PR is closed-not-merged
|
||||||
|
|
||||||
|
|
||||||
## F. Review workflow
|
## F. Review workflow
|
||||||
|
|
||||||
@@ -148,13 +164,15 @@ Worktree folder = branch with `/` replaced by `-`
|
|||||||
|
|
||||||
Only an eligible (non-author) reviewer merges. After a real merge:
|
Only an eligible (non-author) reviewer merges. After a real merge:
|
||||||
|
|
||||||
1. Confirm remote `master` actually contains the merge commit.
|
1. Confirm remote `master` actually contains the merge commit (A PR is not done just because `master` moved. A PR is done only when: Gitea reports the PR merged or reconciliation documents equivalent content on `master`; remote `master` contains the expected content; linked issues are closed; `status:in-progress` is removed).
|
||||||
2. Close/release the issue; remove `status:in-progress` if used.
|
2. Close/release the issue.
|
||||||
3. Delete the remote branch.
|
3. Whenever an issue is closed, check for `status:in-progress`: remove it, or report why it could not be removed.
|
||||||
4. Remove the local branch.
|
4. Do not delete the remote source branch until: PR `merged=true`, or reconciliation confirms content is safely landed, or the issue owner explicitly abandons the work.
|
||||||
5. Remove the branch worktree folder (`scripts/worktree-clean --delete-branch <branch>`).
|
5. Remove the local branch.
|
||||||
6. Fetch/prune.
|
6. Remove the branch worktree folder (`scripts/worktree-clean --delete-branch <branch>`). Branches/worktrees are cleaned only after the above is verified.
|
||||||
7. Confirm the main checkout is clean and current (`0 0` vs remote).
|
7. Fetch/prune.
|
||||||
|
8. Confirm the main checkout is clean and current (`0 0` vs remote).
|
||||||
|
9. Final merge/reconciliation reports must include both: PR metadata (state, merged flag, merge commit/hash) and Git content (remote master hash, expected content present or not).
|
||||||
|
|
||||||
Never run cleanup before the merge is confirmed on remote `master`.
|
Never run cleanup before the merge is confirmed on remote `master`.
|
||||||
|
|
||||||
@@ -165,7 +183,11 @@ Never run cleanup before the merge is confirmed on remote `master`.
|
|||||||
- No issue exists and one cannot be created.
|
- No issue exists and one cannot be created.
|
||||||
- Worktree state is unclear or unexpected.
|
- Worktree state is unclear or unexpected.
|
||||||
- Branch/PR state conflicts with the prompt (e.g. prompt says "merged" but it is not).
|
- Branch/PR state conflicts with the prompt (e.g. prompt says "merged" but it is not).
|
||||||
- A PR is closed but not merged.
|
- A PR is closed but not merged (closed with `merged=false`). In this case:
|
||||||
|
- stop normal review/merge
|
||||||
|
- do not delete branches/worktrees
|
||||||
|
- do not start dependent work
|
||||||
|
- run reconciliation
|
||||||
- Local `master` is ahead of remote unexpectedly.
|
- Local `master` is ahead of remote unexpectedly.
|
||||||
- The authenticated user is the PR author (for review/merge).
|
- The authenticated user is the PR author (for review/merge).
|
||||||
- Secrets/tokens appear in the diff.
|
- Secrets/tokens appear in the diff.
|
||||||
@@ -182,9 +204,10 @@ When in doubt, stop and surface the discrepancy; do not guess or work around a g
|
|||||||
the commits are preserved on a feature branch (local + remote) first, then
|
the commits are preserved on a feature branch (local + remote) first, then
|
||||||
`git reset --hard <remote>/master` to realign. Never discard commits that are
|
`git reset --hard <remote>/master` to realign. Never discard commits that are
|
||||||
not safely pushed elsewhere.
|
not safely pushed elsewhere.
|
||||||
- **PR closed but not merged:** the work is not in mainline. Re-push the branch,
|
- **PR closed but not merged (`merged=false`):** do not merge. Run reconciliation: compare PR content to remote `master` and decide:
|
||||||
reopen (or open a replacement) PR, and let an eligible reviewer merge. Do not
|
- **fully landed:** comment that content is present on `master`, remove `status:in-progress`, keep/close issue as appropriate, clean up only after content equivalence is confirmed.
|
||||||
assume "closed" means "merged" — verify remote `master` contains the commits.
|
- **partially landed:** do not clean up, reopen issue if needed, create corrective issue/PR for missing pieces.
|
||||||
|
- **not landed:** reopen issue if needed, reopen PR or create replacement PR, do not clean up source branch/worktree.
|
||||||
- **Branch deleted before merge:** if the commits still exist locally (a branch or
|
- **Branch deleted before merge:** if the commits still exist locally (a branch or
|
||||||
reflog), re-push them and reopen the PR; otherwise recover via
|
reflog), re-push them and reopen the PR; otherwise recover via
|
||||||
`git fsck --lost-found`. Preserve first, then proceed.
|
`git fsck --lost-found`. Preserve first, then proceed.
|
||||||
@@ -203,6 +226,7 @@ Ready-to-copy templates live in [`templates/`](templates/):
|
|||||||
- [`review-pr.md`](templates/review-pr.md) — review a PR.
|
- [`review-pr.md`](templates/review-pr.md) — review a PR.
|
||||||
- [`merge-pr.md`](templates/merge-pr.md) — merge a PR (eligible reviewer only).
|
- [`merge-pr.md`](templates/merge-pr.md) — merge a PR (eligible reviewer only).
|
||||||
- [`recover-bad-state.md`](templates/recover-bad-state.md) — recover from bad state.
|
- [`recover-bad-state.md`](templates/recover-bad-state.md) — recover from bad state.
|
||||||
|
- [`reconcile-closed-not-merged-pr.md`](templates/reconcile-closed-not-merged-pr.md) — reconcile a closed-not-merged PR.
|
||||||
- [`worktree-cleanup.md`](templates/worktree-cleanup.md) — clean up after merge.
|
- [`worktree-cleanup.md`](templates/worktree-cleanup.md) — clean up after merge.
|
||||||
- [`release-tag.md`](templates/release-tag.md) — create a release tag.
|
- [`release-tag.md`](templates/release-tag.md) — create a release tag.
|
||||||
|
|
||||||
@@ -250,3 +274,9 @@ Release process (see [`templates/release-tag.md`](templates/release-tag.md)):
|
|||||||
6. Create the annotated tag on remote `master` with release notes.
|
6. Create the annotated tag on remote `master` with release notes.
|
||||||
7. Push the tag.
|
7. Push the tag.
|
||||||
8. Create/update release notes if the forge supports it.
|
8. Create/update release notes if the forge supports it.
|
||||||
|
|
||||||
|
Where present, `scripts/release-tag` automates this with all gates built in
|
||||||
|
(SemVer, fetch/prune, on-master, clean tree, local==remote master, HEAD on
|
||||||
|
remote master, no duplicate tag, tests, annotated-only). Safe by default: no
|
||||||
|
push without `--push`; `--dry-run` changes nothing; `--skip-tests` must be
|
||||||
|
explicit and warns.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ Rules (llm-project-workflow):
|
|||||||
author → STOP.
|
author → STOP.
|
||||||
- Do not merge unless the PR is open, mergeable, and its checks/review pass.
|
- Do not merge unless the PR is open, mergeable, and its checks/review pass.
|
||||||
- No force-merge, no bypassing branch protections.
|
- No force-merge, no bypassing branch protections.
|
||||||
|
- If the PR is closed but `merged=false`, STOP and run reconciliation. Do not clean up.
|
||||||
|
|
||||||
Steps:
|
Steps:
|
||||||
1. Verify authenticated identity + active profile.
|
1. Verify authenticated identity + active profile.
|
||||||
@@ -20,9 +21,9 @@ Steps:
|
|||||||
5. Confirm remote master now contains the merge commit.
|
5. Confirm remote master now contains the merge commit.
|
||||||
|
|
||||||
Then run the cleanup template (worktree-cleanup.md):
|
Then run the cleanup template (worktree-cleanup.md):
|
||||||
- close/release issue #<n>, remove status:in-progress
|
- close/release issue #<n>, remove status:in-progress (if it cannot be removed, report why)
|
||||||
- delete remote branch, remove local branch + worktree folder
|
- delete remote branch, remove local branch + worktree folder
|
||||||
- fetch/prune; confirm main checkout is clean and current (0 0).
|
- fetch/prune; confirm main checkout is clean and current (0 0).
|
||||||
|
|
||||||
Handoff: reviewer identity, merge result + commit, cleanup done, issue closed.
|
Handoff: reviewer identity, merge result + commit, cleanup done, issue closed, PR metadata state/merged flag/hash, remote master hash & Git content check.
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Reconcile Closed-Not-Merged PR Prompt
|
||||||
|
|
||||||
|
You are reconciling PR `<pr-number>` in `<repo-name>` which is closed but `merged=false`.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Do not delete branches or worktrees before reconciliation is complete.
|
||||||
|
- Compare the PR's exact content to remote `<default-branch>`.
|
||||||
|
- Determine if the content is fully landed, partially landed, or not landed.
|
||||||
|
|
||||||
|
Workflow:
|
||||||
|
|
||||||
|
1. Verify the PR metadata says `state=closed` and `merged=false`.
|
||||||
|
2. Fetch/prune and inspect remote `<default-branch>`.
|
||||||
|
3. If fully landed: comment that it landed, remove `status:in-progress`, close issue, and clean up.
|
||||||
|
4. If partially landed: reopen issue if needed, create corrective PR for missing pieces, do not clean up.
|
||||||
|
5. If not landed: reopen issue/PR, do not clean up.
|
||||||
|
|
||||||
|
Final handoff:
|
||||||
|
|
||||||
|
- PR metadata (state, merged flag, hash)
|
||||||
|
- Git content verification (remote master hash, expected content present or not)
|
||||||
|
- reconciliation decision (fully/partially/not landed)
|
||||||
|
- issue/label state repaired
|
||||||
@@ -22,8 +22,7 @@ Act per case:
|
|||||||
- Local master ahead of remote: confirm the extra commits live on a branch
|
- Local master ahead of remote: confirm the extra commits live on a branch
|
||||||
pushed to <remote>, THEN git reset --hard <remote>/master. Verify with
|
pushed to <remote>, THEN git reset --hard <remote>/master. Verify with
|
||||||
`git branch --contains <sha>` first.
|
`git branch --contains <sha>` first.
|
||||||
- PR closed but not merged: re-push the branch, reopen/replace the PR, let an
|
- PR closed but not merged (`merged=false`): stop normal flow and use reconcile-closed-not-merged-pr.md instead.
|
||||||
eligible reviewer merge. Do not merge your own.
|
|
||||||
- Branch deleted before merge: recover commits from a local branch/reflog (or
|
- Branch deleted before merge: recover commits from a local branch/reflog (or
|
||||||
git fsck --lost-found), re-push, reopen the PR.
|
git fsck --lost-found), re-push, reopen the PR.
|
||||||
- Unauthorized untracked file: do not commit it; leave pre-existing artifacts.
|
- Unauthorized untracked file: do not commit it; leave pre-existing artifacts.
|
||||||
|
|||||||
@@ -3,6 +3,18 @@
|
|||||||
Copy, fill the `<...>` fields, and paste as the task prompt. Tagging is
|
Copy, fill the `<...>` fields, and paste as the task prompt. Tagging is
|
||||||
irreversible-ish and outward-facing — fail closed on any doubt.
|
irreversible-ish and outward-facing — fail closed on any doubt.
|
||||||
|
|
||||||
|
> If the project ships `scripts/release-tag`, prefer it — it enforces every gate
|
||||||
|
> below automatically and is safe by default (no push without `--push`,
|
||||||
|
> `--dry-run` changes nothing):
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> scripts/release-tag --dry-run <vX.Y.Z>
|
||||||
|
> scripts/release-tag <vX.Y.Z> --notes-file <path>
|
||||||
|
> scripts/release-tag <vX.Y.Z> --notes-file <path> --push
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> The manual steps below are the fallback / what the script does.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Task: cut release <vX.Y.Z> from master.
|
Task: cut release <vX.Y.Z> from master.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""Tests for scripts/release-tag (#50).
|
||||||
|
|
||||||
|
Each test builds a throwaway git repo with a LOCAL bare remote named per
|
||||||
|
RELEASE_TAG_REMOTE — no network, no pushing from the project repo, no real tags
|
||||||
|
created here. The test suite gate is stubbed via RELEASE_TAG_TEST_CMD (true =
|
||||||
|
pass, false = fail) so no real pytest/venv is needed inside the temp repo.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
SCRIPT_SRC = REPO / "scripts" / "release-tag"
|
||||||
|
REMOTE = "prgs"
|
||||||
|
|
||||||
|
|
||||||
|
def _git(cwd, *args):
|
||||||
|
return subprocess.run(["git", *args], cwd=str(cwd),
|
||||||
|
capture_output=True, text=True)
|
||||||
|
|
||||||
|
|
||||||
|
class _ReleaseTagCase(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = Path(tempfile.mkdtemp())
|
||||||
|
self.work = self.tmp / "work"
|
||||||
|
self.bare = self.tmp / "remote.git"
|
||||||
|
self.work.mkdir()
|
||||||
|
_git(self.work, "init", "-b", "master")
|
||||||
|
_git(self.work, "config", "user.email", "t@example.invalid")
|
||||||
|
_git(self.work, "config", "user.name", "Test")
|
||||||
|
(self.work / "README").write_text("hello\n")
|
||||||
|
# Install the script under test and commit it so the worktree is clean.
|
||||||
|
(self.work / "scripts").mkdir()
|
||||||
|
dst = self.work / "scripts" / "release-tag"
|
||||||
|
shutil.copy(SCRIPT_SRC, dst)
|
||||||
|
dst.chmod(0o755)
|
||||||
|
_git(self.work, "add", "README", "scripts/release-tag")
|
||||||
|
_git(self.work, "commit", "-m", "initial")
|
||||||
|
# Seed the bare remote by cloning the work repo (already has master +
|
||||||
|
# the script). Avoids `git push <remote> master`, which the harness blocks.
|
||||||
|
_git(self.tmp, "clone", "--bare", str(self.work), str(self.bare))
|
||||||
|
_git(self.work, "remote", "add", REMOTE, str(self.bare))
|
||||||
|
_git(self.work, "fetch", REMOTE, "--prune")
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
def rt(self, *args, test_cmd="true"):
|
||||||
|
env = dict(os.environ, RELEASE_TAG_REMOTE=REMOTE,
|
||||||
|
RELEASE_TAG_TEST_CMD=test_cmd)
|
||||||
|
proc = subprocess.run(
|
||||||
|
["bash", str(self.work / "scripts" / "release-tag"), *args],
|
||||||
|
cwd=str(self.work), capture_output=True, text=True, env=env)
|
||||||
|
return proc.returncode, proc.stdout, proc.stderr
|
||||||
|
|
||||||
|
def tag_type(self, name):
|
||||||
|
r = _git(self.work, "cat-file", "-t", name)
|
||||||
|
return r.stdout.strip()
|
||||||
|
|
||||||
|
def local_tags(self):
|
||||||
|
return _git(self.work, "tag").stdout.split()
|
||||||
|
|
||||||
|
def remote_has_tag(self, name):
|
||||||
|
r = _git(self.work, "ls-remote", "--tags", REMOTE, f"refs/tags/{name}")
|
||||||
|
return bool(r.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidation(_ReleaseTagCase):
|
||||||
|
|
||||||
|
def test_accepts_valid_semver_dry_run(self):
|
||||||
|
rc, out, err = self.rt("--dry-run", "v0.4.0")
|
||||||
|
self.assertEqual(rc, 0, err)
|
||||||
|
self.assertIn("would create annotated tag v0.4.0", out)
|
||||||
|
self.assertEqual(self.local_tags(), []) # dry-run creates nothing
|
||||||
|
|
||||||
|
def test_rejects_invalid_version(self):
|
||||||
|
for bad in ("v1.2", "1.0.0", "v1.0", "release-1", "vx.y.z"):
|
||||||
|
rc, _, err = self.rt(bad)
|
||||||
|
self.assertEqual(rc, 2, bad)
|
||||||
|
self.assertIn("invalid version", err)
|
||||||
|
|
||||||
|
def test_rejects_dirty_worktree(self):
|
||||||
|
(self.work / "README").write_text("dirty\n")
|
||||||
|
rc, _, err = self.rt("v0.4.0", "--skip-tests")
|
||||||
|
self.assertEqual(rc, 1)
|
||||||
|
self.assertIn("dirty", err)
|
||||||
|
|
||||||
|
def test_rejects_non_master_branch(self):
|
||||||
|
_git(self.work, "checkout", "-b", "feat/issue-1-x")
|
||||||
|
rc, _, err = self.rt("v0.4.0", "--skip-tests")
|
||||||
|
self.assertEqual(rc, 1)
|
||||||
|
self.assertIn("not on master", err)
|
||||||
|
|
||||||
|
def test_rejects_master_remote_mismatch(self):
|
||||||
|
(self.work / "extra").write_text("x\n")
|
||||||
|
_git(self.work, "add", "extra")
|
||||||
|
_git(self.work, "commit", "-m", "local-only") # not pushed
|
||||||
|
rc, _, err = self.rt("v0.4.0", "--skip-tests")
|
||||||
|
self.assertEqual(rc, 1)
|
||||||
|
self.assertIn("master", err)
|
||||||
|
|
||||||
|
def test_rejects_existing_local_tag(self):
|
||||||
|
_git(self.work, "tag", "-a", "v0.4.0", "-m", "pre-existing")
|
||||||
|
rc, _, err = self.rt("v0.4.0", "--skip-tests")
|
||||||
|
self.assertEqual(rc, 1)
|
||||||
|
self.assertIn("already exists", err)
|
||||||
|
|
||||||
|
def test_missing_notes_file_rejected(self):
|
||||||
|
rc, _, err = self.rt("v0.4.0", "--skip-tests", "--notes-file",
|
||||||
|
str(self.tmp / "nope.md"))
|
||||||
|
self.assertEqual(rc, 2)
|
||||||
|
self.assertIn("notes file not found", err)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTagging(_ReleaseTagCase):
|
||||||
|
|
||||||
|
def test_creates_annotated_tag_not_lightweight(self):
|
||||||
|
rc, out, err = self.rt("v0.4.0", "--skip-tests")
|
||||||
|
self.assertEqual(rc, 0, err)
|
||||||
|
self.assertEqual(self.tag_type("v0.4.0"), "tag") # annotated, not "commit"
|
||||||
|
self.assertIn("tag_created: yes", out)
|
||||||
|
|
||||||
|
def test_no_push_without_flag(self):
|
||||||
|
rc, out, _ = self.rt("v0.5.0", "--skip-tests")
|
||||||
|
self.assertEqual(rc, 0)
|
||||||
|
self.assertIn("tag_pushed: no", out)
|
||||||
|
self.assertFalse(self.remote_has_tag("v0.5.0"))
|
||||||
|
|
||||||
|
def test_push_only_with_flag(self):
|
||||||
|
rc, out, err = self.rt("v0.6.0", "--skip-tests", "--push")
|
||||||
|
self.assertEqual(rc, 0, err)
|
||||||
|
self.assertIn("tag_pushed: yes", out)
|
||||||
|
self.assertTrue(self.remote_has_tag("v0.6.0"))
|
||||||
|
|
||||||
|
def test_notes_file_used_as_message(self):
|
||||||
|
notes = self.tmp / "notes.md"
|
||||||
|
notes.write_text("Release v0.4.0\n\n- #50 release-tag helper\n")
|
||||||
|
rc, _, err = self.rt("v0.4.0", "--skip-tests", "--notes-file", str(notes))
|
||||||
|
self.assertEqual(rc, 0, err)
|
||||||
|
msg = _git(self.work, "tag", "-n99", "-l", "v0.4.0").stdout
|
||||||
|
self.assertIn("release-tag helper", msg)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTestsGate(_ReleaseTagCase):
|
||||||
|
|
||||||
|
def test_skip_tests_warns_and_skips(self):
|
||||||
|
rc, out, err = self.rt("v0.4.0", "--skip-tests")
|
||||||
|
self.assertEqual(rc, 0)
|
||||||
|
self.assertIn("WARNING", err)
|
||||||
|
self.assertIn("tests_run: no", out)
|
||||||
|
|
||||||
|
def test_default_runs_tests_and_failure_blocks_tag(self):
|
||||||
|
rc, out, err = self.rt("v0.4.0", test_cmd="false") # tests "fail"
|
||||||
|
self.assertEqual(rc, 1)
|
||||||
|
self.assertIn("tests failed", err)
|
||||||
|
self.assertEqual(self.local_tags(), []) # no tag on failure
|
||||||
|
|
||||||
|
def test_default_runs_tests_and_passes(self):
|
||||||
|
rc, out, err = self.rt("v0.4.0", test_cmd="true")
|
||||||
|
self.assertEqual(rc, 0, err)
|
||||||
|
self.assertIn("tests_run: yes", out)
|
||||||
|
self.assertEqual(self.tag_type("v0.4.0"), "tag")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user