From ab0b8a290a69e0e5a3c79394d109493a3f45ba94 Mon Sep 17 00:00:00 2001 From: Ihab Soliman <2053386+ihabsoliman@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:56:11 -0600 Subject: [PATCH 1/3] feat: add string-token bump strategy Adds `bump_strategy: string-token` as an alternative to conventional commits, for teams that mark release intent with magic substrings in commit messages (`#major`/`#minor`/`#patch`/`#none` by default) rather than structured commit headers. Tag discovery, prefix scoping, prerelease handling and tag creation are shared with the conventional-commits path; only bump derivation differs. Three places do need their own behaviour under the new strategy, each mirroring anothrNick/github-tag-action, from which the token convention comes: - The previous tag and commit-range base are always the latest *stable* tag. The prerelease tag is consulted separately, as a continue-or-restart switch. Incrementing from max(stable, prerelease) instead would turn tags 1.4.0 + 1.4.1-RC.3 with a `#patch` commit into 1.4.2-RC.0 rather than 1.4.1-RC.4. - When no tag matches the prefix yet, the commit range is empty rather than "every commit reachable from HEAD", so the first tag of a new prefixed lineage cannot be decided by an unrelated historical token. - The closed-PR payload fallback is bypassed, since the bash implementation has no equivalent and it would surface commits that a `git log A..A` range never sees. A `none` token, or no token with `default_bump: false`, skips tagging but still echoes the existing stable tag into `new_tag`/`new_version` instead of leaving them empty. Also adds `pre_release`, to force pre-release on or off explicitly rather than inferring it from branch regexes, and `token_match_mode`: `word` (default) requires delimited, case-insensitive tokens, while `substring` reproduces the legacy glob semantics under which `#patches` triggers `#patch`. Existing conventional-commits callers are unaffected: `bump_strategy` defaults to `conventional-commits` and the whole feature is inert unless it is set. fix: normalize push payload commits in the closed-PR fallback `getClosedPRCommits` read `commit.commit.message`, but `push` webhook payload commits are `{ id, message, author }` with no nested `.commit`, so it threw whenever it actually fired - on an empty compare range with a push payload present. The shape conversion was also a `.filter` where a `.map` was intended, which silently passed the original elements through. Existing tests missed both because their fixtures used the API-shaped `{ sha, commit: { message } }`. --- README.md | 72 ++- action.yml | 27 + lib/main.cjs | 261 +++++++--- src/action.ts | 332 +++++++++--- src/string-token.ts | 88 ++++ src/utils.ts | 40 +- tests/action.test.ts | 1009 ++++++++++++++++++++++++++++++++++++ tests/string-token.test.ts | 246 +++++++++ tests/utils.test.ts | 35 +- 9 files changed, 1952 insertions(+), 158 deletions(-) create mode 100644 src/string-token.ts create mode 100644 tests/string-token.test.ts diff --git a/README.md b/README.md index 740b3ce..fa97e54 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ jobs: - **release_branches** _(optional)_ - Comma separated list of branches (JavaScript regular expression accepted) that will generate the release tags. Other branches and pull-requests generate versions postfixed with the commit hash and do not generate any repository tag. Examples: `^master$` or `.*` or `^release.*,^hotfix.*,^master$`... (default: `^master$,^main$`). - **pre_release_branches** _(optional)_ - Comma separated list of branches (JavaScript regular expression accepted) that will generate the pre-release tags. +- **pre_release** _(optional)_ - Force pre-release on (`true`) or off (`false`), overriding what `release_branches`/`pre_release_branches` would infer. Leave unset to keep inferring from the branch. Useful when the caller already knows whether a run is a pre-release (e.g. a reusable workflow that decides from its own inputs) and doesn't want to encode that as branch regexes. #### Filter commits @@ -56,6 +57,19 @@ jobs: Unrecognized values fall back to `compare` with a warning. - **default_branch** _(optional)_ - The repository's default branch (e.g. `main`). Only used by `branch_history: full`, to narrow the range to the commits unique to the current branch. Not auto-detected. +#### Choose a bump strategy + +- **bump_strategy** _(optional)_ - How the bump is derived from commit messages (default: `conventional-commits`). See [Bump strategies](#bump-strategies). + - `conventional-commits` - parse commits with a conventional-commit analyzer. + - `string-token` - scan commit messages for magic tokens such as `#major`. +- **major_string_token** _(optional)_ - Token that triggers a major bump under `bump_strategy: string-token` (default: `#major`). +- **minor_string_token** _(optional)_ - Token that triggers a minor bump under `bump_strategy: string-token` (default: `#minor`). +- **patch_string_token** _(optional)_ - Token that triggers a patch bump under `bump_strategy: string-token` (default: `#patch`). +- **none_string_token** _(optional)_ - Token that suppresses tagging under `bump_strategy: string-token` (default: `#none`). +- **token_match_mode** _(optional)_ - How tokens are matched (default: `word`). See [Matching modes](#matching-modes). + +Setting any `*_string_token` without also setting `bump_strategy: string-token` logs a warning and has no effect. Setting an empty string for one of them disables that bump level entirely. + #### Customize the tag - **default_bump** _(optional)_ - Which type of bump to use when [none is explicitly provided](#bumping) when commiting to a release branch (default: `patch`). You can also set `false` to avoid generating a new tag when none is explicitly provided. Can be `patch, minor or major`. @@ -104,12 +118,68 @@ jobs: - **new_version** - The value of the newly created tag without the prefix. Note that if there hasn't been any new commit, this will be `undefined`. - **previous_tag** - The value of the previous tag (or `v0.0.0` if none). Note that if `custom_tag` is set, this will be `undefined`. - **previous_version** - The value of the previous tag (or `0.0.0` if none) without the prefix. Note that if `custom_tag` is set, this will be `undefined`. -- **release_type** - The computed release type (`major`, `minor`, `patch` or `custom` - can be prefixed with `pre`). +- **release_type** - The computed release type (`major`, `minor`, `patch` or `custom` - can be prefixed with `pre`). Under `bump_strategy: string-token` it is `none` when a `none` token suppressed the bump. - **changelog** - The [conventional changelog](https://github.com/conventional-changelog/conventional-changelog) since the previous tag. - **tag_created** - Whether a tag was actually created/pushed (`true`/`false`). Other outputs (`new_tag`, `new_version`, etc.) may be populated even when this is `false` (e.g. `dry_run`, or a `soft_fail`'d error) - check this output before acting on them. > **_Note:_** This action creates a [lightweight tag](https://developer.github.com/v3/git/refs/#create-a-reference) by default. +### Bump strategies + +Two strategies are available, selected with `bump_strategy`. + +| | `conventional-commits` (default) | `string-token` | +| --- | --- | --- | +| How the bump is decided | Commits are parsed as conventional commits and analyzed structurally | Commit messages are scanned for magic substrings | +| Example commit | `feat(api): add pagination` | `add pagination #minor` | +| Configured by | `commit_analyzer_preset`, `custom_release_rules`, `scopes` | `major_string_token`, `minor_string_token`, `patch_string_token`, `none_string_token`, `token_match_mode` | + +Everything else - prefix-scoped tag discovery, prerelease handling, tag creation - is shared, so the two strategies differ only in how they read intent from commit messages. + +Some vocabulary used throughout this README: + +- **Bump strategy** - how a commit range is turned into `major`/`minor`/`patch`/nothing. +- **Prefix-scoped version lineage** - the set of tags sharing one `tag_prefix`. Each prefix has an independent version history, so `gateway_1.4.0` and `web-app_2.9.0` never influence each other's bumps. +- **Release branch** - a branch matching `release_branches`; it produces plain version tags. Everything else produces prereleases if it matches `pre_release_branches` (or if `pre_release: true` is set). +- **Prerelease identifier** - the label in `1.4.1-RC.3`, taken from `append_to_pre_release_tag` and defaulting to the branch name. + +#### Bumping with string tokens + +Set `bump_strategy: string-token` when your team doesn't write conventional commits but does mark intent in commit messages: + +```yaml +- uses: ihabsoliman/github-tag-action@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + bump_strategy: string-token +``` + +That's the whole configuration - the four tokens default to `#major`, `#minor`, `#patch` and `#none`. Override them for a different house style: + +```yaml + bump_strategy: string-token + major_string_token: "[breaking]" + minor_string_token: "[feature]" + patch_string_token: "[fix]" +``` + +Rules: + +- Every commit message in the range is searched, subject and body alike. A token in any commit counts. +- **Precedence is `major` > `minor` > `patch` > `none`.** A range containing both `#major` and `#none` bumps major - `#none` only wins when no other token appears anywhere in the range. +- When no token matches, `default_bump` applies (set `default_bump: false` to tag nothing instead). +- When the `none` token matches, no tag is created and `new_tag`/`new_version` echo the existing tag rather than being empty, so a workflow using them as an image tag always has a value. +- `scopes` is not supported with this strategy (it filters to conventional commits, which would discard the very commits being scanned) and is ignored with a warning. + +##### Matching modes + +`token_match_mode` controls how strictly tokens are matched: + +- **`word`** (default) - the token must be delimited by non-word characters or a string boundary, and matching is case-insensitive. `#patches` does **not** trigger `#patch`; `#MAJOR` does trigger `#major`. +- **`substring`** - a case-sensitive plain substring search. This is bug-compatible with [anothrNick/github-tag-action](https://github.com/anothrNick/github-tag-action), whose bash implementation used a glob: under this mode `#patches` **does** trigger `#patch`, and `#MAJOR` does **not** match `#major`. Use it when migrating from that action and you need identical results. + +> **_Note:_** the `substring` sharp edge is real - a commit body mentioning "fixed #patches in the docs" will cut a patch release. Prefer distinctive tokens (`[fix]`, `#bump-patch`) if you must use `substring`. + ### Bumping The action will parse the new commits since the last tag using the [semantic-release](https://github.com/semantic-release/semantic-release) conventions. diff --git a/action.yml b/action.yml index b18c0b2..d990a03 100644 --- a/action.yml +++ b/action.yml @@ -20,6 +20,33 @@ inputs: github_token: description: "Required for permission to tag the repo." required: true + bump_strategy: + description: | + How to derive the bump from commit messages (default: `conventional-commits`). + `conventional-commits` parses commits with a conventional-commit analyzer. `string-token` instead scans commit messages for magic tokens (`#major`/`#minor`/`#patch`/`#none` by default) - useful for teams that don't write conventional commits. + required: false + default: "conventional-commits" + major_string_token: + description: "Token that triggers a major bump when `bump_strategy` is `string-token` (default: `#major`)." + required: false + minor_string_token: + description: "Token that triggers a minor bump when `bump_strategy` is `string-token` (default: `#minor`)." + required: false + patch_string_token: + description: "Token that triggers a patch bump when `bump_strategy` is `string-token` (default: `#patch`)." + required: false + none_string_token: + description: "Token that suppresses tagging when `bump_strategy` is `string-token` (default: `#none`). Loses to `major`/`minor`/`patch` tokens if they also appear in the range." + required: false + token_match_mode: + description: | + How `string-token` matches tokens (default: `word`). + `word` requires the token to be delimited and matches case-insensitively, so `#patches` does not trigger `#patch`. `substring` is a case-sensitive plain substring search, bug-compatible with anothrNick/github-tag-action - under which `#patches` *does* trigger `#patch`. + required: false + default: "word" + pre_release: + description: "Force pre-release on (`true`) or off (`false`), overriding `release_branches`/`pre_release_branches` inference. Leave unset to infer from the branch." + required: false default_bump: description: "Which type of bump to use when none explicitly provided when commiting to a release branch (default: `patch`)." required: false diff --git a/lib/main.cjs b/lib/main.cjs index 3955025..5baaf58 100644 --- a/lib/main.cjs +++ b/lib/main.cjs @@ -49507,10 +49507,11 @@ async function getValidTags(tags, prefixRegex) { return validTags; } async function getCommits(baseRef, headRef, options = {}) { + const { skipClosedPrFallback, ...rangeOptions } = options; let commits; - commits = await getCommitRange(baseRef, headRef, options); + commits = await getCommitRange(baseRef, headRef, rangeOptions); info("We found " + commits.length + " commits using classic compare!"); - if (commits.length < 1) { + if (commits.length < 1 && !skipClosedPrFallback) { info( "We did not find enough commits, attempting to scan closed PR method." ); @@ -49533,9 +49534,9 @@ function getClosedPRCommits() { info( "We found " + pr_commit_count + " commits from the Closed PR method." ); - commits = context2.payload.commits.filter((commit) => !!commit.commit.message).filter((commit) => ({ - message: commit.commit.message, - hash: commit.sha + commits = context2.payload.commits.filter((commit) => !!commit.message).map((commit) => ({ + sha: commit.id ?? null, + commit: { message: commit.message } })); debug( "After processing we are going to present " + commits.length + " commits!" @@ -49666,9 +49667,49 @@ function mergeWithDefaultChangelogRules(mappedReleaseRules = []) { return Object.values(mergedRules).filter((rule) => !!rule.section); } +// src/string-token.ts +var TOKEN_MATCH_MODES = ["word", "substring"]; +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} +function matchesToken(log, token, mode) { + if (!token) { + return false; + } + if (mode === "substring") { + return log.includes(token); + } + return new RegExp(`(? commit.message).join("\n"); + if (matchesToken(log, tokens.major, mode)) { + return { kind: "bump", bump: "major" }; + } + if (matchesToken(log, tokens.minor, mode)) { + return { kind: "bump", bump: "minor" }; + } + if (matchesToken(log, tokens.patch, mode)) { + return { kind: "bump", bump: "patch" }; + } + if (matchesToken(log, tokens.none, mode)) { + return { kind: "none" }; + } + return { kind: "no-match" }; +} + // src/action.ts var TAG_CONTEXTS = ["repo", "branch"]; var BRANCH_HISTORIES = ["compare", "last", "full"]; +var BUMP_STRATEGIES = ["conventional-commits", "string-token"]; +var DEFAULT_STRING_TOKENS = { + major: "#major", + minor: "#minor", + patch: "#patch", + none: "#none" +}; async function main() { setOutput("tag_created", "false"); const defaultBump = getInput("default_bump"); @@ -49694,6 +49735,40 @@ async function main() { const commitAnalyzerPreset = getInput("commit_analyzer_preset"); const gitCwd = getInput("source"); const tagMessage = getInput("tag_message"); + const bumpStrategyInput = getInput("bump_strategy") || "conventional-commits"; + if (!BUMP_STRATEGIES.includes(bumpStrategyInput)) { + throw new Error( + `${bumpStrategyInput} is not a valid bump_strategy. Expected one of ${BUMP_STRATEGIES.join(", ")}.` + ); + } + const bumpStrategy = bumpStrategyInput; + const isStringTokenStrategy = bumpStrategy === "string-token"; + const rawStringTokens = { + major: getInput("major_string_token"), + minor: getInput("minor_string_token"), + patch: getInput("patch_string_token"), + none: getInput("none_string_token") + }; + const stringTokens = { + major: rawStringTokens.major || DEFAULT_STRING_TOKENS.major, + minor: rawStringTokens.minor || DEFAULT_STRING_TOKENS.minor, + patch: rawStringTokens.patch || DEFAULT_STRING_TOKENS.patch, + none: rawStringTokens.none || DEFAULT_STRING_TOKENS.none + }; + if (!isStringTokenStrategy && Object.values(rawStringTokens).some(Boolean)) { + warning( + "String token inputs are ignored unless bump_strategy is set to string-token." + ); + } + const tokenMatchModeInput = getInput("token_match_mode") || "word"; + let tokenMatchMode = "word"; + if (TOKEN_MATCH_MODES.includes(tokenMatchModeInput)) { + tokenMatchMode = tokenMatchModeInput; + } else { + warning( + `${tokenMatchModeInput} is not a valid token_match_mode. Falling back to word.` + ); + } if (tagMessage && !createAnnotatedTag) { warning( "tag_message was set but create_annotated_tag is false; tag_message is ignored for lightweight tags." @@ -49739,7 +49814,8 @@ async function main() { const isReleaseBranch = releaseBranches.split(",").some((branch) => currentBranch.match(branch)); const isPreReleaseBranch = preReleaseBranches.split(",").some((branch) => currentBranch.match(branch)); const isPullRequest = isPr(GITHUB_REF); - const isPrerelease = !isReleaseBranch && !isPullRequest && isPreReleaseBranch; + const preReleaseInput = getInput("pre_release"); + const isPrerelease = preReleaseInput !== "" ? /true/i.test(preReleaseInput) : !isReleaseBranch && !isPullRequest && isPreReleaseBranch; const commitRangeOptions = { gitCwd, branchHistory, @@ -49777,7 +49853,9 @@ async function main() { } else { let previousTag; let previousVersion; - if (!latestPrereleaseTag) { + if (isStringTokenStrategy) { + previousTag = latestTag; + } else if (!latestPrereleaseTag) { previousTag = latestTag; } else { previousTag = (0, import_semver4.gte)( @@ -49798,13 +49876,23 @@ async function main() { setOutput("previous_version", previousVersion.version); setOutput("previous_tag", previousTag.name); const previousWasPrerelease = previousVersion.prerelease.length != 0; - commits = await getCommits( - previousTag.commit.sha, - commitRef, - commitRangeOptions - ); + if (isStringTokenStrategy && previousTag.commit.sha === NO_PREVIOUS_TAG_SHA) { + info( + "No previous tag for this prefix; treating the commit range as empty." + ); + commits = []; + } else { + commits = await getCommits(previousTag.commit.sha, commitRef, { + ...commitRangeOptions, + skipClosedPrFallback: isStringTokenStrategy + }); + } debug("We found " + commits.length + " commits to consider!"); - if (scopes.length) { + if (scopes.length && isStringTokenStrategy) { + warning( + "scopes is not supported with bump_strategy: string-token and is ignored." + ); + } else if (scopes.length) { const commitParser = new CommitParser(); const isInScope = (scope) => scopes.split(",").some((includedScope) => scope.match(includedScope)); commits = commits.filter((commit) => { @@ -49817,64 +49905,103 @@ async function main() { } }); } - const isDefaultCommitAnalyzerPreset = commitAnalyzerPreset.toLowerCase() === "angular"; - const analyzeCommitsContext = { - commits, - logger: { log: console.info.bind(console) }, - cwd: process.cwd() - }; - let bump = await analyzeCommits( - { - ...isDefaultCommitAnalyzerPreset ? {} : { preset: commitAnalyzerPreset }, - releaseRules: mappedReleaseRules ? ( - // analyzeCommits doesn't appreciate rules with a section /shrug - mappedReleaseRules.map(({ section, ...rest }) => ({ ...rest })) - ) : void 0 - }, - analyzeCommitsContext - ); - let shouldContinue = true; - if (isPrerelease) { - if (!bump && !previousWasPrerelease && defaultDraftBump === "false") - shouldContinue = false; - if (!bump && defaultPreReleaseBump === "false") { - shouldContinue = false; + if (isStringTokenStrategy) { + const tokenResult = analyzeCommitsByStringToken( + commits, + stringTokens, + tokenMatchMode + ); + const stableVersion = latestTag.name.replace(prefixRegex, ""); + if (tokenResult.kind === "none" || tokenResult.kind === "no-match" && (defaultBump === "false" || String(defaultBump) === "none")) { + info( + "No commit specifies the version bump. Skipping the tag creation." + ); + setOutput("release_type", "none"); + setOutput("new_version", stableVersion); + setOutput("new_tag", latestTag.name); + return; } - } else { - if (!bump && defaultBump === "false") { - shouldContinue = false; + const part = tokenResult.kind === "bump" ? tokenResult.bump : defaultBump; + if (isPrerelease) { + const target = (0, import_semver4.inc)(stableVersion, part); + if (!target) { + throw new Error("Could not increment version."); + } + const previousPrereleaseName = latestPrereleaseTag?.name; + const continuesExistingPrerelease = !!previousPrereleaseName && previousPrereleaseName.includes(target) && previousPrereleaseName.includes(identifier); + newVersion = continuesExistingPrerelease ? (0, import_semver4.inc)( + previousPrereleaseName.replace(prefixRegex, ""), + "prerelease", + identifier + ) ?? "" : `${target}-${identifier}.0`; + setOutput("release_type", `pre${part}`); + } else { + newVersion = (0, import_semver4.inc)(stableVersion, part) ?? ""; + setOutput("release_type", part); } - } - if (isPrerelease && forcePreReleaseBump !== "") { - bump = forcePreReleaseBump; - } - if (!isPrerelease && forceBump !== "") { - bump = forceBump; - } - if (!shouldContinue) { - debug( - "No commit specifies the version bump. Skipping the tag creation." + if (!newVersion || !(0, import_semver4.valid)(newVersion)) { + throw new Error(`${newVersion} is not a valid semver.`); + } + } else { + const isDefaultCommitAnalyzerPreset = commitAnalyzerPreset.toLowerCase() === "angular"; + const analyzeCommitsContext = { + commits, + logger: { log: console.info.bind(console) }, + cwd: process.cwd() + }; + let bump = await analyzeCommits( + { + ...isDefaultCommitAnalyzerPreset ? {} : { preset: commitAnalyzerPreset }, + releaseRules: mappedReleaseRules ? ( + // analyzeCommits doesn't appreciate rules with a section /shrug + mappedReleaseRules.map(({ section, ...rest }) => ({ ...rest })) + ) : void 0 + }, + analyzeCommitsContext ); - return; - } - if (isPrerelease && !bump) { - if (!previousWasPrerelease) - bump = defaultDraftBump; - else bump = defaultPreReleaseBump; - } - if (isPrerelease) { - bump = bump.replace(/^(pre)+/, ""); - } - const releaseType = isPrerelease ? `pre${bump}` : bump || defaultBump; - setOutput("release_type", releaseType); - const incrementedVersion = (0, import_semver4.inc)(previousVersion, releaseType, identifier); - if (!incrementedVersion) { - throw new Error("Could not increment version."); - } - if (!(0, import_semver4.valid)(incrementedVersion)) { - throw new Error(`${incrementedVersion} is not a valid semver.`); + let shouldContinue = true; + if (isPrerelease) { + if (!bump && !previousWasPrerelease && defaultDraftBump === "false") + shouldContinue = false; + if (!bump && defaultPreReleaseBump === "false") { + shouldContinue = false; + } + } else { + if (!bump && defaultBump === "false") { + shouldContinue = false; + } + } + if (isPrerelease && forcePreReleaseBump !== "") { + bump = forcePreReleaseBump; + } + if (!isPrerelease && forceBump !== "") { + bump = forceBump; + } + if (!shouldContinue) { + debug( + "No commit specifies the version bump. Skipping the tag creation." + ); + return; + } + if (isPrerelease && !bump) { + if (!previousWasPrerelease) + bump = defaultDraftBump; + else bump = defaultPreReleaseBump; + } + if (isPrerelease) { + bump = bump.replace(/^(pre)+/, ""); + } + const releaseType = isPrerelease ? `pre${bump}` : bump || defaultBump; + setOutput("release_type", releaseType); + const incrementedVersion = (0, import_semver4.inc)(previousVersion, releaseType, identifier); + if (!incrementedVersion) { + throw new Error("Could not increment version."); + } + if (!(0, import_semver4.valid)(incrementedVersion)) { + throw new Error(`${incrementedVersion} is not a valid semver.`); + } + newVersion = incrementedVersion; } - newVersion = incrementedVersion; } info(`New version is ${newVersion}.`); setOutput("new_version", newVersion); diff --git a/src/action.ts b/src/action.ts index 4cd1900..5c80d1d 100644 --- a/src/action.ts +++ b/src/action.ts @@ -15,7 +15,18 @@ import { mapCustomReleaseRules, mergeWithDefaultChangelogRules, } from './utils.js'; -import { BranchHistory, createTag, listTags } from './github.js'; +import { + BranchHistory, + createTag, + listTags, + NO_PREVIOUS_TAG_SHA, +} from './github.js'; +import { + analyzeCommitsByStringToken, + StringTokenResult, + TOKEN_MATCH_MODES, + TokenMatchMode, +} from './string-token.js'; import { Await } from './ts.js'; const TAG_CONTEXTS = ['repo', 'branch'] as const; @@ -23,6 +34,16 @@ type TagContext = (typeof TAG_CONTEXTS)[number]; const BRANCH_HISTORIES: BranchHistory[] = ['compare', 'last', 'full']; +const BUMP_STRATEGIES = ['conventional-commits', 'string-token'] as const; +type BumpStrategy = (typeof BUMP_STRATEGIES)[number]; + +const DEFAULT_STRING_TOKENS = { + major: '#major', + minor: '#minor', + patch: '#patch', + none: '#none', +} as const; + export default async function main() { core.setOutput('tag_created', 'false'); @@ -55,6 +76,49 @@ export default async function main() { const gitCwd = core.getInput('source'); const tagMessage = core.getInput('tag_message'); + const bumpStrategyInput = + core.getInput('bump_strategy') || 'conventional-commits'; + if (!(BUMP_STRATEGIES as readonly string[]).includes(bumpStrategyInput)) { + throw new Error( + `${bumpStrategyInput} is not a valid bump_strategy. Expected one of ${BUMP_STRATEGIES.join(', ')}.`, + ); + } + const bumpStrategy = bumpStrategyInput as BumpStrategy; + const isStringTokenStrategy = bumpStrategy === 'string-token'; + + // Deliberately no `default:` in action.yml for these: `core.getInput` cannot + // tell a caller-supplied value from a declared default, so the "you set a + // token but not the strategy" warning below would fire on every single + // conventional-commits run if the defaults were declared there. + const rawStringTokens = { + major: core.getInput('major_string_token'), + minor: core.getInput('minor_string_token'), + patch: core.getInput('patch_string_token'), + none: core.getInput('none_string_token'), + }; + const stringTokens = { + major: rawStringTokens.major || DEFAULT_STRING_TOKENS.major, + minor: rawStringTokens.minor || DEFAULT_STRING_TOKENS.minor, + patch: rawStringTokens.patch || DEFAULT_STRING_TOKENS.patch, + none: rawStringTokens.none || DEFAULT_STRING_TOKENS.none, + }; + + if (!isStringTokenStrategy && Object.values(rawStringTokens).some(Boolean)) { + core.warning( + 'String token inputs are ignored unless bump_strategy is set to string-token.', + ); + } + + const tokenMatchModeInput = core.getInput('token_match_mode') || 'word'; + let tokenMatchMode: TokenMatchMode = 'word'; + if ((TOKEN_MATCH_MODES as readonly string[]).includes(tokenMatchModeInput)) { + tokenMatchMode = tokenMatchModeInput as TokenMatchMode; + } else { + core.warning( + `${tokenMatchModeInput} is not a valid token_match_mode. Falling back to word.`, + ); + } + if (tagMessage && !createAnnotatedTag) { core.warning( 'tag_message was set but create_annotated_tag is false; tag_message is ignored for lightweight tags.', @@ -113,7 +177,15 @@ export default async function main() { .split(',') .some((branch) => currentBranch.match(branch)); const isPullRequest = isPr(GITHUB_REF); - const isPrerelease = !isReleaseBranch && !isPullRequest && isPreReleaseBranch; + // An explicit `pre_release` wins outright; leaving it unset keeps the + // branch-derived behaviour. Note this only decides whether the *version* is a + // prerelease - the "neither a release nor a pre-release branch" guard further + // down still uses the branch matches. + const preReleaseInput = core.getInput('pre_release'); + const isPrerelease = + preReleaseInput !== '' + ? /true/i.test(preReleaseInput) + : !isReleaseBranch && !isPullRequest && isPreReleaseBranch; const commitRangeOptions = { gitCwd, @@ -166,7 +238,14 @@ export default async function main() { } else { let previousTag: ReturnType | null; let previousVersion: SemVer | null; - if (!latestPrereleaseTag) { + if (isStringTokenStrategy) { + // entrypoint.sh always computes the bump off the stable tag and treats the + // prerelease tag purely as a continue-or-restart switch (applied further + // down). Selecting max(stable, prerelease) here instead - as the + // conventional-commits path does - would increment from the prerelease + // version and produce e.g. 1.4.2-RC.0 where legacy produces 1.4.1-RC.4. + previousTag = latestTag; + } else if (!latestPrereleaseTag) { previousTag = latestTag; } else { previousTag = gte( @@ -194,14 +273,35 @@ export default async function main() { core.setOutput('previous_tag', previousTag.name); const previousWasPrerelease = previousVersion.prerelease.length != 0; - commits = await getCommits( - previousTag.commit.sha, - commitRef, - commitRangeOptions, - ); + if ( + isStringTokenStrategy && + previousTag.commit.sha === NO_PREVIOUS_TAG_SHA + ) { + // No tag matches this prefix yet. legacy's range is + // `git log ..`, which git reads as HEAD..HEAD and is empty, so + // the first tag of a new prefixed lineage always comes from default_bump. + // Falling through to the API here would walk the entire repo history and + // let an unrelated historical `#major` mint e.g. web-app_1.0.0 instead of + // web-app_0.0.1. + core.info( + 'No previous tag for this prefix; treating the commit range as empty.', + ); + commits = []; + } else { + commits = await getCommits(previousTag.commit.sha, commitRef, { + ...commitRangeOptions, + skipClosedPrFallback: isStringTokenStrategy, + }); + } core.debug('We found ' + commits.length + ' commits to consider!'); - if (scopes.length) { + if (scopes.length && isStringTokenStrategy) { + // The scope filter parses commits as conventional commits and drops + // everything that isn't one, which would starve the token matcher. + core.warning( + 'scopes is not supported with bump_strategy: string-token and is ignored.', + ); + } else if (scopes.length) { const commitParser = new CommitParser(); const isInScope = (scope: string) => scopes.split(',').some((includedScope) => scope.match(includedScope)); @@ -218,91 +318,161 @@ export default async function main() { }); } - const isDefaultCommitAnalyzerPreset = - commitAnalyzerPreset.toLowerCase() === 'angular'; - - const analyzeCommitsContext = { - commits, - logger: { log: console.info.bind(console) }, - cwd: process.cwd(), - }; - - let bump = await analyzeCommits( - { - ...(isDefaultCommitAnalyzerPreset - ? {} - : { preset: commitAnalyzerPreset }), - releaseRules: mappedReleaseRules - ? // analyzeCommits doesn't appreciate rules with a section /shrug - mappedReleaseRules.map(({ section, ...rest }) => ({ ...rest })) - : undefined, - }, - analyzeCommitsContext, - ); + if (isStringTokenStrategy) { + const tokenResult: StringTokenResult = analyzeCommitsByStringToken( + commits, + stringTokens, + tokenMatchMode, + ); + const stableVersion = latestTag.name.replace(prefixRegex, ''); + + // A `none` token always skips, and it does so before any force override is + // considered - legacy has no force concept and the shim never sets one. A + // range with no token at all falls back to default_bump, unless that is + // itself a skip sentinel (`false` here; `none` in the legacy interface, + // accepted too so a stray legacy value can't reach semver.inc()). + if ( + tokenResult.kind === 'none' || + (tokenResult.kind === 'no-match' && + (defaultBump === 'false' || String(defaultBump) === 'none')) + ) { + core.info( + 'No commit specifies the version bump. Skipping the tag creation.', + ); + core.setOutput('release_type', 'none'); + // legacy never leaves new_tag empty - every skip path echoes the existing + // tag, so a consumer feeding it into an image tag always has a value. + // It echoes the *stable* tag even on a prerelease run, because legacy's + // `#none` arm fires before its prerelease block. + core.setOutput('new_version', stableVersion); + core.setOutput('new_tag', latestTag.name); + return; + } + + const part = ( + tokenResult.kind === 'bump' ? tokenResult.bump : defaultBump + ) as ReleaseType; + + if (isPrerelease) { + const target = inc(stableVersion, part); + if (!target) { + throw new Error('Could not increment version.'); + } + const previousPrereleaseName = latestPrereleaseTag?.name; + // Mirrors legacy's + // `[[ "$pre_tag" =~ $new ]] && [[ "$pre_tag" =~ $suffix ]]`: continue the + // existing prerelease series only when it is already working towards this + // same target version with this same identifier, otherwise start a fresh + // one at `.0` (not `.1`). + const continuesExistingPrerelease = + !!previousPrereleaseName && + previousPrereleaseName.includes(target) && + previousPrereleaseName.includes(identifier); + + newVersion = continuesExistingPrerelease + ? (inc( + previousPrereleaseName.replace(prefixRegex, ''), + 'prerelease', + identifier, + ) ?? '') + : `${target}-${identifier}.0`; + + core.setOutput('release_type', `pre${part}`); + } else { + newVersion = inc(stableVersion, part) ?? ''; + core.setOutput('release_type', part); + } - // Determine if we should continue with tag creation based on main vs prerelease branch - let shouldContinue = true; - if (isPrerelease) { - if (!bump && !previousWasPrerelease && defaultDraftBump === 'false') - shouldContinue = false; - if (!bump && defaultPreReleaseBump === 'false') { - shouldContinue = false; + if (!newVersion || !valid(newVersion)) { + throw new Error(`${newVersion} is not a valid semver.`); } } else { - if (!bump && defaultBump === 'false') { - shouldContinue = false; + const isDefaultCommitAnalyzerPreset = + commitAnalyzerPreset.toLowerCase() === 'angular'; + + const analyzeCommitsContext = { + commits, + logger: { log: console.info.bind(console) }, + cwd: process.cwd(), + }; + + let bump = await analyzeCommits( + { + ...(isDefaultCommitAnalyzerPreset + ? {} + : { preset: commitAnalyzerPreset }), + releaseRules: mappedReleaseRules + ? // analyzeCommits doesn't appreciate rules with a section /shrug + mappedReleaseRules.map(({ section, ...rest }) => ({ ...rest })) + : undefined, + }, + analyzeCommitsContext, + ); + + // Determine if we should continue with tag creation based on main vs prerelease branch + let shouldContinue = true; + if (isPrerelease) { + if (!bump && !previousWasPrerelease && defaultDraftBump === 'false') + shouldContinue = false; + if (!bump && defaultPreReleaseBump === 'false') { + shouldContinue = false; + } + } else { + if (!bump && defaultBump === 'false') { + shouldContinue = false; + } } - } - // Determine if we should override the bump to a given `force` version - if (isPrerelease && forcePreReleaseBump !== '') { - bump = forcePreReleaseBump; - } - if (!isPrerelease && forceBump !== '') { - bump = forceBump; - } + // Determine if we should override the bump to a given `force` version + if (isPrerelease && forcePreReleaseBump !== '') { + bump = forcePreReleaseBump; + } + if (!isPrerelease && forceBump !== '') { + bump = forceBump; + } - // Default bump is set to false and we did not find an automatic bump - if (!shouldContinue) { - core.debug( - 'No commit specifies the version bump. Skipping the tag creation.', - ); - return; - } + // Default bump is set to false and we did not find an automatic bump + if (!shouldContinue) { + core.debug( + 'No commit specifies the version bump. Skipping the tag creation.', + ); + return; + } - // If we don't have an automatic bump for the prerelease, just set our bump as the default - if (isPrerelease && !bump) { - if (!previousWasPrerelease) - // previous version is a prerelease -> draft a new version with the default bump and make it a prerelease - bump = defaultDraftBump; - else bump = defaultPreReleaseBump; - } + // If we don't have an automatic bump for the prerelease, just set our bump as the default + if (isPrerelease && !bump) { + if (!previousWasPrerelease) + // previous version is a prerelease -> draft a new version with the default bump and make it a prerelease + bump = defaultDraftBump; + else bump = defaultPreReleaseBump; + } - // `bump` can already carry a 'pre' prefix here (e.g. 'preminor' from a - // custom release rule or default_prerelease_bump), but a prerelease - // branch always gets exactly one 'pre' prefix added below - strip any - // that are already there first, so the result is 'preminor' rather - // than 'preprepatch'/'prepreminor'. - if (isPrerelease) { - bump = bump.replace(/^(pre)+/, ''); - } + // `bump` can already carry a 'pre' prefix here (e.g. 'preminor' from a + // custom release rule or default_prerelease_bump), but a prerelease + // branch always gets exactly one 'pre' prefix added below - strip any + // that are already there first, so the result is 'preminor' rather + // than 'preprepatch'/'prepreminor'. + if (isPrerelease) { + bump = bump.replace(/^(pre)+/, ''); + } - const releaseType: ReleaseType = isPrerelease - ? `pre${bump}` - : bump || defaultBump; - core.setOutput('release_type', releaseType); + const releaseType: ReleaseType = isPrerelease + ? `pre${bump}` + : bump || defaultBump; + core.setOutput('release_type', releaseType); - const incrementedVersion = inc(previousVersion, releaseType, identifier); + const incrementedVersion = inc(previousVersion, releaseType, identifier); - if (!incrementedVersion) { - throw new Error('Could not increment version.'); - } + if (!incrementedVersion) { + throw new Error('Could not increment version.'); + } - if (!valid(incrementedVersion)) { - throw new Error(`${incrementedVersion} is not a valid semver.`); - } + if (!valid(incrementedVersion)) { + throw new Error(`${incrementedVersion} is not a valid semver.`); + } - newVersion = incrementedVersion; + newVersion = incrementedVersion; + } } core.info(`New version is ${newVersion}.`); diff --git a/src/string-token.ts b/src/string-token.ts new file mode 100644 index 0000000..d29c16f --- /dev/null +++ b/src/string-token.ts @@ -0,0 +1,88 @@ +export const TOKEN_MATCH_MODES = ['word', 'substring'] as const; +export type TokenMatchMode = (typeof TOKEN_MATCH_MODES)[number]; + +export interface StringTokens { + major: string; + minor: string; + patch: string; + none: string; +} + +export type StringTokenBump = 'major' | 'minor' | 'patch'; + +/** + * The outcome of scanning a commit range for bump tokens. + * + * This is a discriminated union rather than `'major' | ... | 'none' | undefined` + * on purpose: `'none'` must never be usable as a semver release type. A bare + * `'none'` reaching `` `pre${bump}` `` would produce the release type + * `'prenone'`, which makes `semver.inc()` return null and the run fail with + * "Could not increment version." Keeping "an explicit none token matched" + * (`none`) distinct from "no token matched at all" (`no-match`) also matters + * because they take different paths: the former always skips, the latter falls + * back to `default_bump`. + */ +export type StringTokenResult = + | { kind: 'bump'; bump: StringTokenBump } + | { kind: 'none' } + | { kind: 'no-match' }; + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function matchesToken(log: string, token: string, mode: TokenMatchMode) { + // An empty token disables that bump level. Callers are expected to apply + // their own defaults before getting here. + if (!token) { + return false; + } + + if (mode === 'substring') { + // Bug-compatible with anothrNick/github-tag-action, which matches with a + // bash `case` glob (`*$token*`) over the whole log: case-sensitive, no word + // boundaries. `#patchset` therefore matches `#patch`, which is a documented + // sharp edge rather than an oversight. + return log.includes(token); + } + + // `word` mode requires the token to be delimited, so `#patches` does not + // match `#patch`. A plain `\b` is no help here because tokens typically start + // with a non-word character (`#`), so assert on the characters either side + // instead. Case-insensitive, so `#MAJOR` matches `#major`. + return new RegExp(`(? minor > patch > none on a first-match-wins basis. A range containing + * both `#major` and `#none` therefore bumps major - `#none` only wins when no + * other token appears anywhere. + */ +export function analyzeCommitsByStringToken( + commits: { message: string }[], + tokens: StringTokens, + mode: TokenMatchMode, +): StringTokenResult { + const log = commits.map((commit) => commit.message).join('\n'); + + if (matchesToken(log, tokens.major, mode)) { + return { kind: 'bump', bump: 'major' }; + } + if (matchesToken(log, tokens.minor, mode)) { + return { kind: 'bump', bump: 'minor' }; + } + if (matchesToken(log, tokens.patch, mode)) { + return { kind: 'bump', bump: 'patch' }; + } + if (matchesToken(log, tokens.none, mode)) { + return { kind: 'none' }; + } + return { kind: 'no-match' }; +} diff --git a/src/utils.ts b/src/utils.ts index 53ca72e..b7b0a6e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -77,15 +77,35 @@ interface FinalCommit { }; } +/** A commit as it appears in a `push` webhook payload. */ +interface PushPayloadCommit { + id?: string; + message: string; +} + +export type GetCommitsOptions = CommitRangeOptions & { + /** + * Skip the closed-PR payload fallback when the compare range is empty. + * + * The `string-token` bump strategy sets this: `entrypoint.sh` has no + * equivalent fallback, and using one would diverge from it. Legacy's + * `git log ..` on an already-tagged HEAD is simply empty, + * which falls through to `default_bump`, whereas the payload fallback would + * re-read the pushed commits and could find a token that legacy never saw. + */ + skipClosedPrFallback?: boolean; +}; + export async function getCommits( baseRef: string, headRef: string, - options: CommitRangeOptions = {}, + options: GetCommitsOptions = {}, ): Promise<{ message: string; hash: string | null }[]> { + const { skipClosedPrFallback, ...rangeOptions } = options; let commits: Array; - commits = await getCommitRange(baseRef, headRef, options); + commits = await getCommitRange(baseRef, headRef, rangeOptions); core.info('We found ' + commits.length + ' commits using classic compare!'); - if (commits.length < 1) { + if (commits.length < 1 && !skipClosedPrFallback) { core.info( 'We did not find enough commits, attempting to scan closed PR method.', ); @@ -115,11 +135,17 @@ function getClosedPRCommits() { core.info( 'We found ' + pr_commit_count + ' commits from the Closed PR method.', ); + // `push` payload commits are `{ id, message, author, ... }` - there is no + // nested `.commit`, so they have to be normalised into `FinalCommit` shape + // for the mapping below. Reading `commit.commit.message` directly here used + // to throw; the bug went unnoticed because the second call was a `.filter` + // (whose object literal is always truthy, so the original elements were + // passed through untouched) rather than the intended `.map`. commits = context.payload.commits - .filter((commit: FinalCommit) => !!commit.commit.message) - .filter((commit: FinalCommit) => ({ - message: commit.commit.message, - hash: commit.sha, + .filter((commit: PushPayloadCommit) => !!commit.message) + .map((commit: PushPayloadCommit) => ({ + sha: commit.id ?? null, + commit: { message: commit.message }, })); core.debug( 'After processing we are going to present ' + diff --git a/tests/action.test.ts b/tests/action.test.ts index 5377404..6913d80 100644 --- a/tests/action.test.ts +++ b/tests/action.test.ts @@ -12,6 +12,7 @@ import { setBranch, setCommitSha, setInput, + setInputs, setRepository, } from './helper.test'; @@ -27,10 +28,14 @@ jest.unstable_mockModule('../src/utils.js', () => ({ filterTagsByBranchAncestry: filterTagsByBranchAncestryMock, })); +const realGithub: any = await import('../src/github.js?real'); const listTagsMock = jest.fn(); const mockCreateTag = jest.fn().mockResolvedValue(undefined); jest.unstable_mockModule('../src/github.js', () => ({ + // Spread the real module so non-mocked exports (constants such as + // NO_PREVIOUS_TAG_SHA) resolve, matching how ../src/utils.js is mocked above. + ...realGithub, listTags: listTagsMock, createTag: mockCreateTag, })); @@ -1752,4 +1757,1008 @@ describe('github-tag-action', () => { ); }); }); + + describe('bump_strategy: string-token', () => { + /** Set up a string-token run the way the example-org shim does. */ + function setStringTokenInputs(overrides: Record = {}) { + setInputs({ + bump_strategy: 'string-token', + token_match_mode: 'substring', + major_string_token: '#major', + minor_string_token: '#minor', + patch_string_token: '#patch', + none_string_token: '#none', + default_bump: 'patch', + fetch_all_tags: 'true', + ...overrides, + }); + } + + function tag(name: string, sha = 'tagsha') { + return { name, commit: { sha } }; + } + + describe('configuration', () => { + it('throws on an unrecognised bump_strategy so soft_fail can catch it', async () => { + setInput('bump_strategy', 'conventional-tokens'); + + await expect(action()).rejects.toThrow( + 'conventional-tokens is not a valid bump_strategy.', + ); + expect(mockSetFailed).not.toHaveBeenCalled(); + }); + + it('warns when token inputs are set but the strategy is not', async () => { + getCommitsMock.mockImplementation(async () => [ + { message: 'fix: a thing', hash: null }, + ]); + listTagsMock.mockImplementation(async () => []); + setInput('major_string_token', '#major'); + + await action(); + + expect(mockWarning).toHaveBeenCalledWith( + 'String token inputs are ignored unless bump_strategy is set to string-token.', + ); + }); + + it('does not warn about token inputs on a default conventional-commits run', async () => { + getCommitsMock.mockImplementation(async () => [ + { message: 'fix: a thing', hash: null }, + ]); + listTagsMock.mockImplementation(async () => []); + + await action(); + + expect(mockWarning).not.toHaveBeenCalledWith( + 'String token inputs are ignored unless bump_strategy is set to string-token.', + ); + }); + + it('warns that scopes is unsupported and does not filter commits by it', async () => { + setStringTokenInputs({ tag_prefix: '', scopes: 'api' }); + // No conventional scope anywhere - the scope filter would drop this and + // starve the matcher. + getCommitsMock.mockImplementation(async () => [ + { message: 'rework the thing #minor', hash: null }, + ]); + listTagsMock.mockImplementation(async () => [tag('1.0.0')]); + + await action(); + + expect(mockWarning).toHaveBeenCalledWith( + 'scopes is not supported with bump_strategy: string-token and is ignored.', + ); + expect(mockCreateTag).toHaveBeenCalledWith( + '1.1.0', + expect.any(Boolean), + false, + expect.any(String), + true, + '', + ); + }); + + it('forwards fetch_all_tags so prefixed lineages past the first page are found', async () => { + setStringTokenInputs({ tag_prefix: 'web-app_' }); + getCommitsMock.mockImplementation(async () => [ + { message: 'a change', hash: null }, + ]); + listTagsMock.mockImplementation(async () => [tag('web-app_1.0.0')]); + + await action(); + + expect(listTagsMock).toHaveBeenCalledWith(true); + }); + }); + + // Ported from the vendored fork's test/test_prefix.bats, which is the suite + // that guarded the tag_prefix handling. Legacy defaults DEFAULT_BUMP to + // minor and none of these commits carry a token, so every case exercises the + // default-bump fall-through. + describe('ported test_prefix.bats scenarios', () => { + beforeEach(() => { + setStringTokenInputs({ default_bump: 'minor' }); + }); + + it('creates a new tag with no prefix', async () => { + setInput('tag_prefix', ''); + listTagsMock.mockImplementation(async () => []); + + await action(); + + expect(mockCreateTag).toHaveBeenCalledWith( + '0.1.0', + expect.any(Boolean), + false, + expect.any(String), + true, + '', + ); + }); + + it('bumps a tag with no prefix', async () => { + setInput('tag_prefix', ''); + listTagsMock.mockImplementation(async () => [tag('1.0.0')]); + getCommitsMock.mockImplementation(async () => [ + { message: 'bump', hash: null }, + ]); + + await action(); + + expect(mockCreateTag).toHaveBeenCalledWith( + '1.1.0', + expect.any(Boolean), + false, + expect.any(String), + true, + '', + ); + }); + + it('creates a new tag with a v prefix', async () => { + setInput('tag_prefix', 'v'); + listTagsMock.mockImplementation(async () => []); + + await action(); + + expect(mockCreateTag).toHaveBeenCalledWith( + 'v0.1.0', + expect.any(Boolean), + false, + expect.any(String), + true, + '', + ); + }); + + it('bumps a tag with a v prefix', async () => { + setInput('tag_prefix', 'v'); + listTagsMock.mockImplementation(async () => [tag('v1.0.0')]); + getCommitsMock.mockImplementation(async () => [ + { message: 'bump', hash: null }, + ]); + + await action(); + + expect(mockCreateTag).toHaveBeenCalledWith( + 'v1.1.0', + expect.any(Boolean), + false, + expect.any(String), + true, + '', + ); + }); + + it('creates a new tag with a slash prefix', async () => { + setInput('tag_prefix', 'infra/'); + listTagsMock.mockImplementation(async () => []); + + await action(); + + expect(mockCreateTag).toHaveBeenCalledWith( + 'infra/0.1.0', + expect.any(Boolean), + false, + expect.any(String), + true, + '', + ); + }); + + it('bumps a tag with a slash prefix', async () => { + setInput('tag_prefix', 'infra/'); + listTagsMock.mockImplementation(async () => [tag('infra/1.0.0')]); + getCommitsMock.mockImplementation(async () => [ + { message: 'bump', hash: null }, + ]); + + await action(); + + expect(mockCreateTag).toHaveBeenCalledWith( + 'infra/1.1.0', + expect.any(Boolean), + false, + expect.any(String), + true, + '', + ); + }); + + it('bumps only the tag matching the prefix, ignoring other lineages', async () => { + // The isolation case: an unprefixed 2.x lineage must not leak into the + // infra/ lineage's version history. + setInput('tag_prefix', 'infra/'); + listTagsMock.mockImplementation(async () => [ + tag('2.0.0'), + tag('infra/1.7.0'), + tag('2.1.0'), + ]); + getCommitsMock.mockImplementation(async () => [ + { message: 'bump', hash: null }, + ]); + + await action(); + + expect(mockCreateTag).toHaveBeenCalledWith( + 'infra/1.8.0', + expect.any(Boolean), + false, + expect.any(String), + true, + '', + ); + expect(mockCreateTag).not.toHaveBeenCalledWith( + '2.2.0', + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + ); + }); + }); + + describe('token-driven bumps on a release branch', () => { + beforeEach(() => { + setStringTokenInputs({ tag_prefix: 'web-app_' }); + listTagsMock.mockImplementation(async () => [tag('web-app_1.4.0')]); + }); + + it.each([ + ['#major', 'web-app_2.0.0', 'major'], + ['#minor', 'web-app_1.5.0', 'minor'], + ['#patch', 'web-app_1.4.1', 'patch'], + ])('bumps on %s', async (token, expectedTag, expectedType) => { + getCommitsMock.mockImplementation(async () => [ + { message: `some work ${token}`, hash: null }, + ]); + + await action(); + + expect(mockCreateTag).toHaveBeenCalledWith( + expectedTag, + expect.any(Boolean), + false, + expect.any(String), + true, + '', + ); + expect(mockSetOutput).toHaveBeenCalledWith( + 'release_type', + expectedType, + ); + }); + + it('falls back to default_bump when no token matches', async () => { + getCommitsMock.mockImplementation(async () => [ + { message: 'no markers here', hash: null }, + ]); + + await action(); + + expect(mockCreateTag).toHaveBeenCalledWith( + 'web-app_1.4.1', + expect.any(Boolean), + false, + expect.any(String), + true, + '', + ); + }); + + it('bumps major when both #major and #none appear in the range', async () => { + getCommitsMock.mockImplementation(async () => [ + { message: 'chore: tidy #none', hash: null }, + { message: 'feat: thing #major', hash: null }, + ]); + + await action(); + + expect(mockCreateTag).toHaveBeenCalledWith( + 'web-app_2.0.0', + expect.any(Boolean), + false, + expect.any(String), + true, + '', + ); + }); + + it('re-tags an already-tagged HEAD (legacy FORCE_WITHOUT_CHANGES=true)', async () => { + // Both consumers set FORCE_WITHOUT_CHANGES: "true", and this action has + // no "no new commits" guard, so an empty range bumps by default_bump. + getCommitsMock.mockImplementation(async () => []); + + await action(); + + expect(mockCreateTag).toHaveBeenCalledWith( + 'web-app_1.4.1', + expect.any(Boolean), + false, + expect.any(String), + true, + '', + ); + }); + }); + + describe('skip paths always echo a tag', () => { + beforeEach(() => { + setStringTokenInputs({ tag_prefix: 'web-app_' }); + }); + + it('skips on the none token and echoes the existing tag', async () => { + listTagsMock.mockImplementation(async () => [tag('web-app_1.4.0')]); + getCommitsMock.mockImplementation(async () => [ + { message: 'docs only #none', hash: null }, + ]); + + await action(); + + expect(mockCreateTag).not.toHaveBeenCalled(); + expect(mockSetOutput).toHaveBeenCalledWith('release_type', 'none'); + expect(mockSetOutput).toHaveBeenCalledWith('new_tag', 'web-app_1.4.0'); + expect(mockSetOutput).toHaveBeenCalledWith('new_version', '1.4.0'); + expect(mockSetOutput).toHaveBeenCalledWith('tag_created', 'false'); + }); + + it('echoes the stable tag on a none skip even during a prerelease run', async () => { + // entrypoint.sh's #none arm fires before its prerelease block, so it + // echoes the release tag - never the prerelease one, which would + // otherwise be published as a container tag. + setInputs({ pre_release: 'true', append_to_pre_release_tag: 'RC' }); + listTagsMock.mockImplementation(async () => [ + tag('web-app_1.4.0'), + tag('web-app_1.4.1-RC.3'), + ]); + getCommitsMock.mockImplementation(async () => [ + { message: 'docs only #none', hash: null }, + ]); + + await action(); + + expect(mockCreateTag).not.toHaveBeenCalled(); + expect(mockSetOutput).toHaveBeenCalledWith('new_tag', 'web-app_1.4.0'); + }); + + it.each(['false', 'none'])( + 'skips and echoes when no token matches and default_bump is %s', + async (defaultBump) => { + setInput('default_bump', defaultBump); + listTagsMock.mockImplementation(async () => [tag('web-app_1.4.0')]); + getCommitsMock.mockImplementation(async () => [ + { message: 'no markers here', hash: null }, + ]); + + await action(); + + expect(mockCreateTag).not.toHaveBeenCalled(); + expect(mockSetOutput).toHaveBeenCalledWith( + 'new_tag', + 'web-app_1.4.0', + ); + expect(mockSetFailed).not.toHaveBeenCalled(); + }, + ); + }); + + describe('first tag of a new prefixed lineage', () => { + beforeEach(() => { + setStringTokenInputs({ tag_prefix: 'web-app_' }); + listTagsMock.mockImplementation(async () => [tag('other_9.9.9')]); + }); + + it('does not scan history and uses default_bump', async () => { + // Legacy's range is `git log ""..`, which git reads as HEAD..HEAD + // and is empty. Scanning all commits reachable from HEAD instead would + // let an unrelated historical token decide a new service's first tag. + await action(); + + expect(getCommitsMock).not.toHaveBeenCalled(); + expect(mockCreateTag).toHaveBeenCalledWith( + 'web-app_0.0.1', + expect.any(Boolean), + false, + expect.any(String), + true, + '', + ); + }); + + it('respects initial_version', async () => { + setInput('initial_version', '1.0.0'); + + await action(); + + expect(mockCreateTag).toHaveBeenCalledWith( + 'web-app_1.0.1', + expect.any(Boolean), + false, + expect.any(String), + true, + '', + ); + }); + + it('starts a prerelease series at .0', async () => { + setInputs({ pre_release: 'true', append_to_pre_release_tag: 'RC' }); + + await action(); + + expect(mockCreateTag).toHaveBeenCalledWith( + 'web-app_0.0.1-RC.0', + expect.any(Boolean), + false, + expect.any(String), + true, + '', + ); + }); + }); + + describe('prereleases', () => { + beforeEach(() => { + setStringTokenInputs({ tag_prefix: 'web-app_' }); + setInputs({ pre_release: 'true', append_to_pre_release_tag: 'RC' }); + setBranch('test'); + getCommitsMock.mockImplementation(async () => [ + { message: 'fix a thing #patch', hash: null }, + ]); + }); + + it('continues an existing prerelease series rather than re-bumping the base version', async () => { + // The divergence this whole seam exists for. Incrementing from + // max(stable, prerelease) would yield web-app_1.4.2-RC.0; legacy computes + // the target off the stable tag (1.4.1), sees the prerelease series is + // already working towards it, and bumps the counter instead. + listTagsMock.mockImplementation(async () => [ + tag('web-app_1.4.0'), + tag('web-app_1.4.1-RC.3'), + ]); + + await action(); + + expect(mockCreateTag).toHaveBeenCalledWith( + 'web-app_1.4.1-RC.4', + expect.any(Boolean), + false, + expect.any(String), + true, + '', + ); + expect(mockSetOutput).toHaveBeenCalledWith('release_type', 'prepatch'); + }); + + it('restarts the series at .0 when the token targets a different version', async () => { + listTagsMock.mockImplementation(async () => [ + tag('web-app_1.4.0'), + tag('web-app_1.4.1-RC.3'), + ]); + getCommitsMock.mockImplementation(async () => [ + { message: 'feat: big thing #minor', hash: null }, + ]); + + await action(); + + expect(mockCreateTag).toHaveBeenCalledWith( + 'web-app_1.5.0-RC.0', + expect.any(Boolean), + false, + expect.any(String), + true, + '', + ); + }); + + it('starts a series at .0 when no prerelease tag exists yet', async () => { + listTagsMock.mockImplementation(async () => [tag('web-app_1.4.0')]); + + await action(); + + expect(mockCreateTag).toHaveBeenCalledWith( + 'web-app_1.4.1-RC.0', + expect.any(Boolean), + false, + expect.any(String), + true, + '', + ); + }); + + it('ignores a prerelease series with a different identifier', async () => { + listTagsMock.mockImplementation(async () => [ + tag('web-app_1.4.0'), + tag('web-app_1.4.1-alpha.7'), + ]); + + await action(); + + expect(mockCreateTag).toHaveBeenCalledWith( + 'web-app_1.4.1-RC.0', + expect.any(Boolean), + false, + expect.any(String), + true, + '', + ); + }); + + it('continues the series on a non-v prefix (the crash the vendored fork patched)', async () => { + // Upstream anothrNick handed the prefixed tag straight to the semver CLI, + // which rejected it and killed the run under `set -e`. + setInput('tag_prefix', 'infra/'); + listTagsMock.mockImplementation(async () => [ + tag('infra/1.2.0'), + tag('infra/1.2.1-RC.2'), + ]); + + await action(); + + expect(mockCreateTag).toHaveBeenCalledWith( + 'infra/1.2.1-RC.3', + expect.any(Boolean), + false, + expect.any(String), + true, + '', + ); + expect(mockSetFailed).not.toHaveBeenCalled(); + }); + }); + + describe('pre_release override', () => { + beforeEach(() => { + setStringTokenInputs({ tag_prefix: 'web-app_' }); + listTagsMock.mockImplementation(async () => [tag('web-app_1.4.0')]); + getCommitsMock.mockImplementation(async () => [ + { message: 'fix a thing #patch', hash: null }, + ]); + }); + + it('cuts a release tag on a non-release branch when pre_release is false', async () => { + setBranch('test'); + setInput('pre_release', 'false'); + + await action(); + + expect(mockCreateTag).toHaveBeenCalledWith( + 'web-app_1.4.1', + expect.any(Boolean), + false, + expect.any(String), + true, + '', + ); + }); + + it('cuts a prerelease on a release branch when pre_release is true', async () => { + setBranch('master'); + setInputs({ pre_release: 'true', append_to_pre_release_tag: 'RC' }); + + await action(); + + expect(mockCreateTag).toHaveBeenCalledWith( + 'web-app_1.4.1-RC.0', + expect.any(Boolean), + false, + expect.any(String), + true, + '', + ); + }); + + it('infers from the branch when pre_release is unset', async () => { + setBranch('master'); + + await action(); + + expect(mockCreateTag).toHaveBeenCalledWith( + 'web-app_1.4.1', + expect.any(Boolean), + false, + expect.any(String), + true, + '', + ); + }); + + it('still tags on a branch in neither branch list', async () => { + // pre_release_branches has no default, so ''.split(',') yields [''] and + // match('') is truthy - which is what keeps the "neither a release nor a + // pre-release branch" guard from suppressing tags on arbitrary branches. + setBranch('some/feature-branch'); + setInput('pre_release', 'false'); + + await action(); + + expect(mockCreateTag).toHaveBeenCalledWith( + 'web-app_1.4.1', + expect.any(Boolean), + false, + expect.any(String), + true, + '', + ); + }); + }); + + describe('commit range', () => { + it('bypasses the closed-PR payload fallback', async () => { + // entrypoint.sh has no equivalent fallback; using one would let the push + // payload supply commits legacy never saw. + setStringTokenInputs({ tag_prefix: 'web-app_' }); + listTagsMock.mockImplementation(async () => [tag('web-app_1.4.0')]); + getCommitsMock.mockImplementation(async () => []); + + await action(); + + expect(getCommitsMock).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.objectContaining({ skipClosedPrFallback: true }), + ); + }); + + it('keeps the fallback enabled for conventional-commits callers', async () => { + listTagsMock.mockImplementation(async () => [tag('v1.4.0')]); + getCommitsMock.mockImplementation(async () => [ + { message: 'fix: thing', hash: null }, + ]); + + await action(); + + expect(getCommitsMock).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.objectContaining({ skipClosedPrFallback: false }), + ); + }); + + it('computes the range from the stable tag, not the newer prerelease tag', async () => { + setStringTokenInputs({ tag_prefix: 'web-app_' }); + setInputs({ pre_release: 'true', append_to_pre_release_tag: 'RC' }); + listTagsMock.mockImplementation(async () => [ + tag('web-app_1.4.0', 'stable-sha'), + tag('web-app_1.4.1-RC.3', 'prerelease-sha'), + ]); + getCommitsMock.mockImplementation(async () => [ + { message: 'fix a thing #patch', hash: null }, + ]); + + await action(); + + expect(getCommitsMock).toHaveBeenCalledWith( + 'stable-sha', + expect.any(String), + expect.anything(), + ); + expect(mockSetOutput).toHaveBeenCalledWith( + 'previous_tag', + 'web-app_1.4.0', + ); + }); + }); + + /* + * Real tag histories, taken from the two repositories that consume this via + * the example-org tag-bump shim. Each case is an actual transition that the + * bash action performed, so the expected value is not a prediction - it is + * the tag that exists in the repository today. + * + * Lineages are trimmed to the tags that decide the outcome (the newest stable + * tag and the prerelease series above it); the omitted older tags cannot + * affect the result. Commit messages are representative: not one of the 1420 + * real messages sampled across these repos contains a bump token, so every + * one of these transitions runs through default_bump. + */ + describe('real consumer tag histories', () => { + const REAL_COMMITS = [ + { message: 'PROJ-1234 wire up the exporter', hash: null }, + { + message: 'Merge pull request #412 from example-org/contributor/fix', + hash: null, + }, + { message: 'fix flaky integration test', hash: null }, + ]; + + interface RealCase { + name: string; + prefix: string; + lineage: string[]; + prerelease: false | string; + expected: string; + why: string; + } + + const CASES: RealCase[] = [ + { + name: 'continues an alpha series', + prefix: 'ledger-sync_', + lineage: [ + 'ledger-sync_0.0.11-alpha.1', + 'ledger-sync_0.0.11-alpha.0', + 'ledger-sync_0.0.11-RC.0', + 'ledger-sync_0.0.10', + ], + prerelease: 'alpha', + expected: 'ledger-sync_0.0.11-alpha.2', + why: 'target 0.0.11 from stable 0.0.10; the alpha series is already on it', + }, + { + name: 'continues an alpha series in another lineage', + prefix: 'webhook-server_', + lineage: [ + 'webhook-server_0.0.5-alpha.4', + 'webhook-server_0.0.5-RC.0', + 'webhook-server_0.0.4', + ], + prerelease: 'alpha', + expected: 'webhook-server_0.0.5-alpha.5', + why: 'target 0.0.5 from stable 0.0.4; continue the alpha counter', + }, + { + name: 'promotes a prerelease series to a stable release', + prefix: 'data-exporter_', + lineage: [ + 'data-exporter_0.0.9-alpha.3', + 'data-exporter_0.0.9-RC.0', + 'data-exporter_0.0.8', + ], + prerelease: false, + expected: 'data-exporter_0.0.9', + why: 'a release run ignores the prerelease series and bumps the stable tag', + }, + { + name: 'starts an RC series from a stable tag', + prefix: 'cache-svc_', + lineage: [ + 'cache-svc_0.0.6', + 'cache-svc_0.0.6-alpha.1', + 'cache-svc_0.0.6-RC.0', + ], + prerelease: 'RC', + expected: 'cache-svc_0.0.7-RC.0', + why: 'the newest RC (0.0.6-RC.0) is not on target 0.0.7, so restart at .0', + }, + { + name: 'switches identifier mid-version without inheriting the other series', + prefix: 'cache-svc_', + lineage: [ + 'cache-svc_0.0.7-RC.0', + 'cache-svc_0.0.6', + 'cache-svc_0.0.6-alpha.1', + ], + prerelease: 'alpha', + expected: 'cache-svc_0.0.7-alpha.0', + why: 'an alpha run must not continue the RC series; identifiers are separate', + }, + { + name: 'starts an alpha series for the next patch after a release', + prefix: 'record-sync_', + lineage: [ + 'record-sync_0.0.17', + 'record-sync_0.0.17-alpha.3', + 'record-sync_0.0.17-RC.0', + 'record-sync_0.0.16', + ], + prerelease: 'alpha', + expected: 'record-sync_0.0.18-alpha.0', + why: 'target moves to 0.0.18, so the 0.0.17 alpha series does not apply', + }, + { + name: 'continues an alpha series in a lineage that has no stable tag yet', + prefix: 'web-app_', + lineage: [ + 'web-app_0.0.1-alpha.2', + 'web-app_0.0.1-alpha.1', + 'web-app_0.0.1-alpha.0', + ], + prerelease: 'alpha', + expected: 'web-app_0.0.1-alpha.3', + why: 'no stable tag, so initial_version drives target 0.0.1 and the series continues', + }, + ]; + + it.each(CASES)('$name ($prefix): $why', async (testCase) => { + setInputs({ + bump_strategy: 'string-token', + token_match_mode: 'substring', + default_bump: 'patch', + release_branches: '^main$', + fetch_all_tags: 'true', + tag_prefix: testCase.prefix, + pre_release: testCase.prerelease ? 'true' : 'false', + append_to_pre_release_tag: testCase.prerelease || '', + }); + setBranch(testCase.prerelease ? 'test' : 'main'); + listTagsMock.mockImplementation(async () => + testCase.lineage.map((name) => ({ + name, + commit: { sha: `sha-${name}` }, + })), + ); + getCommitsMock.mockImplementation(async () => REAL_COMMITS); + + await action(); + + expect(mockCreateTag).toHaveBeenCalledWith( + testCase.expected, + expect.any(Boolean), + false, + expect.any(String), + true, + '', + ); + expect(mockSetFailed).not.toHaveBeenCalled(); + }); + + it('isolates all eleven production lineages from each other', async () => { + // Every service prefix in use across both repos, with their real latest + // tags in one list - the shape getValidTags actually sees, since tags are + // repository-global. + const productionTags = [ + 'job-runner_0.0.4', + 'record-sync_0.0.17', + 'data-exporter_0.0.9', + 'webhook-server_0.0.5', + 'cache-svc_0.0.7', + 'public-svc_0.0.1', + 'event-processor_0.0.1', + 'metrics-exporter_0.0.7', + 'ledger-sync_0.0.11', + 'web-app_0.0.1', + 'gateway_0.0.1', + // Bare tags that belong to no service lineage. + '0.0.2-alpha.5', + ]; + const expectations: [string, string][] = [ + ['job-runner_', 'job-runner_0.0.5'], + ['record-sync_', 'record-sync_0.0.18'], + ['data-exporter_', 'data-exporter_0.0.10'], + ['webhook-server_', 'webhook-server_0.0.6'], + ['cache-svc_', 'cache-svc_0.0.8'], + ['public-svc_', 'public-svc_0.0.2'], + ['event-processor_', 'event-processor_0.0.2'], + ['metrics-exporter_', 'metrics-exporter_0.0.8'], + ['ledger-sync_', 'ledger-sync_0.0.12'], + ['web-app_', 'web-app_0.0.2'], + ['gateway_', 'gateway_0.0.2'], + ]; + + for (const [prefix, expected] of expectations) { + jest.clearAllMocks(); + getValidTagsMock.mockImplementation(realUtils.getValidTags); + filterTagsByBranchAncestryMock.mockImplementation( + async (tags: unknown) => tags, + ); + mockCreateTag.mockResolvedValue(undefined); + clearInputs(); + loadDefaultInputs(); + setInputs({ + bump_strategy: 'string-token', + token_match_mode: 'substring', + default_bump: 'patch', + release_branches: '^main$', + fetch_all_tags: 'true', + tag_prefix: prefix, + pre_release: 'false', + }); + setBranch('main'); + listTagsMock.mockImplementation(async () => + productionTags.map((name) => ({ + name, + commit: { sha: `sha-${name}` }, + })), + ); + getCommitsMock.mockImplementation(async () => REAL_COMMITS); + + await action(); + + expect(mockCreateTag).toHaveBeenCalledWith( + expected, + expect.any(Boolean), + false, + expect.any(String), + true, + '', + ); + } + }); + + it('finds the latest tag of a lineage in a list of production size', async () => { + // container-services really has 270 tags across 9 lineages, which is why + // the shim pins fetch_all_tags: true - three lineages had no tag at all in + // the first 100-tag API page. Given the whole list, the newest tag of the + // target lineage must still win regardless of position. + const noise: string[] = []; + for (let v = 1; v <= 17; v++) { + for (const svc of [ + 'record-sync_', + 'data-exporter_', + 'ledger-sync_', + ]) { + noise.push(`${svc}0.0.${v}`); + noise.push(`${svc}0.0.${v}-alpha.0`); + noise.push(`${svc}0.0.${v}-RC.0`); + } + } + noise.push('ledger-sync_0.0.18'); + expect(noise.length).toBeGreaterThan(100); + + setInputs({ + bump_strategy: 'string-token', + token_match_mode: 'substring', + default_bump: 'patch', + release_branches: '^main$', + fetch_all_tags: 'true', + tag_prefix: 'ledger-sync_', + pre_release: 'false', + }); + setBranch('main'); + listTagsMock.mockImplementation(async () => + noise.map((name) => ({ name, commit: { sha: `sha-${name}` } })), + ); + getCommitsMock.mockImplementation(async () => REAL_COMMITS); + + await action(); + + expect(mockCreateTag).toHaveBeenCalledWith( + 'ledger-sync_0.0.19', + expect.any(Boolean), + false, + expect.any(String), + true, + '', + ); + }); + + it('does not detect a bump token in representative real commit messages', async () => { + // Not one of the 1420 real commit messages sampled from these repos + // contains #major/#minor/#patch/#none, so these pipelines depend entirely + // on default_bump. This pins that: a stray token match here would silently + // change every service's versioning. + setInputs({ + bump_strategy: 'string-token', + token_match_mode: 'substring', + default_bump: 'patch', + release_branches: '^main$', + tag_prefix: 'ledger-sync_', + pre_release: 'false', + }); + setBranch('main'); + listTagsMock.mockImplementation(async () => [ + { name: 'ledger-sync_0.0.11', commit: { sha: 'sha' } }, + ]); + getCommitsMock.mockImplementation(async () => [ + ...REAL_COMMITS, + { + message: 'Merge pull request #1234 from example-org/dependabot/pip', + hash: null, + }, + { + message: 'PROJ-1235 initialize ledger-sync project', + hash: null, + }, + { + message: 'chore(deps): bump urllib3 from 2.2.1 to 2.2.2', + hash: null, + }, + ]); + + await action(); + + expect(mockSetOutput).toHaveBeenCalledWith('release_type', 'patch'); + expect(mockCreateTag).toHaveBeenCalledWith( + 'ledger-sync_0.0.12', + expect.any(Boolean), + false, + expect.any(String), + true, + '', + ); + }); + }); + }); }); diff --git a/tests/string-token.test.ts b/tests/string-token.test.ts new file mode 100644 index 0000000..c5e5c26 --- /dev/null +++ b/tests/string-token.test.ts @@ -0,0 +1,246 @@ +import { + analyzeCommitsByStringToken, + StringTokens, +} from '../src/string-token.js'; + +const DEFAULT_TOKENS: StringTokens = { + major: '#major', + minor: '#minor', + patch: '#patch', + none: '#none', +}; + +function commits(...messages: string[]) { + return messages.map((message) => ({ message })); +} + +describe('analyzeCommitsByStringToken', () => { + describe('token detection', () => { + it.each([ + ['#major', 'major'], + ['#minor', 'minor'], + ['#patch', 'patch'], + ] as const)('detects %s as a %s bump', (token, expected) => { + const result = analyzeCommitsByStringToken( + commits(`some change ${token} here`), + DEFAULT_TOKENS, + 'word', + ); + + expect(result).toEqual({ kind: 'bump', bump: expected }); + }); + + it('detects the none token as an explicit skip', () => { + const result = analyzeCommitsByStringToken( + commits('docs only #none'), + DEFAULT_TOKENS, + 'word', + ); + + expect(result).toEqual({ kind: 'none' }); + }); + + it('reports no-match when no token is present', () => { + const result = analyzeCommitsByStringToken( + commits('just a normal commit', 'and another'), + DEFAULT_TOKENS, + 'word', + ); + + expect(result).toEqual({ kind: 'no-match' }); + }); + + it('matches a token in any commit in the range, not just the first', () => { + const result = analyzeCommitsByStringToken( + commits('first', 'second #minor', 'third'), + DEFAULT_TOKENS, + 'word', + ); + + expect(result).toEqual({ kind: 'bump', bump: 'minor' }); + }); + + it('matches a token in a commit body, not only the subject', () => { + const result = analyzeCommitsByStringToken( + commits('subject line\n\nbody explains why #major\n'), + DEFAULT_TOKENS, + 'word', + ); + + expect(result).toEqual({ kind: 'bump', bump: 'major' }); + }); + + it('supports custom tokens', () => { + const result = analyzeCommitsByStringToken( + commits('ship it [feature]'), + { ...DEFAULT_TOKENS, minor: '[feature]' }, + 'word', + ); + + expect(result).toEqual({ kind: 'bump', bump: 'minor' }); + }); + + it('treats a token containing regex metacharacters literally', () => { + // If the token were interpolated into a RegExp unescaped, `[major]` would + // be a character class matching any of m/a/j/o/r. + const tokens = { ...DEFAULT_TOKENS, major: '[major]' }; + + expect( + analyzeCommitsByStringToken(commits('bump [major]'), tokens, 'word'), + ).toEqual({ kind: 'bump', bump: 'major' }); + expect( + analyzeCommitsByStringToken(commits('bump j'), tokens, 'word'), + ).toEqual({ kind: 'no-match' }); + }); + + it('disables a bump level when its token is empty', () => { + const result = analyzeCommitsByStringToken( + commits('this mentions #major'), + { ...DEFAULT_TOKENS, major: '' }, + 'word', + ); + + expect(result).toEqual({ kind: 'no-match' }); + }); + }); + + describe('precedence', () => { + it('prefers major over every other token', () => { + const result = analyzeCommitsByStringToken( + commits('#none', '#patch', '#minor', '#major'), + DEFAULT_TOKENS, + 'word', + ); + + expect(result).toEqual({ kind: 'bump', bump: 'major' }); + }); + + it('prefers minor over patch and none', () => { + const result = analyzeCommitsByStringToken( + commits('#none', '#patch', '#minor'), + DEFAULT_TOKENS, + 'word', + ); + + expect(result).toEqual({ kind: 'bump', bump: 'minor' }); + }); + + it('prefers patch over none', () => { + const result = analyzeCommitsByStringToken( + commits('#none', '#patch'), + DEFAULT_TOKENS, + 'word', + ); + + expect(result).toEqual({ kind: 'bump', bump: 'patch' }); + }); + + it('lets none win only when no other token appears anywhere in the range', () => { + // Bug-compatible with entrypoint.sh, whose `case` arms are evaluated in + // major/minor/patch/none order: a range that says both "#major" and + // "#none" bumps major. + const result = analyzeCommitsByStringToken( + commits('chore: tidy #none', 'feat: thing #major'), + DEFAULT_TOKENS, + 'word', + ); + + expect(result).toEqual({ kind: 'bump', bump: 'major' }); + }); + }); + + describe('token_match_mode: substring (legacy)', () => { + it('matches a token that is a prefix of a longer word', () => { + // INTENTIONAL bug-compatibility with entrypoint.sh's bash `case` glob + // (`*$token*`). Documented as a sharp edge, not an oversight. + const result = analyzeCommitsByStringToken( + commits('fix #patches in the docs'), + DEFAULT_TOKENS, + 'substring', + ); + + expect(result).toEqual({ kind: 'bump', bump: 'patch' }); + }); + + it('is case-sensitive', () => { + const result = analyzeCommitsByStringToken( + commits('BREAKING #MAJOR change'), + DEFAULT_TOKENS, + 'substring', + ); + + expect(result).toEqual({ kind: 'no-match' }); + }); + + it('matches a token glued to surrounding text', () => { + const result = analyzeCommitsByStringToken( + commits('refactor(core):#minor'), + DEFAULT_TOKENS, + 'substring', + ); + + expect(result).toEqual({ kind: 'bump', bump: 'minor' }); + }); + }); + + describe('token_match_mode: word (default)', () => { + it('does not match a token that is a prefix of a longer word', () => { + const result = analyzeCommitsByStringToken( + commits('fix #patches in the docs'), + DEFAULT_TOKENS, + 'word', + ); + + expect(result).toEqual({ kind: 'no-match' }); + }); + + it('is case-insensitive', () => { + const result = analyzeCommitsByStringToken( + commits('BREAKING #MAJOR change'), + DEFAULT_TOKENS, + 'word', + ); + + expect(result).toEqual({ kind: 'bump', bump: 'major' }); + }); + + it('matches a token at the very start and end of the log', () => { + expect( + analyzeCommitsByStringToken(commits('#minor'), DEFAULT_TOKENS, 'word'), + ).toEqual({ kind: 'bump', bump: 'minor' }); + expect( + analyzeCommitsByStringToken( + commits('do a thing #patch'), + DEFAULT_TOKENS, + 'word', + ), + ).toEqual({ kind: 'bump', bump: 'patch' }); + }); + + it('matches a token followed by punctuation', () => { + const result = analyzeCommitsByStringToken( + commits('bump it (#minor).'), + DEFAULT_TOKENS, + 'word', + ); + + expect(result).toEqual({ kind: 'bump', bump: 'minor' }); + }); + + it('does not match a token preceded by a word character', () => { + const result = analyzeCommitsByStringToken( + commits('see also ref#minor'), + DEFAULT_TOKENS, + 'word', + ); + + expect(result).toEqual({ kind: 'no-match' }); + }); + }); + + it('reports no-match for an empty commit range', () => { + expect(analyzeCommitsByStringToken([], DEFAULT_TOKENS, 'word')).toEqual({ + kind: 'no-match', + }); + }); +}); diff --git a/tests/utils.test.ts b/tests/utils.test.ts index e5ccdf8..d220a50 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -296,10 +296,14 @@ describe('utils', () => { expect(commits).toEqual([]); }); + // These fixtures use the real `push` webhook payload shape - `{ id, message }` + // with no nested `.commit`. They previously used an API-shaped + // `{ sha, commit: { message } }`, which masked a bug: the code read + // `commit.commit.message` and would throw on a real payload. it('falls back to the closed-PR commit list when the compare range is empty and the payload has one', async () => { getCommitRangeMock.mockResolvedValue([]); mockContext.payload = { - commits: [{ sha: 'closed-pr-sha', commit: { message: 'fix: y' } }], + commits: [{ id: 'closed-pr-sha', message: 'fix: y' }], }; const commits = await utils.getCommits('base', 'head'); @@ -311,13 +315,40 @@ describe('utils', () => { getCommitRangeMock.mockResolvedValue([]); mockContext.payload = { pull_request: {}, - commits: [{ sha: 'closed-pr-sha', commit: { message: 'fix: y' } }], + commits: [{ id: 'closed-pr-sha', message: 'fix: y' }], }; const commits = await utils.getCommits('base', 'head'); expect(commits).toEqual([]); }); + + it('skips payload commits with no message', async () => { + getCommitRangeMock.mockResolvedValue([]); + mockContext.payload = { + commits: [ + { id: 'a', message: '' }, + { id: 'b', message: 'fix: kept' }, + ], + }; + + const commits = await utils.getCommits('base', 'head'); + + expect(commits).toEqual([{ message: 'fix: kept', hash: 'b' }]); + }); + + it('does not use the closed-PR commit list when skipClosedPrFallback is set', async () => { + getCommitRangeMock.mockResolvedValue([]); + mockContext.payload = { + commits: [{ id: 'closed-pr-sha', message: 'fix: y' }], + }; + + const commits = await utils.getCommits('base', 'head', { + skipClosedPrFallback: true, + }); + + expect(commits).toEqual([]); + }); }); describe('getLatestTag', () => { From 6fe3bff7cdbebeb1c3a1ef9d9ed4ea8844b5326d Mon Sep 17 00:00:00 2001 From: Ihab Soliman <2053386+ihabsoliman@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:43:11 -0600 Subject: [PATCH 2/3] chore(tests): typecheck the test suites and fix the resulting errors tsconfig.json excludes `**/*.test.ts`, so the suites were never part of any tsc program: `npm run build` stayed green while 72 type errors accumulated in them, visible only in an editor. Adds tsconfig.tests.json and chains it into `build`, so CI enforces this from now on. It widens `rootDir` to the repo root because the base config aims it at ./src for an emit that never happens - the shipped bundle comes from esbuild. Fixes all 76 errors. 70 shared one cause: `jest.fn()` with no type argument is typed `Mock`, whose parameter and resolved types collapse to `never`, so `mockResolvedValue`/`mockRejectedValue`/`mockImplementation` reject every argument. Each suite now builds its mocks through a small helper with a permissive signature. Typing them precisely instead - `jest.fn()` - would require widening dozens of intentionally-partial fixtures to full API types for no real safety gain. The remaining six: a `declare module '*?real'` for the unmocked-import trick (the suffix works by forming a distinct module specifier, so the factory registered for the unqueried path does not apply; moduleNameMapper is not involved), and `./helper.test` -> `./helper.test.js`, which jest maps back. --- package.json | 2 +- tests/action.test.ts | 34 +++++++++++++++++++++++----------- tests/github.test.ts | 32 ++++++++++++++++++++++---------- tests/utils.test.ts | 28 ++++++++++++++++++++-------- tsconfig.tests.json | 15 +++++++++++++++ types/test-modules.d.ts | 12 ++++++++++++ 6 files changed, 93 insertions(+), 30 deletions(-) create mode 100644 tsconfig.tests.json create mode 100644 types/test-modules.d.ts diff --git a/package.json b/package.json index 9dde4fd..045bd5e 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "main": "lib/main.cjs", "type": "module", "scripts": { - "build": "tsc --noEmit && node scripts/build.mjs", + "build": "tsc --noEmit && tsc -p tsconfig.tests.json && node scripts/build.mjs", "test": "node --experimental-vm-modules node_modules/.bin/jest --testTimeout 10000", "check": "prettier --check ." }, diff --git a/tests/action.test.ts b/tests/action.test.ts index 6913d80..c77bd09 100644 --- a/tests/action.test.ts +++ b/tests/action.test.ts @@ -14,12 +14,24 @@ import { setInput, setInputs, setRepository, -} from './helper.test'; +} from './helper.test.js'; const realUtils: any = await import('../src/utils.js?real'); -const getCommitsMock = jest.fn(); -const getValidTagsMock = jest.fn(); -const filterTagsByBranchAncestryMock = jest.fn(); +/* + * `jest.fn()` with no type argument is typed `Mock`, whose + * parameter and resolved types collapse to `never` - which makes + * `mockResolvedValue(...)`, `mockRejectedValue(...)` and + * `mockImplementation(...)` reject every argument. These mocks stand in for + * module and API shapes we deliberately do not model exactly (the fixtures are + * partial on purpose), so give them a permissive signature instead of widening + * every fixture to a full API type. + */ +type LooseFn = (...args: any[]) => any; +const mockFn = () => jest.fn(); + +const getCommitsMock = mockFn(); +const getValidTagsMock = mockFn(); +const filterTagsByBranchAncestryMock = mockFn(); jest.unstable_mockModule('../src/utils.js', () => ({ ...realUtils, @@ -29,8 +41,8 @@ jest.unstable_mockModule('../src/utils.js', () => ({ })); const realGithub: any = await import('../src/github.js?real'); -const listTagsMock = jest.fn(); -const mockCreateTag = jest.fn().mockResolvedValue(undefined); +const listTagsMock = mockFn(); +const mockCreateTag = mockFn().mockResolvedValue(undefined); jest.unstable_mockModule('../src/github.js', () => ({ // Spread the real module so non-mocked exports (constants such as @@ -41,14 +53,14 @@ jest.unstable_mockModule('../src/github.js', () => ({ })); const realCore: any = await import('@actions/core?real'); -const mockSetOutput = jest.fn(); -const mockSetFailed = jest.fn(); -const mockWarning = jest.fn(); +const mockSetOutput = mockFn(); +const mockSetFailed = mockFn(); +const mockWarning = mockFn(); jest.unstable_mockModule('@actions/core', () => ({ ...realCore, - debug: jest.fn(), - info: jest.fn(), + debug: mockFn(), + info: mockFn(), warning: mockWarning, setOutput: mockSetOutput, setFailed: mockSetFailed, diff --git a/tests/github.test.ts b/tests/github.test.ts index b501b4b..b30932d 100644 --- a/tests/github.test.ts +++ b/tests/github.test.ts @@ -1,18 +1,30 @@ import { jest, describe, it, expect, beforeEach } from '@jest/globals'; -const listTagsMock = jest.fn(); -const compareCommitsMock = jest.fn(); -const listCommitsMock = jest.fn(); -const paginateMock = jest.fn(); -const getCommitMock = jest.fn(); -const createTagMock = jest.fn(); -const updateRefMock = jest.fn(); -const createRefMock = jest.fn(); -const execMock = jest.fn(); +/* + * `jest.fn()` with no type argument is typed `Mock`, whose + * parameter and resolved types collapse to `never` - which makes + * `mockResolvedValue(...)`, `mockRejectedValue(...)` and + * `mockImplementation(...)` reject every argument. These mocks stand in for + * module and API shapes we deliberately do not model exactly (the fixtures are + * partial on purpose), so give them a permissive signature instead of widening + * every fixture to a full API type. + */ +type LooseFn = (...args: any[]) => any; +const mockFn = () => jest.fn(); + +const listTagsMock = mockFn(); +const compareCommitsMock = mockFn(); +const listCommitsMock = mockFn(); +const paginateMock = mockFn(); +const getCommitMock = mockFn(); +const createTagMock = mockFn(); +const updateRefMock = mockFn(); +const createRefMock = mockFn(); +const execMock = mockFn(); jest.unstable_mockModule('@actions/github', () => ({ context: { repo: { owner: 'mock-owner', repo: 'mock-repo' } }, - getOctokit: jest.fn().mockReturnValue({ + getOctokit: mockFn().mockReturnValue({ paginate: paginateMock, rest: { repos: { diff --git a/tests/utils.test.ts b/tests/utils.test.ts index d220a50..fe5f7d5 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -1,16 +1,28 @@ import { jest, describe, it, expect, beforeEach } from '@jest/globals'; -const getInputMock = jest.fn().mockReturnValue(''); -const warningMock = jest.fn(); -const getCommitRangeMock = jest.fn(); -const getCompareStatusMock = jest.fn(); -const isShallowRepositoryMock = jest.fn(); -const listMergedTagsMock = jest.fn(); +/* + * `jest.fn()` with no type argument is typed `Mock`, whose + * parameter and resolved types collapse to `never` - which makes + * `mockResolvedValue(...)`, `mockRejectedValue(...)` and + * `mockImplementation(...)` reject every argument. These mocks stand in for + * module and API shapes we deliberately do not model exactly (the fixtures are + * partial on purpose), so give them a permissive signature instead of widening + * every fixture to a full API type. + */ +type LooseFn = (...args: any[]) => any; +const mockFn = () => jest.fn(); + +const getInputMock = mockFn().mockReturnValue(''); +const warningMock = mockFn(); +const getCommitRangeMock = mockFn(); +const getCompareStatusMock = mockFn(); +const isShallowRepositoryMock = mockFn(); +const listMergedTagsMock = mockFn(); jest.unstable_mockModule('@actions/core', () => ({ - debug: jest.fn(), + debug: mockFn(), warning: warningMock, - info: jest.fn(), + info: mockFn(), getInput: getInputMock, })); diff --git a/tsconfig.tests.json b/tsconfig.tests.json new file mode 100644 index 0000000..63f9573 --- /dev/null +++ b/tsconfig.tests.json @@ -0,0 +1,15 @@ +{ + /* + * The main tsconfig excludes `**\/*.test.ts`, so the suites were never + * typechecked - 72 type errors had accumulated there unnoticed while + * `npm run build` stayed green. This config typechecks them. + * + * `rootDir` is widened to the repo root because the base config points it at + * `./src` for emit layout; nothing is emitted here (`noEmit`), and the shipped + * bundle is produced by esbuild in scripts/build.mjs regardless. + */ + "extends": "./tsconfig.json", + "compilerOptions": { "rootDir": ".", "noEmit": true }, + "include": ["src/**/*.ts", "types/**/*.ts", "tests/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/types/test-modules.d.ts b/types/test-modules.d.ts new file mode 100644 index 0000000..30f1d60 --- /dev/null +++ b/types/test-modules.d.ts @@ -0,0 +1,12 @@ +/* + * The suites obtain the *unmocked* version of a module by importing it with a + * `?real` query suffix, e.g. `await import('../src/utils.js?real')`. The suffix + * makes it a distinct module specifier, so the factory registered for the + * unqueried path by `jest.unstable_mockModule` does not apply to it, while the + * ESM resolver still resolves it to the same file. Nothing in jest.config.cjs is + * involved - `moduleNameMapper` only rewrites specifiers ending in `.js`. + * + * TypeScript has no notion of that suffix, so declare it here rather than + * repeating `@ts-expect-error` at each import site. + */ +declare module '*?real'; From c2f79c8939edb25ef2ad65b83d88051664f74ae2 Mon Sep 17 00:00:00 2001 From: Ihab Soliman <2053386+ihabsoliman@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:45:18 -0600 Subject: [PATCH 3/3] chore(action): rename action to "Github Tagging Action --- action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/action.yml b/action.yml index d990a03..2ea7ccd 100644 --- a/action.yml +++ b/action.yml @@ -1,4 +1,4 @@ -name: "Auto Tag & Release" +name: "Github Taggin Action" description: "Bump and push git tag on merge" author: "Ihab Soliman" outputs: