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) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-18 11:17:43 -06:00 committed by GitHub
parent 5a36c131a9
commit 45247b7dd2
5 changed files with 286 additions and 21 deletions

View file

@ -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' }}

View file

@ -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"

39
scripts/bump-version.mjs Normal file
View file

@ -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 <new-version>
*/
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 <X.Y.Z>");
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" });

View file

@ -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 = /^(?<type>\w+)(?:\((?<scope>[^)]*)\))?!?:\s*(?<desc>.+)$/;
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);

View file

@ -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 <changelog-entry-file>
*/
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 <changelog-entry-file>");
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`);