Skip to content

Commit 7eeb9aa

Browse files
committed
ci: add CI/CD pipeline with lint, test, version validation, and npm publish
Add GitHub Actions workflow (.github/workflows/ci-publish.yml) with three jobs: - CI: lint, test, and build on Node 20 and 22 - Validate Commits: enforce Conventional Commits on PRs - Publish: detect version bumps, validate bump level against commit types (semver rules), and auto-publish to npm on push to main
1 parent f4c028e commit 7eeb9aa

1 file changed

Lines changed: 339 additions & 0 deletions

File tree

.github/workflows/ci-publish.yml

Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
name: CI & Publish
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
permissions:
10+
contents: read
11+
12+
jobs:
13+
# ──────────────────────────────────────────────
14+
# Job 1: Lint, Test, Build
15+
# ──────────────────────────────────────────────
16+
ci:
17+
name: Lint, Test & Build
18+
runs-on: ubuntu-latest
19+
20+
strategy:
21+
matrix:
22+
node-version: [20, 22]
23+
24+
steps:
25+
- name: Checkout repository
26+
uses: actions/checkout@v4
27+
28+
- name: Setup Node.js ${{ matrix.node-version }}
29+
uses: actions/setup-node@v4
30+
with:
31+
node-version: ${{ matrix.node-version }}
32+
cache: npm
33+
34+
- name: Install dependencies
35+
run: npm ci
36+
37+
- name: Lint (TypeScript type check)
38+
run: npm run lint
39+
40+
- name: Run tests
41+
run: npm test
42+
43+
- name: Build
44+
run: npm run build
45+
46+
# ──────────────────────────────────────────────
47+
# Job 2: Validate commit conventions (PRs only)
48+
# ──────────────────────────────────────────────
49+
validate-commits:
50+
name: Validate Commit Conventions
51+
runs-on: ubuntu-latest
52+
if: github.event_name == 'pull_request'
53+
54+
steps:
55+
- name: Checkout repository
56+
uses: actions/checkout@v4
57+
with:
58+
fetch-depth: 0
59+
60+
- name: Validate PR commits follow Conventional Commits
61+
run: |
62+
echo "🔍 Checking commits in this PR follow Conventional Commits..."
63+
echo ""
64+
65+
BASE_SHA="${{ github.event.pull_request.base.sha }}"
66+
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
67+
COMMITS=$(git log --format="%s" "$BASE_SHA..$HEAD_SHA")
68+
69+
VALID_PATTERN="^(feat|fix|refactor|test|docs|chore|perf|ci)(\([a-z/\-]+\))?(!)?: .+"
70+
HAS_ERROR=false
71+
72+
while IFS= read -r msg; do
73+
[ -z "$msg" ] && continue
74+
if echo "$msg" | grep -qE "$VALID_PATTERN"; then
75+
echo " ✅ $msg"
76+
else
77+
echo " ❌ $msg"
78+
HAS_ERROR=true
79+
fi
80+
done <<< "$COMMITS"
81+
82+
echo ""
83+
if [ "$HAS_ERROR" = true ]; then
84+
echo "❌ Some commits do not follow Conventional Commits format."
85+
echo ""
86+
echo "Expected: <type>(<scope>): <description>"
87+
echo "Types: feat, fix, refactor, test, docs, chore, perf, ci"
88+
echo "Scopes: engine, policy, ledger, proxy, tools, cli, server, rollback, integration/<name>, examples"
89+
echo ""
90+
echo "Examples:"
91+
echo " feat(engine): add rate limiting per tool type"
92+
echo " fix(ledger): correct hash chain validation"
93+
echo " refactor!(engine): redesign session lifecycle"
94+
exit 1
95+
fi
96+
97+
echo "✅ All commits follow Conventional Commits format."
98+
99+
# ──────────────────────────────────────────────
100+
# Job 3: Version bump check, validation & publish
101+
# ──────────────────────────────────────────────
102+
publish:
103+
name: Validate Version & Publish
104+
runs-on: ubuntu-latest
105+
needs: ci
106+
# Only run on pushes to main (not on PRs)
107+
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
108+
109+
steps:
110+
- name: Checkout repository
111+
uses: actions/checkout@v4
112+
with:
113+
fetch-depth: 0
114+
115+
- name: Setup Node.js
116+
uses: actions/setup-node@v4
117+
with:
118+
node-version: 20
119+
cache: npm
120+
registry-url: https://registry.npmjs.org
121+
122+
- name: Install dependencies
123+
run: npm ci
124+
125+
- name: Build
126+
run: npm run build
127+
128+
- name: Detect version bump
129+
id: version-check
130+
run: |
131+
LOCAL_VERSION=$(node -p "require('./package.json').version")
132+
PACKAGE_NAME=$(node -p "require('./package.json').name")
133+
134+
echo "local_version=$LOCAL_VERSION" >> "$GITHUB_OUTPUT"
135+
echo "package_name=$PACKAGE_NAME" >> "$GITHUB_OUTPUT"
136+
137+
echo "📦 Package: $PACKAGE_NAME"
138+
echo "📋 Local version: $LOCAL_VERSION"
139+
140+
# Fetch the latest published version from npm
141+
PUBLISHED_VERSION=$(npm view "$PACKAGE_NAME" version 2>/dev/null || echo "0.0.0")
142+
echo "🌐 Published version: $PUBLISHED_VERSION"
143+
echo "published_version=$PUBLISHED_VERSION" >> "$GITHUB_OUTPUT"
144+
145+
# Determine if version changed and the bump type
146+
node -e "
147+
const local = '$LOCAL_VERSION'.split('.').map(Number);
148+
const published = '$PUBLISHED_VERSION'.split('.').map(Number);
149+
150+
const isNewer =
151+
local[0] > published[0] ||
152+
(local[0] === published[0] && local[1] > published[1]) ||
153+
(local[0] === published[0] && local[1] === published[1] && local[2] > published[2]);
154+
155+
let bumpType = 'none';
156+
if (isNewer) {
157+
if (local[0] > published[0]) bumpType = 'major';
158+
else if (local[1] > published[1]) bumpType = 'minor';
159+
else if (local[2] > published[2]) bumpType = 'patch';
160+
}
161+
162+
const fs = require('fs');
163+
fs.appendFileSync(process.env.GITHUB_OUTPUT, 'version_changed=' + (isNewer ? 'true' : 'false') + '\n');
164+
fs.appendFileSync(process.env.GITHUB_OUTPUT, 'actual_bump=' + bumpType + '\n');
165+
166+
console.log('📊 Version changed: ' + isNewer);
167+
console.log('📊 Actual bump type: ' + bumpType);
168+
if (isNewer) {
169+
console.log('✅ Version bumped: $PUBLISHED_VERSION → $LOCAL_VERSION (' + bumpType + ')');
170+
} else {
171+
console.log('⏭️ Version not bumped ($LOCAL_VERSION <= $PUBLISHED_VERSION)');
172+
}
173+
"
174+
175+
- name: Validate version bump against commit types
176+
id: validate-bump
177+
env:
178+
ACTUAL_BUMP: ${{ steps.version-check.outputs.actual_bump }}
179+
VERSION_CHANGED: ${{ steps.version-check.outputs.version_changed }}
180+
BEFORE_SHA: ${{ github.event.before }}
181+
AFTER_SHA: ${{ github.event.after }}
182+
run: |
183+
echo "──────────────────────────────────────────────"
184+
echo "🔍 Validating version bump against commit types"
185+
echo "──────────────────────────────────────────────"
186+
echo ""
187+
188+
# ── 1. Gather commit messages from this push ──
189+
if [ "$BEFORE_SHA" = "0000000000000000000000000000000000000000" ]; then
190+
COMMIT_MESSAGES=$(git log --format="%s" -1 "$AFTER_SHA")
191+
else
192+
COMMIT_MESSAGES=$(git log --format="%s" "$BEFORE_SHA..$AFTER_SHA")
193+
fi
194+
195+
echo "📝 Commits in this push:"
196+
while IFS= read -r line; do
197+
[ -z "$line" ] && continue
198+
echo " • $line"
199+
done <<< "$COMMIT_MESSAGES"
200+
echo ""
201+
202+
# ── 2. Parse conventional commit types ──
203+
#
204+
# Semver rules (from project dev standards):
205+
# MAJOR → breaking changes / re-architecture (any type with !)
206+
# MINOR → new features / integrations (feat)
207+
# PATCH → bug fixes, perf, refactors (fix, perf, refactor)
208+
# NONE → docs, tests, chore, ci (docs, test, chore, ci)
209+
210+
REQUIRED_BUMP="none"
211+
DETECTED_TYPES=""
212+
213+
while IFS= read -r msg; do
214+
[ -z "$msg" ] && continue
215+
216+
# Check for breaking change indicator (! before colon)
217+
if echo "$msg" | grep -qE "^[a-z]+(\([^)]*\))?!:"; then
218+
REQUIRED_BUMP="major"
219+
TYPE=$(echo "$msg" | grep -oE "^[a-z]+")
220+
DETECTED_TYPES="${DETECTED_TYPES} ${TYPE}!(BREAKING)"
221+
continue
222+
fi
223+
224+
# Extract conventional commit type
225+
TYPE=$(echo "$msg" | grep -oE "^[a-z]+" || true)
226+
227+
case "$TYPE" in
228+
feat)
229+
DETECTED_TYPES="${DETECTED_TYPES} feat"
230+
if [ "$REQUIRED_BUMP" != "major" ]; then
231+
REQUIRED_BUMP="minor"
232+
fi
233+
;;
234+
fix|perf|refactor)
235+
DETECTED_TYPES="${DETECTED_TYPES} ${TYPE}"
236+
if [ "$REQUIRED_BUMP" = "none" ]; then
237+
REQUIRED_BUMP="patch"
238+
fi
239+
;;
240+
docs|test|chore|ci)
241+
DETECTED_TYPES="${DETECTED_TYPES} ${TYPE}"
242+
# No version bump required for these types
243+
;;
244+
*)
245+
DETECTED_TYPES="${DETECTED_TYPES} unknown(${msg:0:20})"
246+
;;
247+
esac
248+
done <<< "$COMMIT_MESSAGES"
249+
250+
echo "🏷️ Detected types:${DETECTED_TYPES}"
251+
echo "📋 Required minimum bump: $REQUIRED_BUMP"
252+
echo "📊 Actual bump: $ACTUAL_BUMP"
253+
echo ""
254+
echo "required_bump=$REQUIRED_BUMP" >> "$GITHUB_OUTPUT"
255+
256+
# ── 3. Bump level comparison ──
257+
bump_level() {
258+
case "$1" in
259+
major) echo 3 ;;
260+
minor) echo 2 ;;
261+
patch) echo 1 ;;
262+
*) echo 0 ;;
263+
esac
264+
}
265+
266+
REQUIRED_LEVEL=$(bump_level "$REQUIRED_BUMP")
267+
ACTUAL_LEVEL=$(bump_level "$ACTUAL_BUMP")
268+
269+
# ── 4. Validate ──
270+
271+
# Case: no bump required and no bump made → OK, skip publish
272+
if [ "$REQUIRED_LEVEL" -eq 0 ] && [ "$ACTUAL_LEVEL" -eq 0 ]; then
273+
echo "✅ No version bump required (docs/test/chore/ci changes only). Publish will be skipped."
274+
exit 0
275+
fi
276+
277+
# Case: bump required but version was not bumped → FAIL
278+
if [ "$REQUIRED_LEVEL" -gt 0 ] && [ "$ACTUAL_LEVEL" -eq 0 ]; then
279+
echo "❌ FAILED: Commits require a ${REQUIRED_BUMP} version bump, but the version was not bumped."
280+
echo ""
281+
echo "Bump the version in package.json before merging:"
282+
echo " • major bump → breaking changes or re-architecture (type!: ...)"
283+
echo " • minor bump → new features or integrations (feat: ...)"
284+
echo " • patch bump → bug fixes, perf, refactors (fix/perf/refactor: ...)"
285+
exit 1
286+
fi
287+
288+
# Case: bump was made but at insufficient level → FAIL
289+
if [ "$REQUIRED_LEVEL" -gt "$ACTUAL_LEVEL" ]; then
290+
echo "❌ FAILED: Version bump level is insufficient!"
291+
echo " Commits require at least a ${REQUIRED_BUMP} bump, but only a ${ACTUAL_BUMP} bump was applied."
292+
echo ""
293+
echo "Project semver rules:"
294+
echo " • MAJOR → breaking changes (commit with '!' e.g. feat!:, refactor!:)"
295+
echo " • MINOR → new features (feat:)"
296+
echo " • PATCH → bug fixes (fix:, perf:, refactor:)"
297+
echo ""
298+
echo "A ${REQUIRED_BUMP} bump (or higher) is needed for this set of changes."
299+
exit 1
300+
fi
301+
302+
# Case: bump level is sufficient → PASS
303+
if [ "$REQUIRED_LEVEL" -le "$ACTUAL_LEVEL" ]; then
304+
echo "✅ Version bump is valid: ${ACTUAL_BUMP} bump satisfies the required ${REQUIRED_BUMP} minimum."
305+
fi
306+
307+
- name: Publish to npm
308+
if: steps.version-check.outputs.version_changed == 'true'
309+
run: npm publish
310+
env:
311+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
312+
313+
- name: Summary
314+
if: always()
315+
env:
316+
VERSION_CHANGED: ${{ steps.version-check.outputs.version_changed }}
317+
PACKAGE_NAME: ${{ steps.version-check.outputs.package_name }}
318+
LOCAL_VERSION: ${{ steps.version-check.outputs.local_version }}
319+
PUBLISHED_VERSION: ${{ steps.version-check.outputs.published_version }}
320+
ACTUAL_BUMP: ${{ steps.version-check.outputs.actual_bump }}
321+
REQUIRED_BUMP: ${{ steps.validate-bump.outputs.required_bump }}
322+
run: |
323+
if [ "$VERSION_CHANGED" = "true" ]; then
324+
echo "### 🚀 Published to npm" >> "$GITHUB_STEP_SUMMARY"
325+
echo "" >> "$GITHUB_STEP_SUMMARY"
326+
echo "| Field | Value |" >> "$GITHUB_STEP_SUMMARY"
327+
echo "|-------|-------|" >> "$GITHUB_STEP_SUMMARY"
328+
echo "| Package | \`${PACKAGE_NAME}\` |" >> "$GITHUB_STEP_SUMMARY"
329+
echo "| Version | \`${LOCAL_VERSION}\` |" >> "$GITHUB_STEP_SUMMARY"
330+
echo "| Previous | \`${PUBLISHED_VERSION}\` |" >> "$GITHUB_STEP_SUMMARY"
331+
echo "| Bump type | \`${ACTUAL_BUMP}\` |" >> "$GITHUB_STEP_SUMMARY"
332+
echo "| Required (from commits) | \`${REQUIRED_BUMP}\` |" >> "$GITHUB_STEP_SUMMARY"
333+
else
334+
echo "### ⏭️ Publish skipped" >> "$GITHUB_STEP_SUMMARY"
335+
echo "" >> "$GITHUB_STEP_SUMMARY"
336+
echo "Version \`${LOCAL_VERSION}\` is not newer than published \`${PUBLISHED_VERSION}\`." >> "$GITHUB_STEP_SUMMARY"
337+
echo "" >> "$GITHUB_STEP_SUMMARY"
338+
echo "To trigger a publish, bump the version in \`package.json\` before merging." >> "$GITHUB_STEP_SUMMARY"
339+
fi

0 commit comments

Comments
 (0)