From 45247b7dd263328bfcd7fa11849a64e2183e7fcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Wed, 18 Mar 2026 11:17:43 -0600 Subject: [PATCH] feat(ci): automate prod-release with version bump, changelog, and tag push (#1194) When the prod environment gate is approved, the pipeline now automatically determines the semver bump from conventional commits, generates a changelog entry, bumps all package versions, commits + tags + pushes (triggering build-native.yml for npm @latest), creates a GitHub Release, and posts to Discord. Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/pipeline.yml | 69 ++++++++++++----- package.json | 3 + scripts/bump-version.mjs | 39 ++++++++++ scripts/generate-changelog.mjs | 137 +++++++++++++++++++++++++++++++++ scripts/update-changelog.mjs | 59 ++++++++++++++ 5 files changed, 286 insertions(+), 21 deletions(-) create mode 100644 scripts/bump-version.mjs create mode 100644 scripts/generate-changelog.mjs create mode 100644 scripts/update-changelog.mjs diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 617e48eb8..df62ec302 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -123,12 +123,18 @@ jobs: environment: prod steps: - uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.RELEASE_PAT }} - uses: actions/setup-node@v6 with: node-version: 24 registry-url: https://registry.npmjs.org + - name: Install dependencies + run: npm ci + - name: Run live LLM tests (optional) continue-on-error: true run: npm run test:live @@ -137,8 +143,48 @@ jobs: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} GSD_LIVE_TESTS: "1" - # NOTE: @latest promotion is handled by the publish-version workflow, - # not by the pipeline. Dev versions should never be tagged as @latest. + - name: Generate changelog and determine version + id: release + run: | + OUTPUT=$(node scripts/generate-changelog.mjs) + echo "$OUTPUT" | jq . + echo "version=$(echo "$OUTPUT" | jq -r '.newVersion')" >> "$GITHUB_OUTPUT" + echo "$OUTPUT" | jq -r '.changelogEntry' > /tmp/changelog-entry.md + echo "$OUTPUT" | jq -r '.releaseNotes' > /tmp/release-notes.md + + - name: Bump version and sync packages + run: node scripts/bump-version.mjs "${{ steps.release.outputs.version }}" + + - name: Update CHANGELOG.md + run: node scripts/update-changelog.mjs /tmp/changelog-entry.md + + - name: Commit, tag, and push + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add package.json package-lock.json CHANGELOG.md native/npm/*/package.json pkg/package.json packages/pi-coding-agent/package.json + git commit -m "release: v${{ steps.release.outputs.version }}" + git tag "v${{ steps.release.outputs.version }}" + git push origin main + git push origin "v${{ steps.release.outputs.version }}" + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "v${{ steps.release.outputs.version }}" \ + --title "v${{ steps.release.outputs.version }}" \ + --notes-file /tmp/release-notes.md \ + --latest + + - name: Post to Discord + if: ${{ secrets.DISCORD_CHANGELOG_WEBHOOK != '' }} + run: | + VERSION="${{ steps.release.outputs.version }}" + NOTES=$(cat /tmp/release-notes.md) + curl -s -X POST "${{ secrets.DISCORD_CHANGELOG_WEBHOOK }}" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg c "**GSD v${VERSION} Released**\n\n${NOTES}\n\n\`npm i gsd-pi@${VERSION}\`" '{content:$c}')" - name: Log in to GHCR uses: docker/login-action@v3 @@ -153,25 +199,6 @@ jobs: docker tag ghcr.io/gsd-build/gsd-pi:${{ needs.dev-publish.outputs.dev-version }} ghcr.io/gsd-build/gsd-pi:latest docker push ghcr.io/gsd-build/gsd-pi:latest - - name: Extract base version - id: base-version - run: | - echo "version=$(echo '${{ needs.dev-publish.outputs.dev-version }}' | sed 's/-dev\..*//')" >> "$GITHUB_OUTPUT" - - - name: Create GitHub Release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - TAG="v${{ steps.base-version.outputs.version }}" - if gh release view "$TAG" >/dev/null 2>&1; then - echo "Release $TAG already exists — skipping" - else - gh release create "$TAG" \ - --title "$TAG" \ - --generate-notes \ - --latest - fi - update-builder: name: Update CI Builder Image if: ${{ github.event.workflow_run.conclusion == 'success' }} diff --git a/package.json b/package.json index 6de3f03dd..3d2b5b697 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,9 @@ "validate-pack": "node scripts/validate-pack.js", "typecheck:extensions": "tsc --noEmit --project tsconfig.extensions.json", "pipeline:version-stamp": "node scripts/version-stamp.mjs", + "release:changelog": "node scripts/generate-changelog.mjs", + "release:bump": "node scripts/bump-version.mjs", + "release:update-changelog": "node scripts/update-changelog.mjs", "docker:build-runtime": "docker build --target runtime -t ghcr.io/gsd-build/gsd-pi .", "docker:build-builder": "docker build --target builder -t ghcr.io/gsd-build/gsd-ci-builder .", "prepublishOnly": "npm run sync-pkg-version && npm run sync-platform-versions && ([ \"$CI\" = 'true' ] || git diff --exit-code || (echo 'ERROR: version sync changed files — commit them before publishing' && exit 1)) && npm run build && npm run typecheck:extensions && npm run validate-pack" diff --git a/scripts/bump-version.mjs b/scripts/bump-version.mjs new file mode 100644 index 000000000..77be226c1 --- /dev/null +++ b/scripts/bump-version.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node +/** + * Bump version in package.json, then sync platform packages and pkg/package.json. + * Usage: node scripts/bump-version.mjs + */ +import { readFileSync, writeFileSync } from "fs"; +import { resolve, dirname } from "path"; +import { execSync } from "child_process"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, ".."); + +const newVersion = process.argv[2]; +if (!newVersion || !/^\d+\.\d+\.\d+$/.test(newVersion)) { + console.error("Usage: node scripts/bump-version.mjs "); + process.exit(1); +} + +// 1. Update root package.json +const pkgPath = resolve(root, "package.json"); +const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); +const oldVersion = pkg.version; +pkg.version = newVersion; +writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n"); +console.log(`[bump-version] package.json: ${oldVersion} → ${newVersion}`); + +// 2. Update packages/pi-coding-agent/package.json (sync-pkg-version reads from here) +const piPkgPath = resolve(root, "packages", "pi-coding-agent", "package.json"); +const piPkg = JSON.parse(readFileSync(piPkgPath, "utf-8")); +piPkg.version = newVersion; +writeFileSync(piPkgPath, JSON.stringify(piPkg, null, 2) + "\n"); +console.log(`[bump-version] pi-coding-agent: ${oldVersion} → ${newVersion}`); + +// 3. Sync platform package versions (reads from root package.json) +execSync("node native/scripts/sync-platform-versions.cjs", { cwd: root, stdio: "inherit" }); + +// 4. Sync pkg/package.json (reads from pi-coding-agent) +execSync("node scripts/sync-pkg-version.cjs", { cwd: root, stdio: "inherit" }); diff --git a/scripts/generate-changelog.mjs b/scripts/generate-changelog.mjs new file mode 100644 index 000000000..1e6411e76 --- /dev/null +++ b/scripts/generate-changelog.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node +/** + * Parse conventional commits since the last stable tag. + * Outputs JSON: { bumpType, newVersion, changelogEntry, releaseNotes } + */ +import { execSync } from "child_process"; +import { readFileSync } from "fs"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, ".."); + +// --------------------------------------------------------------------------- +// 1. Find last stable tag (skip -next, -dev prereleases) +// --------------------------------------------------------------------------- +const allTags = execSync("git tag --sort=-v:refname", { cwd: root, encoding: "utf-8" }) + .trim() + .split("\n") + .filter(Boolean); + +const stableTag = allTags.find((t) => /^v\d+\.\d+\.\d+$/.test(t)); +if (!stableTag) { + console.error("No stable vX.Y.Z tag found"); + process.exit(1); +} + +// --------------------------------------------------------------------------- +// 2. Collect commits since that tag +// --------------------------------------------------------------------------- +const range = `${stableTag}..HEAD`; +const rawLog = execSync( + `git log ${range} --pretty=format:"%H %s" --no-merges`, + { cwd: root, encoding: "utf-8" } +).trim(); + +if (!rawLog) { + console.error(`No commits since ${stableTag}`); + process.exit(1); +} + +// --------------------------------------------------------------------------- +// 3. Parse conventional commits +// --------------------------------------------------------------------------- +const CONVENTIONAL_RE = /^(?\w+)(?:\((?[^)]*)\))?!?:\s*(?.+)$/; +const DISPLAY_FILTER = new Set(["ci", "docs", "test", "tests", "style"]); + +const groups = { Added: [], Fixed: [], Changed: [], Removed: [] }; +const TYPE_MAP = { + feat: "Added", + fix: "Fixed", + refactor: "Changed", + perf: "Changed", + chore: "Changed", + revert: "Removed", +}; + +let hasBreaking = false; +let hasFeat = false; +let userFacingCount = 0; + +for (const line of rawLog.split("\n")) { + const spaceIdx = line.indexOf(" "); + const subject = line.slice(spaceIdx + 1); + + if (subject.includes("BREAKING CHANGE") || subject.includes("!:")) { + hasBreaking = true; + } + + const match = CONVENTIONAL_RE.exec(subject); + if (!match) continue; + + const { type, scope, desc } = match.groups; + + if (type === "feat") hasFeat = true; + + // Skip display-only types but still count them for bump logic + if (DISPLAY_FILTER.has(type)) continue; + + const group = TYPE_MAP[type]; + if (!group) continue; + + userFacingCount++; + const scopePrefix = scope ? `**${scope}**: ` : ""; + groups[group].push(`- ${scopePrefix}${desc}`); +} + +if (userFacingCount === 0) { + console.error(`No user-facing commits since ${stableTag}`); + process.exit(1); +} + +// --------------------------------------------------------------------------- +// 4. Determine bump type and new version +// --------------------------------------------------------------------------- +const bumpType = hasBreaking ? "major" : hasFeat ? "minor" : "patch"; + +const currentPkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf-8")); +const [major, minor, patch] = currentPkg.version.replace(/-.*$/, "").split(".").map(Number); + +let newVersion; +switch (bumpType) { + case "major": + newVersion = `${major + 1}.0.0`; + break; + case "minor": + newVersion = `${major}.${minor + 1}.0`; + break; + case "patch": + newVersion = `${major}.${minor}.${patch + 1}`; + break; +} + +// --------------------------------------------------------------------------- +// 5. Build changelog entry +// --------------------------------------------------------------------------- +const today = new Date().toISOString().slice(0, 10); +const sections = []; + +for (const [heading, items] of Object.entries(groups)) { + if (items.length > 0) { + sections.push(`### ${heading}\n${items.join("\n")}`); + } +} + +const releaseNotes = sections.join("\n\n"); +const changelogEntry = `## [${newVersion}] - ${today}\n\n${releaseNotes}`; + +// --------------------------------------------------------------------------- +// 6. Output JSON +// --------------------------------------------------------------------------- +const output = JSON.stringify( + { bumpType, newVersion, changelogEntry, releaseNotes }, + null, + 2 +); +process.stdout.write(output); diff --git a/scripts/update-changelog.mjs b/scripts/update-changelog.mjs new file mode 100644 index 000000000..8b18a17c5 --- /dev/null +++ b/scripts/update-changelog.mjs @@ -0,0 +1,59 @@ +#!/usr/bin/env node +/** + * Insert a changelog entry into CHANGELOG.md after the [Unreleased] heading, + * and update the version comparison links at the bottom of the file. + * + * Usage: node scripts/update-changelog.mjs + */ +import { readFileSync, writeFileSync } from "fs"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, ".."); + +const entryFile = process.argv[2]; +if (!entryFile) { + console.error("Usage: node scripts/update-changelog.mjs "); + process.exit(1); +} + +const entry = readFileSync(entryFile, "utf-8").trim(); +const changelogPath = resolve(root, "CHANGELOG.md"); +let changelog = readFileSync(changelogPath, "utf-8"); + +// Extract new version from the entry header: "## [X.Y.Z] - YYYY-MM-DD" +const versionMatch = entry.match(/^## \[(\d+\.\d+\.\d+)\]/); +if (!versionMatch) { + console.error("Could not extract version from changelog entry"); + process.exit(1); +} +const newVersion = versionMatch[1]; + +// Insert entry after "## [Unreleased]" line +const unreleased = "## [Unreleased]"; +const idx = changelog.indexOf(unreleased); +if (idx === -1) { + console.error("[Unreleased] heading not found in CHANGELOG.md"); + process.exit(1); +} +const insertPos = idx + unreleased.length; +changelog = changelog.slice(0, insertPos) + "\n\n" + entry + changelog.slice(insertPos); + +// Update comparison links at the bottom of the file +// Replace: [Unreleased]: .../compare/vOLD...HEAD +// With: [Unreleased]: .../compare/vNEW...HEAD +// [NEW]: .../compare/vOLD...vNEW +const linkRe = /\[Unreleased\]: (https:\/\/github\.com\/[^/]+\/[^/]+)\/compare\/v([\d.]+)\.\.\.HEAD/; +const linkMatch = changelog.match(linkRe); +if (linkMatch) { + const [fullMatch, repoUrl, oldVersion] = linkMatch; + const newLinks = [ + `[Unreleased]: ${repoUrl}/compare/v${newVersion}...HEAD`, + `[${newVersion}]: ${repoUrl}/compare/v${oldVersion}...v${newVersion}`, + ].join("\n"); + changelog = changelog.replace(fullMatch, newLinks); +} + +writeFileSync(changelogPath, changelog); +console.log(`[update-changelog] Inserted ${newVersion} entry into CHANGELOG.md`);