diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..25dacd8
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1,2 @@
+message="chore: release v%s"
+min-release-age=3
diff --git a/README.md b/README.md
index 65a631f..f6ba288 100644
--- a/README.md
+++ b/README.md
@@ -133,7 +133,7 @@ You can configure Release Drafter using the following key in your
| `sort-by` | Optional | Sort changelog by merged_at or title. Can be one of: `merged_at`, `title`. Default: `merged_at`. |
| `sort-direction` | Optional | Sort changelog in ascending or descending order. Can be one of: `ascending`, `descending`. Default: `descending`. |
| `prerelease` | Optional | Whether to draft a prerelease, with changes since another prerelease (if applicable). Default `false`. |
-| `prerelease-identifier` | Optional | A string indicating an identifier (alpha, beta, rc, etc), to increment the prerelease version. This automatically enables `prerelease` if not already set to `true`. Default `''`. |
+| `prerelease-identifier` | Optional | A string indicating an identifier (alpha, beta, rc, etc), to increment the prerelease version. This automatically enables `prerelease` when both options come from the same config location; explicit action inputs still take precedence. Default `''`. |
| `include-pre-releases` | Optional | When looking for the last published release to scan changes up-to, include pre-releases. Has no effect if using `prerelease: true` (already enabled). Default `false`. |
| `latest` | Optional | Mark the release as latest. Only works for published releases. Can be one of: `true`, `false`, `legacy`. Default `true`. |
| `version-resolver` | Optional | Adjust the `$RESOLVED_VERSION` variable using labels. Refer to [Version Resolver](#version-resolver) to learn more about this |
@@ -501,7 +501,7 @@ first place, automation may be an acceptable risk for you too.
> - `prerelease-identifier` is not required when `prerelease` is enabled, but
> your prerelease may not be named after / be associated with a tag that is
> semver-compliant to an actual prerelease.
-> - when specified, `prerelease-identifier` enables `prerelease: true`
+> - when specified, `prerelease-identifier` enables `prerelease: true` if both values come from the same config location; explicit action inputs still take precedence over config file values
If you want your stable releases to include changes since the last prerelease
instead of the last stable release, use `include-pre-releases: true`. This can
@@ -536,7 +536,7 @@ specified in your `release-drafter.yml` config.
| `version` | The version to be associated with the GitHub release that's created or updated. This will override any version calculated by the release-drafter. |
| `publish` | A boolean indicating whether the release being created or updated should be immediately published. This may be useful if the output of a previous workflow step determines that a new version of your project has been (or will be) released, as with [`salsify/action-detect-and-tag-new-version`](https://github.com/salsify/action-detect-and-tag-new-version). |
| `prerelease` | Whether to draft a prerelease, with changes since another prerelease (if applicable). Default `false`. |
-| `prerelease-identifier` | A string indicating an identifier (alpha, beta, rc, etc), to increment the prerelease version. This automatically enables `prerelease` if not already set to `true`. Default `''`. |
+| `prerelease-identifier` | A string indicating an identifier (alpha, beta, rc, etc), to increment the prerelease version. This automatically enables `prerelease` when both options come from the same config location; explicit action inputs still take precedence. Default `''`. |
| `include-pre-releases` | When looking for the last published release to scan changes up-to, include pre-releases. Has no effect if using `prerelease: true` (already enabled). Default `false`. |
| `latest` | A string indicating whether the release being created or updated should be marked as latest. |
| `commitish` | A string specifying the target branch for the release being created. |
@@ -562,4 +562,3 @@ inputs to other Actions in the workflow
| `major_version` | Major part of resolved version by [Version Resolver](#version-resolver). i.e. `6` for version `6.3.1` |
| `minor_version` | Minor part of resolved version by [Version Resolver](#version-resolver). i.e. `3` for version `6.3.1` |
| `patch_version` | Patch part of resolved version by [Version Resolver](#version-resolver). i.e. `1` for version `6.3.1` |
-
diff --git a/action.yml b/action.yml
index 6a1598b..b2ab0b1 100644
--- a/action.yml
+++ b/action.yml
@@ -54,7 +54,7 @@ inputs:
default: ''
prerelease-identifier:
description: |
- A string indicating an identifier (alpha, beta, rc, etc), to increment the prerelease version. This automatically enables `prerelease` if not already set to `true`.
+ A string indicating an identifier (alpha, beta, rc, etc), to increment the prerelease version. This automatically enables `prerelease` when both values come from the same config location; explicit action inputs still take precedence.
required: false
default: ''
'include-pre-releases':
diff --git a/badges/coverage.svg b/badges/coverage.svg
deleted file mode 100644
index bf9a53d..0000000
--- a/badges/coverage.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/dist/actions/drafter/run.js b/dist/actions/drafter/run.js
index 9de196c..995f7a2 100644
--- a/dist/actions/drafter/run.js
+++ b/dist/actions/drafter/run.js
@@ -1,5 +1,36 @@
import { C as setFailed, D as __toESM, E as __commonJSMin, S as info, T as warning, _ as isAxiosError, a as _enum, b as error, c as number, d as stringbool, f as datetime, g as context, h as getOctokit, i as ZodDefault, l as object, m as composeConfigGet, n as escapeStringRegexp, o as array, p as paginateGraphql, r as sharedInputSchema, s as boolean, t as stringToRegex, u as string, v as axios, w as setOutput, x as getInput, y as debug } from "../../chunks/common.js";
+import * as fs$1 from "node:fs";
import * as fs from "node:fs";
+//#region src/stepsecurity/subscription-check.ts
+async function validateSubscription$1() {
+ const eventPath = process.env.GITHUB_EVENT_PATH;
+ let repoPrivate;
+ if (eventPath && fs$1.existsSync(eventPath)) repoPrivate = JSON.parse(fs$1.readFileSync(eventPath, "utf8"))?.repository?.private;
+ const upstream = "release-drafter/release-drafter";
+ const action = process.env.GITHUB_ACTION_REPOSITORY;
+ const docsUrl = "https://docs.stepsecurity.io/actions/stepsecurity-maintained-actions";
+ info("");
+ info("\x1B[1;36mStepSecurity Maintained Action\x1B[0m");
+ info(`Secure drop-in replacement for ${upstream}`);
+ if (repoPrivate === false) info("\x1B[32m✓ Free for public repositories\x1B[0m");
+ info(`\u001b[36mLearn more:\u001b[0m ${docsUrl}`);
+ info("");
+ if (repoPrivate === false) return;
+ const serverUrl = process.env.GITHUB_SERVER_URL || "https://github.com";
+ const body = { action: action || "" };
+ if (serverUrl !== "https://github.com") body.ghes_server = serverUrl;
+ try {
+ await axios.post(`https://agent.api.stepsecurity.io/v1/github/${process.env.GITHUB_REPOSITORY}/actions/maintained-actions-subscription`, body, { timeout: 3e3 });
+ } catch (error$3) {
+ if (isAxiosError(error$3) && error$3.response?.status === 403) {
+ error(`\u001b[1;31mThis action requires a StepSecurity subscription for private repositories.\u001b[0m`);
+ error(`\u001b[31mLearn how to enable a subscription: ${docsUrl}\u001b[0m`);
+ process.exit(1);
+ }
+ info("Timeout or API not reachable. Continuing to next step.");
+ }
+}
+//#endregion
//#region src/actions/drafter/config/schemas/common-config.schema.ts
/**
* Configuration parameters that can be specified in both
@@ -918,65 +949,10 @@ var import_valid = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((expo
var mergeInputAndConfig = (params) => {
const { config: originalConfig, input } = params;
const config = structuredClone(originalConfig);
- if (input.commitish) {
- if (config.commitish && config.commitish !== input.commitish) info(`Input's commitish "${input.commitish}" overrides config's commitish "${config.commitish}"`);
- config.commitish = input.commitish;
- }
- if (input.header) {
- if (config.header && config.header !== input.header) info(`Input's header "${input.header}" overrides config's header "${config.header}"`);
- config.header = input.header;
- }
- if (input.footer) {
- if (config.footer && config.footer !== input.footer) info(`Input's footer "${input.footer}" overrides config's footer "${config.footer}"`);
- config.footer = input.footer;
- }
- if (input["prerelease-identifier"]) {
- if (config["prerelease-identifier"] && config["prerelease-identifier"] !== input["prerelease-identifier"]) info(`Input's prerelease-identifier "${input["prerelease-identifier"]}" overrides config's prerelease-identifier "${config["prerelease-identifier"]}"`);
- config["prerelease-identifier"] = input["prerelease-identifier"];
- }
- if (typeof input.prerelease === "boolean") {
- if (typeof config.prerelease === "boolean" && config.prerelease !== input.prerelease) info(`Input's prerelease "${input.prerelease}" overrides config's prerelease "${config.prerelease}"`);
- config.prerelease = input.prerelease;
- }
- if (typeof input["include-pre-releases"] === "boolean") {
- if (typeof config["include-pre-releases"] === "boolean" && config["include-pre-releases"] !== input["include-pre-releases"]) info(`Input's include-pre-releases "${input["include-pre-releases"]}" overrides config's include-pre-releases "${config["include-pre-releases"]}"`);
- config["include-pre-releases"] = input["include-pre-releases"];
- }
- if (typeof input.latest === "boolean") {
- if (typeof config.latest === "boolean" && config.latest !== input.latest) info(`Input's latest "${input.latest}" overrides config's latest "${config.latest}"`);
- config.latest = input.latest;
- }
- if (config.latest && config.prerelease) {
- warning("'prerelease' and 'latest' cannot be both true. Switch 'latest' to false - release will be a pre-release.");
- config.latest = false;
- }
- if (config["prerelease-identifier"] && !config.prerelease) {
- warning(`You specified a 'prerelease-identifier' (${config["prerelease-identifier"]}), but 'prerelease' is set to false. Switching to true.`);
- config.prerelease = true;
- }
- if (input["filter-by-range"]) {
- if (config["filter-by-range"] && config["filter-by-range"] !== input["filter-by-range"]) info(`Input's filter-by-range "${input["filter-by-range"]}" overrides config's filter-by-range "${config["filter-by-range"]}"`);
- config["filter-by-range"] = input["filter-by-range"];
- }
- const commitish = config.commitish || context.ref || context.payload.ref;
- const latest = typeof config.latest !== "boolean" ? true : config.latest;
- const prerelease = typeof config.prerelease !== "boolean" ? false : config.prerelease;
- const replacers = config.replacers.map((r) => {
- try {
- return {
- ...r,
- search: stringToRegex(r.search)
- };
- } catch {
- warning(`Bad replacer regex: '${r.search}'`);
- return false;
- }
- }).filter((r) => !!r);
- const categories = config.categories.map((cat) => {
- const { label, ..._cat } = cat;
- _cat.labels = [...cat.labels, label].filter(Boolean);
- return _cat;
- });
+ applyOverrides(config, input);
+ const { commitish, latest, prerelease } = getParsedDefaults(config);
+ const replacers = getTransformedReplacers(config);
+ const categories = getTransformedCategories(config);
const parsedConfig = {
...config,
commitish,
@@ -985,10 +961,72 @@ var mergeInputAndConfig = (params) => {
replacers,
categories
};
+ validateParsedConfig(parsedConfig);
+ return parsedConfig;
+};
+var applyOverrides = (config, input) => {
+ applyStringOverride(config, input, "commitish");
+ applyStringOverride(config, input, "header");
+ applyStringOverride(config, input, "footer");
+ applyStringOverride(config, input, "prerelease-identifier");
+ applyBooleanOverride(config, input, "prerelease");
+ applyBooleanOverride(config, input, "include-pre-releases");
+ applyBooleanOverride(config, input, "latest");
+ applyStringOverride(config, input, "filter-by-range");
+ applyStringOverride(config, input, "initial-commits-since");
+ applyReleaseModeOverrides(config, input);
+};
+var applyReleaseModeOverrides = (config, input) => {
+ if (config.latest && config.prerelease) {
+ warning("'prerelease' and 'latest' cannot be both true. Switch 'latest' to false - release will be a pre-release.");
+ config.latest = false;
+ }
+ const hasInputPrerelease = typeof input.prerelease === "boolean";
+ const hasInputPrereleaseIdentifier = !!input["prerelease-identifier"];
+ if (config["prerelease-identifier"] && !config.prerelease && (!hasInputPrerelease || hasInputPrereleaseIdentifier)) {
+ warning(`You specified a 'prerelease-identifier' (${config["prerelease-identifier"]}), but 'prerelease' is set to false. Switching to true.`);
+ config.prerelease = true;
+ }
+};
+var applyBooleanOverride = (config, input, key) => {
+ const inputValue = input[key];
+ if (typeof inputValue !== "boolean") return;
+ const configValue = config[key];
+ if (typeof configValue === "boolean" && configValue !== inputValue) info(`Input's ${key} "${inputValue}" overrides config's ${key} "${configValue}"`);
+ config[key] = inputValue;
+};
+var applyStringOverride = (config, input, key) => {
+ const inputValue = input[key];
+ if (!inputValue) return;
+ const configValue = config[key];
+ if (configValue && configValue !== inputValue) info(`Input's ${key} "${inputValue}" overrides config's ${key} "${configValue}"`);
+ config[key] = inputValue;
+};
+var getParsedDefaults = (config) => ({
+ commitish: config.commitish || context.ref || context.payload.ref,
+ latest: typeof config.latest !== "boolean" ? true : config.latest,
+ prerelease: typeof config.prerelease !== "boolean" ? false : config.prerelease
+});
+var getTransformedReplacers = (config) => config.replacers.map((r) => {
+ try {
+ return {
+ ...r,
+ search: stringToRegex(r.search)
+ };
+ } catch {
+ warning(`Bad replacer regex: '${r.search}'`);
+ return false;
+ }
+}).filter((r) => !!r);
+var getTransformedCategories = (config) => config.categories.map((cat) => {
+ const { label, ..._cat } = cat;
+ _cat.labels = [...cat.labels, label].filter(Boolean);
+ return _cat;
+});
+var validateParsedConfig = (parsedConfig) => {
if (!parsedConfig.commitish) throw new Error("'commitish' is required. Please set 'commitish' to a valid value. (defaults to the current ref, but it seems to be undefined in this context)");
if (parsedConfig.categories.filter((category) => category.labels.length === 0).length > 1) throw new Error("Multiple categories detected with no labels. Only one category with no labels is supported for uncategorized pull requests.");
if (parsedConfig["filter-by-range"] && !(0, import_valid.default)(parsedConfig["filter-by-range"])) throw new Error(`'filter-by-range' value "${parsedConfig["filter-by-range"]}" could not be parsed as a valid semver range.`);
- return parsedConfig;
};
//#endregion
//#region src/actions/drafter/config/set-action-output.ts
@@ -1592,12 +1630,12 @@ var VersionDescriptor = class VersionDescriptor {
if (from) {
const ver = typeof from === "object" ? this._isRelease(from) ? this._toSemver(this._stripTag(from.tag_name)) || this._toSemver(this._stripTag(from.name)) : this._toSemver(from) : this._toSemver(this._stripTag(from));
if (!ver) {
- warning(`Failed to parse version from input ${from}. Defaulting to null.`);
+ warning(`Failed to parse version from input ${from}. Defaulting coerced version to null.`);
return null;
}
return ver;
} else {
- warning(`No version input provided. Defaulting to null.`);
+ debug(`Building version descriptor without version input. Defaulting coerced version to null.`);
return null;
}
}
@@ -1642,15 +1680,23 @@ var VersionDescriptor = class VersionDescriptor {
//#region src/actions/drafter/lib/build-release-payload/get-version-info.ts
var getVersionInfo = (params) => {
const { lastRelease, config, input, versionKeyIncrement: _versionKeyIncrement } = params;
+ info(`Resolving version info based on:`);
+ info(` - last release: ${lastRelease?.tag_name || "none"}`);
+ info(` - version input: ${input.version || input.tag || input.name || "none"}`);
+ info(` - version key increment: ${_versionKeyIncrement}`);
let _localIncrement = structuredClone(_versionKeyIncrement);
+ info(`Coerce and parse versions from last release...`);
const versionFromLastRelease = new VersionDescriptor(lastRelease, {
tagPrefix: config["tag-prefix"],
preReleaseIdentifier: config["prerelease-identifier"]
});
+ info(`Parsed version from last release: ${versionFromLastRelease.version?.format() || "none"}.`);
+ info(`Coerce and parse versions from input...`);
const versionFromInput = new VersionDescriptor(input.version || input.tag || input.name, {
tagPrefix: config["tag-prefix"],
preReleaseIdentifier: config["prerelease-identifier"]
});
+ info(`Parsed version from input: ${versionFromInput.version?.format() || "none"}.`);
let referenceVersion;
if (versionFromInput.version) {
_localIncrement = "no_increment";
@@ -2296,6 +2342,7 @@ async function run() {
* `runner.ts` is the entrypoint for tests and should contain all the action's
* main logic.
*/
+await validateSubscription$1();
await run();
//#endregion
export {};
diff --git a/dist/chunks/common.js b/dist/chunks/common.js
index 08ca29a..e0e553f 100644
--- a/dist/chunks/common.js
+++ b/dist/chunks/common.js
@@ -1,10 +1,9 @@
import { createRequire } from "node:module";
-import { existsSync, readFileSync } from "node:fs";
import * as os$1 from "node:os";
import os, { EOL } from "node:os";
import * as crypto from "node:crypto";
import * as fs from "node:fs";
-import { constants, existsSync as existsSync$1, promises, readFileSync as readFileSync$1 } from "node:fs";
+import { constants, existsSync, promises, readFileSync } from "node:fs";
import * as path$1 from "node:path";
import * as http$1 from "node:http";
import * as https$1 from "node:https";
@@ -12,6 +11,7 @@ import * as events from "node:events";
import { StringDecoder } from "node:string_decoder";
import * as child from "node:child_process";
import { setTimeout as setTimeout$1 } from "node:timers";
+import { existsSync as existsSync$1, readFileSync as readFileSync$1 } from "node:fs";
import path, { basename, dirname, isAbsolute, join, normalize } from "node:path";
import process$1 from "node:process";
//#region \0rolldown/runtime.js
@@ -26912,10 +26912,10 @@ var getConfigFileFromFs = (normalizedFilepath) => {
const repoRoot = process$1.env.GITHUB_WORKSPACE;
const configPath = path.join(repoRoot, normalizedFilepath);
info(`Looking for config locally at ${configPath}...`);
- if (!existsSync(repoRoot)) throw new Error(`Root repo path does not exist: ${repoRoot}`);
- if (!existsSync(configPath)) throw new Error(`Config file not found: ${configPath}. Did you clone your sources ? (ex: using @actions/checkout)`);
+ if (!existsSync$1(repoRoot)) throw new Error(`Root repo path does not exist: ${repoRoot}`);
+ if (!existsSync$1(configPath)) throw new Error(`Config file not found: ${configPath}. Did you clone your sources ? (ex: using @actions/checkout)`);
info(`Loading from file: ${configPath}`);
- return readFileSync(configPath, "utf8");
+ return readFileSync$1(configPath, "utf8");
};
//#endregion
//#region node_modules/@actions/github/lib/context.js
@@ -26926,7 +26926,7 @@ var Context = class {
constructor() {
var _a, _b, _c;
this.payload = {};
- if (process.env.GITHUB_EVENT_PATH) if (existsSync$1(process.env.GITHUB_EVENT_PATH)) this.payload = JSON.parse(readFileSync$1(process.env.GITHUB_EVENT_PATH, { encoding: "utf8" }));
+ if (process.env.GITHUB_EVENT_PATH) if (existsSync(process.env.GITHUB_EVENT_PATH)) this.payload = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, { encoding: "utf8" }));
else {
const path = process.env.GITHUB_EVENT_PATH;
process.stdout.write(`GITHUB_EVENT_PATH ${path} does not exist${EOL}`);
diff --git a/drafter/action.yml b/drafter/action.yml
index c088e7e..61b8fe4 100644
--- a/drafter/action.yml
+++ b/drafter/action.yml
@@ -54,7 +54,7 @@ inputs:
default: ''
prerelease-identifier:
description: |
- A string indicating an identifier (alpha, beta, rc, etc), to increment the prerelease version. This automatically enables `prerelease` if not already set to `true`.
+ A string indicating an identifier (alpha, beta, rc, etc), to increment the prerelease version. This automatically enables `prerelease` when both values come from the same config location; explicit action inputs still take precedence.
required: false
default: ''
'include-pre-releases':
diff --git a/package-lock.json b/package-lock.json
index 77f276d..fcbfa02 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "release-drafter",
- "version": "7.2.0",
+ "version": "7.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "release-drafter",
- "version": "7.2.0",
+ "version": "7.2.1",
"license": "ISC",
"dependencies": {
"@actions/core": "^3.0.0",
diff --git a/package.json b/package.json
index cb3844e..da9afc7 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "release-drafter",
"description": "Drafts your next release notes as pull requests are merged into your branch(es).",
- "version": "7.2.0",
+ "version": "7.2.1",
"author": "step-security",
"type": "module",
"private": true,
@@ -27,14 +27,14 @@
"build": "vite build",
"test": "vitest",
"test:run": "vitest run",
- "coverage": "npx tsx src/scripts/generate-coverage-badge.ts",
+ "coverage": "npx tsx src/scripts/coverage-summary.ts",
"tsc:check": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json",
"format:write": "biome format --write .",
"format:check": "biome format .",
"lint": "biome lint .",
"check": "biome check --write .",
"schemas": "npx tsx src/scripts/json-schema.ts",
- "all": "npm run check && npm run tsc:check && npm run test:run && npm run coverage && npm run schemas && npm run build",
+ "all": "npm run check && npm run tsc:check && npm run test:run && npm run schemas && npm run build",
"check:clean": "npx tsx src/scripts/check-clean.ts"
},
"license": "ISC",
diff --git a/src/actions/drafter/config/merge-input-and-config.ts b/src/actions/drafter/config/merge-input-and-config.ts
index 70366eb..ebb7376 100644
--- a/src/actions/drafter/config/merge-input-and-config.ts
+++ b/src/actions/drafter/config/merge-input-and-config.ts
@@ -5,6 +5,27 @@ import { stringToRegex } from 'src/common'
import type { CommonConfig } from './schemas'
import type { Config } from './schemas/config.schema'
+type MutableConfig = ReturnType>
+
+type SharedConfigKey = keyof CommonConfig & keyof MutableConfig
+type SharedKeysByValue = {
+ [Key in SharedConfigKey]: Exclude extends Value
+ ? Exclude extends Value
+ ? Key
+ : never
+ : never
+}[SharedConfigKey]
+
+type SharedStringKey = SharedKeysByValue
+type SharedBooleanKey = SharedKeysByValue
+
+/**
+ * Similar to Config, but with input values merged in and defaults applied.
+ *
+ * @see mergeInputAndConfig
+ */
+export type ParsedConfig = ReturnType
+
/**
* Returns a copy of `config`, updated with values from `input`.
*
@@ -17,110 +38,122 @@ export const mergeInputAndConfig = (params: {
input: CommonConfig
}) => {
const { config: originalConfig, input } = params
+
const config = structuredClone(originalConfig)
- // Handle overrides
- if (input.commitish) {
- if (config.commitish && config.commitish !== input.commitish) {
- core.info(
- `Input's commitish "${input.commitish}" overrides config's commitish "${config.commitish}"`,
- )
- }
- config.commitish = input.commitish
- }
- if (input.header) {
- if (config.header && config.header !== input.header) {
- core.info(
- `Input's header "${input.header}" overrides config's header "${config.header}"`,
- )
- }
- config.header = input.header
- }
- if (input.footer) {
- if (config.footer && config.footer !== input.footer) {
- core.info(
- `Input's footer "${input.footer}" overrides config's footer "${config.footer}"`,
- )
- }
- config.footer = input.footer
- }
- if (input['prerelease-identifier']) {
- if (
- config['prerelease-identifier'] &&
- config['prerelease-identifier'] !== input['prerelease-identifier']
- ) {
- core.info(
- `Input's prerelease-identifier "${input['prerelease-identifier']}" overrides config's prerelease-identifier "${config['prerelease-identifier']}"`,
- )
- }
- config['prerelease-identifier'] = input['prerelease-identifier']
- }
- if (typeof input.prerelease === 'boolean') {
- if (
- typeof config.prerelease === 'boolean' &&
- config.prerelease !== input.prerelease
- ) {
- core.info(
- `Input's prerelease "${input.prerelease}" overrides config's prerelease "${config.prerelease}"`,
- )
- }
- config.prerelease = input.prerelease
- }
- if (typeof input['include-pre-releases'] === 'boolean') {
- if (
- typeof config['include-pre-releases'] === 'boolean' &&
- config['include-pre-releases'] !== input['include-pre-releases']
- ) {
- core.info(
- `Input's include-pre-releases "${input['include-pre-releases']}" overrides config's include-pre-releases "${config['include-pre-releases']}"`,
- )
- }
- config['include-pre-releases'] = input['include-pre-releases']
- }
- if (typeof input.latest === 'boolean') {
- if (typeof config.latest === 'boolean' && config.latest !== input.latest) {
- core.info(
- `Input's latest "${input.latest}" overrides config's latest "${config.latest}"`,
- )
- }
- config.latest = input.latest
+ applyOverrides(config, input)
+
+ const { commitish, latest, prerelease } = getParsedDefaults(config)
+ const replacers = getTransformedReplacers(config)
+ const categories = getTransformedCategories(config)
+
+ // Build parsed config object - alters original type
+ const parsedConfig = {
+ ...config,
+ commitish,
+ latest,
+ prerelease,
+ replacers,
+ categories,
}
+
+ validateParsedConfig(parsedConfig)
+
+ return parsedConfig
+}
+
+const applyOverrides = (config: MutableConfig, input: CommonConfig) => {
+ applyStringOverride(config, input, 'commitish')
+ applyStringOverride(config, input, 'header')
+ applyStringOverride(config, input, 'footer')
+
+ applyStringOverride(config, input, 'prerelease-identifier')
+
+ applyBooleanOverride(config, input, 'prerelease')
+ applyBooleanOverride(config, input, 'include-pre-releases')
+ applyBooleanOverride(config, input, 'latest')
+ applyStringOverride(config, input, 'filter-by-range')
+ applyStringOverride(config, input, 'initial-commits-since')
+
+ applyReleaseModeOverrides(config, input)
+}
+
+const applyReleaseModeOverrides = (
+ config: MutableConfig,
+ input: CommonConfig,
+) => {
if (config.latest && config.prerelease) {
core.warning(
"'prerelease' and 'latest' cannot be both true. Switch 'latest' to false - release will be a pre-release.",
)
config.latest = false
}
- if (config['prerelease-identifier'] && !config.prerelease) {
+
+ const hasInputPrerelease = typeof input.prerelease === 'boolean'
+ const hasInputPrereleaseIdentifier = !!input['prerelease-identifier']
+
+ if (
+ config['prerelease-identifier'] &&
+ !config.prerelease &&
+ (!hasInputPrerelease || hasInputPrereleaseIdentifier)
+ ) {
core.warning(
`You specified a 'prerelease-identifier' (${config['prerelease-identifier']}), but 'prerelease' is set to false. Switching to true.`,
)
config.prerelease = true
}
+}
+
+const applyBooleanOverride = (
+ config: MutableConfig,
+ input: CommonConfig,
+ key: SharedBooleanKey,
+) => {
+ const inputValue = input[key]
+ if (typeof inputValue !== 'boolean') {
+ return
+ }
- if (input['filter-by-range']) {
- if (
- config['filter-by-range'] &&
- config['filter-by-range'] !== input['filter-by-range']
- ) {
- core.info(
- `Input's filter-by-range "${input['filter-by-range']}" overrides config's filter-by-range "${config['filter-by-range']}"`,
- )
- }
- config['filter-by-range'] = input['filter-by-range']
+ const configValue = config[key]
+ if (typeof configValue === 'boolean' && configValue !== inputValue) {
+ core.info(
+ `Input's ${key} "${inputValue}" overrides config's ${key} "${configValue}"`,
+ )
}
- // Write defaults
- const commitish =
- config.commitish || context.ref || (context.payload.ref as string)
- const latest = typeof config.latest !== 'boolean' ? true : config.latest
- const prerelease =
- typeof config.prerelease !== 'boolean' ? false : config.prerelease
+ config[key] = inputValue
+}
- // Apply some transformations
- const replacers = config.replacers
+const applyStringOverride = (
+ config: MutableConfig,
+ input: CommonConfig,
+ key: SharedStringKey,
+) => {
+ const inputValue = input[key]
+ if (!inputValue) {
+ return
+ }
+
+ const configValue = config[key]
+ if (configValue && configValue !== inputValue) {
+ core.info(
+ `Input's ${key} "${inputValue}" overrides config's ${key} "${configValue}"`,
+ )
+ }
+
+ config[key] = inputValue
+}
+
+const getParsedDefaults = (config: MutableConfig) => ({
+ commitish: config.commitish || context.ref || (context.payload.ref as string),
+ latest: typeof config.latest !== 'boolean' ? true : config.latest,
+ prerelease:
+ typeof config.prerelease !== 'boolean' ? false : config.prerelease,
+})
+
+const getTransformedReplacers = (config: MutableConfig) =>
+ config.replacers
.map((r) => {
- // convert 'search' to regex and remove invalid entries
try {
return { ...r, search: stringToRegex(r.search) }
} catch {
@@ -129,23 +162,17 @@ export const mergeInputAndConfig = (params: {
}
})
.filter((r) => !!r)
- const categories = config.categories.map((cat) => {
+
+const getTransformedCategories = (config: MutableConfig) =>
+ config.categories.map((cat) => {
const { label, ..._cat } = cat
_cat.labels = [...cat.labels, label].filter(Boolean) as string[]
return _cat
})
- // Build parsed config object - alters original type
- const parsedConfig = {
- ...config,
- commitish,
- latest,
- prerelease,
- replacers,
- categories,
- }
-
- // Throw some more validation errors
+const validateParsedConfig = (
+ parsedConfig: ReturnType,
+) => {
if (!parsedConfig.commitish) {
throw new Error(
"'commitish' is required. Please set 'commitish' to a valid value. (defaults to the current ref, but it seems to be undefined in this context)",
@@ -167,13 +194,4 @@ export const mergeInputAndConfig = (params: {
`'filter-by-range' value "${parsedConfig['filter-by-range']}" could not be parsed as a valid semver range.`,
)
}
-
- return parsedConfig
}
-
-/**
- * Similar to Config, but with input values merged in and defaults applied.
- *
- * @see mergeInputAndConfig
- */
-export type ParsedConfig = ReturnType
diff --git a/src/actions/drafter/config/schemas/common-config.schema.ts b/src/actions/drafter/config/schemas/common-config.schema.ts
index 6132e8f..47869f3 100644
--- a/src/actions/drafter/config/schemas/common-config.schema.ts
+++ b/src/actions/drafter/config/schemas/common-config.schema.ts
@@ -25,7 +25,7 @@ export const commonConfigSchema = object({
*/
'initial-commits-since': iso.datetime().optional(),
/**
- * A string indicating an identifier (alpha, beta, rc, etc), to increment the prerelease version. This automatically enables `prerelease` if not already set to `true`. Default `''`.
+ * A string indicating an identifier (alpha, beta, rc, etc), to increment the prerelease version. This automatically enables `prerelease` when both values come from the same config location; explicit action inputs still take precedence. Default `''`.
*/
'prerelease-identifier': string().optional(),
/**
diff --git a/src/actions/drafter/lib/build-release-payload/get-version-info.ts b/src/actions/drafter/lib/build-release-payload/get-version-info.ts
index ad19301..2ff6cc4 100644
--- a/src/actions/drafter/lib/build-release-payload/get-version-info.ts
+++ b/src/actions/drafter/lib/build-release-payload/get-version-info.ts
@@ -1,3 +1,4 @@
+import * as core from '@actions/core'
import type { ReleaseType } from 'semver'
import type { Config, ExclusiveInput } from '../../config'
import type { findPreviousReleases } from '../find-previous-releases'
@@ -25,13 +26,26 @@ export const getVersionInfo = (params: {
versionKeyIncrement: _versionKeyIncrement,
} = params
+ core.info(`Resolving version info based on:`)
+ core.info(` - last release: ${lastRelease?.tag_name || 'none'}`)
+ core.info(
+ ` - version input: ${input.version || input.tag || input.name || 'none'}`,
+ )
+ core.info(` - version key increment: ${_versionKeyIncrement}`)
+
let _localIncrement: ReleaseType | 'no_increment' =
structuredClone(_versionKeyIncrement) // local mutable copy
+ core.info(`Coerce and parse versions from last release...`)
const versionFromLastRelease = new VersionDescriptor(lastRelease, {
tagPrefix: config['tag-prefix'],
preReleaseIdentifier: config['prerelease-identifier'],
})
+ core.info(
+ `Parsed version from last release: ${versionFromLastRelease.version?.format() || 'none'}.`,
+ )
+
+ core.info(`Coerce and parse versions from input...`)
const versionFromInput = new VersionDescriptor(
input.version || input.tag || input.name,
{
@@ -39,6 +53,9 @@ export const getVersionInfo = (params: {
preReleaseIdentifier: config['prerelease-identifier'],
},
)
+ core.info(
+ `Parsed version from input: ${versionFromInput.version?.format() || 'none'}.`,
+ )
let referenceVersion: VersionDescriptor
if (versionFromInput.version) {
diff --git a/src/actions/drafter/lib/build-release-payload/version-descriptor.ts b/src/actions/drafter/lib/build-release-payload/version-descriptor.ts
index 36f9233..7bccdda 100644
--- a/src/actions/drafter/lib/build-release-payload/version-descriptor.ts
+++ b/src/actions/drafter/lib/build-release-payload/version-descriptor.ts
@@ -64,13 +64,15 @@ export class VersionDescriptor {
if (!ver) {
core.warning(
- `Failed to parse version from input ${from}. Defaulting to null.`,
+ `Failed to parse version from input ${from}. Defaulting coerced version to null.`,
)
return null
}
return ver
} else {
- core.warning(`No version input provided. Defaulting to null.`)
+ core.debug(
+ `Building version descriptor without version input. Defaulting coerced version to null.`,
+ )
return null
}
}
diff --git a/src/actions/drafter/run.ts b/src/actions/drafter/run.ts
index 135f3ee..781429b 100644
--- a/src/actions/drafter/run.ts
+++ b/src/actions/drafter/run.ts
@@ -8,6 +8,9 @@
* `runner.ts` is the entrypoint for tests and should contain all the action's
* main logic.
*/
+
+import { validateSubscription } from '../../stepsecurity/subscription-check'
import { run } from './runner'
+await validateSubscription()
await run()
diff --git a/src/scripts/coverage-summary.ts b/src/scripts/coverage-summary.ts
new file mode 100644
index 0000000..c673537
--- /dev/null
+++ b/src/scripts/coverage-summary.ts
@@ -0,0 +1,58 @@
+import { appendFileSync, readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+
+type CoverageSummary = {
+ total?: {
+ statements?: { pct?: number; total?: number; covered?: number }
+ branches?: { pct?: number; total?: number; covered?: number }
+ functions?: { pct?: number; total?: number; covered?: number }
+ lines?: { pct?: number; total?: number; covered?: number }
+ }
+}
+
+const coverageSummaryPath = resolve(
+ import.meta.dirname,
+ '../..',
+ 'coverage',
+ 'coverage-summary.json',
+)
+
+const coverageSummaryContent = readFileSync(coverageSummaryPath, {
+ encoding: 'utf-8',
+})
+const coverageSummary = JSON.parse(coverageSummaryContent) as CoverageSummary
+
+const total = coverageSummary.total
+if (!total?.statements?.pct && total?.statements?.pct !== 0) {
+ throw new Error('Unable to read coverage data from coverage-summary.json')
+}
+
+const pct = total.statements.pct
+const threshold = Number(process.env.COVERAGE_THRESHOLD ?? '90')
+const meetsThreshold = pct >= threshold
+
+// Print coverage percentage for CI to capture
+console.log(pct.toFixed(2))
+
+// Write GitHub Actions job summary if running in CI
+const summaryFile = process.env.GITHUB_STEP_SUMMARY
+if (summaryFile) {
+ const emoji = meetsThreshold ? '🟢' : '🔴'
+ const status = meetsThreshold ? 'meets' : 'is below'
+
+ const summary = [
+ `## ${emoji} Code Coverage: ${pct.toFixed(2)}%`,
+ '',
+ `Coverage ${status} the ${threshold.toFixed(0)}% threshold.`,
+ '',
+ '| Metric | Coverage | Covered | Total |',
+ '| --- | --- | --- | --- |',
+ `| Statements | ${total.statements.pct?.toFixed(2)}% | ${total.statements.covered} | ${total.statements.total} |`,
+ `| Branches | ${total.branches?.pct?.toFixed(2)}% | ${total.branches?.covered} | ${total.branches?.total} |`,
+ `| Functions | ${total.functions?.pct?.toFixed(2)}% | ${total.functions?.covered} | ${total.functions?.total} |`,
+ `| Lines | ${total.lines?.pct?.toFixed(2)}% | ${total.lines?.covered} | ${total.lines?.total} |`,
+ '',
+ ].join('\n')
+
+ appendFileSync(summaryFile, summary)
+}
diff --git a/src/scripts/generate-coverage-badge.ts b/src/scripts/generate-coverage-badge.ts
deleted file mode 100644
index 673d4e6..0000000
--- a/src/scripts/generate-coverage-badge.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import { readFileSync, writeFileSync } from 'node:fs'
-import { resolve } from 'node:path'
-
-type CoverageSummary = {
- total?: {
- statements?: {
- pct?: number
- }
- }
-}
-
-const coverageSummaryPath = resolve(
- import.meta.dirname,
- '../..',
- 'coverage',
- 'coverage-summary.json',
-)
-const outputBadgePath = resolve(
- import.meta.dirname,
- '../..',
- 'badges',
- 'coverage.svg',
-)
-
-const coverageSummaryContent = readFileSync(coverageSummaryPath, {
- encoding: 'utf-8',
-})
-const coverageSummary = JSON.parse(coverageSummaryContent) as CoverageSummary
-
-const statementsCoveragePct = coverageSummary.total?.statements?.pct
-
-if (
- typeof statementsCoveragePct !== 'number' ||
- Number.isNaN(statementsCoveragePct)
-) {
- throw new Error(
- 'Unable to read statements coverage percentage from coverage-summary.json',
- )
-}
-
-const label = 'Coverage'
-const message = `${statementsCoveragePct.toFixed(2)}%`
-
-const color =
- statementsCoveragePct >= 90
- ? '#4c1'
- : statementsCoveragePct >= 80
- ? '#97CA00'
- : statementsCoveragePct >= 70
- ? '#dfb317'
- : '#e05d44'
-
-const labelWidth = Math.max(48, Math.ceil(label.length * 6.5 + 14))
-const messageWidth = Math.max(42, Math.ceil(message.length * 6.5 + 14))
-const totalWidth = labelWidth + messageWidth
-
-const labelTextX = Math.round(labelWidth / 2)
-const messageTextX = labelWidth + Math.round(messageWidth / 2)
-
-const badgeSvg = ``
-
-writeFileSync(outputBadgePath, badgeSvg, {
- encoding: 'utf-8',
- flag: 'w',
-})
diff --git a/src/stepsecurity/subscription-check.ts b/src/stepsecurity/subscription-check.ts
new file mode 100644
index 0000000..d6d4834
--- /dev/null
+++ b/src/stepsecurity/subscription-check.ts
@@ -0,0 +1,61 @@
+import * as core from '@actions/core'
+import axios, { isAxiosError } from 'axios'
+import * as fs from 'fs'
+
+export async function validateSubscription(): Promise {
+ const eventPath = process.env.GITHUB_EVENT_PATH
+ let repoPrivate: boolean | undefined
+
+ if (eventPath && fs.existsSync(eventPath)) {
+ const eventData = JSON.parse(fs.readFileSync(eventPath, 'utf8'))
+ repoPrivate = eventData?.repository?.private
+ }
+
+ const upstream = 'release-drafter/release-drafter'
+ const action = process.env.GITHUB_ACTION_REPOSITORY
+ const docsUrl =
+ 'https://docs.stepsecurity.io/actions/stepsecurity-maintained-actions'
+
+ core.info('')
+ core.info('\u001b[1;36mStepSecurity Maintained Action\u001b[0m')
+ core.info(`Secure drop-in replacement for ${upstream}`)
+
+ if (repoPrivate === false) {
+ core.info('\u001b[32m\u2713 Free for public repositories\u001b[0m')
+ }
+ core.info(`\u001b[36mLearn more:\u001b[0m ${docsUrl}`)
+ core.info('')
+
+ if (repoPrivate === false) {
+ return
+ }
+
+ const serverUrl = process.env.GITHUB_SERVER_URL || 'https://github.com'
+ const body: Record = {
+ action: action || '',
+ }
+ if (serverUrl !== 'https://github.com') {
+ body.ghes_server = serverUrl
+ }
+
+ try {
+ await axios.post(
+ `https://agent.api.stepsecurity.io/v1/github/${process.env.GITHUB_REPOSITORY}/actions/maintained-actions-subscription`,
+ body,
+ {
+ timeout: 3000,
+ },
+ )
+ } catch (error) {
+ if (isAxiosError(error) && error.response?.status === 403) {
+ core.error(
+ `\u001b[1;31mThis action requires a StepSecurity subscription for private repositories.\u001b[0m`,
+ )
+ core.error(
+ `\u001b[31mLearn how to enable a subscription: ${docsUrl}\u001b[0m`,
+ )
+ process.exit(1)
+ }
+ core.info('Timeout or API not reachable. Continuing to next step.')
+ }
+}
diff --git a/src/tests/drafter/drafter.e2e.test.ts b/src/tests/drafter/drafter.e2e.test.ts
index bf3f50b..0aacdeb 100644
--- a/src/tests/drafter/drafter.e2e.test.ts
+++ b/src/tests/drafter/drafter.e2e.test.ts
@@ -2657,6 +2657,35 @@ describe('drafter e2e', () => {
expect(gqlScope.isDone()).toBe(true) // should call the mocked endpoints
expect(mocks.core.setFailed).not.toHaveBeenCalled()
})
+
+ it('doesnt let a config prerelease-identifier turn the release into a prerelease', async () => {
+ await mockContext('push')
+ await mockInput('prerelease', 'false')
+ mocks.config.mockReturnValue('config-with-pre-release-identifier')
+ const scope = nockGetAndPostReleases({
+ fetchedReleases: ['release'],
+ })
+ const gqlScope = mockGraphqlQuery({
+ payload: 'graphql-commits-merge-commit',
+ })
+ await runDrafter()
+ expect(mocks.postReleaseBody.mock.lastCall).toMatchInlineSnapshot(`
+ [
+ {
+ "body": "This is a Pre-release with identifier.",
+ "draft": true,
+ "make_latest": "true",
+ "name": "v2.0.1",
+ "prerelease": false,
+ "tag_name": "v2.0.1",
+ "target_commitish": "refs/heads/master",
+ },
+ ]
+ `)
+ expect(scope.isDone()).toBe(true) // should call the mocked endpoints
+ expect(gqlScope.isDone()).toBe(true) // should call the mocked endpoints
+ expect(mocks.core.setFailed).not.toHaveBeenCalled()
+ })
})
describe('with input prerelease and publish: true', () => {
diff --git a/src/tests/drafter/merge-input-and-config.test.ts b/src/tests/drafter/merge-input-and-config.test.ts
index de74a6b..b8ae88e 100644
--- a/src/tests/drafter/merge-input-and-config.test.ts
+++ b/src/tests/drafter/merge-input-and-config.test.ts
@@ -135,6 +135,24 @@ describe('mergeInputAndConfig', () => {
)
})
+ it('should merge input and config with input taking precedence for initial-commits-since', () => {
+ const config = configSchema.parse({
+ template: '$CHANGES',
+ commitish: 'main',
+ 'initial-commits-since': '2026-04-13T12:07:48Z',
+ })
+ const input = commonConfigSchema.parse({
+ 'initial-commits-since': '2026-04-10T11:05:32Z',
+ })
+
+ const result = mergeInputAndConfig({ config, input })
+
+ expect(result['initial-commits-since']).toBe('2026-04-10T11:05:32Z')
+ expect(mocks.core.info).toHaveBeenCalledWith(
+ 'Input\'s initial-commits-since "2026-04-10T11:05:32Z" overrides config\'s initial-commits-since "2026-04-13T12:07:48Z"',
+ )
+ })
+
it('should use config values when input values are not provided', () => {
const config = configSchema.parse({
template: '$CHANGES',
@@ -221,6 +239,23 @@ describe('mergeInputAndConfig', () => {
expect(mocks.core.warning).not.toHaveBeenCalled()
})
+
+ it('should respect explicit input prerelease false over config prerelease-identifier', () => {
+ const config = configSchema.parse({
+ template: '$CHANGES',
+ commitish: 'main',
+ 'prerelease-identifier': 'alpha',
+ })
+ const input = commonConfigSchema.parse({
+ prerelease: false,
+ })
+
+ const result = mergeInputAndConfig({ config, input })
+
+ expect(result.prerelease).toBe(false)
+ expect(result['prerelease-identifier']).toBe('alpha')
+ expect(mocks.core.warning).not.toHaveBeenCalled()
+ })
})
describe('defaults', () => {
@@ -507,6 +542,25 @@ describe('mergeInputAndConfig', () => {
)
})
+ it('should still auto-enable prerelease when prerelease and prerelease-identifier both come from input', () => {
+ const config = configSchema.parse({
+ template: '$CHANGES',
+ commitish: 'main',
+ })
+ const input = commonConfigSchema.parse({
+ prerelease: false,
+ 'prerelease-identifier': 'beta',
+ })
+
+ const result = mergeInputAndConfig({ config, input })
+
+ expect(result['prerelease-identifier']).toBe('beta')
+ expect(result.prerelease).toBe(true)
+ expect(mocks.core.warning).toHaveBeenCalledWith(
+ "You specified a 'prerelease-identifier' (beta), but 'prerelease' is set to false. Switching to true.",
+ )
+ })
+
it('should prioritize input boolean values over config boolean values for both prerelease and latest', () => {
const config = configSchema.parse({
template: '$CHANGES',