Skip to content
Merged
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
6 changes: 6 additions & 0 deletions actions/release-please/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
15 changes: 15 additions & 0 deletions actions/release-please/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions actions/release-please/lib/core.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
94 changes: 94 additions & 0 deletions actions/release-please/lib/index.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
}
3 changes: 3 additions & 0 deletions actions/release-please/lib/main.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { main } from "./index.mjs";

main();
33 changes: 33 additions & 0 deletions tests/node/release-please/core.test.mjs
Original file line number Diff line number Diff line change
@@ -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,
);
});
133 changes: 133 additions & 0 deletions tests/node/release-please/index.test.mjs
Original file line number Diff line number Diff line change
@@ -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));
});
Loading