diff --git a/actions/release-please/README.md b/actions/release-please/README.md index 6ff612d..d917677 100644 --- a/actions/release-please/README.md +++ b/actions/release-please/README.md @@ -86,6 +86,11 @@ The matching manifest starts every package at the sentinel: `initial-version`. Any other starting value is read as a real previous release and gets bumped instead, so the first tag skips the version you asked for. +Every package must set `initial-version`, or the configuration must set one at +the top level, because without it release-please chooses the first version on +its own. The action checks this before releasing, reading the configuration +from the checkout, and `require-initial-version: false` turns the check off. + ## Outputs `releases_created`, `paths_released`, `prs_created` and `prs` are the ones a @@ -102,5 +107,6 @@ monorepo that needs them should read the `paths_released` array instead. | Input | Default | Description | | --- | --- | --- | | `token` | required | Opens the release pull request and pushes the tag. | +| `require-initial-version` | `true` | Fails before releasing when a package has no `initial-version`. | [release-please]: https://github.com/googleapis/release-please diff --git a/actions/release-please/action.yml b/actions/release-please/action.yml index 027fd0d..36f5c1c 100644 --- a/actions/release-please/action.yml +++ b/actions/release-please/action.yml @@ -11,6 +11,13 @@ inputs: access token rather than github.token, so the tag it produces triggers the workflows that react to it. required: true + require-initial-version: + description: >- + Fail before releasing when a package in the configuration has no + `initial-version`, so a new package cannot ship a first version nobody + chose. Reads the configuration from the checkout. + required: false + default: "true" outputs: releases_created: @@ -53,6 +60,14 @@ outputs: runs: using: composite steps: + # A composite action has no `node24` runtime of its own, so this runs the + # Node already on the runner rather than a separate action pinned by ref. + - name: Validate configuration + shell: bash + env: + INPUT_REQUIRE_INITIAL_VERSION: ${{ inputs.require-initial-version }} + run: node "$GITHUB_ACTION_PATH/lib/main.mjs" + - name: Release Please id: release-please uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 diff --git a/actions/release-please/lib/core.mjs b/actions/release-please/lib/core.mjs new file mode 100644 index 0000000..26d3ee7 --- /dev/null +++ b/actions/release-please/lib/core.mjs @@ -0,0 +1,59 @@ +// A stand-in for the functions this action uses from `@actions/core`, carrying +// their names and their behaviour. The package itself is ESM-only and reaches +// `undici` through `@actions/http-client`, so depending on it would mean a +// bundler and a committed `dist/`, and the tests would then exercise something +// other than the file the runner executes. + +import { EOL } from "node:os"; + +// The runner uppercases an input name and replaces spaces, and nothing else. A +// composite action sets these variables itself, so it picks the spelling. +export function getInput(name, options = {}) { + const value = process.env[`INPUT_${name.replace(/ /g, "_").toUpperCase()}`] ?? ""; + + if (options.required && !value) { + throw new Error(`Input required and not supplied: ${name}`); + } + + return options.trimWhitespace === false ? value : value.trim(); +} + +const TRUE_VALUES = ["true", "True", "TRUE"]; +const FALSE_VALUES = ["false", "False", "FALSE"]; + +export function getBooleanInput(name, options = {}) { + const value = getInput(name, options); + + if (TRUE_VALUES.includes(value)) { + return true; + } + + if (FALSE_VALUES.includes(value)) { + return false; + } + + throw new TypeError( + `Input does not meet YAML 1.2 "Core Schema" specification: ${name}\n` + + "Support boolean input list: `true | True | TRUE | false | False | FALSE`", + ); +} + +// Workflow commands end at a newline, so a message carrying one would close the +// annotation and log the remainder as its own line. +function escapeData(value) { + return String(value).replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A"); +} + +export function info(message) { + process.stdout.write(`${message}${EOL}`); +} + +export function error(message) { + process.stdout.write(`::error::${escapeData(message)}${EOL}`); +} + +export function setFailed(message) { + // Not `process.exit`, which can truncate output still buffered on stdout. + process.exitCode = 1; + error(message); +} diff --git a/actions/release-please/lib/index.mjs b/actions/release-please/lib/index.mjs new file mode 100644 index 0000000..7512b6b --- /dev/null +++ b/actions/release-please/lib/index.mjs @@ -0,0 +1,94 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { getBooleanInput, info, setFailed } from "./core.mjs"; + +export const CONFIG_FILE = ".github/release-please-config.json"; + +// Thrown for anything a caller can fix in their repository, so main can report +// it as a GitHub error annotation rather than a stack trace. +export class InputError extends Error {} + +function isPlainObject(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isSet(value) { + return typeof value === "string" && value.trim().length > 0; +} + +// Without `initial-version`, release-please picks the first version itself, so +// a new package ships whatever its defaults happen to be rather than what the +// repository meant. +export function validateInitialVersion(config) { + if (!isPlainObject(config)) { + throw new InputError(`${CONFIG_FILE} must be a JSON object.`); + } + + const packages = config.packages; + if (!isPlainObject(packages) || Object.keys(packages).length === 0) { + throw new InputError(`${CONFIG_FILE} has no \`packages\`, so there is nothing to release.`); + } + + // A top-level value is the default release-please applies to every package, + // but a package naming its own replaces it, even with an empty value. + const fallback = config["initial-version"]; + const effective = (options) => + isPlainObject(options) && Object.hasOwn(options, "initial-version") + ? options["initial-version"] + : fallback; + + const missing = Object.entries(packages) + .filter(([, options]) => !isSet(effective(options))) + .map(([name]) => name); + + if (missing.length > 0) { + throw new InputError( + `Every package in ${CONFIG_FILE} must set a non-empty \`initial-version\`, or leave it out and let the top level set one. Missing: ${missing.join(", ")}.`, + ); + } + + return Object.entries(packages).map( + ([name, options]) => `Package '${name}' starts at ${effective(options)}.`, + ); +} + +export function readConfig(workspace) { + const file = path.join(workspace, CONFIG_FILE); + + let text; + try { + text = fs.readFileSync(file, "utf8"); + } catch { + throw new InputError( + `Could not read ${CONFIG_FILE}. Check the repository out before this action, or set \`require-initial-version: false\`.`, + ); + } + + try { + return JSON.parse(text); + } catch (thrown) { + throw new InputError(`${CONFIG_FILE} is not valid JSON: ${thrown.message}`); + } +} + +export function main() { + try { + if (!getBooleanInput("require_initial_version")) { + info("`require-initial-version` is false, so `initial-version` is not checked."); + return; + } + + const logs = validateInitialVersion(readConfig(process.env.GITHUB_WORKSPACE ?? process.cwd())); + + for (const line of logs) { + info(line); + } + } catch (thrown) { + if (thrown instanceof InputError || thrown instanceof TypeError) { + setFailed(thrown.message); + return; + } + throw thrown; + } +} diff --git a/actions/release-please/lib/main.mjs b/actions/release-please/lib/main.mjs new file mode 100644 index 0000000..89c6de9 --- /dev/null +++ b/actions/release-please/lib/main.mjs @@ -0,0 +1,3 @@ +import { main } from "./index.mjs"; + +main(); diff --git a/tests/node/release-please/core.test.mjs b/tests/node/release-please/core.test.mjs new file mode 100644 index 0000000..85cdabd --- /dev/null +++ b/tests/node/release-please/core.test.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { getBooleanInput } from "../../../actions/release-please/lib/core.mjs"; + +function withInput(name, value, run) { + const key = `INPUT_${name.replace(/ /g, "_").toUpperCase()}`; + process.env[key] = value; + try { + return run(); + } finally { + delete process.env[key]; + } +} + +test("getBooleanInput reads the YAML true spellings", () => { + for (const value of ["true", "True", "TRUE"]) { + assert.equal(withInput("require_initial_version", value, () => getBooleanInput("require_initial_version")), true); + } +}); + +test("getBooleanInput reads the YAML false spellings", () => { + for (const value of ["false", "False", "FALSE"]) { + assert.equal(withInput("require_initial_version", value, () => getBooleanInput("require_initial_version")), false); + } +}); + +test("getBooleanInput rejects anything else", () => { + assert.throws( + () => withInput("require_initial_version", "yes", () => getBooleanInput("require_initial_version")), + TypeError, + ); +}); diff --git a/tests/node/release-please/index.test.mjs b/tests/node/release-please/index.test.mjs new file mode 100644 index 0000000..4061a74 --- /dev/null +++ b/tests/node/release-please/index.test.mjs @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +import { + CONFIG_FILE, + InputError, + readConfig, + validateInitialVersion, +} from "../../../actions/release-please/lib/index.mjs"; + +// assert.throws does not hand back the error, and every failure case here is +// about the message it carries. +function failure(run) { + try { + run(); + } catch (error) { + assert.ok(error instanceof InputError, `not an InputError: ${error}`); + return error; + } + assert.fail("expected an InputError"); +} + +test("every package setting initial-version passes", () => { + const logs = validateInitialVersion({ + packages: { + "actions/a": { component: "a", "initial-version": "0.0.1" }, + "actions/b": { component: "b", "initial-version": "1.0.0" }, + }, + }); + assert.deepEqual(logs, [ + "Package 'actions/a' starts at 0.0.1.", + "Package 'actions/b' starts at 1.0.0.", + ]); +}); + +test("a top-level initial-version covers every package", () => { + const logs = validateInitialVersion({ + "initial-version": "0.0.1", + packages: { ".": { component: "root" } }, + }); + assert.deepEqual(logs, ["Package '.' starts at 0.0.1."]); +}); + +test("a package value wins over the top-level one", () => { + const logs = validateInitialVersion({ + "initial-version": "0.0.1", + packages: { ".": { "initial-version": "0.1.0" } }, + }); + assert.deepEqual(logs, ["Package '.' starts at 0.1.0."]); +}); + +test("an empty package value is not rescued by the top-level one", () => { + const error = failure(() => + validateInitialVersion({ + "initial-version": "0.0.1", + packages: { + "actions/a": {}, + "actions/b": { "initial-version": "" }, + "actions/c": { "initial-version": null }, + }, + }), + ); + assert.match(error.message, /Missing: actions\/b, actions\/c\.$/); +}); + +test("a package without initial-version fails and is named", () => { + const error = failure(() => + validateInitialVersion({ + packages: { + "actions/a": { "initial-version": "0.0.1" }, + "actions/b": { component: "b" }, + "actions/c": {}, + }, + }), + ); + assert.match(error.message, /Missing: actions\/b, actions\/c\.$/); +}); + +test("an empty initial-version is not set", () => { + const error = failure(() => + validateInitialVersion({ packages: { ".": { "initial-version": " " } } }), + ); + assert.match(error.message, /Missing: \.\.$/); +}); + +test("a non-string initial-version is not set", () => { + failure(() => validateInitialVersion({ packages: { ".": { "initial-version": 1 } } })); +}); + +test("a config without packages fails", () => { + const error = failure(() => validateInitialVersion({ "release-type": "simple" })); + assert.match(error.message, /has no `packages`/); +}); + +test("an empty packages object fails", () => { + failure(() => validateInitialVersion({ packages: {} })); +}); + +test("a config that is not an object fails", () => { + failure(() => validateInitialVersion([])); +}); + +function workspace(contents) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "release-please-")); + if (contents !== undefined) { + fs.mkdirSync(path.join(root, ".github")); + fs.writeFileSync(path.join(root, CONFIG_FILE), contents); + } + return root; +} + +test("readConfig parses the configuration in the workspace", () => { + const root = workspace(JSON.stringify({ packages: { ".": {} } })); + assert.deepEqual(readConfig(root), { packages: { ".": {} } }); +}); + +test("readConfig asks for a checkout when the file is absent", () => { + const error = failure(() => readConfig(workspace())); + assert.match(error.message, /Check the repository out before this action/); +}); + +test("readConfig rejects invalid JSON", () => { + const error = failure(() => readConfig(workspace("{"))); + assert.match(error.message, /is not valid JSON/); +}); + +test("the repository's own configuration passes", () => { + const root = path.resolve(import.meta.dirname, "../../.."); + validateInitialVersion(readConfig(root)); +});