From 2f98f073cee2295ae5bf4de81349c7c147709e03 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 19 Feb 2026 11:01:17 +0700 Subject: [PATCH] ci(release): sync github release notes from changelog --- .github/workflows/release-notes-sync.yml | 67 ++++++ scripts/sync-release-notes-from-changelog.mjs | 215 ++++++++++++++++++ 2 files changed, 282 insertions(+) create mode 100644 .github/workflows/release-notes-sync.yml create mode 100644 scripts/sync-release-notes-from-changelog.mjs diff --git a/.github/workflows/release-notes-sync.yml b/.github/workflows/release-notes-sync.yml new file mode 100644 index 000000000..583dad9e1 --- /dev/null +++ b/.github/workflows/release-notes-sync.yml @@ -0,0 +1,67 @@ +name: Release Notes Sync + +on: + push: + tags: + - "v*" + branches: + - main + paths: + - "CHANGELOG.md" + workflow_dispatch: + inputs: + tag: + description: "Release tag to sync (e.g. v0.1.14). Leave empty to use top changelog entry." + required: false + type: string + create_if_missing: + description: "Create release if missing (normally only needed for tag events)." + required: false + default: false + type: boolean + +concurrency: + group: release-notes-sync-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }} + cancel-in-progress: false + +jobs: + sync-release-notes: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Sync release body from changelog + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + EVENT_NAME: ${{ github.event_name }} + REF: ${{ github.ref }} + INPUT_TAG: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && github.ref_name || github.event.inputs.tag }} + INPUT_CREATE_IF_MISSING: ${{ github.event.inputs.create_if_missing }} + shell: bash + run: | + set -euo pipefail + + args=(--repo "$REPO") + + if [ -n "${INPUT_TAG:-}" ]; then + args+=(--tag "$INPUT_TAG") + fi + + create_if_missing="false" + if [ "$EVENT_NAME" = "push" ] && [[ "$REF" == refs/tags/v* ]]; then + create_if_missing="true" + elif [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${INPUT_CREATE_IF_MISSING:-false}" = "true" ]; then + create_if_missing="true" + fi + + if [ "$create_if_missing" = "true" ]; then + args+=(--create-if-missing) + fi + + node scripts/sync-release-notes-from-changelog.mjs "${args[@]}" diff --git a/scripts/sync-release-notes-from-changelog.mjs b/scripts/sync-release-notes-from-changelog.mjs new file mode 100644 index 000000000..842ddfc76 --- /dev/null +++ b/scripts/sync-release-notes-from-changelog.mjs @@ -0,0 +1,215 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const headingPattern = /^##\s+\[?([^\]\s]+)\]?\s*-\s*([0-9]{4}-[0-9]{2}-[0-9]{2})\s*$/; + +function usageAndExit(code = 1) { + const usage = ` +Usage: node scripts/sync-release-notes-from-changelog.mjs [options] + +Options: + --repo Repository slug. Defaults to $GITHUB_REPOSITORY. + --tag Release tag (e.g. v0.1.14). Defaults to latest changelog entry. + --create-if-missing Create release if it does not already exist. +`; + process.stderr.write(usage.trimStart()); + process.stderr.write("\n"); + process.exit(code); +} + +function parseArgs(argv) { + const args = { + repo: process.env.GITHUB_REPOSITORY || "", + tag: "", + createIfMissing: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--repo") { + const value = argv[index + 1]; + if (!value) { + usageAndExit(); + } + args.repo = value; + index += 1; + continue; + } + + if (arg === "--tag") { + const value = argv[index + 1]; + if (!value) { + usageAndExit(); + } + args.tag = value; + index += 1; + continue; + } + + if (arg === "--create-if-missing") { + args.createIfMissing = true; + continue; + } + + usageAndExit(); + } + + if (!args.repo) { + throw new Error("Missing repository. Pass --repo or set GITHUB_REPOSITORY."); + } + + return args; +} + +function normalizeTag(rawTag) { + const trimmed = rawTag.trim().replace(/^refs\/tags\//, ""); + return trimmed.startsWith("v") ? trimmed : `v${trimmed}`; +} + +function parseChangelog(changelogText) { + const lines = changelogText.split(/\r?\n/); + const headings = []; + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + const match = line.match(headingPattern); + if (!match) { + continue; + } + + headings.push({ + version: match[1], + date: match[2], + headingLineIndex: index, + }); + } + + if (headings.length === 0) { + throw new Error( + "No release headings found in CHANGELOG.md. Expected headings like `## 0.1.14 - 2026-02-19`." + ); + } + + return headings.map((heading, index) => { + const nextHeading = headings[index + 1]; + const bodyStart = heading.headingLineIndex + 1; + const bodyEnd = nextHeading ? nextHeading.headingLineIndex : lines.length; + + const bodyLines = lines.slice(bodyStart, bodyEnd); + while (bodyLines.length > 0 && bodyLines[0].trim() === "") { + bodyLines.shift(); + } + while (bodyLines.length > 0 && bodyLines[bodyLines.length - 1].trim() === "") { + bodyLines.pop(); + } + + const notesParts = [`## ${heading.version} - ${heading.date}`]; + if (bodyLines.length > 0) { + notesParts.push("", ...bodyLines); + } + + return { + ...heading, + tag: `v${heading.version}`, + notes: `${notesParts.join("\n").trim()}\n`, + }; + }); +} + +function hasRelease(tag, repo) { + try { + execFileSync("gh", ["release", "view", tag, "--repo", repo], { + stdio: "ignore", + }); + return true; + } catch { + return false; + } +} + +function runGh(args) { + execFileSync("gh", args, { stdio: "inherit" }); +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const changelogPath = path.resolve("CHANGELOG.md"); + const changelogText = readFileSync(changelogPath, "utf8"); + const entries = parseChangelog(changelogText); + + const targetTag = args.tag ? normalizeTag(args.tag) : entries[0].tag; + const targetEntry = entries.find((entry) => entry.tag === targetTag); + + if (!targetEntry) { + throw new Error( + `No matching changelog section found for ${targetTag}.` + ); + } + + const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-release-notes-")); + const notesPath = path.join(tempDir, `${targetTag}-notes.md`); + writeFileSync(notesPath, targetEntry.notes); + + try { + if (hasRelease(targetTag, args.repo)) { + runGh([ + "release", + "edit", + targetTag, + "--repo", + args.repo, + "--notes-file", + notesPath, + ]); + console.log(`Updated release notes for ${targetTag}.`); + return; + } + + if (!args.createIfMissing) { + console.log( + `Release ${targetTag} not found. Skipping because --create-if-missing was not provided.` + ); + return; + } + + try { + runGh([ + "release", + "create", + targetTag, + "--repo", + args.repo, + "--title", + `Paseo ${targetTag}`, + "--notes-file", + notesPath, + "--verify-tag", + ]); + console.log(`Created release ${targetTag} with changelog notes.`); + } catch (createError) { + console.warn( + `Release creation failed for ${targetTag}; attempting edit in case another workflow created it concurrently.` + ); + runGh([ + "release", + "edit", + targetTag, + "--repo", + args.repo, + "--notes-file", + notesPath, + ]); + console.log(`Updated release notes for ${targetTag} after create race.`); + + if (createError instanceof Error) { + console.warn(createError.message); + } + } + } finally { + rmSync(tempDir, { force: true, recursive: true }); + } +} + +main();