feat(P06): 3-tier versioning, branch hierarchy enforcement, ARCHITECTURE-PLAN synthesis
---ci---
phase: 6
milestone: v0.5
status: complete
decisions:
- id: D-006
decision: Research as intermediate work product
rationale: Conclusions update .ci/ files; full research doc intentionally not preserved
confidence: 0.90
- id: D-007
decision: Branch hierarchy enforcement: main > milestone > phase
rationale: Prevents out-of-order merges and semantically wrong tags
confidence: 0.92
- id: D-008
decision: 3-tier versioning: NFR/feature/schema-breaking
rationale: Patch per phase (NFR/feature) or minor per phase (schema-breaking); milestone gets minor (feature) or major (schema-breaking)
confidence: 0.95
requirements:
covered: [VER-06, BRANCH-01, BRANCH-02, ARCH-01]
---/ci---
- Synthesize ARCHITECTURE-PLAN.md into .ci/ci/ARCHITECTURE.md (expanded 51→230 lines)
- Add D-006/D-007/D-008 to .ci/ci/PROJECT.md key decisions table
- Delete ARCHITECTURE-PLAN.md after synthesis
- Rewrite ship.md with 3-tier versioning model + branch hierarchy merge flows
- Rewrite branch-strategy.md with 3-tier versioning + branch hierarchy + version validation
- Add MilestoneType to config types
- Replace isNfrMilestone() with getMilestoneType() returning nfr|feature|schema-breaking
- Add validateMergeOrder(), mergeMilestoneBranch(), computeMilestoneTag() to GitBranch
- Add computeShipVersion(), validateVersionOrder(), resolveMergeTarget() to ship command
- Remove hardcoded v0.5. from error-recovery rollback
- Create .githooks/pre-push for semver ordering + branch hierarchy validation
- Add 15 new tests (370 total, all passing)
This commit is contained in:
+110
-6
@@ -680,7 +680,6 @@ export function createShipCommand(): Command {
|
||||
}
|
||||
|
||||
const config = loadConfig(projectPath);
|
||||
const milestone = "v1.0";
|
||||
|
||||
try {
|
||||
const isGitRepo = execSync("git rev-parse --is-inside-work-tree", {
|
||||
@@ -689,23 +688,34 @@ export function createShipCommand(): Command {
|
||||
}).trim() === "true";
|
||||
|
||||
if (isGitRepo) {
|
||||
console.log(" Computing version...");
|
||||
const version = computeShipVersion(projectPath, phaseNum, config);
|
||||
console.log(` Version: ${version.tag} (${version.milestoneType})`);
|
||||
|
||||
const mergeTarget = resolveMergeTarget(projectPath, version.milestoneType);
|
||||
console.log(` Merge target: ${mergeTarget}`);
|
||||
|
||||
console.log(" Committing and tagging...");
|
||||
const tag = `${milestone}-phase${phaseNum}`;
|
||||
try {
|
||||
if (!validateVersionOrder(projectPath, version.tag)) {
|
||||
console.error(`✗ Version ${version.tag} is not greater than existing tags. Aborting.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
execSync(`git add -A`, { cwd: projectPath, stdio: "pipe" });
|
||||
execSync(`git commit -m "chore: ship phase ${phaseNum}" --allow-empty`, {
|
||||
cwd: projectPath,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execSync(`git tag -a ${tag} -m "CI: Phase ${phaseNum} shipped"`, {
|
||||
execSync(`git tag -a ${version.tag} -m "CI: Phase ${phaseNum} shipped"`, {
|
||||
cwd: projectPath,
|
||||
stdio: "pipe",
|
||||
});
|
||||
console.log(` ✓ Tagged: ${tag}`);
|
||||
console.log(` ✓ Tagged: ${version.tag}`);
|
||||
|
||||
if (config.git.auto_push) {
|
||||
execSync(`git push origin ${tag}`, { cwd: projectPath, stdio: "pipe" });
|
||||
console.log(` ✓ Pushed tag: ${tag}`);
|
||||
execSync(`git push origin ${version.tag}`, { cwd: projectPath, stdio: "pipe" });
|
||||
console.log(` ✓ Pushed tag: ${version.tag}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(` ⚠ Git operations failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
@@ -715,4 +725,98 @@ export function createShipCommand(): Command {
|
||||
|
||||
console.log(`\n✓ Phase ${phaseNum} shipped successfully`);
|
||||
});
|
||||
}
|
||||
|
||||
function computeShipVersion(
|
||||
projectPath: string,
|
||||
phaseNum: number,
|
||||
config: CIConfig
|
||||
): { tag: string; milestoneType: "nfr" | "feature" | "schema-breaking" } {
|
||||
const tags = execSync("git tag -l", { cwd: projectPath, encoding: "utf-8" })
|
||||
.split("\n")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
let major = 0;
|
||||
let minor = 0;
|
||||
let patch = 0;
|
||||
|
||||
for (const tag of tags) {
|
||||
const match = tag.match(/^v(\d+)\.(\d+)\.(\d+)$/);
|
||||
if (match) {
|
||||
const m = parseInt(match[1]);
|
||||
const n = parseInt(match[2]);
|
||||
const p = parseInt(match[3]);
|
||||
if (m > major || (m === major && n > minor) || (m === major && n === minor && p > patch)) {
|
||||
major = m;
|
||||
minor = n;
|
||||
patch = p;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const milestoneType = inferMilestoneType(projectPath);
|
||||
|
||||
let tag: string;
|
||||
if (milestoneType === "schema-breaking") {
|
||||
tag = `v${major}.${minor + phaseNum}.0`;
|
||||
} else {
|
||||
tag = `v${major}.${minor}.${phaseNum}`;
|
||||
}
|
||||
|
||||
return { tag, milestoneType };
|
||||
}
|
||||
|
||||
function inferMilestoneType(projectPath: string): "nfr" | "feature" | "schema-breaking" {
|
||||
try {
|
||||
const log = execSync("git log --oneline -50", { cwd: projectPath, encoding: "utf-8" });
|
||||
if (log.match(/\brefactor\b|\brewrite\b|\bmigrate\b|\brestructure\b/i)) return "schema-breaking";
|
||||
if (log.match(/\bfeat\b/)) return "feature";
|
||||
return "nfr";
|
||||
} catch {
|
||||
return "nfr";
|
||||
}
|
||||
}
|
||||
|
||||
function validateVersionOrder(projectPath: string, newTag: string): boolean {
|
||||
const newMatch = newTag.match(/^v(\d+)\.(\d+)\.(\d+)$/);
|
||||
if (!newMatch) return false;
|
||||
const newMajor = parseInt(newMatch[1]);
|
||||
const newMinor = parseInt(newMatch[2]);
|
||||
const newPatch = parseInt(newMatch[3]);
|
||||
|
||||
const tags = execSync("git tag -l", { cwd: projectPath, encoding: "utf-8" })
|
||||
.split("\n")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const tag of tags) {
|
||||
const match = tag.match(/^v(\d+)\.(\d+)\.(\d+)$/);
|
||||
if (!match) continue;
|
||||
const major = parseInt(match[1]);
|
||||
const minor = parseInt(match[2]);
|
||||
const patch = parseInt(match[3]);
|
||||
|
||||
if (major === newMajor && minor === newMinor && patch >= newPatch) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveMergeTarget(projectPath: string, milestoneType: string): string {
|
||||
try {
|
||||
const branches = execSync("git branch --list", { cwd: projectPath, encoding: "utf-8" })
|
||||
.split("\n")
|
||||
.map((b) => b.trim().replace(/^\*?\s+/, ""))
|
||||
.filter(Boolean);
|
||||
|
||||
const milestoneBranches = branches.filter((b) => b.startsWith("milestone/"));
|
||||
if (milestoneBranches.length > 0) {
|
||||
return milestoneBranches[0];
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return "main";
|
||||
}
|
||||
Reference in New Issue
Block a user