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:
5
.changeset/disable-milestone-tags.md
Normal file
5
.changeset/disable-milestone-tags.md
Normal 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)
|
||||
@@ -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>
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
"search_gitignored": false,
|
||||
"sub_repos": []
|
||||
},
|
||||
"git": {
|
||||
"create_tag": true
|
||||
},
|
||||
"parallelization": {
|
||||
"enabled": true,
|
||||
"plan_level": true,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
88
tests/bug-3086-git-create-tag-config-gate.test.cjs
Normal file
88
tests/bug-3086-git-create-tag-config-gate.test.cjs
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user