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 @@ -Coverage: 89.70%CoverageCoverage89.70%89.70% \ 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 = `${label}: ${message}${label}${label}${message}${message}` - -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',