diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73b58c6..1457d41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,7 @@ jobs: - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} + cache: npm - run: npm ci - run: npm run build - run: npm test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..61bfc21 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,120 @@ +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: 'Release version (e.g., 1.3.0 or 2.0.0-beta.1)' + required: true + type: string + +permissions: + contents: write + id-token: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + # ── Setup ────────────────────────────────── + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + registry-url: https://registry.npmjs.org + + - run: npm ci + + # ── Validate ─────────────────────────────── + - name: Validate version format + run: | + if ! echo "${{ inputs.version }}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*)?$'; then + echo "::error::Invalid version format. Use semver (e.g., 1.3.0 or 2.0.0-beta.1)" + exit 1 + fi + + - name: Check tag doesn't exist + run: | + if git rev-parse "v${{ inputs.version }}" >/dev/null 2>&1; then + echo "::error::Tag v${{ inputs.version }} already exists" + exit 1 + fi + + - name: Check [Unreleased] section exists + run: | + if ! grep -q '## \[Unreleased\]' CHANGELOG.md; then + echo "::error::CHANGELOG.md has no [Unreleased] section" + exit 1 + fi + + # ── Bump version ─────────────────────────── + - name: Bump version in all files + run: npx tsx scripts/bump-version.ts ${{ inputs.version }} > release-notes.md + + # ── Build & Test ─────────────────────────── + - run: npm run build + - run: npm test + + - name: Verify CLI version output + run: | + ACTUAL=$(node dist/cli.js --version) + if [ "$ACTUAL" != "${{ inputs.version }}" ]; then + echo "::error::CLI reports $ACTUAL, expected ${{ inputs.version }}" + exit 1 + fi + + # ── Commit & Tag ─────────────────────────── + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Commit version bump + run: | + git add -A + git commit -m "chore: bump version to ${{ inputs.version }}" + git push origin main + + - name: Create and push tag + run: | + git tag -a "v${{ inputs.version }}" -m "Version ${{ inputs.version }}" + git push origin "v${{ inputs.version }}" + + # ── Publish to npm ───────────────────────── + - name: Determine npm tag + id: npm-tag + run: | + if echo "${{ inputs.version }}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "tag=latest" >> "$GITHUB_OUTPUT" + else + PRE=$(echo "${{ inputs.version }}" | sed 's/^[0-9]*\.[0-9]*\.[0-9]*-//' | sed 's/\..*//') + echo "tag=$PRE" >> "$GITHUB_OUTPUT" + fi + + - name: Publish to npm + run: npm publish --provenance --tag ${{ steps.npm-tag.outputs.tag }} + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + # ── GitHub Release ───────────────────────── + - name: Create GitHub release + run: | + PRERELEASE_FLAG="" + if echo "${{ inputs.version }}" | grep -qE '-'; then + PRERELEASE_FLAG="--prerelease" + fi + gh release create "v${{ inputs.version }}" \ + --title "v${{ inputs.version }}" \ + --notes-file release-notes.md \ + $PRERELEASE_FLAG + env: + GH_TOKEN: ${{ github.token }} + + # ── Restore [Unreleased] ─────────────────── + - name: Restore [Unreleased] section + run: | + sed -i "s/^## \[${{ inputs.version }}\]/## [Unreleased]\n\n---\n\n## [${{ inputs.version }}]/" CHANGELOG.md + git add CHANGELOG.md + git commit -m "chore: restore [Unreleased] section" + git push origin main diff --git a/CHANGELOG.md b/CHANGELOG.md index dc15780..a54462b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to DevFlow will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +--- + ## [1.2.0] - 2026-03-05 ### Added diff --git a/docs/reference/release-process.md b/docs/reference/release-process.md index 28b0f68..800075a 100644 --- a/docs/reference/release-process.md +++ b/docs/reference/release-process.md @@ -1,132 +1,121 @@ # Release Process -Full runbook for creating new DevFlow Kit releases. +One-click releases via GitHub Actions. The developer chooses the version; CI handles everything else. -## 1. Prepare the Release +## Prerequisites (One-Time Setup) -**Update Version** in `package.json`: -- Patch (x.y.Z): Bug fixes, docs, minor tweaks, internal refactoring -- Minor (x.Y.0): New features, commands, CLI options (backwards compatible) -- Major (X.0.0): Breaking changes, removed/renamed commands +1. **Create npm access token** — npmjs.com → Access Tokens → Granular Access Token + - Package: `devflow-kit` only + - Permissions: Read and Write + - Expiration: No expiration (recommended for CI) + +2. **Add GitHub secret** — Repo Settings → Secrets → Actions → `NPM_TOKEN` + +3. **Allow CI to push to main** — Repo Settings → Rules → Rulesets → add "GitHub Actions" to bypass actors + +## During Development + +Update `CHANGELOG.md` `[Unreleased]` section in each PR: -**Update CHANGELOG.md:** ```markdown -## [x.y.z] - YYYY-MM-DD +## [Unreleased] ### Added -- New features - -### Changed -- Modified functionality +- New feature description ### Fixed -- Bug fixes - -### Documentation -- Doc improvements +- Bug fix description --- -[x.y.z]: https://github.com/dean0x/devflow/releases/tag/vx.y.z ``` -## 2. Build and Test +## Creating a Release -```bash -npm run build -node dist/cli.js --version # Verify new version -node dist/cli.js init # Test installation -npm pack --dry-run # Verify package contents -``` +1. Go to **GitHub Actions** → **Release** workflow +2. Click **Run workflow** +3. Enter version (e.g., `1.3.0`) — strict semver, no `v` prefix +4. Click **Run workflow** -## 3. Commit Version Bump +Done. npm package, git tag, and GitHub release are all created automatically. -```bash -git add package.json package-lock.json CHANGELOG.md && \ -git commit -m "chore: bump version to x.y.z - -- Update package.json to x.y.z -- Add CHANGELOG entry for vx.y.z -- Document [summary of changes]" +## What CI Does -git push origin main ``` - -## 4. Publish to npm - -```bash -npm publish -npm view devflow-kit version # Verify +validate version format + → check tag doesn't exist + → check [Unreleased] section exists + → bump version in 21 files (package.json, plugin.json x17, marketplace.json, CHANGELOG.md) + → sync package-lock.json + → build + → test + → verify CLI --version output + → commit "chore: bump version to X.Y.Z" + → push to main + → create + push git tag vX.Y.Z + → npm publish --provenance + → create GitHub release with extracted notes + → restore [Unreleased] section + commit + push ``` -## 5. Create Git Tag and GitHub Release +## Manual Fallback -```bash -git tag -a vx.y.z -m "Version x.y.z - [Brief Description] - -- Key change 1 -- Key change 2 -- Key change 3" - -git push origin vx.y.z -``` +If CI is unavailable, release manually: ```bash -gh release create vx.y.z \ - --title "vx.y.z - [Release Title]" \ - --notes "$(cat <<'EOF' -# DevFlow Kit vx.y.z - -[Brief description] - -## Highlights -- Key improvement 1 -- Key improvement 2 - -## Changes +# 1. Bump all version files +npm run version:bump -- 1.3.0 > release-notes.md -### Added -- New features +# 2. Build and test +npm run build && npm test -### Changed -- Modified functionality +# 3. Commit and push +git add -A +git commit -m "chore: bump version to 1.3.0" +git push origin main -### Fixed -- Bug fixes +# 4. Tag +git tag -a v1.3.0 -m "Version 1.3.0" +git push origin v1.3.0 -## Installation +# 5. Publish +npm publish -\`\`\`bash -npx devflow-kit init -\`\`\` +# 6. GitHub release +gh release create v1.3.0 --title "v1.3.0" --notes-file release-notes.md -## Links -- npm: https://www.npmjs.com/package/devflow-kit -- Changelog: https://github.com/dean0x/devflow/blob/main/CHANGELOG.md -EOF -)" +# 7. Restore [Unreleased] +# Add back the [Unreleased] section above the new version in CHANGELOG.md +git add CHANGELOG.md +git commit -m "chore: restore [Unreleased] section" +git push origin main ``` -## 6. Verify Release +## Troubleshooting -```bash -npm view devflow-kit -gh release view vx.y.z -npx devflow-kit@latest init -``` +| Issue | Fix | +|-------|-----| +| "Tag already exists" | Tag was created but release failed. Delete tag: `git push --delete origin v1.3.0 && git tag -d v1.3.0`, then re-run. | +| "No [Unreleased] section" | CHANGELOG.md is missing the `## [Unreleased]` header. Add it manually above the latest version. | +| npm publish fails (401) | `NPM_TOKEN` secret expired or missing. Generate a new token and update the secret. | +| npm publish fails (403) | Token doesn't have write access to `devflow-kit`. Regenerate with correct package scope. | +| CLI version mismatch | Build output doesn't match expected version. Check that `package.json` was updated correctly. | +| Push to main rejected | GitHub Actions bot not in ruleset bypass list. Update branch protection rules. | ## Release Checklist -- [ ] Version bumped in package.json -- [ ] CHANGELOG.md updated -- [ ] All plugin.json files updated to match -- [ ] marketplace.json updated to match -- [ ] `npm run build` succeeds -- [ ] `npm test` passes -- [ ] `npm pack --dry-run` looks clean (no .map files, no build scripts) -- [ ] Local testing passed -- [ ] Version bump committed and pushed -- [ ] Published to npm -- [ ] Git tag created and pushed -- [ ] GitHub release created -- [ ] npm shows correct version -- [ ] `npx devflow-kit init` works +Items marked with **[auto]** are handled by CI: + +- [ ] CHANGELOG.md `[Unreleased]` section has content +- [x] **[auto]** Version bumped in package.json +- [x] **[auto]** package-lock.json synced +- [x] **[auto]** All 17 plugin.json files updated +- [x] **[auto]** marketplace.json updated +- [x] **[auto]** CHANGELOG.md dated and linked +- [x] **[auto]** Build succeeds +- [x] **[auto]** Tests pass +- [x] **[auto]** CLI `--version` matches +- [x] **[auto]** Committed and pushed to main +- [x] **[auto]** Git tag created +- [x] **[auto]** Published to npm with provenance +- [x] **[auto]** GitHub release created +- [x] **[auto]** `[Unreleased]` section restored diff --git a/package.json b/package.json index e957a74..f98025e 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "dev": "tsc --watch", "cli": "node dist/cli.js", "prepublishOnly": "npm run build", + "version:bump": "npx tsx scripts/bump-version.ts", "test": "vitest run", "test:watch": "vitest" }, diff --git a/scripts/bump-version.ts b/scripts/bump-version.ts new file mode 100644 index 0000000..5519ba3 --- /dev/null +++ b/scripts/bump-version.ts @@ -0,0 +1,176 @@ +/** + * Bump version across all DevFlow files and extract release notes. + * + * Usage: npx tsx scripts/bump-version.ts + * + * Updates: + * - package.json + * - package-lock.json (via npm install --package-lock-only) + * - 17 plugins/devflow-*\/.claude-plugin/plugin.json + * - .claude-plugin/marketplace.json + * - CHANGELOG.md ([Unreleased] → [version] - date + compare link) + * + * Writes extracted release notes to stdout. + */ + +import { readFileSync, writeFileSync } from 'fs'; +import { execSync } from 'child_process'; +import { join } from 'path'; + +const ROOT = join(import.meta.dirname, '..'); + +// ── Helpers ────────────────────────────────────────────────── + +function readJson(path: string): Record { + return JSON.parse(readFileSync(path, 'utf-8')); +} + +function writeJson(path: string, data: Record): void { + writeFileSync(path, JSON.stringify(data, null, 2) + '\n'); +} + +function fail(msg: string): never { + process.stderr.write(`error: ${msg}\n`); + process.exit(1); +} + +function isSemver(v: string): boolean { + return /^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*)?$/.test(v); +} + +function today(): string { + const d = new Date(); + const yyyy = d.getFullYear(); + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + return `${yyyy}-${mm}-${dd}`; +} + +// ── Validate input ─────────────────────────────────────────── + +const newVersion = process.argv[2]; + +if (!newVersion) { + fail('usage: bump-version.ts (e.g. 1.3.0)'); +} + +if (!isSemver(newVersion)) { + fail(`invalid semver: "${newVersion}". Use format X.Y.Z or X.Y.Z-pre.N (e.g. 1.3.0, 2.0.0-beta.1, 1.4.0-rc.1)`); +} + +// ── 1. package.json ────────────────────────────────────────── + +const pkgPath = join(ROOT, 'package.json'); +const pkg = readJson(pkgPath) as { version: string }; +const oldVersion = pkg.version; + +if (newVersion === oldVersion) { + fail(`version ${newVersion} is already the current version`); +} + +pkg.version = newVersion; +writeJson(pkgPath, pkg); +process.stderr.write(` package.json: ${oldVersion} → ${newVersion}\n`); + +// ── 2. package-lock.json ───────────────────────────────────── + +execSync('npm install --package-lock-only', { cwd: ROOT, stdio: 'pipe' }); +process.stderr.write(` package-lock.json: synced\n`); + +// ── 3. Plugin plugin.json files ────────────────────────────── + +const pluginJsonPaths = execSync( + 'find plugins/devflow-*/.claude-plugin/plugin.json -type f', + { cwd: ROOT, encoding: 'utf-8' } +) + .trim() + .split('\n') + .filter(Boolean); + +for (const rel of pluginJsonPaths) { + const abs = join(ROOT, rel); + const pj = readJson(abs) as { version: string }; + pj.version = newVersion; + writeJson(abs, pj); +} +process.stderr.write(` plugin.json: ${pluginJsonPaths.length} plugins updated\n`); + +// ── 4. marketplace.json ────────────────────────────────────── + +const marketplacePath = join(ROOT, '.claude-plugin', 'marketplace.json'); +const marketplace = readJson(marketplacePath) as { + plugins: Array<{ version: string }>; +}; + +let marketplaceCount = 0; +for (const plugin of marketplace.plugins) { + plugin.version = newVersion; + marketplaceCount++; +} +writeJson(marketplacePath, marketplace); +process.stderr.write(` marketplace.json: ${marketplaceCount} entries updated\n`); + +// ── 5. CHANGELOG.md ───────────────────────────────────────── + +const changelogPath = join(ROOT, 'CHANGELOG.md'); +let changelog = readFileSync(changelogPath, 'utf-8'); + +if (!changelog.includes('## [Unreleased]')) { + fail('CHANGELOG.md has no [Unreleased] section'); +} + +// Replace [Unreleased] header with versioned header +changelog = changelog.replace( + '## [Unreleased]', + `## [${newVersion}] - ${today()}` +); + +// Insert compare link before the old version's link reference +const compareLinkNew = `[${newVersion}]: https://github.com/dean0x/devflow/compare/v${oldVersion}...v${newVersion}`; +const oldVersionLink = `[${oldVersion}]:`; +const oldLinkIdx = changelog.indexOf(oldVersionLink); + +if (oldLinkIdx !== -1) { + changelog = + changelog.slice(0, oldLinkIdx) + + compareLinkNew + '\n' + + changelog.slice(oldLinkIdx); +} else { + // No existing link for old version — append at end + changelog = changelog.trimEnd() + '\n\n' + compareLinkNew + '\n'; +} + +writeFileSync(changelogPath, changelog); +process.stderr.write(` CHANGELOG.md: [Unreleased] → [${newVersion}] - ${today()}\n`); + +// ── 6. Extract release notes to stdout ────────────────────── + +const versionHeader = `## [${newVersion}]`; +const headerIdx = changelog.indexOf(versionHeader); + +if (headerIdx === -1) { + fail('could not find version header in CHANGELOG after update'); +} + +const contentStart = changelog.indexOf('\n', headerIdx) + 1; + +// Find the next `---` separator which marks end of this version's notes +const separatorIdx = changelog.indexOf('\n---\n', contentStart); +let releaseNotes: string; + +if (separatorIdx !== -1) { + releaseNotes = changelog.slice(contentStart, separatorIdx).trim(); +} else { + // Fallback: find next version header + const nextHeader = changelog.indexOf('\n## [', contentStart); + if (nextHeader !== -1) { + releaseNotes = changelog.slice(contentStart, nextHeader).trim(); + } else { + releaseNotes = changelog.slice(contentStart).trim(); + } +} + +// Write release notes to stdout (workflow captures this) +process.stdout.write(releaseNotes + '\n'); + +process.stderr.write(`\ndone. ${newVersion} bumped across all files.\n`);