diff --git a/.github/workflows/release-golang.yml b/.github/workflows/release-golang.yml index b8b919d59..1e32b291f 100644 --- a/.github/workflows/release-golang.yml +++ b/.github/workflows/release-golang.yml @@ -18,13 +18,22 @@ name: Release (Go) # gh workflow run release-golang.yml -f version=0.1.0-alpha.1 --ref # # The ref must carry the EQL v4 bundle (packages/eql's v4 build), or the -# build job stops at "Build the EQL SQL bundles". A version with a pre-release +# build job stops at "Build the EQL SQL bundles". Its +# languages/golang/CHANGELOG.md must have a `## []` section, which +# becomes the release notes, or the run stops at the `notes` job. A version with a pre-release # suffix may be cut from any branch and is published as a GitHub pre-release; # a final version is cut from main only, and not while go.mod pins vitaminc's # Go bindings at a pseudo-version (scripts/go-release-version.mjs). # -# Three jobs, split by what they may touch: +# Four jobs, split by what they may touch: # +# - `notes` holds no write token and runs no third-party code: it takes the +# version's section of languages/golang/CHANGELOG.md +# (scripts/go-release-notes.mjs, Node built-ins only) and refuses a version +# that has none. `build` waits for it, so a missing section stops the +# release before anything is built. `publish` puts its output on the +# release page; that is safe because nothing in this job could have +# written it but the script. # - `build` holds no write token. It is the only job that runs third-party # code (the toolchain installs, cargo build scripts and proc macros, the Go # tests), and it hands the guests, their hashes and the SQL to `publish` as @@ -70,8 +79,41 @@ concurrency: cancel-in-progress: false jobs: + notes: + name: Release notes from the CHANGELOG + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 5 + env: + VERSION: ${{ inputs.version }} + outputs: + notes: ${{ steps.notes.outputs.notes }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + # A multi-line output needs a delimiter no line of the notes can be, + # so it is random. Empty notes are refused here too, whatever the + # script's exit status: the release page is the one place they show. + - name: Take this version's section of the CHANGELOG + id: notes + run: | + set -euo pipefail + node scripts/go-release-notes.mjs "$VERSION" > notes.md + if ! grep -q '[^[:space:]]' notes.md; then + echo "::error::scripts/go-release-notes.mjs gave no notes for $VERSION" + exit 1 + fi + delimiter="notes_$(openssl rand -hex 16)" + { + echo "notes<<$delimiter" + cat notes.md + echo "$delimiter" + } >> "$GITHUB_OUTPUT" + build: name: Build and test the release + needs: notes runs-on: blacksmith-16vcpu-ubuntu-2204 timeout-minutes: 90 env: @@ -242,7 +284,7 @@ jobs: publish: name: Tag and release - needs: build + needs: [notes, build] runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 15 # Required reviewers set on this environment in the repository settings @@ -336,9 +378,12 @@ jobs: git tag -a "$tag" -m "Go module v$VERSION (built from $GITHUB_SHA)" git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "refs/tags/$tag" + # The CHANGELOG section arrives through the environment, never + # interpolated into the script, so its text is data to the shell. - name: Create the GitHub release env: GH_TOKEN: ${{ github.token }} + NOTES: ${{ needs.notes.outputs.notes }} run: | set -euo pipefail tag="languages/golang/v$VERSION" @@ -349,6 +394,7 @@ jobs: exit 0 fi { + printf '%s\n\n### Install\n\n' "$NOTES" echo "Go module \`github.com/cipherstash/stack/languages/golang\` v$VERSION, built from $GITHUB_SHA." echo echo '```sh' diff --git a/AGENTS.md b/AGENTS.md index 73a0a56c3..4663eed8b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,7 @@ Every npm package except EQL lives under `languages/typescript/`: packages in `l links are provenance only. - `packages/stack-auth`, `packages/stack-profile`, `packages/stack-kms`, `packages/stack-encrypt`, `packages/stack-encrypt-derive`, `packages/stack-guest-abi`: The Rust crates imported from `cipherstash/cipherstash-suite` with their history — `stack-auth` and `stack-profile` (published to crates.io, one version group), `stack-kms` (published to crates.io from 0.1.0, its own version group, re-exported by `stack-encrypt` as `stack_encrypt::kms`), `stack-encrypt` and `stack-encrypt-derive` (published to crates.io from 0.1.0, one version group; `eql-bindings`' `stack-encrypt` feature depends on them from the registry), and `stack-guest-abi` (`publish = false`). They are the members of the **root Cargo workspace**, with the three node binding crates below. See "Working on the Rust crates". - `languages/typescript/packages/auth`, `languages/typescript/packages/profile`, `languages/typescript/packages/stack-auth-wasm`: The node bindings of those crates. `@cipherstash/auth` (napi-rs v2) and its six `platforms/*` packages are published to npm from this repository by `release.yml` (`auth-artifacts`, `publish-auth`); a change to what it ships, the `stack-auth` crate included, needs an `@cipherstash/auth` changeset (`require-auth-npm-changeset.yml`). `@cipherstash/profile` and its platforms are private and never published; `@cipherstash/stack-auth-wasm` is private and builds the wasm that `@cipherstash/auth` ships. Their `build` and `test` scripts never invoke cargo; `build:native`, `build:debug` and `test:cargo` do. -- `languages/golang`: The Go module — the SDK `encrypt` with its generated-code support `encrypt/gensupport` and the policy packages `encrypt/policy` and `encrypt/policy/protosource`; the credential package `auth`; the generator `stashgen` and its command `cmd/stashgen`; and `internal` (the shared guest plumbing and the `record` wire model). A wazero host with no cgo. Its two WASI guests (`encrypt/guest`, `auth/guest`) are detached Cargo workspaces built by `mise run wasm:guest:build` and `mise run wasm:auth-guest:build`; `mise run wasm:guest:build:deterministic` builds the seeded TEST build the hermetic Go tests use; the `.wasm` files they embed are gitignored. The stack-encrypt guest has a second build with the EQL types (`mise run wasm:guest:build:eql`, cargo feature `eql`, linking `eql-bindings` by path; `wasm:guest:build:eql:deterministic` is its test build), embedded by `encrypt/eql` and registered on import, so a program whose generated code names an EQL type runs it (ADR-0007, amended). `encrypt/eql`'s types and `Types` table are generated from the EQL catalog by `eql-codegen` (`eql_gen.go`, written by `mise run types:generate` in `packages/eql` and drift-gated by `types:check` and `cargo test -p eql-codegen`). Generated `*_stash.go` files are committed and CI fails when `go generate ./...` changes one. Releases are cut by hand with `.github/workflows/release-golang.yml` (`gh workflow run release-golang.yml -f version=X.Y.Z[-pre] --ref `): because the guests are gitignored on main and Go fetches a version as the files committed at its tag, the workflow builds them, commits them on a release-only commit no branch points at, tags it `languages/golang/vX.Y.Z` and pushes the tag alone, then attaches the EQL v4 SQL bundle to the GitHub release. The ref must carry the EQL v4 bundle, or the build stops at "Build the EQL SQL bundles". `scripts/go-release-version.mjs` refuses, before anything is built, every version Go would refuse (non-canonical semver, `+build` metadata, a v2+ major on a module path with no `/vN` suffix, a version shaped like a pseudo-version), a final version dispatched from anywhere but `main`, and a final version while `languages/golang/go.mod` pins a `github.com/cipherstash/vitaminc/…` module at a pseudo-version (vitaminc's Go bindings have no tags, and a TEMPORARY pin on an unreleased commit must be gone before the module is tagged); a pre-release may come from a branch and may carry such a pin. The build job also refuses a version the Go proxy already lists in `@v/list`, which catches a tag deleted after the proxy fetched it; it never asks the proxy for the version itself before the tag exists, because the proxy would cache "not found" for up to 30 minutes and `verify` would fail after the push. The build checks the `Cargo.lock` of the EQL workspace and both guest workspaces with `--locked` first, since none of those builds passes it. It runs in a job with no write token and hands the guests to a `publish` job that holds `contents: write` and runs only git and gh. `publish` fetches the commit with plain unauthenticated git rather than `actions/checkout`, whose `token` input defaults to the job's writable token, and refuses a release commit whose tree is not the one the build tested. `publish` takes the paths it commits from the workflow's fixed `GUESTS` list, never from the artifact, and refuses a `guests.sha256` that is not exactly one SHA-256 line for each of those three guests: the build job runs third-party code after writing that manifest, so a path read from it could put any file on the tag (`scripts/__tests__/go-release-artifact.test.mjs`). A version is immutable once the Go proxy has fetched it, so a tag is never moved or deleted: if `publish` or `verify` fails after the tag is pushed, use "Re-run failed jobs" on the same run, which keeps a tag that run pushed and creates or completes the release; a new dispatch of the same version is refused. Never tag a main commit by hand: `go get` would fetch a module with no guests. +- `languages/golang`: The Go module — the SDK `encrypt` with its generated-code support `encrypt/gensupport` and the policy packages `encrypt/policy` and `encrypt/policy/protosource`; the credential package `auth`; the generator `stashgen` and its command `cmd/stashgen`; and `internal` (the shared guest plumbing and the `record` wire model). A wazero host with no cgo. Its two WASI guests (`encrypt/guest`, `auth/guest`) are detached Cargo workspaces built by `mise run wasm:guest:build` and `mise run wasm:auth-guest:build`; `mise run wasm:guest:build:deterministic` builds the seeded TEST build the hermetic Go tests use; the `.wasm` files they embed are gitignored. The stack-encrypt guest has a second build with the EQL types (`mise run wasm:guest:build:eql`, cargo feature `eql`, linking `eql-bindings` by path; `wasm:guest:build:eql:deterministic` is its test build), embedded by `encrypt/eql` and registered on import, so a program whose generated code names an EQL type runs it (ADR-0007, amended). `encrypt/eql`'s types and `Types` table are generated from the EQL catalog by `eql-codegen` (`eql_gen.go`, written by `mise run types:generate` in `packages/eql` and drift-gated by `types:check` and `cargo test -p eql-codegen`). Generated `*_stash.go` files are committed and CI fails when `go generate ./...` changes one. Releases are cut by hand with `.github/workflows/release-golang.yml` (`gh workflow run release-golang.yml -f version=X.Y.Z[-pre] --ref `): because the guests are gitignored on main and Go fetches a version as the files committed at its tag, the workflow builds them, commits them on a release-only commit no branch points at, tags it `languages/golang/vX.Y.Z` and pushes the tag alone, then attaches the EQL v4 SQL bundle to the GitHub release. The ref must carry the EQL v4 bundle, or the build stops at "Build the EQL SQL bundles". `scripts/go-release-version.mjs` refuses, before anything is built, every version Go would refuse (non-canonical semver, `+build` metadata, a v2+ major on a module path with no `/vN` suffix, a version shaped like a pseudo-version), a final version dispatched from anywhere but `main`, and a final version while `languages/golang/go.mod` pins a `github.com/cipherstash/vitaminc/…` module at a pseudo-version (vitaminc's Go bindings have no tags, and a TEMPORARY pin on an unreleased commit must be gone before the module is tagged); a pre-release may come from a branch and may carry such a pin. The build job also refuses a version the Go proxy already lists in `@v/list`, which catches a tag deleted after the proxy fetched it; it never asks the proxy for the version itself before the tag exists, because the proxy would cache "not found" for up to 30 minutes and `verify` would fail after the push. The build checks the `Cargo.lock` of the EQL workspace and both guest workspaces with `--locked` first, since none of those builds passes it. The release page's notes are the version's `## [X.Y.Z]` section of `languages/golang/CHANGELOG.md`, taken by `scripts/go-release-notes.mjs` in a `notes` job that runs no third-party code and that `build` waits for: a version with no section, or an empty one, is refused before anything is built, so write the section before dispatching. It runs in a job with no write token and hands the guests to a `publish` job that holds `contents: write` and runs only git and gh. `publish` fetches the commit with plain unauthenticated git rather than `actions/checkout`, whose `token` input defaults to the job's writable token, and refuses a release commit whose tree is not the one the build tested. `publish` takes the paths it commits from the workflow's fixed `GUESTS` list, never from the artifact, and refuses a `guests.sha256` that is not exactly one SHA-256 line for each of those three guests: the build job runs third-party code after writing that manifest, so a path read from it could put any file on the tag (`scripts/__tests__/go-release-artifact.test.mjs`). A version is immutable once the Go proxy has fetched it, so a tag is never moved or deleted: if `publish` or `verify` fails after the tag is pushed, use "Re-run failed jobs" on the same run, which keeps a tag that run pushed and creates or completes the release; a new dispatch of the same version is refused. Never tag a main commit by hand: `go get` would fetch a module with no guests. - `e2e/*`: Cross-package end-to-end tests (package managers, supply chain, Prisma example README) - `languages/typescript/examples/*`: Working apps (basic, prisma, supabase-worker) - `docs/plans/*`: Internal design plans. User-facing documentation lives at https://cipherstash.com/docs (not in this repo). diff --git a/scripts/__tests__/go-release-notes.test.mjs b/scripts/__tests__/go-release-notes.test.mjs new file mode 100644 index 000000000..8df755150 --- /dev/null +++ b/scripts/__tests__/go-release-notes.test.mjs @@ -0,0 +1,262 @@ +import { spawnSync } from 'node:child_process' +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + absoluteLinks, + changelogSection, + releaseNotes, + releaseTag, + unwrap, +} from '../go-release-notes.mjs' +import { REPO_ROOT } from './lib/repo-root.mjs' +import { readWorkflow } from './lib/workflows.mjs' + +/** + * `release-golang.yml` puts a version's CHANGELOG section on its release + * page. Before this, the page carried only install commands and checksums, + * and an upgrade step that existed only in the CHANGELOG never reached the + * people reading the release. + */ + +const CHANGELOG = `# Changelog + +## [Unreleased] + +## [0.2.0] - 2026-12-01 + +### Added + +- **A thing.** It wraps + onto a second line. + +## [0.1.0-rc.3] - 2026-11-02 + +### Upgrading + +Install the bundle. See the +[permissions reference](../../packages/eql/docs/reference/permissions.md). + +## [0.1.0-rc.30] + +Not rc.3. +` + +describe('changelogSection', () => { + it('takes the body up to the next version heading, without its own heading', () => { + expect(changelogSection(CHANGELOG, '0.2.0')).toBe( + '### Added\n\n- **A thing.** It wraps\n onto a second line.', + ) + }) + + it('matches the version exactly, not as a prefix or a pattern', () => { + expect(changelogSection(CHANGELOG, '0.1.0-rc.3')).toContain( + 'Install the bundle.', + ) + expect(changelogSection(CHANGELOG, '0.1.0-rc.30')).toBe('Not rc.3.') + expect(() => changelogSection(CHANGELOG, '0x1x0-rc.3')).toThrow( + /no "## \[0x1x0-rc\.3\]" section/, + ) + }) + + it('refuses a version with no section, naming it', () => { + expect(() => changelogSection(CHANGELOG, '0.1.0-rc.4')).toThrow( + /CHANGELOG\.md has no "## \[0\.1\.0-rc\.4\]" section/, + ) + }) + + it('refuses a section with nothing in it', () => { + expect(() => + changelogSection('## [1.0.0]\n\n\n## [0.9.0]\n\nx\n', '1.0.0'), + ).toThrow(/is empty/) + }) +}) + +describe('absoluteLinks', () => { + const ref = releaseTag('0.1.0-rc.3') + const blob = `https://github.com/cipherstash/stack/blob/${ref}` + + it('points a relative link at the file at the tag, resolved from languages/golang', () => { + expect(absoluteLinks('[a](encrypt/README.md#eql-columns)', ref)).toBe( + `[a](${blob}/languages/golang/encrypt/README.md#eql-columns)`, + ) + expect(absoluteLinks('[b](../../packages/eql/x.md)', ref)).toBe( + `[b](${blob}/packages/eql/x.md)`, + ) + expect(absoluteLinks('[c](/SECURITY.md)', ref)).toBe( + `[c](${blob}/SECURITY.md)`, + ) + }) + + it('leaves a link with a scheme, or only an anchor, alone', () => { + for (const link of [ + '[k](https://keepachangelog.com/en/1.0.0/)', + '[m](mailto:security@cipherstash.com)', + '[h](#breaking)', + ]) { + expect(absoluteLinks(link, ref)).toBe(link) + } + }) + + it('refuses a link that climbs out of the repository', () => { + expect(() => absoluteLinks('[x](../../../etc/passwd)', ref)).toThrow( + /outside the repository/, + ) + }) +}) + +describe('unwrap', () => { + it('joins a wrapped paragraph and keeps blank lines between paragraphs', () => { + expect(unwrap('one\ntwo\nthree\n\nfour\n five')).toBe( + 'one two three\n\nfour five', + ) + }) + + it('joins a list item with its indented continuation, and starts a new item at each marker', () => { + expect( + unwrap( + '- first\n more\n- second\n1. one\n more\n2. two\n - nested\n more', + ), + ).toBe('- first more\n- second\n1. one more\n2. two\n - nested more') + }) + + it('never joins onto or into a heading, a table row or a quote', () => { + const text = '### Heading\nline\n| a | b |\n| - | - |\n> quote\nnext' + expect(unwrap(text)).toBe( + '### Heading\nline\n| a | b |\n| - | - |\n> quote next', + ) + }) + + it('leaves a fenced code block exactly as it is', () => { + const text = 'Run:\n\n```sh\ngo get a\ngo install b\n```\nafter\nthis' + expect(unwrap(text)).toBe( + 'Run:\n\n```sh\ngo get a\ngo install b\n```\nafter this', + ) + }) +}) + +describe('the real CHANGELOG', () => { + const changelog = readFileSync( + join(REPO_ROOT, 'languages/golang/CHANGELOG.md'), + 'utf8', + ) + const versions = [...changelog.matchAll(/^## \[(\d[^\]]*)\]/gm)].map( + ([, version]) => version, + ) + + it('has one section per version', () => { + expect(versions).toContain('0.1.0-rc.3') + expect(new Set(versions).size).toBe(versions.length) + }) + + it.each(versions)( + 'gives notes for %s with no relative link left', + (version) => { + const notes = releaseNotes(changelog, version) + expect(notes.length).toBeGreaterThan(0) + for (const [, target] of notes.matchAll(/\]\(([^)\s]+)\)/g)) { + expect(target).toMatch(/^(https?:|mailto:|#)/) + } + }, + ) +}) + +describe('release-golang.yml', () => { + const workflow = readWorkflow('.github/workflows/release-golang.yml') + const { notes, build, publish } = workflow.jobs + const notesStep = notes.steps.find((step) => step.id === 'notes') + const releaseStep = publish.steps.find( + (step) => step.name === 'Create the GitHub release', + ) + + it('makes the notes in a job that cannot write and runs nothing but checkout and the script', () => { + expect(notes.permissions).toBeUndefined() + expect(workflow.permissions).toEqual({ contents: 'read' }) + for (const step of notes.steps) { + if (step.uses) + expect(step.uses).toMatch(/^actions\/checkout@[0-9a-f]{40}$/) + } + expect(notesStep.run).toContain( + 'node scripts/go-release-notes.mjs "$VERSION"', + ) + }) + + it('stops the release before the build when the notes fail', () => { + expect([build.needs].flat()).toContain('notes') + expect([publish.needs].flat()).toEqual( + expect.arrayContaining(['notes', 'build']), + ) + }) + + it('hands the notes to publish through the environment, never into its script', () => { + // biome-ignore lint/suspicious/noTemplateCurlyInString: a literal GitHub Actions expression. + expect(releaseStep.env.NOTES).toBe('${{ needs.notes.outputs.notes }}') + expect(releaseStep.run).toContain('"$NOTES"') + expect(releaseStep.run).not.toContain('${{') + for (const step of publish.steps) { + expect(step.run ?? '').not.toMatch(/\bnode\b/) + } + }) + + /** + * Run the real step for `version`, from a scratch directory standing in for + * the checkout's root, so the notes.md it writes lands outside the + * repository. The script and the real CHANGELOG are copied in at their + * paths; a symlink would not do, since the script's main-guard compares + * argv[1], which would be the link, with its own real path. + */ + const runNotesStep = (version) => { + const dir = mkdtempSync(join(tmpdir(), 'go-release-notes-')) + const output = join(dir, 'output') + try { + for (const file of [ + 'scripts/go-release-notes.mjs', + 'languages/golang/CHANGELOG.md', + ]) { + mkdirSync(dirname(join(dir, file)), { recursive: true }) + copyFileSync(join(REPO_ROOT, file), join(dir, file)) + } + const result = spawnSync('bash', ['-c', notesStep.run], { + cwd: dir, + encoding: 'utf8', + env: { ...process.env, VERSION: version, GITHUB_OUTPUT: output }, + }) + return { + status: result.status, + stderr: result.stderr, + written: existsSync(output) ? readFileSync(output, 'utf8') : '', + } + } finally { + rmSync(dir, { recursive: true, force: true }) + } + } + + it("the notes step's output is the script's notes for the version", () => { + const { status, written } = runNotesStep('0.1.0-rc.3') + expect(status).toBe(0) + const match = written.match( + /^notes<<(notes_[0-9a-f]{32})\n([\s\S]*)\n\1\n$/, + ) + expect(match).not.toBeNull() + const changelog = readFileSync( + join(REPO_ROOT, 'languages/golang/CHANGELOG.md'), + 'utf8', + ) + expect(match[2]).toBe(releaseNotes(changelog, '0.1.0-rc.3')) + }) + + it('the notes step fails, and writes no output, for a version with no section', () => { + const { status, stderr, written } = runNotesStep('0.1.0-rc.999') + expect(status).not.toBe(0) + expect(stderr).toContain('no "## [0.1.0-rc.999]" section') + expect(written).toBe('') + }) +}) diff --git a/scripts/go-release-notes.mjs b/scripts/go-release-notes.mjs new file mode 100644 index 000000000..678a6ff8b --- /dev/null +++ b/scripts/go-release-notes.mjs @@ -0,0 +1,156 @@ +#!/usr/bin/env node +// Writes the release notes `.github/workflows/release-golang.yml` puts on a +// Go module release: the version's section of languages/golang/CHANGELOG.md. +// +// node scripts/go-release-notes.mjs +// +// The notes are printed to stdout. A version with no CHANGELOG section, or an +// empty one, is refused, so a release cannot ship without notes. The workflow +// runs this in its `notes` job, which `build` waits for, so a missing section +// stops the release before anything is built. +// +// The section is changed in two ways on its way to the release page: +// +// 1. RELATIVE LINKS BECOME ABSOLUTE. A link in the CHANGELOG is relative to +// languages/golang/, and resolves on github.com only when the page is that +// file. On a release page it would point nowhere, so each one is rewritten +// to the file at the version's tag, which is never moved once pushed. +// 2. SOFT-WRAPPED LINES ARE JOINED. The CHANGELOG wraps its prose at 80 +// columns. GitHub renders a release body as it renders a comment, with +// every newline a line break, so wrapped paragraphs and list items would +// show as ragged lines. Each paragraph and list item is joined into one +// line; fenced code blocks are left as they are. +// +// Node built-ins only: the `notes` job runs no third-party code, which is why +// `publish`, the job that can write, may take its output as the release body. + +import { readFileSync } from 'node:fs' +import { join, posix, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const REPO_ROOT = resolve(import.meta.dirname, '..') +const CHANGELOG = join(REPO_ROOT, 'languages/golang/CHANGELOG.md') +// The CHANGELOG's directory, relative to the repository root: its relative +// links resolve from here. +const CHANGELOG_DIR = 'languages/golang' +const REPOSITORY_URL = 'https://github.com/cipherstash/stack' + +const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + +/** The tag `release-golang.yml` pushes for `version`. */ +export const releaseTag = (version) => `languages/golang/v${version}` + +/** + * The body of the `## []` section of `changelog`, without its + * heading: everything up to the next `## ` heading, with the blank lines at + * either end dropped. Throws when there is no such section, or it is empty. + */ +export function changelogSection(changelog, version) { + const heading = new RegExp(`^## \\[${escapeRegExp(version)}\\](?:\\s|$)`) + const lines = changelog.split('\n') + const start = lines.findIndex((line) => heading.test(line)) + if (start === -1) { + throw new Error( + `languages/golang/CHANGELOG.md has no "## [${version}]" section; add one with this release's changes before releasing it`, + ) + } + let end = lines.findIndex((line, i) => i > start && line.startsWith('## ')) + if (end === -1) end = lines.length + const body = lines + .slice(start + 1, end) + .join('\n') + .trim() + if (body === '') { + throw new Error( + `the "## [${version}]" section of languages/golang/CHANGELOG.md is empty`, + ) + } + return body +} + +/** + * `markdown` with every relative inline link target, `[text](target)`, + * rewritten to the file at `ref` on github.com. A target with a scheme + * (`https:`, `mailto:`) or that is only an anchor (`#heading`) is left alone; + * a target that resolves outside the repository is refused. + */ +export function absoluteLinks(markdown, ref) { + return markdown.replace(/\]\(([^)\s]+)\)/g, (link, target) => { + if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith('#')) { + return link + } + const hash = target.indexOf('#') + const path = hash === -1 ? target : target.slice(0, hash) + const anchor = hash === -1 ? '' : target.slice(hash) + const resolved = posix.normalize( + path.startsWith('/') ? path.slice(1) : posix.join(CHANGELOG_DIR, path), + ) + if (resolved === '..' || resolved.startsWith('../')) { + throw new Error(`link target ${target} is outside the repository`) + } + return `](${REPOSITORY_URL}/blob/${ref}/${resolved}${anchor})` + }) +} + +const FENCE = /^\s*(```|~~~)/ +// A line that starts a block of its own rather than continuing the one above. +const BLOCK_START = /^\s*(#{1,6}\s|[-*+]\s|\d+[.)]\s|>|\||```|~~~)/ + +/** + * `markdown` with each soft-wrapped paragraph and list item joined into one + * line. A line joins the one above when both have text and it does not start + * a block (a heading, a list item, a quote, a table row or a fence); its + * leading whitespace becomes one space. Fenced code blocks, headings and + * table rows are never joined onto. + */ +export function unwrap(markdown) { + const out = [] + let fenced = false + let joinable = false + for (const line of markdown.split('\n')) { + if (FENCE.test(line)) { + fenced = !fenced + out.push(line) + joinable = false + continue + } + if (fenced || line.trim() === '') { + out.push(line) + joinable = false + continue + } + if (joinable && !BLOCK_START.test(line)) { + out[out.length - 1] = `${out[out.length - 1].trimEnd()} ${line.trim()}` + continue + } + out.push(line) + joinable = !/^\s*(#{1,6}\s|\|)/.test(line) + } + return out.join('\n') +} + +/** The release notes for `version`, from the text of the CHANGELOG. */ +export function releaseNotes(changelog, version) { + return unwrap( + absoluteLinks(changelogSection(changelog, version), releaseTag(version)), + ) +} + +function main() { + const [version] = process.argv.slice(2) + if (!version) { + console.error('usage: go-release-notes.mjs ') + process.exit(2) + } + try { + process.stdout.write( + `${releaseNotes(readFileSync(CHANGELOG, 'utf8'), version)}\n`, + ) + } catch (error) { + // stderr: the workflow takes stdout as the notes. + console.error(`::error::${error.message}`) + process.exit(1) + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) main()