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:
parent
5a36c131a9
commit
45247b7dd2
5 changed files with 286 additions and 21 deletions
69
.github/workflows/pipeline.yml
vendored
69
.github/workflows/pipeline.yml
vendored
|
|
@ -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' }}
|
||||
|
|
|
|||
|
|
@ -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
39
scripts/bump-version.mjs
Normal 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" });
|
||||
137
scripts/generate-changelog.mjs
Normal file
137
scripts/generate-changelog.mjs
Normal 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);
|
||||
59
scripts/update-changelog.mjs
Normal file
59
scripts/update-changelog.mjs
Normal 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`);
|
||||
Loading…
Add table
Reference in a new issue