Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 81 additions & 1 deletion .github/scripts/release-workflow.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -253,10 +255,83 @@ function assertExistingRelease(env = process.env) {
return ctx;
}

const NOTES_START = "<!-- release-notes:start -->";
const NOTES_END = "<!-- release-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 <validate-dispatch|resolve-version|assert-dispatch-checkout|tag-and-dispatch|assert-tag-run|assert-existing-release>");
fail("usage: release-workflow.js <validate-dispatch|resolve-version|assert-dispatch-checkout|tag-and-dispatch|assert-tag-run|assert-existing-release|build-release-notes>");
}

switch (command) {
Expand All @@ -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}`);
}
Expand All @@ -297,4 +375,6 @@ module.exports = {
validateDispatch,
latestTag,
resolveVersion,
buildReleaseNotes,
extractNotesBlock,
};
13 changes: 12 additions & 1 deletion .github/scripts/release-workflow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<!-- release-notes:start -->\n- Add a thing\n- Fix a thing\n<!-- release-notes:end -->\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<!-- release-notes:start -->\n- unterminated"), null);
assert.strictEqual(extractNotesBlock("plain commit, no markers"), null);

console.log("release-workflow tests passed");
6 changes: 5 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

Expand Down
6 changes: 6 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down