diff --git a/.github/scripts/release-workflow.js b/.github/scripts/release-workflow.js index 0a48b61..2afcabe 100644 --- a/.github/scripts/release-workflow.js +++ b/.github/scripts/release-workflow.js @@ -2,6 +2,8 @@ const { execFileSync } = require("child_process"); const fs = require("fs"); +const path = require("path"); +const os = require("os"); const { normalizeVersion, bumpVersion } = require("./release-version"); function fail(message) { @@ -253,10 +255,83 @@ function assertExistingRelease(env = process.env) { return ctx; } +const NOTES_START = ""; +const NOTES_END = ""; + +// Tag of the previous release (most recent v*.*.* reachable from HEAD's parent). +// HEAD is the freshly-tagged release commit, so HEAD^ excludes it. +function previousReleaseTag() { + try { + return read("git", ["describe", "--tags", "--abbrev=0", "--match", "v*.*.*", "HEAD^"]); + } catch (err) { + return null; + } +} + +function extractNotesBlock(message) { + const start = message.indexOf(NOTES_START); + if (start === -1) { + return null; + } + const end = message.indexOf(NOTES_END, start + NOTES_START.length); + if (end === -1) { + return null; + } + return message.slice(start + NOTES_START.length, end).trim(); +} + +// Assemble GoReleaser release notes from the marker blocks the public export +// embeds in each export commit message, for commits since the previous release. +// Writes the notes file and emits `notes_flag` so the workflow can pass it to +// GoReleaser; when no markers are found the flag is empty and GoReleaser falls +// back to its built-in changelog (i.e. current behavior). +function buildReleaseNotes(env = process.env) { + const outputFile = + env.RELEASE_NOTES_FILE || path.join(env.RUNNER_TEMP || os.tmpdir(), "release-notes.md"); + + try { + run("git", ["fetch", "--tags", "--force", "origin"], { stdio: "ignore" }); + } catch (err) { + // Tags are usually present from a full-depth checkout; fall back to local. + } + + const prev = previousReleaseTag(); + const range = prev ? `${prev}..HEAD` : "HEAD"; + + // git log is newest-first; reverse so notes read oldest-to-newest. + const shas = read("git", ["log", "--format=%H", range]) + .split("\n") + .map((sha) => sha.trim()) + .filter(Boolean) + .reverse(); + + const blocks = []; + for (const sha of shas) { + const block = extractNotesBlock(read("git", ["log", "-1", "--format=%B", sha])); + if (block) { + blocks.push(block); + } + } + + const notes = blocks.join("\n").trim(); + fs.writeFileSync(outputFile, notes ? `${notes}\n` : ""); + + const flag = notes ? `--release-notes=${outputFile}` : ""; + if (env.GITHUB_OUTPUT) { + fs.appendFileSync(env.GITHUB_OUTPUT, `notes_flag=${flag}\n`); + } + if (notes) { + console.log(`Release notes written to ${outputFile} (${blocks.length} export block(s), range ${range}).`); + } else { + console.log(`No export release-notes markers in ${range}; using GoReleaser default notes.`); + } + return notes; +} + function main(argv = process.argv.slice(2)) { const command = argv[0]; if (!command || argv.length !== 1) { - fail("usage: release-workflow.js "); + fail("usage: release-workflow.js "); } switch (command) { @@ -278,6 +353,9 @@ function main(argv = process.argv.slice(2)) { case "assert-existing-release": assertExistingRelease(); break; + case "build-release-notes": + buildReleaseNotes(); + break; default: fail(`unknown command: ${command}`); } @@ -297,4 +375,6 @@ module.exports = { validateDispatch, latestTag, resolveVersion, + buildReleaseNotes, + extractNotesBlock, }; diff --git a/.github/scripts/release-workflow.test.js b/.github/scripts/release-workflow.test.js index 4a4e5c4..a037a1a 100644 --- a/.github/scripts/release-workflow.test.js +++ b/.github/scripts/release-workflow.test.js @@ -2,7 +2,7 @@ const assert = require("assert"); const { normalizeVersion, bumpVersion } = require("./release-version"); -const { validateDispatch, resolveVersion } = require("./release-workflow"); +const { validateDispatch, resolveVersion, extractNotesBlock } = require("./release-workflow"); function env(overrides) { return { @@ -102,4 +102,15 @@ assert.deepStrictEqual(resolveVersion({ VERSION_INPUT: "v1.2.3" }), { tagRef: "refs/tags/v1.2.3", }); +// extractNotesBlock: pull the marker-wrapped notes out of an export commit body +assert.strictEqual( + extractNotesBlock( + "Public export: 1 change\n\n\n- Add a thing\n- Fix a thing\n\n\nCioCliPublicExport-RevId: abc123" + ), + "- Add a thing\n- Fix a thing" +); +assert.strictEqual(extractNotesBlock("Add Customer.io CLI source\n\nCioCliPublicExport-RevId: abc"), null); +assert.strictEqual(extractNotesBlock("subject\n\n- unterminated"), null); +assert.strictEqual(extractNotesBlock("plain commit, no markers"), null); + console.log("release-workflow tests passed"); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9e447a9..4e7a7fd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -196,10 +196,14 @@ jobs: - name: Test run: go test ./... + - name: Build release notes + id: notes + run: node .github/scripts/release-workflow.js build-release-notes + - uses: goreleaser/goreleaser-action@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 # v7.2.1 with: version: "~> v2" - args: release --clean + args: release --clean ${{ steps.notes.outputs.notes_flag }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 5f684c3..ca79eed 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -25,6 +25,12 @@ archives: checksum: name_template: "checksums.txt" +# Create the GitHub release as a draft so a human reviews the generated notes +# (see release-workflow.js build-release-notes / --release-notes) and clicks +# Publish. Artifacts still build and upload to the draft. +release: + draft: true + changelog: sort: asc filters: