feat(workflow): add git.create_tag config to disable milestone tagging

Adds boolean config key `git.create_tag` (default: true, fully backcompat)
so projects with their own release flow can disable GSD's automatic
`git tag -a v[X.Y]` on milestone completion. Also adds tag-collision
pre-check to prevent silent failure on re-run. Closes #3086

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-14 12:06:36 -04:00
parent 625ccf5217
commit da21edfb59
11 changed files with 134 additions and 5 deletions

View File

@@ -0,0 +1,5 @@
---
type: Added
pr: 0
---
**Configurable milestone git-tag creation** — new `git.create_tag` boolean config (default `true`, backcompat) lets projects with their own release flow disable GSD's automatic `git tag -a v[X.Y]` on milestone completion. Set via `/gsd:settings` or `gsd config-set git.create_tag false`. Also adds tag-collision pre-check to prevent silent failure when re-running a milestone close. (#0)

View File

@@ -126,7 +126,7 @@ Output: Milestone archived (roadmap + requirements), PROJECT.md evolved, git tag
- `.planning/REQUIREMENTS.md` deleted (fresh for next milestone)
- ROADMAP.md collapsed to one-line entry
- PROJECT.md updated with current state
- Git tag v{{version}} created
- Git tag v{{version}} created (if `git.create_tag` enabled)
- Commit successful
- User knows next steps (including need for fresh requirements)
</success_criteria>

View File

@@ -542,6 +542,7 @@ All four fields are **optional and additive** — STATE.md files without them ke
|---------|------|---------|-------------|
| `git.branching_strategy` | enum | `none` | `none`, `phase`, or `milestone` |
| `git.base_branch` | string | `main` | The integration branch that phase/milestone branches are created from and merged back into. Override when your repo uses `master` or a release branch |
| `git.create_tag` | boolean | `true` | Create a git tag (`v[X.Y]`) on milestone completion. Set to `false` for projects with their own release flow |
| `git.phase_branch_template` | string | `gsd/phase-{phase}-{slug}` | Branch name template for phase strategy |
| `git.milestone_branch_template` | string | `gsd/{milestone}-{slug}` | Branch name template for milestone strategy |
| `git.quick_branch_template` | string or null | `null` | Optional branch name template for `/gsd-quick` tasks |

View File

@@ -48,7 +48,7 @@ const VALID_CONFIG_KEYS = new Set([
'code_quality.fallow.profile',
'code_quality.fallow.mcp',
'ship.pr_body_sections',
'git.branching_strategy', 'git.base_branch', 'git.phase_branch_template', 'git.milestone_branch_template', 'git.quick_branch_template',
'git.branching_strategy', 'git.base_branch', 'git.create_tag', 'git.phase_branch_template', 'git.milestone_branch_template', 'git.quick_branch_template',
'planning.commit_docs', 'planning.search_gitignored', 'planning.sub_repos',
'review.ollama_host', 'review.lm_studio_host', 'review.llama_cpp_host',
'review.default_reviewers',

View File

@@ -432,6 +432,13 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
}
}
// #3086 — git.create_tag: boolean only
if (keyPath === 'git.create_tag') {
if (typeof parsedValue !== 'boolean') {
error(`Invalid git.create_tag '${value}'. Must be a boolean (true or false).`);
}
}
if (keyPath === 'ship.pr_body_sections') {
validateShipPrBodySections(parsedValue);
}
@@ -492,6 +499,7 @@ const SCHEMA_DEFAULTS = {
'context_window': 200000,
'executor.stall_detect_interval_minutes': 5,
'executor.stall_threshold_minutes': 10,
'git.create_tag': true,
};
function cmdConfigGet(cwd, keyPath, raw, defaultValue) {
@@ -509,6 +517,10 @@ function cmdConfigGet(cwd, keyPath, raw, defaultValue) {
} else if (hasDefault) {
output(defaultValue, raw, String(defaultValue));
return;
} else if (Object.prototype.hasOwnProperty.call(SCHEMA_DEFAULTS, keyPath)) {
const def = SCHEMA_DEFAULTS[keyPath];
output(def, raw, String(def));
return;
} else {
error('No config.json found at ' + configPath, ERROR_REASON.CONFIG_NO_FILE);
}

View File

@@ -30,6 +30,7 @@ Configuration options for `.planning/` directory behavior.
| `search_gitignored` | `false` | Add `--no-ignore` to broad rg searches |
| `git.branching_strategy` | `"none"` | Git branching approach: `"none"`, `"phase"`, or `"milestone"` |
| `git.base_branch` | `null` (auto-detect) | Target branch for PRs and merges (e.g. `"master"`, `"develop"`). When `null`, auto-detects from `git symbolic-ref refs/remotes/origin/HEAD`, falling back to `"main"`. |
| `git.create_tag` | `true` | Create git tags on milestone completion |
| `git.phase_branch_template` | `"gsd/phase-{phase}-{slug}"` | Branch template for phase strategy |
| `git.milestone_branch_template` | `"gsd/{milestone}-{slug}"` | Branch template for milestone strategy |
| `git.quick_branch_template` | `null` | Optional branch template for quick-task runs |
@@ -286,6 +287,7 @@ Set via `git.*` namespace (e.g., `"git": { "branching_strategy": "phase" }`).
|-----|------|---------|----------------|-------------|
| `git.branching_strategy` | string | `"none"` | `"none"`, `"phase"`, `"milestone"` | Git branching approach for phase/milestone isolation |
| `git.base_branch` | string\|null | `null` (auto-detect) | Any branch name | Target branch for PRs and merges; auto-detects from `origin/HEAD` when `null` |
| `git.create_tag` | boolean | `true` | `true`, `false` | Create git tags on milestone completion |
| `git.phase_branch_template` | string | `"gsd/phase-{phase}-{slug}"` | Template with `{phase}`, `{slug}` | Branch naming template for `phase` strategy |
| `git.milestone_branch_template` | string | `"gsd/{milestone}-{slug}"` | Template with `{milestone}`, `{slug}` | Branch naming template for `milestone` strategy |
| `git.quick_branch_template` | string\|null | `null` | Template with `{slug}` | Optional branch template for quick-task runs |

View File

@@ -28,6 +28,9 @@
"search_gitignored": false,
"sub_repos": []
},
"git": {
"create_tag": true
},
"parallelization": {
"enabled": true,
"plan_level": true,

View File

@@ -724,9 +724,16 @@ fi
<step name="git_tag">
<config-check>
Read `git.create_tag` via `gsd-sdk query config-get git.create_tag 2>/dev/null || echo "true"`.
If the result is `false` → skip this step entirely and proceed to `git_commit_milestone`.
</config-check>
Create git tag:
```bash
# Pre-check: skip if tag already exists (prevents silent failure on retry)
if git rev-parse "v${milestone_version}" >/dev/null 2>&1; then echo "Tag v${milestone_version} already exists, skipping"; exit 0; fi
git tag -a v[X.Y] -m "v[X.Y] [Name]
Delivered: [One sentence]

View File

@@ -246,6 +246,15 @@ AskUserQuestion([
{ label: "Per Milestone", description: "Create branch for entire milestone (gsd/{version}-{name})" }
]
},
{
question: "Create git tags on milestone completion?",
header: "Git Tagging",
multiSelect: false,
options: [
{ label: "Yes (Recommended)", description: "Tag releases with version (e.g., v1.0) on milestone completion" },
{ label: "No", description: "Skip git tagging — use if your project doesn't use tags or uses a different release convention" }
]
},
{
question: "Enable context window warnings? (injects advisory messages when context is getting full)",
header: "Ctx Warnings",
@@ -349,7 +358,8 @@ Merge new settings into existing config.json:
},
"git": {
"branching_strategy": "none" | "phase" | "milestone",
"quick_branch_template": <string|null>
"quick_branch_template": <string|null>,
"create_tag": true/false
},
"hooks": {
"context_warnings": true/false,
@@ -450,6 +460,7 @@ Display:
| UI Safety Gate | {On/Off} |
| AI Integration Phase | {On/Off} |
| Git Branching | {None/Per Phase/Per Milestone} |
| Git Tagging | {On/Off} |
| Skip Discuss | {On/Off} |
| Context Warnings | {On/Off} |
| Saved as Defaults | {Yes/No} |
@@ -470,7 +481,7 @@ Quick commands:
<success_criteria>
- [ ] Current config read
- [ ] User presented with 22 settings (profile + workflow toggles + features + git branching + ctx warnings), grouped into six sections: Planning, Execution, Docs & Output, Features, Model & Pipeline, Misc. `code_review_depth` is conditional on `code_review=on`.
- [ ] User presented with 23 settings (profile + workflow toggles + features + git branching + git tagging + ctx warnings), grouped into six sections: Planning, Execution, Docs & Output, Features, Model & Pipeline, Misc. `code_review_depth` is conditional on `code_review=on`.
- [ ] Config updated with model_profile, workflow, and git sections
- [ ] User offered to save as global defaults (~/.gsd/defaults.json)
- [ ] Changes confirmed to user

View File

@@ -50,7 +50,7 @@ export const VALID_CONFIG_KEYS: ReadonlySet<string> = new Set([
'code_quality.fallow.profile',
'code_quality.fallow.mcp',
'ship.pr_body_sections',
'git.branching_strategy', 'git.base_branch', 'git.phase_branch_template', 'git.milestone_branch_template', 'git.quick_branch_template',
'git.branching_strategy', 'git.base_branch', 'git.create_tag', 'git.phase_branch_template', 'git.milestone_branch_template', 'git.quick_branch_template',
'planning.commit_docs', 'planning.search_gitignored', 'planning.sub_repos',
'review.default_reviewers',
'review.ollama_host', 'review.lm_studio_host', 'review.llama_cpp_host',

View File

@@ -0,0 +1,88 @@
// allow-test-rule: workflow-markdown-is-the-runtime-contract
// Justification: complete-milestone.md IS the runtime — the agent reads and
// follows it directly. Asserting the <config-check> block is present in the
// markdown is the only way to verify the gate is wired. Per CONTEXT.md L611.
'use strict';
/**
* #3086 — git.create_tag config gate for milestone tagging.
*
* Tests:
* A. Default value: fresh project returns `true` for git.create_tag
* B. config-set false → config-get returns false
* C. Invalid value (e.g. "maybe") is rejected by schema validator
* D. complete-milestone.md workflow contains the <config-check> gate for git.create_tag
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
const WORKFLOW_PATH = path.join(
__dirname,
'..',
'get-shit-done',
'workflows',
'complete-milestone.md',
);
describe('#3086: git.create_tag config key', () => {
test('A. fresh project: config-get git.create_tag returns true (default)', (t) => {
const tmpDir = createTempProject('gsd-3086-default-');
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-get', 'git.create_tag'], tmpDir, { HOME: tmpDir });
assert.ok(result.success, `config-get git.create_tag failed:\n${result.error}`);
assert.strictEqual(
result.output.trim(),
'true',
`Expected default value 'true', got: '${result.output.trim()}'`,
);
});
test('B. config-set git.create_tag false → config-get returns false', (t) => {
const tmpDir = createTempProject('gsd-3086-set-false-');
t.after(() => cleanup(tmpDir));
const setResult = runGsdTools(['config-set', 'git.create_tag', 'false'], tmpDir, {
HOME: tmpDir,
});
assert.ok(setResult.success, `config-set git.create_tag false failed:\n${setResult.error}`);
const getResult = runGsdTools(['config-get', 'git.create_tag'], tmpDir, { HOME: tmpDir });
assert.ok(getResult.success, `config-get after set failed:\n${getResult.error}`);
assert.strictEqual(
getResult.output.trim(),
'false',
`Expected 'false' after set, got: '${getResult.output.trim()}'`,
);
});
test('C. config-set git.create_tag with invalid value "maybe" is rejected', (t) => {
const tmpDir = createTempProject('gsd-3086-invalid-');
t.after(() => cleanup(tmpDir));
const result = runGsdTools(['config-set', 'git.create_tag', 'maybe'], tmpDir, {
HOME: tmpDir,
});
assert.ok(
!result.success,
`Expected config-set to fail for invalid value "maybe", but it succeeded`,
);
});
test('D. complete-milestone.md contains <config-check> gate for git.create_tag', () => {
const content = fs.readFileSync(WORKFLOW_PATH, 'utf8');
assert.ok(
content.includes('git.create_tag'),
'complete-milestone.md must reference git.create_tag in a <config-check> block',
);
assert.ok(
content.includes('<config-check>'),
'complete-milestone.md must have a <config-check> block in the git_tag step',
);
});
});