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
2 changes: 1 addition & 1 deletion .github/release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"release-type": "simple",
"bump-minor-pre-major": true,
"bump-patch-for-minor-pre-major": true,
"bump-patch-for-minor-pre-major": false,
"include-component-in-tag": true,
"include-v-in-tag": true,
"tag-separator": "@",
Expand Down
45 changes: 37 additions & 8 deletions actions/release-please/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,11 @@ workflows that react to a release.

## Configuration

The configuration is a file in your repository, because release-please fetches
it from the branch over the API rather than from the checkout. Where it lives
is fixed:
The action validates configuration from the checkout before release-please
fetches it from the branch over the API. Check out the repository before using
the action. Each configuration response consumed by release-please must match
the validated checkout; a changed configuration stops the run. The configuration
and manifest paths are fixed:

```
.github/release-please-config.json
Expand All @@ -49,14 +51,15 @@ is fixed:
Those paths are not inputs. One location for every repository is the reason
this action exists; a knob invites back the drift it was built to remove.

Start a repository from this configuration and change only `packages`:
Use this configuration as a starting point. Adapt `packages`, the release type,
tag formatting, changelog sections and plugins to the repository:

```json
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"release-type": "simple",
"bump-minor-pre-major": true,
"bump-patch-for-minor-pre-major": true,
"bump-patch-for-minor-pre-major": false,
"include-component-in-tag": true,
"include-v-in-tag": true,
"tag-separator": "@",
Expand Down Expand Up @@ -86,10 +89,36 @@ 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.

### Validation policy

The action validates these release settings for every package. They must
resolve to `false`:

- `bump-patch-for-minor-pre-major`
- `draft`
- `draft-pull-request`
- `prerelease`

`include-v-in-tag` must resolve to `true` for every package.

Package overrides take precedence over top-level settings. Validation checks
each package's effective value, so an override can satisfy the policy even
when the top-level value differs. Omitted settings use release-please's native
defaults: `false` for the settings above and `true` for `include-v-in-tag`.
Explicit values must be booleans; `null` and strings are rejected. A top-level,
nonempty `signoff` is required.

Other boolean settings are checked for their types, without forcing a shared
value. Settings such as `bump-minor-pre-major`, tag formatting, `release-type`
and plugins remain repository-specific. `versioning` must be a string when
provided, and its effective value must not be `always-bump-patch`, which would
override the required feature-bump behavior.

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.
its own. `require-initial-version: false` disables only this requirement.
Checkout validation, the shared field policy and mandatory sign-off still
apply.

## Outputs

Expand All @@ -107,6 +136,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`. |
| `require-initial-version` | `true` | Requires an `initial-version` for every package. Disabling it does not bypass other configuration validation. |

[release-please]: https://github.com/googleapis/release-please
5 changes: 5 additions & 0 deletions actions/release-please/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,14 @@ runs:

- name: Release Please
id: release-please
env:
NODE_OPTIONS: --import "${{ github.action_path }}/lib/runtime-guard.mjs"
INPUT_REQUIRE_INITIAL_VERSION: ${{ inputs.require-initial-version }}
uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0
with:
token: ${{ inputs.token }}
github-api-url: ${{ github.api_url }}
github-graphql-url: ${{ github.graphql_url }}
# These paths are deliberately not inputs: one location for every
# repository is the reason this action exists.
config-file: .github/release-please-config.json
Expand Down
56 changes: 56 additions & 0 deletions actions/release-please/lib/config-fetch-guard.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { createHash } from "node:crypto";

import {
CONFIG_FILE,
InputError,
validateInitialVersion,
validateReleaseDefaults,
validateSignoff,
} from "./index.mjs";

export function gitBlobSha(bytes) {
return createHash("sha1").update(`blob ${bytes.length}\0`).update(bytes).digest("hex");
}

export function createConfigFetchGuard(fetch, { snapshot, repository, apiUrl = "https://api.github.com", requireInitialVersion = true }) {
const bytes = Buffer.from(snapshot);
const config = JSON.parse(bytes.toString("utf8"));
validateReleaseDefaults(config);
validateSignoff(config);
if (requireInitialVersion) validateInitialVersion(config);
const sha = gitBlobSha(bytes);
const base = new URL(apiUrl);
const repoPath = `${base.pathname.replace(/\/$/, "")}/repos/${repository}/`;
const directories = new Map();
const mismatch = () => new InputError(`${CONFIG_FILE}: configuration differs from the validated checkout. Check out the configuration used by the release branch before releasing.`);

function verifyContent(data) {
if (data.encoding !== "base64" || typeof data.content !== "string") {
throw new InputError(`${CONFIG_FILE}: GitHub did not return base64 configuration content.`);
}
if (!Buffer.from(data.content, "base64").equals(bytes)) throw mismatch();
}

return async function guardedFetch(input, init) {
const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url);
const response = await fetch(input, init);
if (url.origin !== base.origin || !url.pathname.toLowerCase().startsWith(repoPath.toLowerCase()) || !response.ok) return response;
const endpoint = decodeURIComponent(url.pathname.slice(repoPath.length));
const method = init?.method ?? input?.method ?? "GET";
if (method.toUpperCase() !== "GET") return response;

if (endpoint.startsWith("git/trees/")) {
const ref = endpoint.slice("git/trees/".length);
const prefix = directories.get(ref) ?? "";
const data = await response.clone().json();
for (const entry of data.tree ?? []) {
const path = prefix + entry.path;
if (entry.type === "tree" && path === ".github") directories.set(entry.sha, ".github/");
if (path === CONFIG_FILE && entry.sha !== sha) throw mismatch();
}
} else if (endpoint === `git/blobs/${sha}` || endpoint === `contents/${CONFIG_FILE}`) {
verifyContent(await response.clone().json());
}
return response;
};
}
89 changes: 86 additions & 3 deletions actions/release-please/lib/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ import { getBooleanInput, info, setFailed } from "./core.mjs";

export const CONFIG_FILE = ".github/release-please-config.json";

// Policy changes must not alter upstream's behavior for omitted settings.
const releaseRules = Object.freeze({
"bump-patch-for-minor-pre-major": { required: false, fallback: false },
draft: { required: false, fallback: false },
"draft-pull-request": { required: false, fallback: false },
prerelease: { required: false, fallback: false },
"include-v-in-tag": { required: true, fallback: true },
"bump-minor-pre-major": {},
"include-component-in-tag": {},
"separate-pull-requests": {},
});

// 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 {}
Expand Down Expand Up @@ -53,6 +65,73 @@ export function validateInitialVersion(config) {
);
}

function packageOptions(config) {
if (!isPlainObject(config)) {
throw new InputError(`${CONFIG_FILE} must be a JSON object.`);
}
if (!isPlainObject(config.packages) || Object.keys(config.packages).length === 0) {
throw new InputError(`${CONFIG_FILE} has no \`packages\`, so there is nothing to release.`);
}
const packages = Object.entries(config.packages);
for (const [name, options] of packages) {
if (!isPlainObject(options)) {
throw new InputError(`Package '${name}' in ${CONFIG_FILE} must be a JSON object.`);
}
}
return packages;
}

function validateBooleans(options, location) {
for (const key of Object.keys(releaseRules)) {
if (Object.hasOwn(options, key) && typeof options[key] !== "boolean") {
throw new InputError(`${location} must set \`${key}\` to a boolean when provided.`);
}
}
}

function validateVersioningType(options, location) {
if (Object.hasOwn(options, "versioning") && typeof options.versioning !== "string") {
throw new InputError(`${location} must set \`versioning\` to a string when provided.`);
}
}

export function validateReleaseDefaults(config) {
const packages = packageOptions(config);
validateBooleans(config, CONFIG_FILE);
validateVersioningType(config, CONFIG_FILE);
for (const [name, options] of packages) {
const location = `Package '${name}' in ${CONFIG_FILE}`;
validateBooleans(options, location);
validateVersioningType(options, location);
const versioning = Object.hasOwn(options, "versioning")
? options.versioning
: Object.hasOwn(config, "versioning") ? config.versioning : "default";
if (versioning === "always-bump-patch") {
throw new InputError(`${location} must not use \`versioning: always-bump-patch\`, which overrides feature minor releases.`);
}
for (const [key, rule] of Object.entries(releaseRules)) {
if (!Object.hasOwn(rule, "required")) {
continue;
}
const effective = Object.hasOwn(options, key)
? options[key]
: Object.hasOwn(config, key) ? config[key] : rule.fallback;
if (effective !== rule.required) {
throw new InputError(`${location} must use \`${key}: ${rule.required}\` to match the shared release defaults.`);
}
}
}
}

export function validateSignoff(config) {
if (!isPlainObject(config)) {
throw new InputError(`${CONFIG_FILE} must be a JSON object.`);
}
if (!isSet(config.signoff)) {
throw new InputError(`${CONFIG_FILE} must set a non-empty string \`signoff\` for release commits.`);
}
}

export function readConfig(workspace) {
const file = path.join(workspace, CONFIG_FILE);

Expand All @@ -61,7 +140,7 @@ export function readConfig(workspace) {
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\`.`,
`Could not read ${CONFIG_FILE}. Check the repository out before this action.`,
);
}

Expand All @@ -74,12 +153,16 @@ export function readConfig(workspace) {

export function main() {
try {
if (!getBooleanInput("require_initial_version")) {
const requireInitialVersion = getBooleanInput("require_initial_version");
const config = readConfig(process.env.GITHUB_WORKSPACE ?? process.cwd());
validateReleaseDefaults(config);
validateSignoff(config);
Comment thread
yordis marked this conversation as resolved.
if (!requireInitialVersion) {
info("`require-initial-version` is false, so `initial-version` is not checked.");
return;
}

const logs = validateInitialVersion(readConfig(process.env.GITHUB_WORKSPACE ?? process.cwd()));
const logs = validateInitialVersion(config);

for (const line of logs) {
info(line);
Expand Down
15 changes: 15 additions & 0 deletions actions/release-please/lib/runtime-guard.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import fs from "node:fs";
import path from "node:path";

import { getBooleanInput } from "./core.mjs";
import { createConfigFetchGuard } from "./config-fetch-guard.mjs";
import { CONFIG_FILE, InputError } from "./index.mjs";

if (!process.env.GITHUB_REPOSITORY) throw new InputError("GITHUB_REPOSITORY is required to guard release configuration reads.");
const snapshot = fs.readFileSync(path.join(process.env.GITHUB_WORKSPACE ?? process.cwd(), CONFIG_FILE));
globalThis.fetch = createConfigFetchGuard(globalThis.fetch, {
snapshot,
repository: process.env.GITHUB_REPOSITORY,
apiUrl: process.env.GITHUB_API_URL ?? "https://api.github.com",
requireInitialVersion: getBooleanInput("require_initial_version"),
});
Loading
Loading