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:
Jon Chery
2026-05-29 17:18:10 +00:00
parent 3d069319b5
commit ab6af144b7
14 changed files with 696 additions and 93 deletions
+80
View File
@@ -0,0 +1,80 @@
#!/bin/bash
# CI pre-push hook: enforce versioning and branching rules
# Install: git config core.hooksPath .githooks
zero="0000000000000000000000000000000000000000"
while read local_ref local_oid remote_ref remote_oid; do
if [ "$local_oid" = "$zero" ]; then
continue
fi
# Check pushed tags
if echo "$local_ref" | grep -qE "^refs/tags/"; then
tag_name=$(echo "$local_ref" | sed 's|^refs/tags/||')
# Validate semver format
if echo "$tag_name" | grep -qE "^v[0-9]+\.[0-9]+\.[0-9]+$"; then
tag_major=$(echo "$tag_name" | sed 's/v\([0-9]*\)\.[0-9]*\.[0-9]*/\1/')
tag_minor=$(echo "$tag_name" | sed 's/v[0-9]*\.\([0-9]*\)\.[0-9]*/\1/')
tag_patch=$(echo "$tag_name" | sed 's/v[0-9]*\.[0-9]*\.\([0-9]*\)/\1/')
# Check for semver ordering violations
for existing_tag in $(git tag -l "v${tag_major}.${tag_minor}.*" 2>/dev/null); do
if [ "$existing_tag" = "$tag_name" ]; then
continue
fi
existing_patch=$(echo "$existing_tag" | sed 's/v[0-9]*\.[0-9]*\.\([0-9]*\)/\1/')
if [ "$existing_patch" -ge "$tag_patch" ] && [ "$tag_patch" -le "$existing_patch" ]; then
echo "ERROR: Tag $tag_name is not greater than existing tag $existing_tag"
echo " Milestone tags must be the NEXT version (e.g., v0.6.0 after v0.5.1-5, NOT v0.5.0)"
exit 1
fi
done
# Check for milestone-tags-below-phase-tags
# If this is a .0 tag (milestone), verify no .N tags exist with higher patch
if [ "$tag_patch" = "0" ]; then
for existing_tag in $(git tag -l "v${tag_major}.${tag_minor}.*" 2>/dev/null); do
existing_patch=$(echo "$existing_tag" | sed 's/v[0-9]*\.[0-9]*\.\([0-9]*\)/\1/')
if [ "$existing_patch" -gt 0 ] && [ "$existing_patch" -gt "$tag_patch" ]; then
echo "ERROR: Milestone tag $tag_name is below existing phase tags (e.g., $existing_tag)"
echo " Feature milestone completion must be tagged as v${tag_major}.$(($tag_minor + 1)).0, not v${tag_major}.${tag_minor}.0"
exit 1
fi
done
fi
fi
fi
# Check branch merges: reject direct-to-main pushes if milestone branch exists
if echo "$local_ref" | grep -qE "^refs/heads/main$"; then
milestone_branches=$(git branch -r 2>/dev/null | grep 'milestone/v' | grep -v ':$' || true)
if [ -n "$milestone_branches" ]; then
# Allow if this is a merge commit from a milestone branch
merge_parents=$(git cat-file -p "$local_oid" 2>/dev/null | grep "^parent" | wc -l)
if [ "$merge_parents" -lt 2 ]; then
# Not a merge commit — check if there are active milestone branches
active_milestones=""
for mb in $milestone_branches; do
clean_name=$(echo "$mb" | sed 's|^[^/]*/||' | tr -d ' ')
merged=$(git branch -r --merged origin/main 2>/dev/null | grep "$clean_name" || true)
if [ -z "$merged" ]; then
active_milestones="$active_milestones $clean_name"
fi
done
if [ -n "$active_milestones" ]; then
echo "WARNING: Pushing directly to main while active milestone branches exist:"
for ms in $active_milestones; do
echo " - $ms"
done
echo " Phase branches should merge into the milestone branch first."
# Warning only — not blocking. The code-level enforcement in git-branch.ts
# is the hard gate; this hook is a safety net.
fi
fi
fi
fi
done
exit 0
+106 -47
View File
@@ -23,7 +23,7 @@ Canonical branch naming and lifecycle conventions for CI. Branches encode projec
**Lifecycle:** **Lifecycle:**
1. Created at phase start by `GitBranch.createPhaseBranch()` 1. Created at phase start by `GitBranch.createPhaseBranch()`
2. All task commits for the phase land on this branch 2. All task commits for the phase land on this branch
3. Merged to main (squash) on phase completion 3. Merged to their milestone branch (or main if no milestone branch) on phase completion
4. Merged = phase complete, active = phase in progress, absent = not started 4. Merged = phase complete, active = phase in progress, absent = not started
### Milestone Branches ### Milestone Branches
@@ -42,14 +42,33 @@ Canonical branch naming and lifecycle conventions for CI. Branches encode projec
**Lifecycle:** **Lifecycle:**
1. Created at first phase of milestone by `GitBranch.createMilestoneBranch()` 1. Created at first phase of milestone by `GitBranch.createMilestoneBranch()`
2. Spans multiple phases within the same milestone 2. Spans multiple phases within the same milestone
3. Merged to main on milestone completion 3. All phase branches merge into this branch on completion
4. Merged = milestone complete, active = milestone in progress 4. Merged to main on milestone completion
5. Merged = milestone complete, active = milestone in progress
### Hotfix Branches ### Hotfix Branches
**Format:** `hotfix/description` **Format:** `hotfix/description`
Created for urgent fixes outside the normal phase flow. Merged directly to main. Created for urgent fixes outside the normal phase flow. Merged directly to main (exception to hierarchy).
## Branch Hierarchy (Enforced)
```text
main ─── milestone/vX.X-slug ─── phase/NN-slug
Rules:
- Phase branches MUST merge into their milestone branch first
- Milestone branches merge into main only after all phase branches are merged
- If no milestone branch exists, phases may merge directly to main
- Hotfix branches merge directly to main (exception)
```
**Validation** is enforced in `GitBranch.mergePhaseBranch()` and `createShipCommand()`:
- Phase → main: rejected if milestone branch exists for this milestone
- Phase → milestone: allowed
- Milestone → main: allowed only after all phase branches are merged
- Hotfix → main: allowed
## Branch Status Inference ## Branch Status Inference
@@ -69,67 +88,97 @@ const branches = gitContext.getBranches();
## Merge Strategy ## Merge Strategy
Default: **squash merge** into main. Default: **squash merge**.
Phase branches squash-merge into their milestone branch. Milestone branches squash-merge into main. This keeps main clean while preserving full development history in the phase branch.
```typescript ```typescript
gitBranch.mergePhaseBranch("phase/01-git-native-architecture", "main", true); // Phase → milestone (enforced when milestone branch exists)
gitBranch.mergePhaseBranch("phase/01-git-native-architecture", "milestone/v0.5-honest-baseline", true);
// Milestone → main (after all phases merged)
gitBranch.mergeMilestoneBranch("milestone/v0.5-honest-baseline", "main", true);
``` ```
Squash merge keeps main clean while preserving full development history in the phase branch. Phase branches can be deleted after merge if desired. Phase branches can be deleted after merge if desired.
## Versioning and Releases ## Versioning and Releases
**Every merge to main creates a release. No exceptions.** Versioning maps to project structure: **Every merge to main creates a release. No exceptions.** Versioning follows a 3-tier model based on milestone type:
| Version Part | When | Example | ### 3-Tier Versioning Model
|-------------|------|---------|
| **Major** (X.0.0) | Project-level refactor or schema change | `v1.0.0` |
| **Minor** (0.X.0) | Every milestone completion | `v0.3.0` |
| **Patch** (0.0.X) | Every phase completion | `v0.2.3` |
### Phase completion (patch release) | Milestone Type | Condition | Phase release | Milestone release |
|---------------|-----------|---------------|-------------------|
| **NFR** | All phases: fix/chore/docs/perf/refactor/test | Patch (`vX.Y.Z`) | None |
| **Feature** | Any phase is `feat`, no schema break | Patch (`vX.Y.Z`) | Minor — `vX.(Y+1).0` |
| **Schema-breaking** | Refactor/schema break/new direction | Minor — `vX.(Y+N).0` per phase | Major — `v(X+1).0.0` |
**IMPORTANT:** Milestone tags are always the NEXT version, never the base:
- Feature: patches v0.5.1v0.5.5 → milestone tag is v0.6.0 (NOT v0.5.0)
- Schema-breaking: minors v0.3.0, v0.4.0, v0.5.0 → milestone tag is v1.0.0
- NFR: no milestone tag — the milestone is implicit from the patch sequence
Determine milestone type via `getMilestoneType()` which returns `"nfr" | "feature" | "schema-breaking"`.
### Phase completion
**NFR/Feature (patch release):**
```bash ```bash
git checkout main git checkout milestone/v0.5-honest-baseline # or main if no milestone branch
git merge --squash phase/01-git-native-architecture git merge --squash phase/01-quick-wins
git commit -m "docs(P01): complete git-native-architecture phase" git commit -m "docs(P01): complete quick-wins phase"
git tag -a v0.2.1 -m "v0.2.1: git-native-architecture" git tag -a v0.5.1 -m "v0.5.1: quick-wins"
git push origin main --tags git push origin main --tags
# Create Gitea release for v0.2.1 # Create Gitea release for v0.5.1
``` ```
Phase number within the milestone determines the patch version (1st phase = .1, 2nd phase = .2, etc.) Phase number within the milestone determines the patch version (1st phase = .1, 2nd phase = .2, etc.)
### Milestone completion (minor release) **Schema-breaking (minor release per phase):**
```bash ```bash
git checkout main git checkout milestone/v0.5-schema-rewrite
git merge --squash milestone/v0.2-git-native git merge --squash phase/01-core-refactor
git commit -m "docs(milestone): complete git-native" git commit -m "docs(P01): complete core-refactor phase"
git tag -a v0.2.0 -m "v0.2.0: git-native" git tag -a v0.5.0 -m "v0.5.0: core-refactor"
git push origin main --tags git push origin main --tags
# Create Gitea release for v0.2.0 with full milestone summary # Create Gitea release for v0.5.0
``` ```
### Major release Each schema-breaking phase bumps the minor. 1st phase = next available minor, 2nd = minor+1, etc.
When the project undergoes a schema-breaking change (e.g., switching from file-based to git-native architecture), bump the major version. Major releases follow the same merge → tag → release flow. ### Milestone completion
## NFR Milestone Versioning **Feature (minor release):**
```bash
# All phases already merged into milestone branch
git checkout main
git merge --squash milestone/v0.5-honest-baseline
git commit -m "docs(milestone): complete honest-baseline"
git tag -a v0.6.0 -m "v0.6.0: honest-baseline" # NEXT minor, NOT v0.5.0
git push origin main --tags
# Create Gitea release for v0.6.0 with full milestone summary
```
NFR milestones and feature milestones follow different versioning rules: **Schema-breaking (major release):**
```bash
# All phases already merged into milestone branch
git checkout main
git merge --squash milestone/v0.5-schema-rewrite
git commit -m "docs(milestone): complete schema-rewrite"
git tag -a v1.0.0 -m "v1.0.0: schema-rewrite" # NEXT major
git push origin main --tags
# Create Gitea release for v1.0.0 with full milestone summary
```
**NFR milestones** — all phases are `fix`, `chore`, `docs`, `perf`, `refactor`, or `test`: **NFR milestones produce no milestone tag.** The last phase's patch version is the final release.
- Each phase gets a progressive patch version (v0.1.1, v0.1.2, v0.1.3)
- No separate milestone tag — the milestone is implicit from the patch sequence
- Example: milestone v0.1 with phases P01 (chore), P02 (test), P03 (perf) → v0.1.1, v0.1.2, v0.1.3
**Feature milestones** — any phase is `feat`: ### Version Validation
- Each phase gets a progressive patch version
- On milestone completion, tag a minor version (e.g., v0.2.0)
- Example: milestone v0.2 with phases P01 (feat), P02 (feat), P03 (fix) → v0.2.1, v0.2.2, v0.2.3 + milestone tag v0.3.0
Determine milestone type by checking `isNfrMilestone()` which inspects all phase commit types within the milestone. Before creating any tag:
1. Tag must be strictly greater than all existing tags on the same major.minor line
2. Milestone completion tag must be next minor (feature) or next major (schema-breaking)
3. NEVER create a tag that is semantically below existing phase tags
## Multi-Project Branch Naming ## Multi-Project Branch Naming
@@ -155,7 +204,7 @@ const milestones = gitBranch.listMilestones();
## Branch Creation Rules ## Branch Creation Rules
1. Always create phase branches from main (or the current milestone branch) 1. Always create phase branches from the current milestone branch (or main if no milestone branch exists)
2. Never create a branch for a completed phase — it should already be merged 2. Never create a branch for a completed phase — it should already be merged
3. Milestone branches span phases — don't create one per phase 3. Milestone branches span phases — don't create one per phase
4. Use `GitBranch.createPhaseBranch()` to ensure consistent naming 4. Use `GitBranch.createPhaseBranch()` to ensure consistent naming
@@ -164,25 +213,35 @@ const milestones = gitBranch.listMilestones();
## Working with Phase Branches ## Working with Phase Branches
```bash ```bash
# Create a phase branch # Create a milestone branch first
git checkout -b phase/01-git-native-architecture git checkout main
git checkout -b milestone/v0.5-honest-baseline
# Create a phase branch from the milestone
git checkout -b phase/01-quick-wins
# Commit work with ---ci--- blocks # Commit work with ---ci--- blocks
git commit -m "feat(P01-01-01): implement commit parser git commit -m "feat(P01-01-01): implement commit parser
---ci--- ---ci---
phase: 1 phase: 1
milestone: v0.2 milestone: v0.5
plan: 01-01 plan: 01-01
task: 01-01-01 task: 01-01-01
status: execute status: execute
---/ci---" ---/ci---"
# Merge on completion # Merge phase into milestone on completion
git checkout milestone/v0.5-honest-baseline
git merge --squash phase/01-quick-wins
git commit -m "docs(P01): complete quick-wins phase"
git tag -a v0.5.1 -m "v0.5.1: quick-wins"
# After all phases, merge milestone into main
git checkout main git checkout main
git merge --squash phase/01-git-native-architecture git merge --squash milestone/v0.5-honest-baseline
git commit -m "docs(P01): complete git-native-architecture phase" git commit -m "docs(milestone): complete honest-baseline"
git tag -a v0.2.1 -m "v0.2.1: git-native-architecture" git tag -a v0.6.0 -m "v0.6.0: honest-baseline"
git push origin main --tags git push origin main --tags
``` ```
+61 -21
View File
@@ -1,19 +1,23 @@
--- ---
description: Ship CI phase or milestone — test, tag, release. Every phase gets a patch release. Every milestone gets a minor (or major) release. Full autopilot. description: Ship CI phase or milestone — test, tag, release. Every phase and milestone gets a release. Full autopilot.
--- ---
# CI Ship # CI Ship
Ship a CI phase or milestone. Every ship creates a release — no exceptions. Ship a CI phase or milestone. Every ship creates a release — no exceptions.
**Versioning rule:** **3-Tier Versioning Model:**
- **Major** (X.0.0): Project-level refactor or schema changes
- **Minor** (0.X.0): Feature milestone completion only
- **Patch** (0.0.X): Every phase completion
**NFR versioning:** | Milestone Type | Condition | Phase release | Milestone release |
- NFR milestones (all phases are fix/chore/docs/perf/refactor/test): progressive patch versions only (v0.1.1, v0.1.2, v0.1.3). No minor milestone tag. |---------------|-----------|---------------|-------------------|
- Feature milestones (any feat phase): progressive patch versions per phase + minor milestone tag on completion (e.g., v0.2.0). | **NFR** | All phases: fix/chore/docs/perf/refactor/test | Patch (`vX.Y.Z`) | None |
| **Feature** | Any phase is `feat`, no schema break | Patch (`vX.Y.Z`) | Minor — `vX.(Y+1).0` |
| **Schema-breaking** | Refactor/schema break/new direction | Minor — `vX.(Y+N).0` per phase | Major — `v(X+1).0.0` |
**CRITICAL:** Milestone tags are always the NEXT version, never the base:
- Feature: patches v0.5.1v0.5.5 → milestone tag is v0.6.0 (NOT v0.5.0)
- Schema-breaking: minors v0.3.0, v0.4.0, v0.5.0 → milestone tag is v1.0.0
- NFR: no milestone tag — the milestone is implicit from the patch sequence
**Usage:** `ci-ship [phase_number|milestone]` **Usage:** `ci-ship [phase_number|milestone]`
@@ -36,7 +40,7 @@ git log --max-count=10
git branch -a git branch -a
``` ```
Determine what is being shipped: a single phase (patch release) or an entire milestone (minor/major release). Determine what is being shipped: a single phase or an entire milestone.
Read `.ci/ROADMAP.md` to determine: Read `.ci/ROADMAP.md` to determine:
- Current milestone version (e.g., `v0.2`) - Current milestone version (e.g., `v0.2`)
@@ -57,22 +61,51 @@ If any fail: iterate autonomously until tests pass. Do NOT ask the user for guid
## Step 3: Compute Version ## Step 3: Compute Version
Determine the release version from what is being shipped. Check `isNfrMilestone()` for versioning behavior: Determine milestone type by calling `getMilestoneType()` which returns `"nfr" | "feature" | "schema-breaking"`:
| What's shipping | Milestone Type | Version bump | Tag format | Example | | What's shipping | Milestone Type | Phase release | Milestone release | Example |
|----------------|---------------|-------------|------------|---------| |----------------|---------------|-------------|------------|---------|
| Single phase | NFR | Patch | `vX.Y.Z` | `v0.1.3` (3rd NFR phase in milestone v0.1) | | Single phase | NFR | Patch `vX.Y.Z` | N/A | v0.1.3 (3rd NFR phase) |
| Single phase | Feature | Patch | `vX.Y.Z` | `v0.2.3` (3rd phase in feature milestone v0.2) | | Single phase | Feature | Patch `vX.Y.Z` | N/A | v0.2.3 (3rd feature phase) |
| Milestone completion | NFR | Patch (last phase) | `vX.Y.Z` | `v0.1.3` (no minor tag) | | Single phase | Schema-breaking | Minor `vX.(Y+N).0` | N/A | v0.4.0 (2nd schema-breaking phase) |
| Milestone completion | Feature | Minor | `vX.Y.0` | `v0.3.0` (feature milestone v0.3 complete) | | Milestone completion | NFR | Patch (last phase) | None | v0.1.3 (no milestone tag) |
| Project refactor/schema change | Any | Major | `vX.0.0` | `v1.0.0` (breaking schema) | | Milestone completion | Feature | Last patch | Minor `vX.(Y+1).0` | v0.3.0 (NOT v0.2.0) |
| Milestone completion | Schema-breaking | Last minor | Major `v(X+1).0.0` | v1.0.0 |
Count completed phases in the current milestone to determine the patch number. Phase number within the milestone determines the increment:
- NFR/Feature: 1st phase = .1, 2nd = .2, etc. (v0.5.1, v0.5.2)
- Schema-breaking: 1st phase = next minor, 2nd = minor+1, etc. (v0.3.0, v0.4.0)
**Before creating ANY tag, validate:**
1. The tag must be strictly greater than all existing tags on the same major.minor line
2. Milestone completion tag must be the next minor (feature) or next major (schema-breaking)
3. NEVER create a milestone tag that is semantically below existing phase tags (e.g., v0.5.0 when v0.5.1 already exists)
## Step 4: Merge Branch ## Step 4: Merge Branch
### Phase ship (patch release) ### Branch hierarchy: main > milestone/vX.X-slug > phase/NN-slug
Phases MUST merge into their milestone branch (or to main if no milestone branch exists). Milestones merge into main only after all phases are complete.
### Phase ship
**If milestone branch exists:**
```bash
git checkout milestone/vX.Y-slug
git merge --squash phase/NN-slug
git commit -m "docs(P##): complete [phase-name] phase
---ci---
phase: [N]
milestone: [vX.Y]
status: complete
requirements:
covered: [REQ-01, REQ-02]
partial: []
---/ci---"
```
**If no milestone branch exists (single-phase milestone):**
```bash ```bash
git checkout main git checkout main
git merge --squash phase/NN-slug git merge --squash phase/NN-slug
@@ -88,9 +121,10 @@ requirements:
---/ci---" ---/ci---"
``` ```
### Milestone ship (minor/major release) ### Milestone ship (after last phase)
```bash ```bash
# Verify all phase branches are merged into milestone branch
git checkout main git checkout main
git merge --squash milestone/vX.Y-slug git merge --squash milestone/vX.Y-slug
git commit -m "docs(milestone): complete [milestone-name] git commit -m "docs(milestone): complete [milestone-name]
@@ -109,6 +143,12 @@ git tag -a vX.Y.Z -m "vX.Y.Z: [phase-name or milestone-name]"
git push origin main --tags git push origin main --tags
``` ```
**Tag format by milestone type:**
- NFR/Feature phase: patch format (`v0.5.1`, `v0.5.2`)
- Schema-breaking phase: minor format (`v0.3.0`, `v0.4.0`)
- Feature milestone: next minor (`v0.6.0`, NOT `v0.5.0`)
- Schema-breaking milestone: next major (`v1.0.0`)
## Step 6: Create Release ## Step 6: Create Release
**Every ship creates a Gitea release. No exceptions.** **Every ship creates a Gitea release. No exceptions.**
@@ -145,7 +185,7 @@ Commit the file updates.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Phase [N]: [name] Phase [N]: [name]
Milestone: [vX.Y] Milestone: [vX.Y] ([nfr|feature|schema-breaking])
Version: vX.Y.Z Version: vX.Y.Z
Release: https://git.cloudinit.dev/continuous-intelligence/ci/releases/tag/vX.Y.Z Release: https://git.cloudinit.dev/continuous-intelligence/ci/releases/tag/vX.Y.Z
Status: complete Status: complete
@@ -158,6 +198,6 @@ Requirements covered: [N]
Commits: [N] Commits: [N]
[If milestone complete:] [If milestone complete:]
All phases in milestone v0.2 complete. Milestone released. All phases in milestone v0.2 complete. Milestone released as vX.Y.Z.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
``` ```
+3 -2
View File
@@ -1,12 +1,13 @@
{ {
"name": "@continuous-intelligence/ci", "name": "@continuous-intelligence/ci",
"version": "0.1.0", "version": "0.4.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@continuous-intelligence/ci", "name": "@continuous-intelligence/ci",
"version": "0.1.0", "version": "0.4.0",
"hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"commander": "^12.1.0", "commander": "^12.1.0",
Regular → Executable
View File
+110 -6
View File
@@ -680,7 +680,6 @@ export function createShipCommand(): Command {
} }
const config = loadConfig(projectPath); const config = loadConfig(projectPath);
const milestone = "v1.0";
try { try {
const isGitRepo = execSync("git rev-parse --is-inside-work-tree", { const isGitRepo = execSync("git rev-parse --is-inside-work-tree", {
@@ -689,23 +688,34 @@ export function createShipCommand(): Command {
}).trim() === "true"; }).trim() === "true";
if (isGitRepo) { 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..."); console.log(" Committing and tagging...");
const tag = `${milestone}-phase${phaseNum}`;
try { 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 add -A`, { cwd: projectPath, stdio: "pipe" });
execSync(`git commit -m "chore: ship phase ${phaseNum}" --allow-empty`, { execSync(`git commit -m "chore: ship phase ${phaseNum}" --allow-empty`, {
cwd: projectPath, cwd: projectPath,
stdio: "pipe", 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, cwd: projectPath,
stdio: "pipe", stdio: "pipe",
}); });
console.log(` ✓ Tagged: ${tag}`); console.log(` ✓ Tagged: ${version.tag}`);
if (config.git.auto_push) { if (config.git.auto_push) {
execSync(`git push origin ${tag}`, { cwd: projectPath, stdio: "pipe" }); execSync(`git push origin ${version.tag}`, { cwd: projectPath, stdio: "pipe" });
console.log(` ✓ Pushed tag: ${tag}`); console.log(` ✓ Pushed tag: ${version.tag}`);
} }
} catch (err) { } catch (err) {
console.warn(` ⚠ Git operations failed: ${err instanceof Error ? err.message : String(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`); 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";
} }
+59 -1
View File
@@ -263,7 +263,7 @@ describe("CiFiles", () => {
overview: "NFR-only", overview: "NFR-only",
phases: [ phases: [
{ number: 1, name: "test-coverage", description: "Add tests", status: "in_progress", dependsOn: [], requirements: [], successCriteria: [] }, { number: 1, name: "test-coverage", description: "Add tests", status: "in_progress", dependsOn: [], requirements: [], successCriteria: [] },
{ number: 2, name: "refactor-api", description: "Refactor", status: "not_started", dependsOn: [], requirements: [], successCriteria: [] }, { number: 2, name: "perf-tune", description: "Tune perf", status: "not_started", dependsOn: [], requirements: [], successCriteria: [] },
], ],
}; };
ciFiles.writeRoadmapMd(roadmap); ciFiles.writeRoadmapMd(roadmap);
@@ -289,6 +289,64 @@ describe("CiFiles", () => {
}); });
}); });
describe("getMilestoneType", () => {
it("returns nfr when no roadmap exists", () => {
const ciFiles = new CiFiles(dir);
expect(ciFiles.getMilestoneType()).toBe("nfr");
});
it("returns nfr when phases are all NFR types", () => {
const ciFiles = new CiFiles(dir, "nfr-proj2");
ciFiles.ensureProjectDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({
projects: [{ slug: "nfr-proj2", name: "NFR Project 2", default: true }],
active_project: "nfr-proj2",
}));
const roadmap: RoadmapMd = {
overview: "NFR-only",
phases: [
{ number: 1, name: "fix-bug", description: "Fix bug", status: "in_progress", dependsOn: [], requirements: [], successCriteria: [] },
],
};
ciFiles.writeRoadmapMd(roadmap);
expect(ciFiles.getMilestoneType()).toBe("nfr");
});
it("returns feature when phases include feat work", () => {
const ciFiles = new CiFiles(dir, "feat-proj2");
ciFiles.ensureProjectDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({
projects: [{ slug: "feat-proj2", name: "Feature Project 2", default: true }],
active_project: "feat-proj2",
}));
const roadmap: RoadmapMd = {
overview: "feature",
phases: [
{ number: 1, name: "auth-flow", description: "Auth feature", status: "in_progress", dependsOn: [], requirements: [], successCriteria: [] },
],
};
ciFiles.writeRoadmapMd(roadmap);
expect(ciFiles.getMilestoneType()).toBe("feature");
});
it("returns schema-breaking when phases include refactor/rewrite/migrate", () => {
const ciFiles = new CiFiles(dir, "schema-proj");
ciFiles.ensureProjectDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({
projects: [{ slug: "schema-proj", name: "Schema Project", default: true }],
active_project: "schema-proj",
}));
const roadmap: RoadmapMd = {
overview: "schema-breaking",
phases: [
{ number: 1, name: "refactor-core", description: "Refactor core", status: "in_progress", dependsOn: [], requirements: [], successCriteria: [] },
],
};
ciFiles.writeRoadmapMd(roadmap);
expect(ciFiles.getMilestoneType()).toBe("schema-breaking");
});
});
describe("multi-project file paths", () => { describe("multi-project file paths", () => {
it("writes PROJECT.md to project subdirectory when slug is set", () => { it("writes PROJECT.md to project subdirectory when slug is set", () => {
const ciFiles = new CiFiles(dir, "my-app"); const ciFiles = new CiFiles(dir, "my-app");
+19 -6
View File
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
import { writeFile, readFile, ensureDir, fileExists } from "../utils/file.js"; import { writeFile, readFile, ensureDir, fileExists } from "../utils/file.js";
import { PipelineStage } from "../types/pipeline.js"; import { PipelineStage } from "../types/pipeline.js";
import { MilestoneType } from "../types/config.js";
const CI_DIR = ".ci"; const CI_DIR = ".ci";
@@ -467,19 +468,31 @@ export class CiFiles {
this.writeRoadmapMd(roadmap); this.writeRoadmapMd(roadmap);
} }
isNfrMilestone(): boolean { getMilestoneType(): MilestoneType {
const roadmap = this.readRoadmapMd(); const roadmap = this.readRoadmapMd();
if (!roadmap) return true; if (!roadmap) return "nfr";
const nfrTypes: string[] = ["fix", "chore", "docs", "perf", "refactor", "test"]; const nfrTypes: string[] = ["fix", "chore", "docs", "perf", "refactor", "test"];
const schemaBreakKeywords: string[] = ["refactor", "rewrite", "rearchitecture", "migrate", "restructure"];
let hasFeature = false;
let hasSchemaBreak = false;
for (const phase of roadmap.phases) { for (const phase of roadmap.phases) {
if (phase.status === "in_progress" || phase.status === "not_started") { if (phase.status === "in_progress" || phase.status === "not_started" || phase.status === "complete") {
const phaseName = phase.name.toLowerCase(); const phaseName = phase.name.toLowerCase();
const hasFeature = !nfrTypes.some((t) => phaseName.includes(t)) && !phaseName.includes("bug") && !phaseName.includes("tune") && !phaseName.includes("refresh"); const isNfr = nfrTypes.some((t) => phaseName.includes(t)) || phaseName.includes("bug") || phaseName.includes("tune") || phaseName.includes("refresh");
if (hasFeature) return false; if (!isNfr) hasFeature = true;
if (schemaBreakKeywords.some((k) => phaseName.includes(k))) hasSchemaBreak = true;
} }
} }
return true;
if (hasSchemaBreak) return "schema-breaking";
if (hasFeature) return "feature";
return "nfr";
}
isNfrMilestone(): boolean {
return this.getMilestoneType() === "nfr";
} }
private parseProjectMd(content: string): ProjectMd { private parseProjectMd(content: string): ProjectMd {
+6 -5
View File
@@ -81,17 +81,18 @@ export class ErrorRecovery {
this.git(`branch -D ${phaseBranch}`); this.git(`branch -D ${phaseBranch}`);
} }
const tag = `v0.5.${phase}`; const tags = this.git("tag -l").split("\n").map((t) => t.trim()).filter(Boolean);
const tags = this.git("tag -l").split("\n").map((t) => t.trim()); const phaseTagPattern = new RegExp(`^v\\d+\\.\\d+\\.${phase}$`);
if (tags.includes(tag)) { const matchingTag = tags.find((t) => phaseTagPattern.test(t));
this.git(`tag -d ${tag}`); if (matchingTag) {
this.git(`tag -d ${matchingTag}`);
} }
return { return {
recovered: true, recovered: true,
strategy: "rollback", strategy: "rollback",
attempts: 1, attempts: 1,
message: `Rolled back phase ${phase}: ${reason}. Branch ${branchExists ? `${phaseBranch} deleted` : "not found"}. Tag ${tags.includes(tag) ? `${tag} deleted` : "not found"}.`, message: `Rolled back phase ${phase}: ${reason}. Branch ${branchExists ? `${phaseBranch} deleted` : "not found"}. Tag ${matchingTag ? `${matchingTag} deleted` : "not found"}.`,
}; };
} catch (err) { } catch (err) {
return { return {
+82
View File
@@ -137,4 +137,86 @@ describe("GitBranch", () => {
expect(result.name).toMatch(/^phase\/01-/); expect(result.name).toMatch(/^phase\/01-/);
}); });
}); });
describe("validateMergeOrder", () => {
it("rejects phase → main when milestone branch exists", () => {
const gitBranch = new GitBranch(repoDir);
gitBranch.createMilestoneBranch("v0.5", "baseline");
gitBranch.createPhaseBranch(1, "auth");
execSync(`git checkout main`, { cwd: repoDir, stdio: "pipe" });
const result = gitBranch.validateMergeOrder("phase/01-auth", "main");
expect(result.valid).toBe(false);
expect(result.reason).toContain("milestone");
});
it("allows phase → milestone branch", () => {
const gitBranch = new GitBranch(repoDir);
gitBranch.createMilestoneBranch("v0.5", "baseline");
gitBranch.createPhaseBranch(1, "auth");
execSync(`git checkout milestone/v0.5-baseline`, { cwd: repoDir, stdio: "pipe" });
const result = gitBranch.validateMergeOrder("phase/01-auth", "milestone/v0.5-baseline");
expect(result.valid).toBe(true);
});
it("allows phase → main when no milestone branch exists", () => {
const gitBranch = new GitBranch(repoDir);
gitBranch.createPhaseBranch(1, "auth");
execSync(`git checkout main`, { cwd: repoDir, stdio: "pipe" });
const result = gitBranch.validateMergeOrder("phase/01-auth", "main");
expect(result.valid).toBe(true);
});
it("allows hotfix → main", () => {
const gitBranch = new GitBranch(repoDir);
gitBranch.createMilestoneBranch("v0.5", "baseline");
execSync(`git checkout -b hotfix/critical-fix main`, { cwd: repoDir, stdio: "pipe" });
fs.writeFileSync(path.join(repoDir, "fix.txt"), "fix");
execSync(`git add . && git commit -m "hotfix: critical fix"`, { cwd: repoDir, stdio: "pipe" });
execSync(`git checkout main`, { cwd: repoDir, stdio: "pipe" });
const result = gitBranch.validateMergeOrder("hotfix/critical-fix", "main");
expect(result.valid).toBe(true);
});
});
describe("computeMilestoneTag", () => {
it("computes next minor for feature milestone", () => {
execSync(`git tag -a v0.5.1 -m "v0.5.1"`, { cwd: repoDir, stdio: "pipe" });
execSync(`git tag -a v0.5.2 -m "v0.5.2"`, { cwd: repoDir, stdio: "pipe" });
const gitBranch = new GitBranch(repoDir);
const tag = gitBranch.computeMilestoneTag("feature");
expect(tag).toBe("v0.6.0");
});
it("computes next major for schema-breaking milestone", () => {
execSync(`git tag -a v0.5.1 -m "v0.5.1"`, { cwd: repoDir, stdio: "pipe" });
const gitBranch = new GitBranch(repoDir);
const tag = gitBranch.computeMilestoneTag("schema-breaking");
expect(tag).toBe("v1.0.0");
});
it("starts from v0.1.0 when no tags exist", () => {
const gitBranch = new GitBranch(repoDir);
const tag = gitBranch.computeMilestoneTag("feature");
expect(tag).toBe("v0.1.0");
});
});
describe("mergeMilestoneBranch", () => {
it("rejects merge when unmerged phase branches remain", () => {
const gitBranch = new GitBranch(repoDir);
gitBranch.createMilestoneBranch("v0.5", "baseline");
gitBranch.createPhaseBranch(1, "auth");
execSync(`git checkout main`, { cwd: repoDir, stdio: "pipe" });
const result = gitBranch.mergeMilestoneBranch("milestone/v0.5-baseline", "main", true);
expect(result.success).toBe(false);
expect(result.message).toContain("unmerged");
});
});
}); });
+113
View File
@@ -1,5 +1,6 @@
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { GitContext, BranchInfo } from "./git-context.js"; import { GitContext, BranchInfo } from "./git-context.js";
import { MilestoneType } from "../types/config.js";
export interface BranchCreateResult { export interface BranchCreateResult {
name: string; name: string;
@@ -108,6 +109,11 @@ export class GitBranch {
targetBranch: string, targetBranch: string,
squash: boolean = true squash: boolean = true
): BranchMergeResult { ): BranchMergeResult {
const validation = this.validateMergeOrder(phaseBranchName, targetBranch);
if (!validation.valid) {
return { success: false, squash, message: `Merge rejected: ${validation.reason}` };
}
const branches = this.gitContext.getBranches(); const branches = this.gitContext.getBranches();
const phaseBranch = branches.find((b) => b.name === phaseBranchName); const phaseBranch = branches.find((b) => b.name === phaseBranchName);
if (!phaseBranch) { if (!phaseBranch) {
@@ -136,6 +142,113 @@ export class GitBranch {
}; };
} }
mergeMilestoneBranch(
milestoneBranchName: string,
targetBranch: string,
squash: boolean = true
): BranchMergeResult {
const branches = this.gitContext.getBranches();
const milestoneBranch = branches.find((b) => b.name === milestoneBranchName);
if (!milestoneBranch) {
return { success: false, squash, message: `Branch ${milestoneBranchName} not found` };
}
const phaseBranches = branches.filter(
(b) => b.type === "phase" && !b.merged
);
if (phaseBranches.length > 0) {
return {
success: false,
squash,
message: `Cannot merge milestone: ${phaseBranches.length} unmerged phase branch(es) remain (${phaseBranches.map((b) => b.name).join(", ")})`,
};
}
this.git(`checkout ${targetBranch}`);
const mergeCmd = squash
? `merge --squash ${milestoneBranchName}`
: `merge --no-ff ${milestoneBranchName}`;
const result = this.git(mergeCmd);
if (result === "" && !squash) {
return { success: false, squash, message: `Merge conflict on ${milestoneBranchName}` };
}
if (squash) {
this.git(`commit -m "docs: merge milestone branch ${milestoneBranchName}"`);
}
return {
success: true,
squash,
message: `Merged ${milestoneBranchName} into ${targetBranch} (squash: ${squash})`,
};
}
validateMergeOrder(sourceBranch: string, targetBranch: string): { valid: boolean; reason: string } {
const branches = this.gitContext.getBranches();
const source = branches.find((b) => b.name === sourceBranch);
if (!source) return { valid: false, reason: `Source branch ${sourceBranch} not found` };
if (source.type === "hotfix") {
return { valid: true, reason: "Hotfix branches may merge to any target" };
}
if (source.type === "phase" && targetBranch === "main") {
const milestoneBranches = branches.filter(
(b) => b.type === "milestone" && !b.merged
);
if (milestoneBranches.length > 0) {
return {
valid: false,
reason: `Phase branch must merge into milestone branch first (active: ${milestoneBranches.map((b) => b.name).join(", ")}). Merge into main only through the milestone branch.`,
};
}
}
if (source.type === "milestone" && targetBranch === "main") {
const phaseBranches = branches.filter(
(b) => b.type === "phase" && !b.merged
);
if (phaseBranches.length > 0) {
return {
valid: false,
reason: `Milestone cannot merge to main: ${phaseBranches.length} unmerged phase branch(es) remain.`,
};
}
}
return { valid: true, reason: "Merge order is valid" };
}
computeMilestoneTag(milestoneType: MilestoneType): string {
const tags = this.git("tag -l").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;
}
}
}
if (milestoneType === "schema-breaking") {
return `v${major + 1}.0.0`;
}
return `v${major}.${minor + 1}.0`;
}
getPhaseStatus(phaseNumber: number): PhaseBranchInfo | null { getPhaseStatus(phaseNumber: number): PhaseBranchInfo | null {
const branches = this.gitContext.getBranches(); const branches = this.gitContext.getBranches();
const phaseBranch = branches.find( const phaseBranch = branches.find(
+41
View File
@@ -279,4 +279,45 @@ status: execute
expect(ctx.isNfrMilestone()).toBe(false); expect(ctx.isNfrMilestone()).toBe(false);
}); });
}); });
describe("getMilestoneType", () => {
it("returns nfr when only NFR commits exist", () => {
commit(repoDir, `chore(P01): cleanup
---ci---
phase: 1
milestone: v0.1.1
status: execute
---/ci---`);
const ctx = new GitContext(repoDir);
expect(ctx.getMilestoneType()).toBe("nfr");
});
it("returns feature when feat commits exist", () => {
commit(repoDir, `feat(P01): add feature
---ci---
phase: 1
milestone: v1.0
status: execute
---/ci---`);
const ctx = new GitContext(repoDir);
expect(ctx.getMilestoneType()).toBe("feature");
});
it("returns schema-breaking when refactor commits exist", () => {
commit(repoDir, `refactor(P01): rewrite core
---ci---
phase: 1
milestone: v0.5
status: execute
---/ci---`);
const ctx = new GitContext(repoDir);
expect(ctx.getMilestoneType()).toBe("schema-breaking");
});
});
}); });
+14 -5
View File
@@ -7,6 +7,8 @@ import {
import { parseCommitMessage } from "./commit-parser.js"; import { parseCommitMessage } from "./commit-parser.js";
import { PipelineStage } from "../types/pipeline.js"; import { PipelineStage } from "../types/pipeline.js";
import { MilestoneType } from "../types/config.js";
export interface ProjectState { export interface ProjectState {
currentPhase: number; currentPhase: number;
currentMilestone: string; currentMilestone: string;
@@ -342,13 +344,20 @@ export class GitContext {
return null; return null;
} }
isNfrMilestone(): boolean { getMilestoneType(): MilestoneType {
const commits = this.getRecentCommits(100); const commits = this.getRecentCommits(100);
let hasAnyCiCommit = false;
for (const commit of commits) { for (const commit of commits) {
if (commit.type === "feat" && commit.ci) { if (!commit.ci) continue;
return false; hasAnyCiCommit = true;
} if (commit.type === "feat") return "feature";
if (commit.type === "refactor" || commit.scope === "init") return "schema-breaking";
} }
return true; if (!hasAnyCiCommit) return "nfr";
return "nfr";
}
isNfrMilestone(): boolean {
return this.getMilestoneType() === "nfr";
} }
} }
+2
View File
@@ -6,6 +6,8 @@ export type ModelProfile = "quality" | "speed" | "balanced";
export type BranchingStrategy = "phase" | "feature" | "trunk"; export type BranchingStrategy = "phase" | "feature" | "trunk";
export type MilestoneType = "nfr" | "feature" | "schema-breaking";
export type PhaseName = "research" | "plan" | "execute" | "verify" | "complete"; export type PhaseName = "research" | "plan" | "execute" | "verify" | "complete";
export type AgentName = export type AgentName =