From 9a161b665da1501d228c82a78a64f620d990094e Mon Sep 17 00:00:00 2001 From: Kevin de Jong Date: Tue, 24 Jun 2025 11:34:42 +0200 Subject: [PATCH 1/3] fix: handle breaking changes when current version is dev This commit restructures the SdkVer handling based on use-case mapping, simplifying the overall complexity of maintaining the flow. This was needed to tackle the specific use case for failure of creating a new breaking change from a `dev` or `rc` version. i.e. `1.0.0-dev001.SHA` could not lead to `2.0.0-rc01` even when specifying that this is a breaking change. --- .prettierrc.json | 8 +- src/actions/bump.ts | 4 +- src/actions/validate.ts | 2 +- src/bump.ts | 1042 -------------------------------------- src/bump/bump.ts | 172 +++++++ src/bump/sdkver.ts | 457 +++++++++++++++++ src/bump/semver.ts | 468 +++++++++++++++++ src/semver.ts | 8 +- test/bump.sdkver.test.ts | 44 +- test/bump.test.ts | 2 +- 10 files changed, 1141 insertions(+), 1066 deletions(-) delete mode 100644 src/bump.ts create mode 100644 src/bump/bump.ts create mode 100644 src/bump/sdkver.ts create mode 100644 src/bump/semver.ts diff --git a/.prettierrc.json b/.prettierrc.json index d9c8fc5b..528b93d1 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -6,5 +6,11 @@ "singleQuote": false, "trailingComma": "es5", "bracketSpacing": true, - "arrowParens": "avoid" + "arrowParens": "avoid", + "overrides": [{ + "files": "src/bump/sdkver.ts", + "options": { + "printWidth": 120 + } + }] } diff --git a/src/actions/bump.ts b/src/actions/bump.ts index 34ed5071..5a3ee852 100644 --- a/src/actions/bump.ts +++ b/src/actions/bump.ts @@ -17,12 +17,12 @@ import * as core from "@actions/core"; import { context } from "@actions/github"; +import { bumpSdkVer } from "../bump/sdkver"; import { - bumpSdkVer, bumpSemVer, getVersionBumpTypeAndMessages, printNonCompliance, -} from "../bump"; +} from "../bump/semver"; import { Configuration } from "../config"; import { getConfig } from "../github"; import { diff --git a/src/actions/validate.ts b/src/actions/validate.ts index df732938..5f3b7c53 100644 --- a/src/actions/validate.ts +++ b/src/actions/validate.ts @@ -15,7 +15,6 @@ */ import * as core from "@actions/core"; -import { getVersionBumpType } from "../bump"; import { ConventionalCommitMessage } from "../commit"; import { Configuration } from "../config"; @@ -27,6 +26,7 @@ import { validatePrTitle, validatePrTitleBump, } from "../validate"; +import { getVersionBumpType } from "../bump/semver"; /** * Determine labels to add based on the provided conventional commits diff --git a/src/bump.ts b/src/bump.ts deleted file mode 100644 index fa584207..00000000 --- a/src/bump.ts +++ /dev/null @@ -1,1042 +0,0 @@ -/** - * Copyright (C) 2022, TomTom (http://tomtom.com). - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import * as core from "@actions/core"; -import { RequestError } from "@octokit/request-error"; - -import { generateChangelogForCommits, generateChangelog } from "./changelog"; -import { Configuration } from "./config"; -import { - createBranch, - createRelease, - createTag, - currentHeadMatchesTag, - getCommitsBetweenRefs, - getRunNumber, - getAllTags, - getRelease, - getShaForTag, - isPullRequestEvent, - matchTagsToCommits, - updateDraftRelease, -} from "./github"; -import { ConventionalCommitMessage } from "./commit"; -import { SemVer, SemVerType } from "./semver"; -import { BumpError } from "./errors"; -import { - ICommit, - IValidationResult, - IVersionBumpTypeAndMessages, - ReleaseMode, - SdkVerBumpType, - IVersionOutput, - IGitHubRelease, - IGitTag, - ReleaseType, - IBumpInfo, -} from "./interfaces"; -import { outputCommitListErrors, processCommits } from "./validate"; - -const RC_PREFIX = "rc"; - -/** - * Return the first eight characters of a string. - * - * To be used as a shortened version of the 40-character SHA1 version. - */ -function shortSha(sha: string): string { - return sha.substring(0, 8); -} - -/** - * Returns a SemVer object if: - * - the `tagSha` and `commitSha` match - * - the `tagName` tag reference is SemVer-compatible - * - the `prefix` exactly matches the `tagName`'s prefix (if any), - or the provided `prefix` is "*" - * - * @param prefix Specifies the exact prefix of the tags to be considered, - * '*' means "any" - * @param tagName The tag reference name - * @param tagSha The tag's SHA1 hash - * @param commitSha The SHA1 hash to compare to - * - * @return {Semver | null} A SemVer object representing the value of `tagName`, - * or `null` if the provided parameters don't match - */ -function getSemVerIfMatches( - prefix: string, - tagName: string, - tagSha: string, - commitSha: string -): SemVer | null { - if (commitSha === tagSha) { - const dbg = (tag: string, commit: string, message: string): void => { - core.debug(`Tag '${tag}' on commit '${commit.slice(0, 6)}' ${message}`); - }; - const sv: SemVer | null = SemVer.fromString(tagName); - if (sv) { - // If provided, make sure that the prefix matches as well - // Asterisk is a special case, meaning 'any prefix' - if (sv.prefix === prefix || prefix === "*") { - dbg(tagName, commitSha, `matches prefix ${prefix}`); - return sv; - } - dbg(tagName, commitSha, `does not match prefix ${prefix}`); - } else { - dbg(tagName, commitSha, "is not a SemVer"); - } - } - - return null; -} - -/** Validates a list of commits in a bump context, which differs slightly to - * pull request validation runs, as some rules need to be disabled. - */ -function processCommitsForBump( - commits: ICommit[], - config: Configuration -): IValidationResult[] { - // We'll relax certain rules while processing these commits; these are - // commits/pull request titles that (ideally) have been validated - // _before_ they were merged, and certain GitHub CI settings may append - // a reference to the PR number in merge commits. - const configCopy = config.copy(); - configCopy.setRuleActive("C014", false); // SubjectExceedsLineLengthLimit - configCopy.setRuleActive("C019", false); // SubjectContainsIssueReference - - return processCommits(commits, configCopy); -} - -/** - * Determines the highest SemVer bump level based on the provided - * list of Conventional Commits - */ -export function getVersionBumpType( - messages: ConventionalCommitMessage[] -): SemVerType { - let highestBump: SemVerType = SemVerType.NONE; - - for (const message of messages) { - if (highestBump !== SemVerType.MAJOR) { - core.debug( - `Commit type '${message.type}'${ - message.breakingChange ? " (BREAKING)" : "" - }, has bump type: ${SemVerType[message.bump]}` - ); - highestBump = message.bump > highestBump ? message.bump : highestBump; - } - } - - return highestBump; -} - -/** - * Within the current context, examine all commits reachable from from `context.sha` - * and match them to _all_ the tags found in the repo. - * Each commit shall be tried to be matched to any of the tags found in chronological - * order (i.e. the time the tag was pushed). - * The closest tag that is SemVer-compatible and matches the `prefix` value as - * configured in the `config` object shall be returned as a SemVer object, and - * the highest bump type encountered in the commits _since_ that tag shall be returned. - * - MAJOR: breaking changes, - * - MINOR: feat commits, - * - PATCH: fix commits, plus any tag matching one of `extra_patch_tags`, if configured - * - * @param targetSha The sha on which to start listing commits - * @param config A Configuration object, which optionally contains the `prefix` value - * that processed versions must match, or a list of Conventional Commit type - * tags that should bump the patch version field (aside from "fix"). - * - * @return {IVersionBumpTypeAndMessages} - returns an object containing: - - the SemVer object or null if no (acceptable) SemVer was found. - - the highest bump encountered, or SemVerType.NONE if [0] is null - - list of ConventionalCommitMessage objects up to the found SemVer tag - - state of "initial development"; if no version is found, err on the - safe side and declare "initial development" (if configured as such) - */ -export async function getVersionBumpTypeAndMessages( - targetSha: string, - config: Configuration -): Promise { - core.debug("Fetching repository tags.."); - const tags = await getAllTags(); - core.debug(`Fetch complete; found ${tags.length} tags`); - const tagMatcher = (commitSha: string): SemVer | null => { - // Try and match this commit's hash to one of the tags in `tags` - for (const tag of tags) { - let semVer: SemVer | null = null; - core.debug(`Considering tag ${tag.name} (${tag.sha}) on ${commitSha}`); - semVer = getSemVerIfMatches( - config.versionPrefix, - tag.name, - tag.sha, - commitSha - ); - if (semVer) { - // We've found a tag that matches to this commit. Now, we need to make sure that - // we return the _highest_ version tag associated with this commit. - core.debug( - `Matching tag found (${tag.name}), checking other tags for commit ${commitSha}..` - ); - const matchTags = tags.filter(t => t.sha === commitSha); - if (matchTags.length > 1) { - core.debug(`${matchTags.length} other tags found`); - matchTags.sort((lhs, rhs) => SemVer.sortSemVer(lhs.name, rhs.name)); - semVer = null; - while (semVer === null && matchTags.length !== 0) { - const t = matchTags.pop(); - if (!t) break; - semVer = getSemVerIfMatches( - config.versionPrefix, - t.name, - t.sha, - commitSha - ); - } - } else { - core.debug(`No other tags found`); - // Just the one tag; carry on. - } - - return semVer; - } - } - core.debug(`Commit ${commitSha.slice(0, 6)} is not associated with a tag`); - return null; - }; - const [version, commitList] = await matchTagsToCommits(targetSha, tagMatcher); - - const results = processCommitsForBump(commitList, config); - const convCommits = results - .map(r => r.message) - .filter((r): r is ConventionalCommitMessage => r !== undefined); - - return { - foundVersion: version, - requiredBump: getVersionBumpType(convCommits), - processedCommits: results, - initialDevelopment: - config.initialDevelopment && - (!version || (version && version.major === 0)), - }; -} - -/** - * Tries to update an existing draft GitHub release. - * Not prerelease-type-aware, and only succeeds if a prerelease - * version already exists. Behavior: - * 1.2.3-dev4 -> 1.2.3-dev5 - * 2.3.4-alpha104 -> 2.3.4-alpha105 - * 3.4.5-rc1 -> 3.4.5-rc2 - * 4.5.6 -> undefined - * - * Returns the new prerelease version name if update was successful, - * `undefined` otherwise. - */ -async function tryUpdateDraftRelease( - cv: SemVer, - changelog: string, - sha: string -): Promise { - const latestDraftRelease = await getRelease({ - prefixToMatch: cv.prefix, - draftOnly: true, - fullReleasesOnly: false, - }); - if (!latestDraftRelease) return; - - const currentDraftVersion = SemVer.fromString(latestDraftRelease.name); - if (!currentDraftVersion) { - core.info(`Couldn't parse ${latestDraftRelease.name} as SemVer`); - return; - } - - const npv = currentDraftVersion.nextPrerelease(); - if (!npv) return; - npv.build = shortSha(sha); - - const updatedRelease = await updateDraftRelease( - latestDraftRelease.id, - npv.toString(), - npv.toString(), - sha, - changelog - ); - if (!updatedRelease) { - core.info(`Error renaming existing draft release.`); - } - - return updatedRelease; -} - -async function newDraftRelease( - currentVersion: SemVer, - changelog: string, - sha: string, - prefix: string -): Promise { - // Either update went wrong or there was nothing to update - const nextPrereleaseVersion = currentVersion.nextPatch(); - nextPrereleaseVersion.build = currentVersion.build; - if (prefix === "dev") { - nextPrereleaseVersion.prerelease = `${prefix}001.${shortSha(sha)}`; - } else { - nextPrereleaseVersion.prerelease = `${prefix}001`; - } - const newRelease = await createRelease( - nextPrereleaseVersion.toString(), - sha, - changelog, - true, - false - ); - - return newRelease; -} - -export async function bumpDraftRelease( - bumpInfo: IVersionBumpTypeAndMessages, - changelog: string, - sha: string, - preRelPrefix: string -): Promise { - const cv = bumpInfo.foundVersion; - if (!cv) throw Error("Found version is falsy"); // should never happen - const result = - (await tryUpdateDraftRelease(cv, changelog, sha)) ?? - (await newDraftRelease(cv, changelog, sha, preRelPrefix)); - - if (result) { - core.info(`ℹ️ Next prerelease: ${result.name}`); - } else { - core.warning(`⚠️ No prerelease created.`); - } - - return result; -} - -/** - * Prints information about any non-compliance found in the provided list - */ -export function printNonCompliance(commits: IValidationResult[]): void { - const nonCompliantCommits = commits.filter(c => !c.message); - - if (nonCompliantCommits.length > 0) { - const totalLen = commits.length; - const ncLen = nonCompliantCommits.length; - - core.info(""); // for vertical whitespace - - if (ncLen === totalLen) { - const commitsDoNotComply = - totalLen === 1 - ? "The only encountered commit does not comply" - : `None of the encountered ${totalLen} commits comply`; - - core.warning( - `${commitsDoNotComply} with the Conventional Commits specification, ` + - "so the intended bump level could not be determined.\n" + - "As a result, no version bump will be performed." - ); - } else { - const [pluralDo, pluralBe] = ncLen === 1 ? ["does", "is"] : ["do", "are"]; - - core.warning( - `${ncLen} of the encountered ${totalLen} commits ` + - `${pluralDo} not comply with the Conventional Commits ` + - `specification and ${pluralBe} therefore NOT considered ` + - "while determining the bump level." - ); - } - const pluralS = ncLen === 1 ? "" : "s"; - core.info(`⚠️ Non-compliant commit${pluralS}:`); - outputCommitListErrors(nonCompliantCommits, false); - } -} - -export async function publishBump( - nextVersion: SemVer, - releaseMode: ReleaseMode, - headSha: string, - changelog: string, - isBranchAllowedToPublish: boolean, - discussionCategoryName?: string, - updateDraftId?: number -): Promise<{ release?: IGitHubRelease; tag?: IGitTag }> { - let releaseMetadata: IGitHubRelease | undefined; - let tagMetadata: IGitTag | undefined; - - const nv = nextVersion.toString(); - core.info(`ℹ️ Next version: ${nv}`); - core.endGroup(); - if (releaseMode !== "none") { - if (!isBranchAllowedToPublish) { - return {}; - } - if (isPullRequestEvent()) { - core.startGroup( - `ℹ️ Not creating ${releaseMode} on a pull request event.` - ); - core.info( - "We cannot create a release or tag in a pull request context, due to " + - "potential parallelism (i.e. races) in pull request builds." - ); - return {}; - } - core.startGroup(`ℹ️ Creating ${releaseMode} ${nv}..`); - try { - if (releaseMode === "tag") { - tagMetadata = await createTag(nv, headSha); - } else { - // If version is a prerelease, but not an RC, create a draft release - // If version is an RC, create a GitHub "pre-release" - const isRc = nextVersion.prerelease.startsWith(RC_PREFIX); - const isDev = nextVersion.prerelease !== "" && !isRc; - if (updateDraftId) { - releaseMetadata = await updateDraftRelease( - updateDraftId, - nv, - nv, - headSha, - changelog, - isDev, // draft - isRc // prerelease - ); - - if (!releaseMetadata) { - core.info( - `Error renaming existing draft release, ` + - `creating new draft release.` - ); - } - } - - if (!releaseMetadata) { - releaseMetadata = await createRelease( - nv, - headSha, - changelog, - isDev, - isRc, - discussionCategoryName - ); - - // Only set the tag information in case we created a release - // which implicitly creates a tag (i.e. not applicable for draft-releases). - if (releaseMetadata) { - tagMetadata = { - name: releaseMetadata.name, - ref: `refs/tags/${releaseMetadata.name}`, - sha: headSha, - }; - } - } - } - } catch (ex: unknown) { - // The most likely failure is a preexisting tag, in which case - // a RequestError with statuscode 422 will be thrown - const commit = await getShaForTag(`refs/tags/${nv}`); - if (ex instanceof RequestError && ex.status === 422 && commit) { - core.setFailed( - `Unable to create ${releaseMode}; the tag "${nv}" already exists in the repository, ` + - `it currently points to ${commit}.\n` + - "You can find the branch(es) associated with the tag with:\n" + - ` git fetch -t; git branch --contains ${nv}` - ); - } else if (ex instanceof RequestError) { - core.setFailed( - `Unable to create ${releaseMode} with the name "${nv}" due to ` + - `HTTP request error (status ${ex.status}):\n${ex.message}` - ); - } else if (ex instanceof Error) { - core.setFailed( - `Unable to create ${releaseMode} with the name "${nv}":\n${ex.message}` - ); - } else { - core.setFailed(`Unknown error during ${releaseMode} creation`); - throw ex; - } - core.endGroup(); - return {}; - } - core.info("Succeeded"); - } else { - core.startGroup(`ℹ️ Not creating tag or release for ${nv}..`); - core.info( - "To create a lightweight Git tag or GitHub release when the version is bumped, run this action with:\n" + - ' - "create-release" set to "true" to create a GitHub release, or\n' + - ' - "create-tag" set to "true" for a lightweight Git tag.\n' + - "Note that setting both options is not needed, since a GitHub release implicitly creates a Git tag." - ); - return {}; - } - - return { - release: releaseMetadata, - tag: tagMetadata, - }; -} - -export async function bumpSemVer( - config: Configuration, - bumpInfo: IVersionBumpTypeAndMessages, - releaseMode: ReleaseMode, - branchName: string, - headSha: string, - isBranchAllowedToPublish: boolean, - createChangelog: boolean -): Promise { - const compliantCommits = bumpInfo.processedCommits - .filter(c => c.message !== undefined) - .map(c => ({ - msg: c.message as ConventionalCommitMessage, - sha: c.input.sha.slice(0, 8), - })); - - for (const { msg, sha } of compliantCommits) { - const bumpString = msg.bump === 0 ? "No" : SemVerType[msg.bump]; - core.info(`- ${bumpString} bump for commit (${sha}): ${msg.subject}`); - } - - // Reject MAJOR and MINOR version bumps if we're on a release branch - // (Purposefully do this check _after_ listing the processed commits.) - if ( - new RegExp(config.releaseBranches).test(branchName) && - [SemVerType.MAJOR, SemVerType.MINOR].includes(bumpInfo.requiredBump) - ) { - core.setFailed( - `A ${SemVerType[bumpInfo.requiredBump]} bump is requested, but ` + - `we can only create PATCH bumps on a release branch.` - ); - return; - } - - let bumpMetadata: IBumpInfo | undefined; - - if (bumpInfo.foundVersion) { - const bumpResult = bumpInfo.foundVersion.bump( - bumpInfo.requiredBump, - config.initialDevelopment - ); - if (bumpResult) { - bumpMetadata = { - from: bumpInfo.foundVersion, - to: bumpResult.version, - type: SemVerType[bumpResult.increment].toLowerCase() as ReleaseType, - }; - } - } - - let versionMetadata: IVersionOutput | undefined; - - let bumped = false; - - let changelog = ""; - if (createChangelog) changelog = await generateChangelog(bumpInfo); - - if (bumpMetadata) { - const buildMetadata = core.getInput("build-metadata"); - if (buildMetadata) { - bumpMetadata.to.build = buildMetadata; - } - - const { release, tag } = await publishBump( - bumpMetadata.to, - releaseMode, - headSha, - changelog, - isBranchAllowedToPublish, - config.releaseDiscussionCategory - ); - - versionMetadata = { - bump: { - from: bumpMetadata.from.toString(), - to: bumpMetadata.to.toString(), - type: bumpMetadata.type as ReleaseType, - }, - tag, - release, - }; - - // If we have a release and/or a tag, we consider the bump successful - bumped = release !== undefined || tag !== undefined; - } else { - core.info("ℹ️ No bump necessary"); - } - core.endGroup(); - - if (!bumped && config.prereleasePrefix !== undefined) { - // When configured to create GitHub releases, and the `bump-prereleases` config item - // evaluates to `true`. - if ( - isBranchAllowedToPublish && - !isPullRequestEvent() && - releaseMode === "release" - ) { - // Create/rename draft release - const draftRelease = await bumpDraftRelease( - bumpInfo, - changelog, - headSha, - config.prereleasePrefix - ); - - if (!draftRelease) { - return; - } - - core.info(`ℹ️ Created draft prerelease version ${draftRelease.name}`); - if (!bumpInfo.foundVersion) throw Error("Found version is falsy"); // should never happen - - return { - release: draftRelease, - bump: { - from: bumpInfo.foundVersion.toString(), - to: draftRelease.name, - type: "prerelease", - }, - }; - } else { - const reason = - isBranchAllowedToPublish !== true - ? `the current branch is not allowed to publish` - : isPullRequestEvent() - ? "we cannot publish from a pull request event" - : releaseMode !== "release" - ? `we can only do so when the 'create-release' input is provided to be 'true'` - : "we didn't think of writing an error message here"; - core.info(`ℹ️ While configured to bump prereleases, ${reason}.`); - } - } - - return bumped ? versionMetadata : undefined; -} - -function getNextSdkVer( - currentVersion: SemVer, - sdkVerBumpType: SdkVerBumpType, - isReleaseBranch: boolean, - headMatchesTag: boolean, - hasBreakingChange: boolean, - devPrereleaseText: string, - headSha: string, - isInitialDevelopment: boolean -): IBumpInfo | undefined { - const currentIsRc = currentVersion.prerelease.startsWith(RC_PREFIX); - const currentIsRel = currentVersion.prerelease === ""; - - const fatal = (msg: string): void => { - throw new BumpError(msg); - }; - const bumpOrError = (t: SemVerType): SemVer => { - const bumpResult = currentVersion.bump(t, isInitialDevelopment); - if (!bumpResult?.version) { - throw new BumpError(`Bump ${t.toString()} for ${currentVersion} failed`); - } - return bumpResult.version; - }; - - core.info(`Determining SDK bump for version ${currentVersion.toString()}:`); - core.info( - ` - current version type: ${ - currentIsRel ? "release" : currentIsRc ? "release candidate" : "dev" - }` - ); - core.info(` - bump type: ${sdkVerBumpType}`); - core.info(` - branch type: ${isReleaseBranch ? "" : "not "}release`); - core.info(` - breaking changes: ${hasBreakingChange ? "yes" : "no"}`); - - let nextVersion: SemVer | null = null; - let nextBumpType: SdkVerBumpType | null = null; - - if (isReleaseBranch) { - // If current branch HEAD is a release candidate: - // !createRel && !createRc = bump rc-val - // !createRel && createRc = bump rc-val - // createRel && !createRc = promote to full release - // Else if current branch HEAD is a full release: - // !createRel && !createRc = bump fix version (patch field) - // !createRel && createRc = error - // createRel && !createRc = bump fix version (patch field) - // Else - // error - - if (!currentIsRc && !currentIsRel) { - fatal( - "Release branches can only contain release candidates or full releases. " + - `'${currentVersion.toString()}' is neither.` - ); - } - // Special case: we allow breaking changes on a release branch if that - // release branch still contains an RC for the next API version, in which - // case, the MINOR and PATCH fields will be 0 (1.2.3 -> 2.0.0-rc1) - if ( - hasBreakingChange && - !(currentIsRc && currentVersion.minor === 0 && currentVersion.patch === 0) - ) { - fatal("Breaking changes are not allowed on release branches."); - } - - // Only bump if we need to; we don't want to generate a new RC or release - // when nothing has changed since the last RC or release, unless it is a - // promotion from RC to full release. - if (headMatchesTag && !(sdkVerBumpType === "rel" && currentIsRc)) { - core.info(` - head matches latest tag on release branch`); - } else if (sdkVerBumpType === "rel") { - if (currentIsRel) { - // Pushes on release branches with a finalized release always - // bump PATCH, no exception. - nextVersion = bumpOrError(SemVerType.PATCH); - nextBumpType = "rel"; - } else if (currentIsRc) { - // A release bump on a release candidate results in a full release - const nv = SemVer.copy(currentVersion); - nv.prerelease = ""; - nextVersion = nv; - nextBumpType = "rel"; - } - } else { - // Bumps for "rc" and "dev" are identical on a release branch - if (currentIsRc) { - // We need to keep the pre intact (undefined), but the post needs to be - // cleared, as that contains the commit hash of the previous dev version. - // Also zero pad to at least two digits. - nextVersion = currentVersion.nextPrerelease(undefined, "", 2); - nextBumpType = "rc"; - if (!nextVersion) { - fatal( - `Unable to bump RC version for: ${currentVersion.toString()}; ` + - `make sure it contains an index number.` - ); - } - } else { - // Current version is a release, so bump patch - nextVersion = bumpOrError(SemVerType.PATCH); - nextBumpType = "rel"; - } - } - } else { - // !isReleaseBranch - // If current branch HEAD is a release candidate: - // dev bump = bump dev prerelease for next minor (do nothing here) - // rc bump = create new rc for _next_ version - // rel && rc_sha == head_sha = "promote" to new full release - // rel && rc_sha != head_sha = create full release for _next_ major - // Else if current branch HEAD is a full release: - // !createRel && !createRc = bump dev prerelease for next minor (do nothing here) - // !createRel && createRc = create new rc for _next_ version - // createRel && !createRc = create new full release - // Else - // !createRel && !createRc = bump dev prerelease (do nothing here) - // !createRel && createRc = create new rc for _next_ version - // createRel && !createRc = create new full release - const releaseBump = hasBreakingChange ? SemVerType.MAJOR : SemVerType.MINOR; - if (sdkVerBumpType === "rel") { - // Special case for release bumps if the current version is an RC: - // only promote (i.e. strip prerelease) if HEAD matches that RC's SHA. - // If not, get the next major/minor. - if (currentIsRel || (currentIsRc && !headMatchesTag)) { - nextVersion = bumpOrError(releaseBump); - } else { - // Behavior for rc and dev is the same - nextVersion = SemVer.copy(currentVersion); - nextVersion.prerelease = ""; - nextVersion.build = ""; - } - nextBumpType = "rel"; - } else if (sdkVerBumpType === "rc") { - if (currentIsRel || currentIsRc) { - // ^^^^ - // This may be slightly counter-intuitive: RC increments can - // only be done on a release branch, so performing an RC bump - // on a non-release branch where the HEAD itself is an RC results - // in creating an RC for the _next_ version: - // 1.2.0-rc1 -> 1.3.0-rc1 (not 1.2.0-rc2). - nextVersion = bumpOrError(releaseBump); - } else { - // Current HEAD is a dev prerelease - nextVersion = SemVer.copy(currentVersion); - nextVersion.build = ""; - } - nextVersion.prerelease = `${RC_PREFIX}01`; - nextBumpType = "rc"; - } else if (sdkVerBumpType === "dev") { - // TODO: decide on how best to handle hasBreakingChange in this case - if (currentIsRel || currentIsRc) { - nextVersion = bumpOrError(releaseBump); - nextVersion.prerelease = `${devPrereleaseText}001`; - nextBumpType = "dev"; - } else { - // Keep prefix, clear postfix, zero pad to at least three digits - nextVersion = currentVersion.nextPrerelease(undefined, "", 3); - nextBumpType = "dev"; - if (!nextVersion) { - // This can only happen if the current version is something - // unexpected and invalid, like a prerelease without a number, e.g.: - // 1.2.3-rc 1.2.3-dev 1.2.3-testing - nextVersion = bumpOrError(SemVerType.MINOR); - nextVersion.prerelease = `${devPrereleaseText}001`; - core.warning( - `Failed to bump the prerelease for version ${currentVersion.toString()}` + - `; moving to next release version ${nextVersion.toString()}` - ); - } - } - } - } - - core.info(` - next version: ${nextVersion?.toString() ?? "none"}`); - if (!nextVersion && !headMatchesTag) { - fatal(`Unable to bump version for: ${currentVersion.toString()}`); - } - const buildMetadata = core.getInput("build-metadata"); - nextVersion = nextVersion as SemVer; - if (buildMetadata) { - nextVersion.build = buildMetadata; - } - - if (nextBumpType === "dev") { - nextVersion.prerelease += `.${shortSha(headSha)}`; - } - - if (nextVersion && nextBumpType) { - return { - from: currentVersion, - to: nextVersion, - type: nextBumpType, - }; - } -} - -/** - * Bump and release/tag SDK versions - */ -export async function bumpSdkVer( - config: Configuration, - bumpInfo: IVersionBumpTypeAndMessages, - releaseMode: ReleaseMode, - sdkVerBumpType: SdkVerBumpType, - headSha: string, - branchName: string, - isBranchAllowedToPublish: boolean, - createChangelog: boolean -): Promise { - const isReleaseBranch = new RegExp(config.releaseBranches).test(branchName); - let hasBreakingChange = bumpInfo.processedCommits.some( - c => c.message?.breakingChange - ); - if (!bumpInfo.foundVersion) return; // should never happen - - // SdkVer requires a prerelease, so apply the default if not set - config.prereleasePrefix = config.prereleasePrefix ?? "dev"; - - let cv = SemVer.copy(bumpInfo.foundVersion); - - // Do not bump major version when breaking change is found in case - // the max configured major version is already reached - if ( - config.sdkverMaxMajor !== undefined && - config.sdkverMaxMajor > 0 && - cv.major >= config.sdkverMaxMajor - ) { - hasBreakingChange = false; - } - - // Get the latest draft release matching our current version's prefix. - // Don't look at the draft version on a release branch; the current version - // should always reflect the version to be bumped (as no dev releases are - // allowed on a release branch) - const latestDraft = await getRelease({ - prefixToMatch: cv.prefix, - draftOnly: true, - fullReleasesOnly: false, - }); - const latestRelease = await getRelease({ - prefixToMatch: cv.prefix, - draftOnly: false, - fullReleasesOnly: true, - }); - - core.info( - `Current version: ${cv.toString()}, latest GitHub release draft: ${ - latestDraft?.name ?? "NONE" - }, latest GitHub release: ${latestRelease?.name ?? "NONE"}` - ); - - if (!isReleaseBranch && latestDraft) { - // If we're not on a release branch and a draft version exists that is - // newer than the latest tag, we continue with that - const draftVersion = SemVer.fromString(latestDraft.name); - if (draftVersion && cv.lessThan(draftVersion)) { - cv = draftVersion; - } - } - // TODO: This is wasteful, as this info has already been available before - const headMatchesTag = await currentHeadMatchesTag(cv.toString()); - const bump = getNextSdkVer( - cv, - sdkVerBumpType, - isReleaseBranch, - headMatchesTag, - hasBreakingChange, - config.prereleasePrefix ?? "dev", - headSha, - config.initialDevelopment - ); - - let bumped = false; - let changelog = ""; - let releaseBranchName: string | undefined; - let versionOutput: IVersionOutput | undefined; - - if (bump?.to) { - // Since we want the changelog since the last _full_ release, we - // can only rely on the `bumpInfo` if the "current version" is a - // full release. In other cases, we need to gather some information - // to generate the proper changelog. - const previousRelease = await getRelease({ - prefixToMatch: bump.to.prefix, - draftOnly: false, - fullReleasesOnly: true, - constraint: { - major: bump.to.major, - minor: bump.to.minor, - }, - }); - core.info( - `The full release preceding the current one is ${ - previousRelease?.name ?? "undefined" - }` - ); - - if (createChangelog) { - if (previousRelease && cv.prerelease) { - const toVersion = - // Since "dev" releases on non-release-branches result in a draft - // release, we'll need to use the commit sha. - bump.type === "dev" ? shortSha(headSha) : bump.to.toString(); - - changelog = await generateChangelogForCommits( - previousRelease.name, - toVersion, - await collectChangelogCommits(previousRelease.name, config) - ); - } else { - changelog = await generateChangelog(bumpInfo); - } - } - - const { release, tag } = await publishBump( - bump.to, - releaseMode, - headSha, - changelog, - isBranchAllowedToPublish, - config.releaseDiscussionCategory, - // Re-use the latest draft release only when not running on a release branch, - // otherwise we might randomly reset a `dev-N` number chain. - !isReleaseBranch ? latestDraft?.id : undefined - ); - - versionOutput = { - tag, - release, - bump: { - from: bumpInfo.foundVersion.toString(), - to: bump.to.toString(), - type: bump.type as ReleaseType, - }, - }; - - // If we have a release and/or a tag, we consider the bump successful - bumped = release !== undefined || tag !== undefined; - } - - if (!bumped) { - core.info("ℹ️ No bump was performed"); - } else { - // Create a release branch for releases and RC's if we're configured to do so - // and are currently not running on a release branch. - if ( - config.sdkverCreateReleaseBranches !== undefined && - !isReleaseBranch && - bump?.type !== "dev" && - bump?.to - ) { - releaseBranchName = `${config.sdkverCreateReleaseBranches}${bump.to.major}.${bump.to.minor}`; - core.info(`Creating release branch ${releaseBranchName}..`); - try { - await createBranch(`refs/heads/${releaseBranchName}`, headSha); - } catch (ex: unknown) { - if (ex instanceof RequestError && ex.status === 422) { - core.warning( - `The branch '${releaseBranchName}' already exists` + - `${getRunNumber() !== 1 ? " (NOTE: this is a re-run)." : "."}` - ); - } else if (ex instanceof RequestError) { - core.warning( - `Unable to create release branch '${releaseBranchName}' due to ` + - `HTTP request error (status ${ex.status}):\n${ex.message}` - ); - } else if (ex instanceof Error) { - core.warning( - `Unable to create release branch '${releaseBranchName}':\n${ex.message}` - ); - } else { - core.warning(`Unknown error during ${releaseMode} creation`); - throw ex; - } - } - } - } - - core.endGroup(); - - return bumped ? versionOutput : undefined; -} - -/** - * For SdkVer, the latest tag (i.e. "current version") may not be the starting - * point we want for generating a changelog; in this context, we want to get a - * list of commits since the last _full_ release. - * - * Returns an object containing: - * - the name of the last full release reachable from our current version - * - the list of valid Conventional Commit objects since that release - */ -async function collectChangelogCommits( - previousRelease: string, - config: Configuration -): Promise { - core.startGroup(`📜 Gathering changelog information`); - const commits = await getCommitsBetweenRefs(previousRelease); - core.info( - `Processing commit list (since ${previousRelease}) ` + - `for changelog generation:\n-> ` + - `${commits.map(c => c.message.split("\n")[0]).join("\n-> ")}` - ); - - const processedCommits = processCommitsForBump(commits, config); - - core.endGroup(); - return processedCommits - .map(c => c.message) - .filter(c => c) as ConventionalCommitMessage[]; -} diff --git a/src/bump/bump.ts b/src/bump/bump.ts new file mode 100644 index 00000000..67c97ffa --- /dev/null +++ b/src/bump/bump.ts @@ -0,0 +1,172 @@ +import * as core from "@actions/core"; +import { RequestError } from "@octokit/request-error"; + +import { Configuration } from "../config"; +import { + createRelease, + createTag, + getShaForTag, + isPullRequestEvent, + updateDraftRelease, +} from "../github"; +import { + ICommit, + IGitHubRelease, + IGitTag, + IValidationResult, + ReleaseMode, +} from "../interfaces"; +import { SemVer } from "../semver"; +import { processCommits } from "../validate"; + +export const RC_PREFIX = "rc"; + +/** + * Return the first eight characters of a string. + * + * To be used as a shortened version of the 40-character SHA1 version. + */ +export function shortSha(sha: string): string { + return sha.substring(0, 8); +} + +/** Validates a list of commits in a bump context, which differs slightly to + * pull request validation runs, as some rules need to be disabled. + */ +export function processCommitsForBump( + commits: ICommit[], + config: Configuration +): IValidationResult[] { + // We'll relax certain rules while processing these commits; these are + // commits/pull request titles that (ideally) have been validated + // _before_ they were merged, and certain GitHub CI settings may append + // a reference to the PR number in merge commits. + const configCopy = config.copy(); + configCopy.setRuleActive("C014", false); // SubjectExceedsLineLengthLimit + configCopy.setRuleActive("C019", false); // SubjectContainsIssueReference + + return processCommits(commits, configCopy); +} + +export async function publishBump( + nextVersion: SemVer, + releaseMode: ReleaseMode, + headSha: string, + changelog: string, + isBranchAllowedToPublish: boolean, + discussionCategoryName?: string, + updateDraftId?: number +): Promise<{ release?: IGitHubRelease; tag?: IGitTag }> { + let releaseMetadata: IGitHubRelease | undefined; + let tagMetadata: IGitTag | undefined; + + const nv = nextVersion.toString(); + core.info(`ℹ️ Next version: ${nv}`); + core.endGroup(); + if (releaseMode !== "none") { + if (!isBranchAllowedToPublish) { + return {}; + } + if (isPullRequestEvent()) { + core.startGroup( + `ℹ️ Not creating ${releaseMode} on a pull request event.` + ); + core.info( + "We cannot create a release or tag in a pull request context, due to " + + "potential parallelism (i.e. races) in pull request builds." + ); + return {}; + } + core.startGroup(`ℹ️ Creating ${releaseMode} ${nv}..`); + try { + if (releaseMode === "tag") { + tagMetadata = await createTag(nv, headSha); + } else { + // If version is a prerelease, but not an RC, create a draft release + // If version is an RC, create a GitHub "pre-release" + const isRc = nextVersion.prerelease.startsWith(RC_PREFIX); + const isDev = nextVersion.prerelease !== "" && !isRc; + if (updateDraftId) { + releaseMetadata = await updateDraftRelease( + updateDraftId, + nv, + nv, + headSha, + changelog, + isDev, // draft + isRc // prerelease + ); + + if (!releaseMetadata) { + core.info( + `Error renaming existing draft release, ` + + `creating new draft release.` + ); + } + } + + if (!releaseMetadata) { + releaseMetadata = await createRelease( + nv, + headSha, + changelog, + isDev, + isRc, + discussionCategoryName + ); + + // Only set the tag information in case we created a release + // which implicitly creates a tag (i.e. not applicable for draft-releases). + if (releaseMetadata) { + tagMetadata = { + name: releaseMetadata.name, + ref: `refs/tags/${releaseMetadata.name}`, + sha: headSha, + }; + } + } + } + } catch (ex: unknown) { + // The most likely failure is a preexisting tag, in which case + // a RequestError with statuscode 422 will be thrown + const commit = await getShaForTag(`refs/tags/${nv}`); + if (ex instanceof RequestError && ex.status === 422 && commit) { + core.setFailed( + `Unable to create ${releaseMode}; the tag "${nv}" already exists in the repository, ` + + `it currently points to ${commit}.\n` + + "You can find the branch(es) associated with the tag with:\n" + + ` git fetch -t; git branch --contains ${nv}` + ); + } else if (ex instanceof RequestError) { + core.setFailed( + `Unable to create ${releaseMode} with the name "${nv}" due to ` + + `HTTP request error (status ${ex.status}):\n${ex.message}` + ); + } else if (ex instanceof Error) { + core.setFailed( + `Unable to create ${releaseMode} with the name "${nv}":\n${ex.message}` + ); + } else { + core.setFailed(`Unknown error during ${releaseMode} creation`); + throw ex; + } + core.endGroup(); + return {}; + } + core.info("Succeeded"); + } else { + core.startGroup(`ℹ️ Not creating tag or release for ${nv}..`); + core.info( + "To create a lightweight Git tag or GitHub release when the version is bumped, run this action with:\n" + + ' - "create-release" set to "true" to create a GitHub release, or\n' + + ' - "create-tag" set to "true" for a lightweight Git tag.\n' + + "Note that setting both options is not needed, since a GitHub release implicitly creates a Git tag." + ); + return {}; + } + + return { + release: releaseMetadata, + tag: tagMetadata, + }; +} diff --git a/src/bump/sdkver.ts b/src/bump/sdkver.ts new file mode 100644 index 00000000..4288969a --- /dev/null +++ b/src/bump/sdkver.ts @@ -0,0 +1,457 @@ +/** + * Copyright (C) 2022, TomTom (http://tomtom.com). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as core from "@actions/core"; +import { RequestError } from "@octokit/request-error"; + +import { generateChangelogForCommits, generateChangelog } from "../changelog"; +import { Configuration } from "../config"; +import { createBranch, currentHeadMatchesTag, getCommitsBetweenRefs, getRunNumber, getRelease } from "../github"; +import { ConventionalCommitMessage } from "../commit"; +import { SemVer } from "../semver"; +import { BumpError } from "../errors"; +import * as interfaces from "../interfaces"; +import { processCommitsForBump, publishBump, RC_PREFIX, shortSha } from "./bump"; + +interface VersionUpdateParams { + currentVersion: SemVer; + currentType: interfaces.SdkVerBumpType; + bumpType: interfaces.SdkVerBumpType; + isReleaseBranch: boolean; + headMatchesTag: boolean; + hasBreakingChange: boolean; + devPrereleaseText: string; + headSha: string; + isInitialDevelopment: boolean; +} + +interface VersionUpdateCase { + currentType: interfaces.SdkVerBumpType; + bumpType: interfaces.SdkVerBumpType; + isReleaseBranch: boolean; + updater: (params: VersionUpdateParams) => interfaces.IBumpInfo | undefined; +} + +// prettier-ignore +const versionUpdateCases: VersionUpdateCase[] = [ + // Main branch, current development version + { currentType: "dev", bumpType: "dev", isReleaseBranch: false, updater: (params) => { return updateDevelopmentVersion(params); } }, + { currentType: "dev", bumpType: "rc" , isReleaseBranch: false, updater: (params) => { return promoteToReleaseCandidateVersion(params); } }, + { currentType: "dev", bumpType: "rel", isReleaseBranch: false, updater: (params) => { return promoteToReleaseVersion(params); } }, + + // Main branch, current release candidate version + { currentType: "rc" , bumpType: "dev", isReleaseBranch: false, updater: (params) => { return newDevelopmentVersion(params); } }, + { currentType: "rc" , bumpType: "rc" , isReleaseBranch: false, updater: (params) => { return newReleaseCandidateVersion(params); } }, + { currentType: "rc" , bumpType: "rel", isReleaseBranch: false, updater: (params) => { return promoteToReleaseVersion(params); } }, + + // Main branch, current release version + { currentType: "rel", bumpType: "dev", isReleaseBranch: false, updater: (params) => { return newDevelopmentVersion(params); } }, + { currentType: "rel", bumpType: "rc" , isReleaseBranch: false, updater: (params) => { return newReleaseCandidateVersion(params); } }, + { currentType: "rel", bumpType: "rel", isReleaseBranch: false, updater: (params) => { return newReleaseVersion(params); } }, + + // Release branch, current development version - this is not allowed and will throw an error + // { currentType: "dev" ...} + + // Release branch, current release candidate version + { currentType: "rc" , bumpType: "dev", isReleaseBranch: true, updater: (params) => { return updateReleaseCandidateVersion(params); } }, + { currentType: "rc" , bumpType: "rc" , isReleaseBranch: true, updater: (params) => { return updateReleaseCandidateVersion(params); } }, + { currentType: "rc" , bumpType: "rel", isReleaseBranch: true, updater: (params) => { return promoteToReleaseVersion(params); } }, + + // Release branch, current release version + { currentType: "rel", bumpType: "dev", isReleaseBranch: true, updater: (params) => { return updateReleaseVersion(params); } }, + { currentType: "rel", bumpType: "rc" , isReleaseBranch: true, updater: (params) => { return updateReleaseVersion(params); } }, + { currentType: "rel", bumpType: "rel", isReleaseBranch: true, updater: (params) => { return updateReleaseVersion(params); } }, +]; + +/** + * Increments a development version, i.e. 1.0.0-dev001.SHA -> 1.0.0-dev002.SHA + * + * Exceptions: + * - A new development version is created when the previous version is not using a SdkVer compatible prerelease pattern. + */ +function updateDevelopmentVersion(params: VersionUpdateParams): interfaces.IBumpInfo { + let nextVersion = params.currentVersion.nextPrerelease(undefined, "", 3); + if (!nextVersion) return newDevelopmentVersion(params); + + nextVersion.prerelease = `${nextVersion.prerelease}.${shortSha(params.headSha)}`; + return { from: params.currentVersion, to: nextVersion, type: "dev" }; +} + +/** + * Increments a release candidate version, i.e. 1.0.0-rc01 -> 1.0.0-rc02 + * + * Exceptions: + * - No release candidate version is created when the head matches a tag. + * - Release candidates can only be updated on a release branch. + * - Breaking changes are not allowed when updating a release candidate version. + * - A new release candidate will not be created when the previous version is not using a SdkVer compatible prerelease pattern. + */ +function updateReleaseCandidateVersion(params: VersionUpdateParams): interfaces.IBumpInfo { + if (params.headMatchesTag) + throw new BumpError("Do now update release candidate version when the head matches a tag."); + if (!params.isReleaseBranch) throw new BumpError("Cannot update release candidate version on a non-release branch."); + if (params.hasBreakingChange) throw new BumpError("Cannot update release candidates with a breaking change."); + + let nextVersion = params.currentVersion.nextPrerelease(undefined, "", 2); + if (!nextVersion) + throw new BumpError(`Failed to determine next prerelease version from ${params.currentVersion.toString()}`); + + return { from: params.currentVersion, to: nextVersion, type: "rc" }; +} + +/** + * Updates a release version, i.e. 1.0.0 -> 1.0.1 + * + * Exceptions: + * - No release version is created when the head matches a tag. + * - Release versions can only be updated on a release branch. + * - Breaking changes are not allowed when updating a release version. + */ +function updateReleaseVersion(params: VersionUpdateParams): interfaces.IBumpInfo { + if (params.headMatchesTag) throw new BumpError("Cannot update release version when the head matches a tag."); + if (!params.isReleaseBranch) throw new BumpError("Cannot update release version on a non-release branch."); + if (params.hasBreakingChange) throw new BumpError("Cannot update release version with breaking change."); + + return { from: params.currentVersion, to: params.currentVersion.nextPatch(), type: "rel" }; +} + +/** + * Creates a new development version, i.e. + * Non-breaking: 1.0.0-rc01 --> 1.1.0-dev001.SHA + * Breaking: 1.0.0-rc01 --> 2.0.0-dev001.SHA + * + * Exceptions: + * - New development versions can only be created on a main branch. + */ +function newDevelopmentVersion(params: VersionUpdateParams): interfaces.IBumpInfo { + if (params.isReleaseBranch) throw new BumpError("Cannot create a new development version on a release branch."); + + let nextVersion = SemVer.copy(params.currentVersion); + nextVersion = params.hasBreakingChange ? nextVersion.nextMajor(params.isInitialDevelopment) : nextVersion.nextMinor(); + nextVersion.prerelease = `dev001.${shortSha(params.headSha)}`; + + return { from: params.currentVersion, to: nextVersion, type: "dev" }; +} + +/** + * Creates a new Release Candidate version, i.e. + * Non-breaking: 1.0.0-dev001.SHA --> 1.0.0-rc01 + * Breaking: 1.0.0-dev001.SHA --> 2.0.0-rc01 + * + * Exceptions: + * - New Release Candidate versions can only be created on a main branch. + */ +function newReleaseCandidateVersion(params: VersionUpdateParams): interfaces.IBumpInfo { + if (params.isReleaseBranch) throw new BumpError("Cannot create a new release candidate version on a release branch."); + + let nextVersion = SemVer.copy(params.currentVersion); + nextVersion = params.hasBreakingChange ? nextVersion.nextMajor(params.isInitialDevelopment) : nextVersion.nextMinor(); + nextVersion.prerelease = `rc01`; + + return { from: params.currentVersion, to: nextVersion, type: "rc" }; +} + +/** + * Creates a new release version, i.e. + * Non-breaking: 1.0.0 --> 1.1.0 + * Breaking: 1.0.0 --> 2.0.0 + */ +function newReleaseVersion(params: VersionUpdateParams): interfaces.IBumpInfo { + let nextVersion = params.hasBreakingChange + ? params.currentVersion.nextMajor(params.isInitialDevelopment) + : params.currentVersion.nextMinor(); + return { from: params.currentVersion, to: nextVersion, type: "rel" }; +} + +/** + * Promotes a development version to a Release Candidate version, i.e. + * Non-breaking: 1.0.0-dev001.SHA -> 1.0.0-rc01 + * Breaking: 1.0.0-dev001.SHA -> 2.0.0-rc01 + */ +function promoteToReleaseCandidateVersion(params: VersionUpdateParams): interfaces.IBumpInfo { + let nextVersion: SemVer | null = params.hasBreakingChange + ? params.currentVersion.nextMajor() + : SemVer.copy(params.currentVersion); + nextVersion.prerelease = `${RC_PREFIX}01`; + nextVersion.build = ""; + + return { from: params.currentVersion, to: nextVersion, type: "rc" }; +} + +/** + * Promotes a development- or release candidate- version to a release version, i.e. + * Non-breaking: 1.0.0-dev001.SHA -> 1.0.0 + * Breaking: 1.0.0-dev001.SHA -> 2.0.0 + * + * Exceptions: + * - Cannot apply a breaking change on a release branch. + * + * NOTE: This function can be called from both main and release branches for release candidates. + */ +function promoteToReleaseVersion(params: VersionUpdateParams): interfaces.IBumpInfo { + if (params.hasBreakingChange) { + if (params.isReleaseBranch) throw new BumpError("Cannot promote to release version with breaking change."); + return newReleaseVersion(params); + } + + let nextVersion: SemVer | null = SemVer.copy(params.currentVersion); + nextVersion.prerelease = ""; + nextVersion.build = ""; + + return { from: params.currentVersion, to: nextVersion, type: "rel" }; +} + +/** + * Finds the version update case that matches the provided parameters. + */ +function findVersionUpdateCase(params: VersionUpdateParams): VersionUpdateCase | undefined { + return versionUpdateCases.find( + c => + c.currentType === params.currentType && + c.bumpType === params.bumpType && + c.isReleaseBranch === params.isReleaseBranch + ); +} + +/** + * Determines the next SDK version based on the current version and the bump type. + * @throws BumpError if no matching version update case is found. + */ +function getNextSdkVer( + currentVersion: SemVer, + sdkVerBumpType: interfaces.SdkVerBumpType, + isReleaseBranch: boolean, + headMatchesTag: boolean, + hasBreakingChange: boolean, + devPrereleaseText: string, + headSha: string, + isInitialDevelopment: boolean +): interfaces.IBumpInfo | undefined { + let currentReleaseType: interfaces.SdkVerBumpType; + if (currentVersion.prerelease.startsWith(RC_PREFIX)) { + currentReleaseType = "rc"; + } else if (currentVersion.prerelease === "") { + currentReleaseType = "rel"; + } else { + currentReleaseType = "dev"; + } + + core.info(`Determining SDK bump for version ${currentVersion.toString()}:`); + core.info(` - current version type: ${currentReleaseType}`); + core.info(` - bump type: ${sdkVerBumpType}`); + core.info(` - branch type: ${isReleaseBranch ? "" : "not "}release`); + core.info(` - breaking changes: ${hasBreakingChange ? "yes" : "no"}`); + + const params: VersionUpdateParams = { + currentVersion, + currentType: currentReleaseType, + bumpType: sdkVerBumpType, + isReleaseBranch, + headMatchesTag, + hasBreakingChange, + devPrereleaseText, + headSha, + isInitialDevelopment, + }; + + const match = findVersionUpdateCase(params); + if (match) { + return match.updater(params); + } + + throw new BumpError( + `No version update case found for bump type '${sdkVerBumpType}' ` + + `on release branch '${isReleaseBranch}', ` + + `head matches tag: ${headMatchesTag}, ` + + `has breaking change: ${hasBreakingChange}, ` + + `initial development: ${isInitialDevelopment}` + ); +} + +/** + * Bump and release/tag SDK versions + */ +export async function bumpSdkVer( + config: Configuration, + bumpInfo: interfaces.IVersionBumpTypeAndMessages, + releaseMode: interfaces.ReleaseMode, + sdkVerBumpType: interfaces.SdkVerBumpType, + headSha: string, + branchName: string, + isBranchAllowedToPublish: boolean, + createChangelog: boolean +): Promise { + const isReleaseBranch = new RegExp(config.releaseBranches).test(branchName); + let hasBreakingChange = bumpInfo.processedCommits.some(c => c.message?.breakingChange); + if (!bumpInfo.foundVersion) return; // should never happen + + // SdkVer requires a prerelease, so apply the default if not set + config.prereleasePrefix = config.prereleasePrefix ?? "dev"; + + let cv = SemVer.copy(bumpInfo.foundVersion); + + // Do not bump major version when breaking change is found in case + // the max configured major version is already reached + if (config.sdkverMaxMajor !== undefined && config.sdkverMaxMajor > 0 && cv.major >= config.sdkverMaxMajor) { + console.log(`Maximum major version ${config.sdkverMaxMajor} reached, not bumping major version.`); + hasBreakingChange = false; + } + + // Get the latest draft release matching our current version's prefix. + // Don't look at the draft version on a release branch; the current version + // should always reflect the version to be bumped (as no dev releases are + // allowed on a release branch) + const latestDraft = await getRelease({ prefixToMatch: cv.prefix, draftOnly: true, fullReleasesOnly: false }); + const latestRelease = await getRelease({ prefixToMatch: cv.prefix, draftOnly: false, fullReleasesOnly: true }); + + core.info( + `Current version: ${cv.toString()}, latest GitHub release draft: ${latestDraft?.name ?? "NONE"}, latest GitHub release: ${latestRelease?.name ?? "NONE"}` + ); + + if (!isReleaseBranch && latestDraft) { + // If we're not on a release branch and a draft version exists that is + // newer than the latest tag, we continue with that + const draftVersion = SemVer.fromString(latestDraft.name); + if (draftVersion && cv.lessThan(draftVersion)) { + cv = draftVersion; + } + } + + // TODO: This is wasteful, as this info has already been available before + const headMatchesTag = await currentHeadMatchesTag(cv.toString()); + const bump = getNextSdkVer( + cv, + sdkVerBumpType, + isReleaseBranch, + headMatchesTag, + hasBreakingChange, + config.prereleasePrefix ?? "dev", + headSha, + config.initialDevelopment + ); + + let bumped = false; + let changelog = ""; + let releaseBranchName: string | undefined; + let versionOutput: interfaces.IVersionOutput | undefined; + + if (bump?.to) { + // Since we want the changelog since the last _full_ release, we can only rely on the `bumpInfo` if the "current version" is a + // full release. In other cases, we need to gather some information to generate the proper changelog. + const previousRelease = await getRelease({ + prefixToMatch: bump.to.prefix, + draftOnly: false, + fullReleasesOnly: true, + constraint: { major: bump.to.major, minor: bump.to.minor }, + }); + core.info(`The full release preceding the current one is ${previousRelease?.name ?? "undefined"}`); + + if (createChangelog) { + if (previousRelease && cv.prerelease) { + // Since "dev" releases on non-release-branches result in a draft release, we'll need to use the commit sha. + const toVersion = bump.type === "dev" ? shortSha(headSha) : bump.to.toString(); + + const changelogCommits = await collectChangelogCommits(previousRelease.name, config); + changelog = await generateChangelogForCommits(previousRelease.name, toVersion, changelogCommits); + } else { + changelog = await generateChangelog(bumpInfo); + } + } + + const { release, tag } = await publishBump( + bump.to, + releaseMode, + headSha, + changelog, + isBranchAllowedToPublish, + config.releaseDiscussionCategory, + // Re-use the latest draft release only when not running on a release branch, otherwise we might randomly reset a `dev-N` number chain. + !isReleaseBranch ? latestDraft?.id : undefined + ); + + versionOutput = { + tag, + release, + bump: { + from: bumpInfo.foundVersion.toString(), + to: bump.to.toString(), + type: bump.type as interfaces.ReleaseType, + }, + }; + + // If we have a release and/or a tag, we consider the bump successful + bumped = release !== undefined || tag !== undefined; + } + + if (!bumped) { + core.info("ℹ️ No bump was performed"); + } else { + // Create a release branch for releases and RC's if we're configured to do so and are currently not running on a release branch. + if (config.sdkverCreateReleaseBranches !== undefined && !isReleaseBranch && bump?.type !== "dev" && bump?.to) { + releaseBranchName = `${config.sdkverCreateReleaseBranches}${bump.to.major}.${bump.to.minor}`; + core.info(`Creating release branch ${releaseBranchName}..`); + try { + await createBranch(`refs/heads/${releaseBranchName}`, headSha); + } catch (ex: unknown) { + if (ex instanceof RequestError && ex.status === 422) { + core.warning( + `The branch '${releaseBranchName}' already exists ${getRunNumber() !== 1 ? " (NOTE: this is a re-run)." : "."}` + ); + } else if (ex instanceof RequestError) { + core.warning( + `Unable to create release branch '${releaseBranchName}' due to HTTP request error (status ${ex.status}):\n${ex.message}` + ); + } else if (ex instanceof Error) { + core.warning(`Unable to create release branch '${releaseBranchName}':\n${ex.message}`); + } else { + core.warning(`Unknown error during ${releaseMode} creation`); + throw ex; + } + } + } + } + + core.endGroup(); + + return bumped ? versionOutput : undefined; +} + +/** + * For SdkVer, the latest tag (i.e. "current version") may not be the starting + * point we want for generating a changelog; in this context, we want to get a + * list of commits since the last _full_ release. + * + * Returns an object containing: + * - the name of the last full release reachable from our current version + * - the list of valid Conventional Commit objects since that release + */ +async function collectChangelogCommits( + previousRelease: string, + config: Configuration +): Promise { + core.startGroup(`📜 Gathering changelog information`); + + const commits = await getCommitsBetweenRefs(previousRelease); + core.info( + `Processing commit list (since ${previousRelease}) for changelog generation:\n-> ` + + `${commits.map(c => c.message.split("\n")[0]).join("\n-> ")}` + ); + + const processedCommits = processCommitsForBump(commits, config); + core.endGroup(); + + return processedCommits.map(c => c.message).filter(c => c) as ConventionalCommitMessage[]; +} diff --git a/src/bump/semver.ts b/src/bump/semver.ts new file mode 100644 index 00000000..fba95d63 --- /dev/null +++ b/src/bump/semver.ts @@ -0,0 +1,468 @@ +/** + * Copyright (C) 2022, TomTom (http://tomtom.com). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as core from "@actions/core"; + +import { generateChangelog } from "../changelog"; +import { Configuration } from "../config"; +import { + createRelease, + getAllTags, + getRelease, + isPullRequestEvent, + matchTagsToCommits, + updateDraftRelease, +} from "../github"; +import { ConventionalCommitMessage } from "../commit"; +import { SemVer, SemVerType } from "../semver"; +import { + IValidationResult, + IVersionBumpTypeAndMessages, + ReleaseMode, + IVersionOutput, + IGitHubRelease, + ReleaseType, + IBumpInfo, +} from "../interfaces"; +import { outputCommitListErrors } from "../validate"; +import { processCommitsForBump, publishBump, shortSha } from "./bump"; + +/** + * Returns a SemVer object if: + * - the `tagSha` and `commitSha` match + * - the `tagName` tag reference is SemVer-compatible + * - the `prefix` exactly matches the `tagName`'s prefix (if any), + or the provided `prefix` is "*" + * + * @param prefix Specifies the exact prefix of the tags to be considered, + * '*' means "any" + * @param tagName The tag reference name + * @param tagSha The tag's SHA1 hash + * @param commitSha The SHA1 hash to compare to + * + * @return {Semver | null} A SemVer object representing the value of `tagName`, + * or `null` if the provided parameters don't match + */ +function getSemVerIfMatches( + prefix: string, + tagName: string, + tagSha: string, + commitSha: string +): SemVer | null { + if (commitSha === tagSha) { + const dbg = (tag: string, commit: string, message: string): void => { + core.debug(`Tag '${tag}' on commit '${commit.slice(0, 6)}' ${message}`); + }; + const sv: SemVer | null = SemVer.fromString(tagName); + if (sv) { + // If provided, make sure that the prefix matches as well + // Asterisk is a special case, meaning 'any prefix' + if (sv.prefix === prefix || prefix === "*") { + dbg(tagName, commitSha, `matches prefix ${prefix}`); + return sv; + } + dbg(tagName, commitSha, `does not match prefix ${prefix}`); + } else { + dbg(tagName, commitSha, "is not a SemVer"); + } + } + + return null; +} + +/** + * Determines the highest SemVer bump level based on the provided + * list of Conventional Commits + */ +export function getVersionBumpType( + messages: ConventionalCommitMessage[] +): SemVerType { + let highestBump: SemVerType = SemVerType.NONE; + + for (const message of messages) { + if (highestBump !== SemVerType.MAJOR) { + core.debug( + `Commit type '${message.type}'${ + message.breakingChange ? " (BREAKING)" : "" + }, has bump type: ${SemVerType[message.bump]}` + ); + highestBump = message.bump > highestBump ? message.bump : highestBump; + } + } + + return highestBump; +} + +/** + * Within the current context, examine all commits reachable from from `context.sha` + * and match them to _all_ the tags found in the repo. + * Each commit shall be tried to be matched to any of the tags found in chronological + * order (i.e. the time the tag was pushed). + * The closest tag that is SemVer-compatible and matches the `prefix` value as + * configured in the `config` object shall be returned as a SemVer object, and + * the highest bump type encountered in the commits _since_ that tag shall be returned. + * - MAJOR: breaking changes, + * - MINOR: feat commits, + * - PATCH: fix commits, plus any tag matching one of `extra_patch_tags`, if configured + * + * @param targetSha The sha on which to start listing commits + * @param config A Configuration object, which optionally contains the `prefix` value + * that processed versions must match, or a list of Conventional Commit type + * tags that should bump the patch version field (aside from "fix"). + * + * @return {IVersionBumpTypeAndMessages} + returns an object containing: + - the SemVer object or null if no (acceptable) SemVer was found. + - the highest bump encountered, or SemVerType.NONE if [0] is null + - list of ConventionalCommitMessage objects up to the found SemVer tag + - state of "initial development"; if no version is found, err on the + safe side and declare "initial development" (if configured as such) + */ +export async function getVersionBumpTypeAndMessages( + targetSha: string, + config: Configuration +): Promise { + core.debug("Fetching repository tags.."); + const tags = await getAllTags(); + core.debug(`Fetch complete; found ${tags.length} tags`); + const tagMatcher = (commitSha: string): SemVer | null => { + // Try and match this commit's hash to one of the tags in `tags` + for (const tag of tags) { + let semVer: SemVer | null = null; + core.debug(`Considering tag ${tag.name} (${tag.sha}) on ${commitSha}`); + semVer = getSemVerIfMatches( + config.versionPrefix, + tag.name, + tag.sha, + commitSha + ); + if (semVer) { + // We've found a tag that matches to this commit. Now, we need to make sure that + // we return the _highest_ version tag associated with this commit. + core.debug( + `Matching tag found (${tag.name}), checking other tags for commit ${commitSha}..` + ); + const matchTags = tags.filter(t => t.sha === commitSha); + if (matchTags.length > 1) { + core.debug(`${matchTags.length} other tags found`); + matchTags.sort((lhs, rhs) => SemVer.sortSemVer(lhs.name, rhs.name)); + semVer = null; + while (semVer === null && matchTags.length !== 0) { + const t = matchTags.pop(); + if (!t) break; + semVer = getSemVerIfMatches( + config.versionPrefix, + t.name, + t.sha, + commitSha + ); + } + } else { + core.debug(`No other tags found`); + // Just the one tag; carry on. + } + + return semVer; + } + } + core.debug(`Commit ${commitSha.slice(0, 6)} is not associated with a tag`); + return null; + }; + const [version, commitList] = await matchTagsToCommits(targetSha, tagMatcher); + + const results = processCommitsForBump(commitList, config); + const convCommits = results + .map(r => r.message) + .filter((r): r is ConventionalCommitMessage => r !== undefined); + + return { + foundVersion: version, + requiredBump: getVersionBumpType(convCommits), + processedCommits: results, + initialDevelopment: + config.initialDevelopment && + (!version || (version && version.major === 0)), + }; +} + +/** + * Tries to update an existing draft GitHub release. + * Not prerelease-type-aware, and only succeeds if a prerelease + * version already exists. Behavior: + * 1.2.3-dev4 -> 1.2.3-dev5 + * 2.3.4-alpha104 -> 2.3.4-alpha105 + * 3.4.5-rc1 -> 3.4.5-rc2 + * 4.5.6 -> undefined + * + * Returns the new prerelease version name if update was successful, + * `undefined` otherwise. + */ +async function tryUpdateDraftRelease( + cv: SemVer, + changelog: string, + sha: string +): Promise { + const latestDraftRelease = await getRelease({ + prefixToMatch: cv.prefix, + draftOnly: true, + fullReleasesOnly: false, + }); + if (!latestDraftRelease) return; + + const currentDraftVersion = SemVer.fromString(latestDraftRelease.name); + if (!currentDraftVersion) { + core.info(`Couldn't parse ${latestDraftRelease.name} as SemVer`); + return; + } + + const npv = currentDraftVersion.nextPrerelease(); + if (!npv) return; + npv.build = shortSha(sha); + + const updatedRelease = await updateDraftRelease( + latestDraftRelease.id, + npv.toString(), + npv.toString(), + sha, + changelog + ); + if (!updatedRelease) { + core.info(`Error renaming existing draft release.`); + } + + return updatedRelease; +} + +async function newDraftRelease( + currentVersion: SemVer, + changelog: string, + sha: string, + prefix: string +): Promise { + // Either update went wrong or there was nothing to update + const nextPrereleaseVersion = currentVersion.nextPatch(); + nextPrereleaseVersion.build = currentVersion.build; + if (prefix === "dev") { + nextPrereleaseVersion.prerelease = `${prefix}001.${shortSha(sha)}`; + } else { + nextPrereleaseVersion.prerelease = `${prefix}001`; + } + const newRelease = await createRelease( + nextPrereleaseVersion.toString(), + sha, + changelog, + true, + false + ); + + return newRelease; +} + +export async function bumpDraftRelease( + bumpInfo: IVersionBumpTypeAndMessages, + changelog: string, + sha: string, + preRelPrefix: string +): Promise { + const cv = bumpInfo.foundVersion; + if (!cv) throw Error("Found version is falsy"); // should never happen + const result = + (await tryUpdateDraftRelease(cv, changelog, sha)) ?? + (await newDraftRelease(cv, changelog, sha, preRelPrefix)); + + if (result) { + core.info(`ℹ️ Next prerelease: ${result.name}`); + } else { + core.warning(`⚠️ No prerelease created.`); + } + + return result; +} + +/** + * Prints information about any non-compliance found in the provided list + */ +export function printNonCompliance(commits: IValidationResult[]): void { + const nonCompliantCommits = commits.filter(c => !c.message); + + if (nonCompliantCommits.length > 0) { + const totalLen = commits.length; + const ncLen = nonCompliantCommits.length; + + core.info(""); // for vertical whitespace + + if (ncLen === totalLen) { + const commitsDoNotComply = + totalLen === 1 + ? "The only encountered commit does not comply" + : `None of the encountered ${totalLen} commits comply`; + + core.warning( + `${commitsDoNotComply} with the Conventional Commits specification, ` + + "so the intended bump level could not be determined.\n" + + "As a result, no version bump will be performed." + ); + } else { + const [pluralDo, pluralBe] = ncLen === 1 ? ["does", "is"] : ["do", "are"]; + + core.warning( + `${ncLen} of the encountered ${totalLen} commits ` + + `${pluralDo} not comply with the Conventional Commits ` + + `specification and ${pluralBe} therefore NOT considered ` + + "while determining the bump level." + ); + } + const pluralS = ncLen === 1 ? "" : "s"; + core.info(`⚠️ Non-compliant commit${pluralS}:`); + outputCommitListErrors(nonCompliantCommits, false); + } +} + +export async function bumpSemVer( + config: Configuration, + bumpInfo: IVersionBumpTypeAndMessages, + releaseMode: ReleaseMode, + branchName: string, + headSha: string, + isBranchAllowedToPublish: boolean, + createChangelog: boolean +): Promise { + const compliantCommits = bumpInfo.processedCommits + .filter(c => c.message !== undefined) + .map(c => ({ + msg: c.message as ConventionalCommitMessage, + sha: c.input.sha.slice(0, 8), + })); + + for (const { msg, sha } of compliantCommits) { + const bumpString = msg.bump === 0 ? "No" : SemVerType[msg.bump]; + core.info(`- ${bumpString} bump for commit (${sha}): ${msg.subject}`); + } + + // Reject MAJOR and MINOR version bumps if we're on a release branch + // (Purposefully do this check _after_ listing the processed commits.) + if ( + new RegExp(config.releaseBranches).test(branchName) && + [SemVerType.MAJOR, SemVerType.MINOR].includes(bumpInfo.requiredBump) + ) { + core.setFailed( + `A ${SemVerType[bumpInfo.requiredBump]} bump is requested, but ` + + `we can only create PATCH bumps on a release branch.` + ); + return; + } + + let bumpMetadata: IBumpInfo | undefined; + + if (bumpInfo.foundVersion) { + const bumpResult = bumpInfo.foundVersion.bump( + bumpInfo.requiredBump, + config.initialDevelopment + ); + if (bumpResult) { + bumpMetadata = { + from: bumpInfo.foundVersion, + to: bumpResult.version, + type: SemVerType[bumpResult.increment].toLowerCase() as ReleaseType, + }; + } + } + + let versionMetadata: IVersionOutput | undefined; + + let bumped = false; + + let changelog = ""; + if (createChangelog) changelog = await generateChangelog(bumpInfo); + + if (bumpMetadata) { + const buildMetadata = core.getInput("build-metadata"); + if (buildMetadata) { + bumpMetadata.to.build = buildMetadata; + } + + const { release, tag } = await publishBump( + bumpMetadata.to, + releaseMode, + headSha, + changelog, + isBranchAllowedToPublish, + config.releaseDiscussionCategory + ); + + versionMetadata = { + bump: { + from: bumpMetadata.from.toString(), + to: bumpMetadata.to.toString(), + type: bumpMetadata.type as ReleaseType, + }, + tag, + release, + }; + + // If we have a release and/or a tag, we consider the bump successful + bumped = release !== undefined || tag !== undefined; + } else { + core.info("ℹ️ No bump necessary"); + } + core.endGroup(); + + if (!bumped && config.prereleasePrefix !== undefined) { + // When configured to create GitHub releases, and the `bump-prereleases` config item + // evaluates to `true`. + if ( + isBranchAllowedToPublish && + !isPullRequestEvent() && + releaseMode === "release" + ) { + // Create/rename draft release + const draftRelease = await bumpDraftRelease( + bumpInfo, + changelog, + headSha, + config.prereleasePrefix + ); + + if (!draftRelease) { + return; + } + + core.info(`ℹ️ Created draft prerelease version ${draftRelease.name}`); + if (!bumpInfo.foundVersion) throw Error("Found version is falsy"); // should never happen + + return { + release: draftRelease, + bump: { + from: bumpInfo.foundVersion.toString(), + to: draftRelease.name, + type: "prerelease", + }, + }; + } else { + const reason = + isBranchAllowedToPublish !== true + ? `the current branch is not allowed to publish` + : isPullRequestEvent() + ? "we cannot publish from a pull request event" + : releaseMode !== "release" + ? `we can only do so when the 'create-release' input is provided to be 'true'` + : "we didn't think of writing an error message here"; + core.info(`ℹ️ While configured to bump prereleases, ${reason}.`); + } + } + + return bumped ? versionMetadata : undefined; +} diff --git a/src/semver.ts b/src/semver.ts index 0ec365a4..d7e3050e 100644 --- a/src/semver.ts +++ b/src/semver.ts @@ -112,7 +112,13 @@ export class SemVer implements ISemVer { return `${this.prefix}${this.major}.${this.minor}.${this.patch}${prerelease}${build}`; } - nextMajor(): SemVer { + nextMajor(initialDevelopment?: boolean): SemVer { + if (initialDevelopment && this.major <= 0) { + // Bumping major version during initial development is prohibited, + // bump the minor version instead. + return this.nextMinor(); + } + return new SemVer({ major: this.major + 1, minor: 0, diff --git a/test/bump.sdkver.test.ts b/test/bump.sdkver.test.ts index 8d48e4ad..301d2635 100644 --- a/test/bump.sdkver.test.ts +++ b/test/bump.sdkver.test.ts @@ -324,6 +324,7 @@ const testSuiteDefinitions = [ ["main branch" , "1.2.0" , "dev" , undefined , "master" , true , `2.0.0-dev001.${U.HEAD_SHA_ABBREV_8}`, "dev" , false , 0 ], ["main branch, draft init", "0.2.0" , "dev" , "0.3.0-dev001.2" , "master" , true , `0.3.0-dev002.${U.HEAD_SHA_ABBREV_8}`, "dev" , true , 0 ], ["main branch, draft max" , "1.2.0" , "dev" , "1.3.0-dev001.2" , "master" , true , `1.3.0-dev002.${U.HEAD_SHA_ABBREV_8}`, "dev" , false , 1 ], + ["main branch, draft max2", "1.2.0" , "dev" , "1.3.0-dev001.2" , "master" , true , `2.0.0-dev001.${U.HEAD_SHA_ABBREV_8}`, "dev" , false , 2 ], ["main branch, draft" , "1.2.0" , "dev" , "1.3.0-dev001.2" , "master" , true , `1.3.0-dev002.${U.HEAD_SHA_ABBREV_8}`, "dev" , false , 0 ], ["release branch" , "1.2.0" , "dev" , undefined , "release/1.2.0", true , undefined , "" , false , 0 ], ["release branch, draft" , "1.2.0" , "dev" , "1.3.0-dev001.3" , "release/1.2.0", true , undefined , "" , false , 0 ], @@ -334,29 +335,35 @@ const testSuiteDefinitions = [ { suite: "Rc bumps with breaking changes", tests: [ - // [ test description , version , bump , latest draft , branch , breaking?, expected version , expected bump , initial development?, max major version ] - ["main branch, init" , "0.2.0" , "rc" , undefined , "master" , true , "0.3.0-rc01" , "rc" , true , 0 ], - ["main branch, no init" , "0.2.0" , "rc" , undefined , "master" , true , "1.0.0-rc01" , "rc" , false , 0 ], - ["main branch, max" , "1.2.0" , "rc" , undefined , "master" , true , "1.3.0-rc01" , "rc" , false , 1 ], - ["main branch, max2" , "1.2.0" , "rc" , undefined , "master" , true , "2.0.0-rc01" , "rc" , false , 2 ], - ["main branch" , "1.2.0" , "rc" , undefined , "master" , true , "2.0.0-rc01" , "rc" , false , 0 ], - ["main branch+RC" , "1.2.0-rc01" , "rc" , undefined , "master" , true , "2.0.0-rc01" , "rc" , false , 0 ], - ["release branch" , "1.2.0" , "rc" , undefined , "release/1.2.0", true , undefined , "" , false , 0 ], - ["release branch+RC" , "1.2.0-rc01" , "rc" , undefined , "release/1.2.0", true , undefined , "" , false , 0 ], - ["RB+ RC for next major", "2.0.0-rc01" , "dev" , undefined , "release/2.0.0", true , "2.0.0-rc02" , "rc" , false , 0 ], + // [ test description , version , bump , latest draft , branch , breaking?, expected version , expected bump , initial development?, max major version ] + ["main branch, init" , "0.2.0" , "rc" , undefined , "master" , true , "0.3.0-rc01" , "rc" , true , 0 ], + ["main branch, no init" , "0.2.0" , "rc" , undefined , "master" , true , "1.0.0-rc01" , "rc" , false , 0 ], + ["main branch, max" , "1.2.0" , "rc" , undefined , "master" , true , "1.3.0-rc01" , "rc" , false , 1 ], + ["main branch, max2" , "1.2.0" , "rc" , undefined , "master" , true , "2.0.0-rc01" , "rc" , false , 2 ], + ["main branch, dev" , "1.2.0" , "rc" , "1.3.0-dev001.2", "master" , true , "2.0.0-rc01" , "rc" , false , 0 ], + ["main branch, dev max" , "1.2.0" , "rc" , "1.3.0-dev001.2", "master" , true , "1.3.0-rc01" , "rc" , false , 1 ], + ["main branch, dev max2", "1.2.0" , "rc" , "1.3.0-dev001.2", "master" , true , "2.0.0-rc01" , "rc" , false , 2 ], + ["main branch" , "1.2.0" , "rc" , undefined , "master" , true , "2.0.0-rc01" , "rc" , false , 0 ], + ["main branch+RC" , "1.2.0-rc01" , "rc" , undefined , "master" , true , "2.0.0-rc01" , "rc" , false , 0 ], + ["release branch" , "1.2.0" , "rc" , undefined , "release/1.2.0", true , undefined , "" , false , 0 ], + ["release branch+RC" , "1.2.0-rc01" , "rc" , undefined , "release/1.2.0", true , undefined , "" , false , 0 ], + ["RB+ RC for next major", "2.0.0-rc01" , "dev" , undefined , "release/2.0.0", true , undefined , "" , false , 0 ], ], }, { suite: "Release bumps with breaking changes", tests: [ - // [ test description , version , bump , latest draft , branch , breaking?, expected version, expected bump , initial development?, max major version ] - ["main branch, init" , "0.2.0" , "rel" , undefined , "master" , true , "0.3.0" , "rel" , true , 0 ], - ["main branch, no init" , "0.2.0" , "rel" , undefined , "master" , true , "1.0.0" , "rel" , false , 0 ], - ["main branch, max" , "1.2.0" , "rel" , undefined , "master" , true , "1.3.0" , "rel" , false , 1 ], - ["main branch, max2" , "1.2.0" , "rel" , undefined , "master" , true , "2.0.0" , "rel" , false , 2 ], - ["main branch" , "1.2.0" , "rel" , undefined , "master" , true , "2.0.0" , "rel" , false , 0 ], - ["release branch" , "1.2.0" , "rel" , undefined , "release/1.2.0", true , undefined , "" , false , 0 ], - ["release branch+RC" , "1.2.0-rc01" , "rel" , undefined , "release/1.2.0", true , undefined , "" , false , 0 ], + // [ test description , version , bump , latest draft , branch , breaking?, expected version, expected bump , initial development?, max major version ] + ["main branch, init" , "0.2.0" , "rel" , undefined , "master" , true , "0.3.0" , "rel" , true , 0 ], + ["main branch, no init" , "0.2.0" , "rel" , undefined , "master" , true , "1.0.0" , "rel" , false , 0 ], + ["main branch, max" , "1.2.0" , "rel" , undefined , "master" , true , "1.3.0" , "rel" , false , 1 ], + ["main branch, max2" , "1.2.0" , "rel" , undefined , "master" , true , "2.0.0" , "rel" , false , 2 ], + ["main branch, dev" , "1.2.0" , "rel" , "1.3.0-dev001.2", "master" , true , "2.0.0" , "rel" , false , 0 ], + ["main branch, dev max" , "1.2.0" , "rel" , "1.3.0-dev001.2", "master" , true , "1.3.0" , "rel" , false , 1 ], + ["main branch, dev max2", "1.2.0" , "rel" , "1.3.0-dev001.2", "master" , true , "2.0.0" , "rel" , false , 2 ], + ["main branch" , "1.2.0" , "rel" , undefined , "master" , true , "2.0.0" , "rel" , false , 0 ], + ["release branch" , "1.2.0" , "rel" , undefined , "release/1.2.0", true , undefined , "" , false , 0 ], + ["release branch+RC" , "1.2.0-rc01" , "rel" , undefined , "release/1.2.0", true , undefined , "" , false , 0 ], ], }, // DRAFT RELEASE HANDLING @@ -383,6 +390,7 @@ const testSuiteDefinitions = [ ["rb: previous version" , "1.2.0" , "rc" , "1.1.0-dev034", "release/1.2.0", false , "1.2.1" , "rel" , false , 0 ], ["rb: current version" , "1.2.0" , "rc" , "1.2.0-dev023", "release/1.2.0", false , "1.2.1" , "rel" , false , 0 ], ["rb: next version" , "1.2.0" , "rc" , "1.3.0-dev019", "release/1.2.0", false , "1.2.1" , "rel" , false , 0 ], + ], }, // MISCELLANEOUS ERRORS diff --git a/test/bump.test.ts b/test/bump.test.ts index aeb1754b..976137e7 100644 --- a/test/bump.test.ts +++ b/test/bump.test.ts @@ -21,7 +21,7 @@ import * as bumpaction from "../src/actions/bump"; import * as changelog from "../src/changelog"; import * as validate from "../src/validate"; -import { getVersionBumpTypeAndMessages } from "../src/bump"; +import { getVersionBumpTypeAndMessages } from "../src/bump/semver"; import * as fs from "fs"; import { SemVer } from "../src/semver"; import * as U from "./test_utils"; From 91d3f1a48b3b8d2b379b2f2f7a97ab8a59f8931e Mon Sep 17 00:00:00 2001 From: Kevin de Jong Date: Tue, 24 Jun 2025 15:58:45 +0200 Subject: [PATCH 2/3] fixup! fix: handle breaking changes when current version is dev --- dist/bump/index.js | 1006 ++++++++++++++++++++++------------------ dist/cli/index.js | 7 +- dist/validate/index.js | 616 +++++++----------------- src/bump/bump.ts | 16 + src/bump/sdkver.ts | 2 +- src/bump/semver.ts | 2 +- 6 files changed, 761 insertions(+), 888 deletions(-) diff --git a/dist/bump/index.js b/dist/bump/index.js index 9e93d028..a2531d07 100644 --- a/dist/bump/index.js +++ b/dist/bump/index.js @@ -33041,7 +33041,8 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.run = void 0; const core = __importStar(__nccwpck_require__(7484)); const github_1 = __nccwpck_require__(3228); -const bump_1 = __nccwpck_require__(2123); +const sdkver_1 = __nccwpck_require__(6503); +const semver_1 = __nccwpck_require__(2786); const config_1 = __nccwpck_require__(5354); const github_2 = __nccwpck_require__(9248); /** @@ -33080,7 +33081,7 @@ async function run() { 'a Git tag is implicitly created when using "create-release".'); } core.startGroup("🔍 Finding latest topological tag.."); - const bumpInfo = await (0, bump_1.getVersionBumpTypeAndMessages)(github_1.context.sha, config); + const bumpInfo = await (0, semver_1.getVersionBumpTypeAndMessages)(github_1.context.sha, config); if (!bumpInfo.foundVersion) { // We haven't found a (matching) SemVer tag in the commit and tag list core.setOutput("current-version", ""); @@ -33100,7 +33101,7 @@ async function run() { ? "This repository is under 'initial development'; breaking changes will bump the `MINOR` version." : "Enforcing version `1.0.0` as we are no longer in `initial development`."); } - (0, bump_1.printNonCompliance)(bumpInfo.processedCommits); + (0, semver_1.printNonCompliance)(bumpInfo.processedCommits); core.info(""); const createChangelog = core.getBooleanInput("create-changelog"); const releaseTypeInput = core.getInput("release-type"); @@ -33111,7 +33112,7 @@ async function run() { core.warning("The input value 'release-type' has no effect when using SemVer as the version scheme."); } core.startGroup("🔍 Determining SemVer bump"); - versionInfo = await (0, bump_1.bumpSemVer)(config, bumpInfo, releaseMode, branchName, github_1.context.sha, isBranchAllowedToPublish, createChangelog); + versionInfo = await (0, semver_1.bumpSemVer)(config, bumpInfo, releaseMode, branchName, github_1.context.sha, isBranchAllowedToPublish, createChangelog); } else if (config.versionScheme === "sdkver") { if (!["rel", "rc", "dev", ""].includes(releaseTypeInput)) { @@ -33121,7 +33122,7 @@ async function run() { core.startGroup("🔍 Determining SdkVer bump"); // For non-release branches, a flow similar to SemVer can be followed, // but release branches get linear increments. - versionInfo = await (0, bump_1.bumpSdkVer)(config, bumpInfo, releaseMode, releaseType, github_1.context.sha, branchName, isBranchAllowedToPublish, createChangelog); + versionInfo = await (0, sdkver_1.bumpSdkVer)(config, bumpInfo, releaseMode, releaseType, github_1.context.sha, branchName, isBranchAllowedToPublish, createChangelog); } else { throw new Error(`Unimplemented 'version-scheme': ${config.versionScheme}`); @@ -33170,13 +33171,13 @@ function checkBranchPublishingPermission(config) { /***/ }), -/***/ 2123: +/***/ 2194: /***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { "use strict"; /** - * Copyright (C) 2022, TomTom (http://tomtom.com). + * Copyright (C) 2025, TomTom (http://tomtom.com). * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -33214,15 +33215,12 @@ var __importStar = (this && this.__importStar) || function (mod) { return result; }; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.bumpSdkVer = exports.bumpSemVer = exports.publishBump = exports.printNonCompliance = exports.bumpDraftRelease = exports.getVersionBumpTypeAndMessages = exports.getVersionBumpType = void 0; +exports.publishBump = exports.processCommitsForBump = exports.shortSha = exports.RC_PREFIX = void 0; const core = __importStar(__nccwpck_require__(7484)); const request_error_1 = __nccwpck_require__(3708); -const changelog_1 = __nccwpck_require__(8397); const github_1 = __nccwpck_require__(9248); -const semver_1 = __nccwpck_require__(1475); -const errors_1 = __nccwpck_require__(3916); const validate_1 = __nccwpck_require__(397); -const RC_PREFIX = "rc"; +exports.RC_PREFIX = "rc"; /** * Return the first eight characters of a string. * @@ -33231,6 +33229,548 @@ const RC_PREFIX = "rc"; function shortSha(sha) { return sha.substring(0, 8); } +exports.shortSha = shortSha; +/** Validates a list of commits in a bump context, which differs slightly to + * pull request validation runs, as some rules need to be disabled. + */ +function processCommitsForBump(commits, config) { + // We'll relax certain rules while processing these commits; these are + // commits/pull request titles that (ideally) have been validated + // _before_ they were merged, and certain GitHub CI settings may append + // a reference to the PR number in merge commits. + const configCopy = config.copy(); + configCopy.setRuleActive("C014", false); // SubjectExceedsLineLengthLimit + configCopy.setRuleActive("C019", false); // SubjectContainsIssueReference + return (0, validate_1.processCommits)(commits, configCopy); +} +exports.processCommitsForBump = processCommitsForBump; +async function publishBump(nextVersion, releaseMode, headSha, changelog, isBranchAllowedToPublish, discussionCategoryName, updateDraftId) { + let releaseMetadata; + let tagMetadata; + const nv = nextVersion.toString(); + core.info(`ℹ️ Next version: ${nv}`); + core.endGroup(); + if (releaseMode !== "none") { + if (!isBranchAllowedToPublish) { + return {}; + } + if ((0, github_1.isPullRequestEvent)()) { + core.startGroup(`ℹ️ Not creating ${releaseMode} on a pull request event.`); + core.info("We cannot create a release or tag in a pull request context, due to " + + "potential parallelism (i.e. races) in pull request builds."); + return {}; + } + core.startGroup(`ℹ️ Creating ${releaseMode} ${nv}..`); + try { + if (releaseMode === "tag") { + tagMetadata = await (0, github_1.createTag)(nv, headSha); + } + else { + // If version is a prerelease, but not an RC, create a draft release + // If version is an RC, create a GitHub "pre-release" + const isRc = nextVersion.prerelease.startsWith(exports.RC_PREFIX); + const isDev = nextVersion.prerelease !== "" && !isRc; + if (updateDraftId) { + releaseMetadata = await (0, github_1.updateDraftRelease)(updateDraftId, nv, nv, headSha, changelog, isDev, // draft + isRc // prerelease + ); + if (!releaseMetadata) { + core.info(`Error renaming existing draft release, ` + + `creating new draft release.`); + } + } + if (!releaseMetadata) { + releaseMetadata = await (0, github_1.createRelease)(nv, headSha, changelog, isDev, isRc, discussionCategoryName); + // Only set the tag information in case we created a release + // which implicitly creates a tag (i.e. not applicable for draft-releases). + if (releaseMetadata) { + tagMetadata = { + name: releaseMetadata.name, + ref: `refs/tags/${releaseMetadata.name}`, + sha: headSha, + }; + } + } + } + } + catch (ex) { + // The most likely failure is a preexisting tag, in which case + // a RequestError with statuscode 422 will be thrown + const commit = await (0, github_1.getShaForTag)(`refs/tags/${nv}`); + if (ex instanceof request_error_1.RequestError && ex.status === 422 && commit) { + core.setFailed(`Unable to create ${releaseMode}; the tag "${nv}" already exists in the repository, ` + + `it currently points to ${commit}.\n` + + "You can find the branch(es) associated with the tag with:\n" + + ` git fetch -t; git branch --contains ${nv}`); + } + else if (ex instanceof request_error_1.RequestError) { + core.setFailed(`Unable to create ${releaseMode} with the name "${nv}" due to ` + + `HTTP request error (status ${ex.status}):\n${ex.message}`); + } + else if (ex instanceof Error) { + core.setFailed(`Unable to create ${releaseMode} with the name "${nv}":\n${ex.message}`); + } + else { + core.setFailed(`Unknown error during ${releaseMode} creation`); + throw ex; + } + core.endGroup(); + return {}; + } + core.info("Succeeded"); + } + else { + core.startGroup(`ℹ️ Not creating tag or release for ${nv}..`); + core.info("To create a lightweight Git tag or GitHub release when the version is bumped, run this action with:\n" + + ' - "create-release" set to "true" to create a GitHub release, or\n' + + ' - "create-tag" set to "true" for a lightweight Git tag.\n' + + "Note that setting both options is not needed, since a GitHub release implicitly creates a Git tag."); + return {}; + } + return { + release: releaseMetadata, + tag: tagMetadata, + }; +} +exports.publishBump = publishBump; + + +/***/ }), + +/***/ 6503: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +/** + * Copyright (C) 2025, TomTom (http://tomtom.com). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.bumpSdkVer = void 0; +const core = __importStar(__nccwpck_require__(7484)); +const request_error_1 = __nccwpck_require__(3708); +const changelog_1 = __nccwpck_require__(8397); +const github_1 = __nccwpck_require__(9248); +const semver_1 = __nccwpck_require__(1475); +const errors_1 = __nccwpck_require__(3916); +const bump_1 = __nccwpck_require__(2194); +// prettier-ignore +const versionUpdateCases = [ + // Main branch, current development version + { currentType: "dev", bumpType: "dev", isReleaseBranch: false, updater: (params) => { return updateDevelopmentVersion(params); } }, + { currentType: "dev", bumpType: "rc", isReleaseBranch: false, updater: (params) => { return promoteToReleaseCandidateVersion(params); } }, + { currentType: "dev", bumpType: "rel", isReleaseBranch: false, updater: (params) => { return promoteToReleaseVersion(params); } }, + // Main branch, current release candidate version + { currentType: "rc", bumpType: "dev", isReleaseBranch: false, updater: (params) => { return newDevelopmentVersion(params); } }, + { currentType: "rc", bumpType: "rc", isReleaseBranch: false, updater: (params) => { return newReleaseCandidateVersion(params); } }, + { currentType: "rc", bumpType: "rel", isReleaseBranch: false, updater: (params) => { return promoteToReleaseVersion(params); } }, + // Main branch, current release version + { currentType: "rel", bumpType: "dev", isReleaseBranch: false, updater: (params) => { return newDevelopmentVersion(params); } }, + { currentType: "rel", bumpType: "rc", isReleaseBranch: false, updater: (params) => { return newReleaseCandidateVersion(params); } }, + { currentType: "rel", bumpType: "rel", isReleaseBranch: false, updater: (params) => { return newReleaseVersion(params); } }, + // Release branch, current development version - this is not allowed and will throw an error + // { currentType: "dev" ...} + // Release branch, current release candidate version + { currentType: "rc", bumpType: "dev", isReleaseBranch: true, updater: (params) => { return updateReleaseCandidateVersion(params); } }, + { currentType: "rc", bumpType: "rc", isReleaseBranch: true, updater: (params) => { return updateReleaseCandidateVersion(params); } }, + { currentType: "rc", bumpType: "rel", isReleaseBranch: true, updater: (params) => { return promoteToReleaseVersion(params); } }, + // Release branch, current release version + { currentType: "rel", bumpType: "dev", isReleaseBranch: true, updater: (params) => { return updateReleaseVersion(params); } }, + { currentType: "rel", bumpType: "rc", isReleaseBranch: true, updater: (params) => { return updateReleaseVersion(params); } }, + { currentType: "rel", bumpType: "rel", isReleaseBranch: true, updater: (params) => { return updateReleaseVersion(params); } }, +]; +/** + * Increments a development version, i.e. 1.0.0-dev001.SHA -> 1.0.0-dev002.SHA + * + * Exceptions: + * - A new development version is created when the previous version is not using a SdkVer compatible prerelease pattern. + */ +function updateDevelopmentVersion(params) { + let nextVersion = params.currentVersion.nextPrerelease(undefined, "", 3); + if (!nextVersion) + return newDevelopmentVersion(params); + nextVersion.prerelease = `${nextVersion.prerelease}.${(0, bump_1.shortSha)(params.headSha)}`; + return { from: params.currentVersion, to: nextVersion, type: "dev" }; +} +/** + * Increments a release candidate version, i.e. 1.0.0-rc01 -> 1.0.0-rc02 + * + * Exceptions: + * - No release candidate version is created when the head matches a tag. + * - Release candidates can only be updated on a release branch. + * - Breaking changes are not allowed when updating a release candidate version. + * - A new release candidate will not be created when the previous version is not using a SdkVer compatible prerelease pattern. + */ +function updateReleaseCandidateVersion(params) { + if (params.headMatchesTag) + throw new errors_1.BumpError("Do now update release candidate version when the head matches a tag."); + if (!params.isReleaseBranch) + throw new errors_1.BumpError("Cannot update release candidate version on a non-release branch."); + if (params.hasBreakingChange) + throw new errors_1.BumpError("Cannot update release candidates with a breaking change."); + let nextVersion = params.currentVersion.nextPrerelease(undefined, "", 2); + if (!nextVersion) + throw new errors_1.BumpError(`Failed to determine next prerelease version from ${params.currentVersion.toString()}`); + return { from: params.currentVersion, to: nextVersion, type: "rc" }; +} +/** + * Updates a release version, i.e. 1.0.0 -> 1.0.1 + * + * Exceptions: + * - No release version is created when the head matches a tag. + * - Release versions can only be updated on a release branch. + * - Breaking changes are not allowed when updating a release version. + */ +function updateReleaseVersion(params) { + if (params.headMatchesTag) + throw new errors_1.BumpError("Cannot update release version when the head matches a tag."); + if (!params.isReleaseBranch) + throw new errors_1.BumpError("Cannot update release version on a non-release branch."); + if (params.hasBreakingChange) + throw new errors_1.BumpError("Cannot update release version with breaking change."); + return { from: params.currentVersion, to: params.currentVersion.nextPatch(), type: "rel" }; +} +/** + * Creates a new development version, i.e. + * Non-breaking: 1.0.0-rc01 --> 1.1.0-dev001.SHA + * Breaking: 1.0.0-rc01 --> 2.0.0-dev001.SHA + * + * Exceptions: + * - New development versions can only be created on a main branch. + */ +function newDevelopmentVersion(params) { + if (params.isReleaseBranch) + throw new errors_1.BumpError("Cannot create a new development version on a release branch."); + let nextVersion = semver_1.SemVer.copy(params.currentVersion); + nextVersion = params.hasBreakingChange ? nextVersion.nextMajor(params.isInitialDevelopment) : nextVersion.nextMinor(); + nextVersion.prerelease = `dev001.${(0, bump_1.shortSha)(params.headSha)}`; + return { from: params.currentVersion, to: nextVersion, type: "dev" }; +} +/** + * Creates a new Release Candidate version, i.e. + * Non-breaking: 1.0.0-dev001.SHA --> 1.0.0-rc01 + * Breaking: 1.0.0-dev001.SHA --> 2.0.0-rc01 + * + * Exceptions: + * - New Release Candidate versions can only be created on a main branch. + */ +function newReleaseCandidateVersion(params) { + if (params.isReleaseBranch) + throw new errors_1.BumpError("Cannot create a new release candidate version on a release branch."); + let nextVersion = semver_1.SemVer.copy(params.currentVersion); + nextVersion = params.hasBreakingChange ? nextVersion.nextMajor(params.isInitialDevelopment) : nextVersion.nextMinor(); + nextVersion.prerelease = `rc01`; + return { from: params.currentVersion, to: nextVersion, type: "rc" }; +} +/** + * Creates a new release version, i.e. + * Non-breaking: 1.0.0 --> 1.1.0 + * Breaking: 1.0.0 --> 2.0.0 + */ +function newReleaseVersion(params) { + let nextVersion = params.hasBreakingChange + ? params.currentVersion.nextMajor(params.isInitialDevelopment) + : params.currentVersion.nextMinor(); + return { from: params.currentVersion, to: nextVersion, type: "rel" }; +} +/** + * Promotes a development version to a Release Candidate version, i.e. + * Non-breaking: 1.0.0-dev001.SHA -> 1.0.0-rc01 + * Breaking: 1.0.0-dev001.SHA -> 2.0.0-rc01 + */ +function promoteToReleaseCandidateVersion(params) { + let nextVersion = params.hasBreakingChange + ? params.currentVersion.nextMajor() + : semver_1.SemVer.copy(params.currentVersion); + nextVersion.prerelease = `${bump_1.RC_PREFIX}01`; + nextVersion.build = ""; + return { from: params.currentVersion, to: nextVersion, type: "rc" }; +} +/** + * Promotes a development- or release candidate- version to a release version, i.e. + * Non-breaking: 1.0.0-dev001.SHA -> 1.0.0 + * Breaking: 1.0.0-dev001.SHA -> 2.0.0 + * + * Exceptions: + * - Cannot apply a breaking change on a release branch. + * + * NOTE: This function can be called from both main and release branches for release candidates. + */ +function promoteToReleaseVersion(params) { + if (params.hasBreakingChange) { + if (params.isReleaseBranch) + throw new errors_1.BumpError("Cannot promote to release version with breaking change."); + return newReleaseVersion(params); + } + let nextVersion = semver_1.SemVer.copy(params.currentVersion); + nextVersion.prerelease = ""; + nextVersion.build = ""; + return { from: params.currentVersion, to: nextVersion, type: "rel" }; +} +/** + * Finds the version update case that matches the provided parameters. + */ +function findVersionUpdateCase(params) { + return versionUpdateCases.find(c => c.currentType === params.currentType && + c.bumpType === params.bumpType && + c.isReleaseBranch === params.isReleaseBranch); +} +/** + * Determines the next SDK version based on the current version and the bump type. + * @throws BumpError if no matching version update case is found. + */ +function getNextSdkVer(currentVersion, sdkVerBumpType, isReleaseBranch, headMatchesTag, hasBreakingChange, devPrereleaseText, headSha, isInitialDevelopment) { + let currentReleaseType; + if (currentVersion.prerelease.startsWith(bump_1.RC_PREFIX)) { + currentReleaseType = "rc"; + } + else if (currentVersion.prerelease === "") { + currentReleaseType = "rel"; + } + else { + currentReleaseType = "dev"; + } + core.info(`Determining SDK bump for version ${currentVersion.toString()}:`); + core.info(` - current version type: ${currentReleaseType}`); + core.info(` - bump type: ${sdkVerBumpType}`); + core.info(` - branch type: ${isReleaseBranch ? "" : "not "}release`); + core.info(` - breaking changes: ${hasBreakingChange ? "yes" : "no"}`); + const params = { + currentVersion, + currentType: currentReleaseType, + bumpType: sdkVerBumpType, + isReleaseBranch, + headMatchesTag, + hasBreakingChange, + devPrereleaseText, + headSha, + isInitialDevelopment, + }; + const match = findVersionUpdateCase(params); + if (match) { + return match.updater(params); + } + throw new errors_1.BumpError(`No version update case found for bump type '${sdkVerBumpType}' ` + + `on release branch '${isReleaseBranch}', ` + + `head matches tag: ${headMatchesTag}, ` + + `has breaking change: ${hasBreakingChange}, ` + + `initial development: ${isInitialDevelopment}`); +} +/** + * Bump and release/tag SDK versions + */ +async function bumpSdkVer(config, bumpInfo, releaseMode, sdkVerBumpType, headSha, branchName, isBranchAllowedToPublish, createChangelog) { + const isReleaseBranch = new RegExp(config.releaseBranches).test(branchName); + let hasBreakingChange = bumpInfo.processedCommits.some(c => c.message?.breakingChange); + if (!bumpInfo.foundVersion) + return; // should never happen + // SdkVer requires a prerelease, so apply the default if not set + config.prereleasePrefix = config.prereleasePrefix ?? "dev"; + let cv = semver_1.SemVer.copy(bumpInfo.foundVersion); + // Do not bump major version when breaking change is found in case + // the max configured major version is already reached + if (config.sdkverMaxMajor !== undefined && config.sdkverMaxMajor > 0 && cv.major >= config.sdkverMaxMajor) { + console.log(`Maximum major version ${config.sdkverMaxMajor} reached, not bumping major version.`); + hasBreakingChange = false; + } + // Get the latest draft release matching our current version's prefix. + // Don't look at the draft version on a release branch; the current version + // should always reflect the version to be bumped (as no dev releases are + // allowed on a release branch) + const latestDraft = await (0, github_1.getRelease)({ prefixToMatch: cv.prefix, draftOnly: true, fullReleasesOnly: false }); + const latestRelease = await (0, github_1.getRelease)({ prefixToMatch: cv.prefix, draftOnly: false, fullReleasesOnly: true }); + core.info(`Current version: ${cv.toString()}, latest GitHub release draft: ${latestDraft?.name ?? "NONE"}, latest GitHub release: ${latestRelease?.name ?? "NONE"}`); + if (!isReleaseBranch && latestDraft) { + // If we're not on a release branch and a draft version exists that is + // newer than the latest tag, we continue with that + const draftVersion = semver_1.SemVer.fromString(latestDraft.name); + if (draftVersion && cv.lessThan(draftVersion)) { + cv = draftVersion; + } + } + // TODO: This is wasteful, as this info has already been available before + const headMatchesTag = await (0, github_1.currentHeadMatchesTag)(cv.toString()); + const bump = getNextSdkVer(cv, sdkVerBumpType, isReleaseBranch, headMatchesTag, hasBreakingChange, config.prereleasePrefix ?? "dev", headSha, config.initialDevelopment); + let bumped = false; + let changelog = ""; + let releaseBranchName; + let versionOutput; + if (bump?.to) { + // Since we want the changelog since the last _full_ release, we can only rely on the `bumpInfo` if the "current version" is a + // full release. In other cases, we need to gather some information to generate the proper changelog. + const previousRelease = await (0, github_1.getRelease)({ + prefixToMatch: bump.to.prefix, + draftOnly: false, + fullReleasesOnly: true, + constraint: { major: bump.to.major, minor: bump.to.minor }, + }); + core.info(`The full release preceding the current one is ${previousRelease?.name ?? "undefined"}`); + if (createChangelog) { + if (previousRelease && cv.prerelease) { + // Since "dev" releases on non-release-branches result in a draft release, we'll need to use the commit sha. + const toVersion = bump.type === "dev" ? (0, bump_1.shortSha)(headSha) : bump.to.toString(); + const changelogCommits = await collectChangelogCommits(previousRelease.name, config); + changelog = await (0, changelog_1.generateChangelogForCommits)(previousRelease.name, toVersion, changelogCommits); + } + else { + changelog = await (0, changelog_1.generateChangelog)(bumpInfo); + } + } + const { release, tag } = await (0, bump_1.publishBump)(bump.to, releaseMode, headSha, changelog, isBranchAllowedToPublish, config.releaseDiscussionCategory, + // Re-use the latest draft release only when not running on a release branch, otherwise we might randomly reset a `dev-N` number chain. + !isReleaseBranch ? latestDraft?.id : undefined); + versionOutput = { + tag, + release, + bump: { + from: bumpInfo.foundVersion.toString(), + to: bump.to.toString(), + type: bump.type, + }, + }; + // If we have a release and/or a tag, we consider the bump successful + bumped = release !== undefined || tag !== undefined; + } + if (!bumped) { + core.info("ℹ️ No bump was performed"); + } + else { + // Create a release branch for releases and RC's if we're configured to do so and are currently not running on a release branch. + if (config.sdkverCreateReleaseBranches !== undefined && !isReleaseBranch && bump?.type !== "dev" && bump?.to) { + releaseBranchName = `${config.sdkverCreateReleaseBranches}${bump.to.major}.${bump.to.minor}`; + core.info(`Creating release branch ${releaseBranchName}..`); + try { + await (0, github_1.createBranch)(`refs/heads/${releaseBranchName}`, headSha); + } + catch (ex) { + if (ex instanceof request_error_1.RequestError && ex.status === 422) { + core.warning(`The branch '${releaseBranchName}' already exists ${(0, github_1.getRunNumber)() !== 1 ? " (NOTE: this is a re-run)." : "."}`); + } + else if (ex instanceof request_error_1.RequestError) { + core.warning(`Unable to create release branch '${releaseBranchName}' due to HTTP request error (status ${ex.status}):\n${ex.message}`); + } + else if (ex instanceof Error) { + core.warning(`Unable to create release branch '${releaseBranchName}':\n${ex.message}`); + } + else { + core.warning(`Unknown error during ${releaseMode} creation`); + throw ex; + } + } + } + } + core.endGroup(); + return bumped ? versionOutput : undefined; +} +exports.bumpSdkVer = bumpSdkVer; +/** + * For SdkVer, the latest tag (i.e. "current version") may not be the starting + * point we want for generating a changelog; in this context, we want to get a + * list of commits since the last _full_ release. + * + * Returns an object containing: + * - the name of the last full release reachable from our current version + * - the list of valid Conventional Commit objects since that release + */ +async function collectChangelogCommits(previousRelease, config) { + core.startGroup(`📜 Gathering changelog information`); + const commits = await (0, github_1.getCommitsBetweenRefs)(previousRelease); + core.info(`Processing commit list (since ${previousRelease}) for changelog generation:\n-> ` + + `${commits.map(c => c.message.split("\n")[0]).join("\n-> ")}`); + const processedCommits = (0, bump_1.processCommitsForBump)(commits, config); + core.endGroup(); + return processedCommits.map(c => c.message).filter(c => c); +} + + +/***/ }), + +/***/ 2786: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +/** + * Copyright (C) 2025, TomTom (http://tomtom.com). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.bumpSemVer = exports.printNonCompliance = exports.bumpDraftRelease = exports.getVersionBumpTypeAndMessages = exports.getVersionBumpType = void 0; +const core = __importStar(__nccwpck_require__(7484)); +const changelog_1 = __nccwpck_require__(8397); +const github_1 = __nccwpck_require__(9248); +const semver_1 = __nccwpck_require__(1475); +const validate_1 = __nccwpck_require__(397); +const bump_1 = __nccwpck_require__(2194); /** * Returns a SemVer object if: * - the `tagSha` and `commitSha` match @@ -33268,19 +33808,6 @@ function getSemVerIfMatches(prefix, tagName, tagSha, commitSha) { } return null; } -/** Validates a list of commits in a bump context, which differs slightly to - * pull request validation runs, as some rules need to be disabled. - */ -function processCommitsForBump(commits, config) { - // We'll relax certain rules while processing these commits; these are - // commits/pull request titles that (ideally) have been validated - // _before_ they were merged, and certain GitHub CI settings may append - // a reference to the PR number in merge commits. - const configCopy = config.copy(); - configCopy.setRuleActive("C014", false); // SubjectExceedsLineLengthLimit - configCopy.setRuleActive("C019", false); // SubjectContainsIssueReference - return (0, validate_1.processCommits)(commits, configCopy); -} /** * Determines the highest SemVer bump level based on the provided * list of Conventional Commits @@ -33358,7 +33885,7 @@ async function getVersionBumpTypeAndMessages(targetSha, config) { return null; }; const [version, commitList] = await (0, github_1.matchTagsToCommits)(targetSha, tagMatcher); - const results = processCommitsForBump(commitList, config); + const results = (0, bump_1.processCommitsForBump)(commitList, config); const convCommits = results .map(r => r.message) .filter((r) => r !== undefined); @@ -33399,7 +33926,7 @@ async function tryUpdateDraftRelease(cv, changelog, sha) { const npv = currentDraftVersion.nextPrerelease(); if (!npv) return; - npv.build = shortSha(sha); + npv.build = (0, bump_1.shortSha)(sha); const updatedRelease = await (0, github_1.updateDraftRelease)(latestDraftRelease.id, npv.toString(), npv.toString(), sha, changelog); if (!updatedRelease) { core.info(`Error renaming existing draft release.`); @@ -33411,7 +33938,7 @@ async function newDraftRelease(currentVersion, changelog, sha, prefix) { const nextPrereleaseVersion = currentVersion.nextPatch(); nextPrereleaseVersion.build = currentVersion.build; if (prefix === "dev") { - nextPrereleaseVersion.prerelease = `${prefix}001.${shortSha(sha)}`; + nextPrereleaseVersion.prerelease = `${prefix}001.${(0, bump_1.shortSha)(sha)}`; } else { nextPrereleaseVersion.prerelease = `${prefix}001`; @@ -33464,95 +33991,6 @@ function printNonCompliance(commits) { } } exports.printNonCompliance = printNonCompliance; -async function publishBump(nextVersion, releaseMode, headSha, changelog, isBranchAllowedToPublish, discussionCategoryName, updateDraftId) { - let releaseMetadata; - let tagMetadata; - const nv = nextVersion.toString(); - core.info(`ℹ️ Next version: ${nv}`); - core.endGroup(); - if (releaseMode !== "none") { - if (!isBranchAllowedToPublish) { - return {}; - } - if ((0, github_1.isPullRequestEvent)()) { - core.startGroup(`ℹ️ Not creating ${releaseMode} on a pull request event.`); - core.info("We cannot create a release or tag in a pull request context, due to " + - "potential parallelism (i.e. races) in pull request builds."); - return {}; - } - core.startGroup(`ℹ️ Creating ${releaseMode} ${nv}..`); - try { - if (releaseMode === "tag") { - tagMetadata = await (0, github_1.createTag)(nv, headSha); - } - else { - // If version is a prerelease, but not an RC, create a draft release - // If version is an RC, create a GitHub "pre-release" - const isRc = nextVersion.prerelease.startsWith(RC_PREFIX); - const isDev = nextVersion.prerelease !== "" && !isRc; - if (updateDraftId) { - releaseMetadata = await (0, github_1.updateDraftRelease)(updateDraftId, nv, nv, headSha, changelog, isDev, // draft - isRc // prerelease - ); - if (!releaseMetadata) { - core.info(`Error renaming existing draft release, ` + - `creating new draft release.`); - } - } - if (!releaseMetadata) { - releaseMetadata = await (0, github_1.createRelease)(nv, headSha, changelog, isDev, isRc, discussionCategoryName); - // Only set the tag information in case we created a release - // which implicitly creates a tag (i.e. not applicable for draft-releases). - if (releaseMetadata) { - tagMetadata = { - name: releaseMetadata.name, - ref: `refs/tags/${releaseMetadata.name}`, - sha: headSha, - }; - } - } - } - } - catch (ex) { - // The most likely failure is a preexisting tag, in which case - // a RequestError with statuscode 422 will be thrown - const commit = await (0, github_1.getShaForTag)(`refs/tags/${nv}`); - if (ex instanceof request_error_1.RequestError && ex.status === 422 && commit) { - core.setFailed(`Unable to create ${releaseMode}; the tag "${nv}" already exists in the repository, ` + - `it currently points to ${commit}.\n` + - "You can find the branch(es) associated with the tag with:\n" + - ` git fetch -t; git branch --contains ${nv}`); - } - else if (ex instanceof request_error_1.RequestError) { - core.setFailed(`Unable to create ${releaseMode} with the name "${nv}" due to ` + - `HTTP request error (status ${ex.status}):\n${ex.message}`); - } - else if (ex instanceof Error) { - core.setFailed(`Unable to create ${releaseMode} with the name "${nv}":\n${ex.message}`); - } - else { - core.setFailed(`Unknown error during ${releaseMode} creation`); - throw ex; - } - core.endGroup(); - return {}; - } - core.info("Succeeded"); - } - else { - core.startGroup(`ℹ️ Not creating tag or release for ${nv}..`); - core.info("To create a lightweight Git tag or GitHub release when the version is bumped, run this action with:\n" + - ' - "create-release" set to "true" to create a GitHub release, or\n' + - ' - "create-tag" set to "true" for a lightweight Git tag.\n' + - "Note that setting both options is not needed, since a GitHub release implicitly creates a Git tag."); - return {}; - } - return { - release: releaseMetadata, - tag: tagMetadata, - }; -} -exports.publishBump = publishBump; async function bumpSemVer(config, bumpInfo, releaseMode, branchName, headSha, isBranchAllowedToPublish, createChangelog) { const compliantCommits = bumpInfo.processedCommits .filter(c => c.message !== undefined) @@ -33593,7 +34031,7 @@ async function bumpSemVer(config, bumpInfo, releaseMode, branchName, headSha, is if (buildMetadata) { bumpMetadata.to.build = buildMetadata; } - const { release, tag } = await publishBump(bumpMetadata.to, releaseMode, headSha, changelog, isBranchAllowedToPublish, config.releaseDiscussionCategory); + const { release, tag } = await (0, bump_1.publishBump)(bumpMetadata.to, releaseMode, headSha, changelog, isBranchAllowedToPublish, config.releaseDiscussionCategory); versionMetadata = { bump: { from: bumpMetadata.from.toString(), @@ -33647,331 +34085,6 @@ async function bumpSemVer(config, bumpInfo, releaseMode, branchName, headSha, is return bumped ? versionMetadata : undefined; } exports.bumpSemVer = bumpSemVer; -function getNextSdkVer(currentVersion, sdkVerBumpType, isReleaseBranch, headMatchesTag, hasBreakingChange, devPrereleaseText, headSha, isInitialDevelopment) { - const currentIsRc = currentVersion.prerelease.startsWith(RC_PREFIX); - const currentIsRel = currentVersion.prerelease === ""; - const fatal = (msg) => { - throw new errors_1.BumpError(msg); - }; - const bumpOrError = (t) => { - const bumpResult = currentVersion.bump(t, isInitialDevelopment); - if (!bumpResult?.version) { - throw new errors_1.BumpError(`Bump ${t.toString()} for ${currentVersion} failed`); - } - return bumpResult.version; - }; - core.info(`Determining SDK bump for version ${currentVersion.toString()}:`); - core.info(` - current version type: ${currentIsRel ? "release" : currentIsRc ? "release candidate" : "dev"}`); - core.info(` - bump type: ${sdkVerBumpType}`); - core.info(` - branch type: ${isReleaseBranch ? "" : "not "}release`); - core.info(` - breaking changes: ${hasBreakingChange ? "yes" : "no"}`); - let nextVersion = null; - let nextBumpType = null; - if (isReleaseBranch) { - // If current branch HEAD is a release candidate: - // !createRel && !createRc = bump rc-val - // !createRel && createRc = bump rc-val - // createRel && !createRc = promote to full release - // Else if current branch HEAD is a full release: - // !createRel && !createRc = bump fix version (patch field) - // !createRel && createRc = error - // createRel && !createRc = bump fix version (patch field) - // Else - // error - if (!currentIsRc && !currentIsRel) { - fatal("Release branches can only contain release candidates or full releases. " + - `'${currentVersion.toString()}' is neither.`); - } - // Special case: we allow breaking changes on a release branch if that - // release branch still contains an RC for the next API version, in which - // case, the MINOR and PATCH fields will be 0 (1.2.3 -> 2.0.0-rc1) - if (hasBreakingChange && - !(currentIsRc && currentVersion.minor === 0 && currentVersion.patch === 0)) { - fatal("Breaking changes are not allowed on release branches."); - } - // Only bump if we need to; we don't want to generate a new RC or release - // when nothing has changed since the last RC or release, unless it is a - // promotion from RC to full release. - if (headMatchesTag && !(sdkVerBumpType === "rel" && currentIsRc)) { - core.info(` - head matches latest tag on release branch`); - } - else if (sdkVerBumpType === "rel") { - if (currentIsRel) { - // Pushes on release branches with a finalized release always - // bump PATCH, no exception. - nextVersion = bumpOrError(semver_1.SemVerType.PATCH); - nextBumpType = "rel"; - } - else if (currentIsRc) { - // A release bump on a release candidate results in a full release - const nv = semver_1.SemVer.copy(currentVersion); - nv.prerelease = ""; - nextVersion = nv; - nextBumpType = "rel"; - } - } - else { - // Bumps for "rc" and "dev" are identical on a release branch - if (currentIsRc) { - // We need to keep the pre intact (undefined), but the post needs to be - // cleared, as that contains the commit hash of the previous dev version. - // Also zero pad to at least two digits. - nextVersion = currentVersion.nextPrerelease(undefined, "", 2); - nextBumpType = "rc"; - if (!nextVersion) { - fatal(`Unable to bump RC version for: ${currentVersion.toString()}; ` + - `make sure it contains an index number.`); - } - } - else { - // Current version is a release, so bump patch - nextVersion = bumpOrError(semver_1.SemVerType.PATCH); - nextBumpType = "rel"; - } - } - } - else { - // !isReleaseBranch - // If current branch HEAD is a release candidate: - // dev bump = bump dev prerelease for next minor (do nothing here) - // rc bump = create new rc for _next_ version - // rel && rc_sha == head_sha = "promote" to new full release - // rel && rc_sha != head_sha = create full release for _next_ major - // Else if current branch HEAD is a full release: - // !createRel && !createRc = bump dev prerelease for next minor (do nothing here) - // !createRel && createRc = create new rc for _next_ version - // createRel && !createRc = create new full release - // Else - // !createRel && !createRc = bump dev prerelease (do nothing here) - // !createRel && createRc = create new rc for _next_ version - // createRel && !createRc = create new full release - const releaseBump = hasBreakingChange ? semver_1.SemVerType.MAJOR : semver_1.SemVerType.MINOR; - if (sdkVerBumpType === "rel") { - // Special case for release bumps if the current version is an RC: - // only promote (i.e. strip prerelease) if HEAD matches that RC's SHA. - // If not, get the next major/minor. - if (currentIsRel || (currentIsRc && !headMatchesTag)) { - nextVersion = bumpOrError(releaseBump); - } - else { - // Behavior for rc and dev is the same - nextVersion = semver_1.SemVer.copy(currentVersion); - nextVersion.prerelease = ""; - nextVersion.build = ""; - } - nextBumpType = "rel"; - } - else if (sdkVerBumpType === "rc") { - if (currentIsRel || currentIsRc) { - // ^^^^ - // This may be slightly counter-intuitive: RC increments can - // only be done on a release branch, so performing an RC bump - // on a non-release branch where the HEAD itself is an RC results - // in creating an RC for the _next_ version: - // 1.2.0-rc1 -> 1.3.0-rc1 (not 1.2.0-rc2). - nextVersion = bumpOrError(releaseBump); - } - else { - // Current HEAD is a dev prerelease - nextVersion = semver_1.SemVer.copy(currentVersion); - nextVersion.build = ""; - } - nextVersion.prerelease = `${RC_PREFIX}01`; - nextBumpType = "rc"; - } - else if (sdkVerBumpType === "dev") { - // TODO: decide on how best to handle hasBreakingChange in this case - if (currentIsRel || currentIsRc) { - nextVersion = bumpOrError(releaseBump); - nextVersion.prerelease = `${devPrereleaseText}001`; - nextBumpType = "dev"; - } - else { - // Keep prefix, clear postfix, zero pad to at least three digits - nextVersion = currentVersion.nextPrerelease(undefined, "", 3); - nextBumpType = "dev"; - if (!nextVersion) { - // This can only happen if the current version is something - // unexpected and invalid, like a prerelease without a number, e.g.: - // 1.2.3-rc 1.2.3-dev 1.2.3-testing - nextVersion = bumpOrError(semver_1.SemVerType.MINOR); - nextVersion.prerelease = `${devPrereleaseText}001`; - core.warning(`Failed to bump the prerelease for version ${currentVersion.toString()}` + - `; moving to next release version ${nextVersion.toString()}`); - } - } - } - } - core.info(` - next version: ${nextVersion?.toString() ?? "none"}`); - if (!nextVersion && !headMatchesTag) { - fatal(`Unable to bump version for: ${currentVersion.toString()}`); - } - const buildMetadata = core.getInput("build-metadata"); - nextVersion = nextVersion; - if (buildMetadata) { - nextVersion.build = buildMetadata; - } - if (nextBumpType === "dev") { - nextVersion.prerelease += `.${shortSha(headSha)}`; - } - if (nextVersion && nextBumpType) { - return { - from: currentVersion, - to: nextVersion, - type: nextBumpType, - }; - } -} -/** - * Bump and release/tag SDK versions - */ -async function bumpSdkVer(config, bumpInfo, releaseMode, sdkVerBumpType, headSha, branchName, isBranchAllowedToPublish, createChangelog) { - const isReleaseBranch = new RegExp(config.releaseBranches).test(branchName); - let hasBreakingChange = bumpInfo.processedCommits.some(c => c.message?.breakingChange); - if (!bumpInfo.foundVersion) - return; // should never happen - // SdkVer requires a prerelease, so apply the default if not set - config.prereleasePrefix = config.prereleasePrefix ?? "dev"; - let cv = semver_1.SemVer.copy(bumpInfo.foundVersion); - // Do not bump major version when breaking change is found in case - // the max configured major version is already reached - if (config.sdkverMaxMajor !== undefined && - config.sdkverMaxMajor > 0 && - cv.major >= config.sdkverMaxMajor) { - hasBreakingChange = false; - } - // Get the latest draft release matching our current version's prefix. - // Don't look at the draft version on a release branch; the current version - // should always reflect the version to be bumped (as no dev releases are - // allowed on a release branch) - const latestDraft = await (0, github_1.getRelease)({ - prefixToMatch: cv.prefix, - draftOnly: true, - fullReleasesOnly: false, - }); - const latestRelease = await (0, github_1.getRelease)({ - prefixToMatch: cv.prefix, - draftOnly: false, - fullReleasesOnly: true, - }); - core.info(`Current version: ${cv.toString()}, latest GitHub release draft: ${latestDraft?.name ?? "NONE"}, latest GitHub release: ${latestRelease?.name ?? "NONE"}`); - if (!isReleaseBranch && latestDraft) { - // If we're not on a release branch and a draft version exists that is - // newer than the latest tag, we continue with that - const draftVersion = semver_1.SemVer.fromString(latestDraft.name); - if (draftVersion && cv.lessThan(draftVersion)) { - cv = draftVersion; - } - } - // TODO: This is wasteful, as this info has already been available before - const headMatchesTag = await (0, github_1.currentHeadMatchesTag)(cv.toString()); - const bump = getNextSdkVer(cv, sdkVerBumpType, isReleaseBranch, headMatchesTag, hasBreakingChange, config.prereleasePrefix ?? "dev", headSha, config.initialDevelopment); - let bumped = false; - let changelog = ""; - let releaseBranchName; - let versionOutput; - if (bump?.to) { - // Since we want the changelog since the last _full_ release, we - // can only rely on the `bumpInfo` if the "current version" is a - // full release. In other cases, we need to gather some information - // to generate the proper changelog. - const previousRelease = await (0, github_1.getRelease)({ - prefixToMatch: bump.to.prefix, - draftOnly: false, - fullReleasesOnly: true, - constraint: { - major: bump.to.major, - minor: bump.to.minor, - }, - }); - core.info(`The full release preceding the current one is ${previousRelease?.name ?? "undefined"}`); - if (createChangelog) { - if (previousRelease && cv.prerelease) { - const toVersion = - // Since "dev" releases on non-release-branches result in a draft - // release, we'll need to use the commit sha. - bump.type === "dev" ? shortSha(headSha) : bump.to.toString(); - changelog = await (0, changelog_1.generateChangelogForCommits)(previousRelease.name, toVersion, await collectChangelogCommits(previousRelease.name, config)); - } - else { - changelog = await (0, changelog_1.generateChangelog)(bumpInfo); - } - } - const { release, tag } = await publishBump(bump.to, releaseMode, headSha, changelog, isBranchAllowedToPublish, config.releaseDiscussionCategory, - // Re-use the latest draft release only when not running on a release branch, - // otherwise we might randomly reset a `dev-N` number chain. - !isReleaseBranch ? latestDraft?.id : undefined); - versionOutput = { - tag, - release, - bump: { - from: bumpInfo.foundVersion.toString(), - to: bump.to.toString(), - type: bump.type, - }, - }; - // If we have a release and/or a tag, we consider the bump successful - bumped = release !== undefined || tag !== undefined; - } - if (!bumped) { - core.info("ℹ️ No bump was performed"); - } - else { - // Create a release branch for releases and RC's if we're configured to do so - // and are currently not running on a release branch. - if (config.sdkverCreateReleaseBranches !== undefined && - !isReleaseBranch && - bump?.type !== "dev" && - bump?.to) { - releaseBranchName = `${config.sdkverCreateReleaseBranches}${bump.to.major}.${bump.to.minor}`; - core.info(`Creating release branch ${releaseBranchName}..`); - try { - await (0, github_1.createBranch)(`refs/heads/${releaseBranchName}`, headSha); - } - catch (ex) { - if (ex instanceof request_error_1.RequestError && ex.status === 422) { - core.warning(`The branch '${releaseBranchName}' already exists` + - `${(0, github_1.getRunNumber)() !== 1 ? " (NOTE: this is a re-run)." : "."}`); - } - else if (ex instanceof request_error_1.RequestError) { - core.warning(`Unable to create release branch '${releaseBranchName}' due to ` + - `HTTP request error (status ${ex.status}):\n${ex.message}`); - } - else if (ex instanceof Error) { - core.warning(`Unable to create release branch '${releaseBranchName}':\n${ex.message}`); - } - else { - core.warning(`Unknown error during ${releaseMode} creation`); - throw ex; - } - } - } - } - core.endGroup(); - return bumped ? versionOutput : undefined; -} -exports.bumpSdkVer = bumpSdkVer; -/** - * For SdkVer, the latest tag (i.e. "current version") may not be the starting - * point we want for generating a changelog; in this context, we want to get a - * list of commits since the last _full_ release. - * - * Returns an object containing: - * - the name of the last full release reachable from our current version - * - the list of valid Conventional Commit objects since that release - */ -async function collectChangelogCommits(previousRelease, config) { - core.startGroup(`📜 Gathering changelog information`); - const commits = await (0, github_1.getCommitsBetweenRefs)(previousRelease); - core.info(`Processing commit list (since ${previousRelease}) ` + - `for changelog generation:\n-> ` + - `${commits.map(c => c.message.split("\n")[0]).join("\n-> ")}`); - const processedCommits = processCommitsForBump(commits, config); - core.endGroup(); - return processedCommits - .map(c => c.message) - .filter(c => c); -} /***/ }), @@ -36496,7 +36609,12 @@ class SemVer { const build = this.build ? `+${this.build}` : ""; return `${this.prefix}${this.major}.${this.minor}.${this.patch}${prerelease}${build}`; } - nextMajor() { + nextMajor(initialDevelopment) { + if (initialDevelopment && this.major <= 0) { + // Bumping major version during initial development is prohibited, + // bump the minor version instead. + return this.nextMinor(); + } return new SemVer({ major: this.major + 1, minor: 0, diff --git a/dist/cli/index.js b/dist/cli/index.js index 8e10cd52..bec40f92 100755 --- a/dist/cli/index.js +++ b/dist/cli/index.js @@ -35064,7 +35064,12 @@ class SemVer { const build = this.build ? `+${this.build}` : ""; return `${this.prefix}${this.major}.${this.minor}.${this.patch}${prerelease}${build}`; } - nextMajor() { + nextMajor(initialDevelopment) { + if (initialDevelopment && this.major <= 0) { + // Bumping major version during initial development is prohibited, + // bump the minor version instead. + return this.nextMinor(); + } return new SemVer({ major: this.major + 1, minor: 0, diff --git a/dist/validate/index.js b/dist/validate/index.js index 59823dfa..ed7311a3 100644 --- a/dist/validate/index.js +++ b/dist/validate/index.js @@ -33040,17 +33040,17 @@ var __importStar = (this && this.__importStar) || function (mod) { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.run = void 0; const core = __importStar(__nccwpck_require__(7484)); -const bump_1 = __nccwpck_require__(2123); const config_1 = __nccwpck_require__(5354); const github_1 = __nccwpck_require__(9248); const Label = __importStar(__nccwpck_require__(8249)); const semver_1 = __nccwpck_require__(1475); const validate_1 = __nccwpck_require__(397); +const semver_2 = __nccwpck_require__(2786); /** * Determine labels to add based on the provided conventional commits */ async function determineLabels(conventionalCommits, config) { - const highestBumpType = (0, bump_1.getVersionBumpType)(conventionalCommits); + const highestBumpType = (0, semver_2.getVersionBumpType)(conventionalCommits); if (highestBumpType === semver_1.SemVerType.NONE) { return []; } @@ -33105,13 +33105,13 @@ exports.run = run; /***/ }), -/***/ 2123: +/***/ 2194: /***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { "use strict"; /** - * Copyright (C) 2022, TomTom (http://tomtom.com). + * Copyright (C) 2025, TomTom (http://tomtom.com). * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -33149,15 +33149,12 @@ var __importStar = (this && this.__importStar) || function (mod) { return result; }; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.bumpSdkVer = exports.bumpSemVer = exports.publishBump = exports.printNonCompliance = exports.bumpDraftRelease = exports.getVersionBumpTypeAndMessages = exports.getVersionBumpType = void 0; +exports.publishBump = exports.processCommitsForBump = exports.shortSha = exports.RC_PREFIX = void 0; const core = __importStar(__nccwpck_require__(7484)); const request_error_1 = __nccwpck_require__(3708); -const changelog_1 = __nccwpck_require__(8397); const github_1 = __nccwpck_require__(9248); -const semver_1 = __nccwpck_require__(1475); -const errors_1 = __nccwpck_require__(3916); const validate_1 = __nccwpck_require__(397); -const RC_PREFIX = "rc"; +exports.RC_PREFIX = "rc"; /** * Return the first eight characters of a string. * @@ -33166,6 +33163,165 @@ const RC_PREFIX = "rc"; function shortSha(sha) { return sha.substring(0, 8); } +exports.shortSha = shortSha; +/** Validates a list of commits in a bump context, which differs slightly to + * pull request validation runs, as some rules need to be disabled. + */ +function processCommitsForBump(commits, config) { + // We'll relax certain rules while processing these commits; these are + // commits/pull request titles that (ideally) have been validated + // _before_ they were merged, and certain GitHub CI settings may append + // a reference to the PR number in merge commits. + const configCopy = config.copy(); + configCopy.setRuleActive("C014", false); // SubjectExceedsLineLengthLimit + configCopy.setRuleActive("C019", false); // SubjectContainsIssueReference + return (0, validate_1.processCommits)(commits, configCopy); +} +exports.processCommitsForBump = processCommitsForBump; +async function publishBump(nextVersion, releaseMode, headSha, changelog, isBranchAllowedToPublish, discussionCategoryName, updateDraftId) { + let releaseMetadata; + let tagMetadata; + const nv = nextVersion.toString(); + core.info(`ℹ️ Next version: ${nv}`); + core.endGroup(); + if (releaseMode !== "none") { + if (!isBranchAllowedToPublish) { + return {}; + } + if ((0, github_1.isPullRequestEvent)()) { + core.startGroup(`ℹ️ Not creating ${releaseMode} on a pull request event.`); + core.info("We cannot create a release or tag in a pull request context, due to " + + "potential parallelism (i.e. races) in pull request builds."); + return {}; + } + core.startGroup(`ℹ️ Creating ${releaseMode} ${nv}..`); + try { + if (releaseMode === "tag") { + tagMetadata = await (0, github_1.createTag)(nv, headSha); + } + else { + // If version is a prerelease, but not an RC, create a draft release + // If version is an RC, create a GitHub "pre-release" + const isRc = nextVersion.prerelease.startsWith(exports.RC_PREFIX); + const isDev = nextVersion.prerelease !== "" && !isRc; + if (updateDraftId) { + releaseMetadata = await (0, github_1.updateDraftRelease)(updateDraftId, nv, nv, headSha, changelog, isDev, // draft + isRc // prerelease + ); + if (!releaseMetadata) { + core.info(`Error renaming existing draft release, ` + + `creating new draft release.`); + } + } + if (!releaseMetadata) { + releaseMetadata = await (0, github_1.createRelease)(nv, headSha, changelog, isDev, isRc, discussionCategoryName); + // Only set the tag information in case we created a release + // which implicitly creates a tag (i.e. not applicable for draft-releases). + if (releaseMetadata) { + tagMetadata = { + name: releaseMetadata.name, + ref: `refs/tags/${releaseMetadata.name}`, + sha: headSha, + }; + } + } + } + } + catch (ex) { + // The most likely failure is a preexisting tag, in which case + // a RequestError with statuscode 422 will be thrown + const commit = await (0, github_1.getShaForTag)(`refs/tags/${nv}`); + if (ex instanceof request_error_1.RequestError && ex.status === 422 && commit) { + core.setFailed(`Unable to create ${releaseMode}; the tag "${nv}" already exists in the repository, ` + + `it currently points to ${commit}.\n` + + "You can find the branch(es) associated with the tag with:\n" + + ` git fetch -t; git branch --contains ${nv}`); + } + else if (ex instanceof request_error_1.RequestError) { + core.setFailed(`Unable to create ${releaseMode} with the name "${nv}" due to ` + + `HTTP request error (status ${ex.status}):\n${ex.message}`); + } + else if (ex instanceof Error) { + core.setFailed(`Unable to create ${releaseMode} with the name "${nv}":\n${ex.message}`); + } + else { + core.setFailed(`Unknown error during ${releaseMode} creation`); + throw ex; + } + core.endGroup(); + return {}; + } + core.info("Succeeded"); + } + else { + core.startGroup(`ℹ️ Not creating tag or release for ${nv}..`); + core.info("To create a lightweight Git tag or GitHub release when the version is bumped, run this action with:\n" + + ' - "create-release" set to "true" to create a GitHub release, or\n' + + ' - "create-tag" set to "true" for a lightweight Git tag.\n' + + "Note that setting both options is not needed, since a GitHub release implicitly creates a Git tag."); + return {}; + } + return { + release: releaseMetadata, + tag: tagMetadata, + }; +} +exports.publishBump = publishBump; + + +/***/ }), + +/***/ 2786: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +/** + * Copyright (C) 2025, TomTom (http://tomtom.com). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.bumpSemVer = exports.printNonCompliance = exports.bumpDraftRelease = exports.getVersionBumpTypeAndMessages = exports.getVersionBumpType = void 0; +const core = __importStar(__nccwpck_require__(7484)); +const changelog_1 = __nccwpck_require__(8397); +const github_1 = __nccwpck_require__(9248); +const semver_1 = __nccwpck_require__(1475); +const validate_1 = __nccwpck_require__(397); +const bump_1 = __nccwpck_require__(2194); /** * Returns a SemVer object if: * - the `tagSha` and `commitSha` match @@ -33203,19 +33359,6 @@ function getSemVerIfMatches(prefix, tagName, tagSha, commitSha) { } return null; } -/** Validates a list of commits in a bump context, which differs slightly to - * pull request validation runs, as some rules need to be disabled. - */ -function processCommitsForBump(commits, config) { - // We'll relax certain rules while processing these commits; these are - // commits/pull request titles that (ideally) have been validated - // _before_ they were merged, and certain GitHub CI settings may append - // a reference to the PR number in merge commits. - const configCopy = config.copy(); - configCopy.setRuleActive("C014", false); // SubjectExceedsLineLengthLimit - configCopy.setRuleActive("C019", false); // SubjectContainsIssueReference - return (0, validate_1.processCommits)(commits, configCopy); -} /** * Determines the highest SemVer bump level based on the provided * list of Conventional Commits @@ -33293,7 +33436,7 @@ async function getVersionBumpTypeAndMessages(targetSha, config) { return null; }; const [version, commitList] = await (0, github_1.matchTagsToCommits)(targetSha, tagMatcher); - const results = processCommitsForBump(commitList, config); + const results = (0, bump_1.processCommitsForBump)(commitList, config); const convCommits = results .map(r => r.message) .filter((r) => r !== undefined); @@ -33334,7 +33477,7 @@ async function tryUpdateDraftRelease(cv, changelog, sha) { const npv = currentDraftVersion.nextPrerelease(); if (!npv) return; - npv.build = shortSha(sha); + npv.build = (0, bump_1.shortSha)(sha); const updatedRelease = await (0, github_1.updateDraftRelease)(latestDraftRelease.id, npv.toString(), npv.toString(), sha, changelog); if (!updatedRelease) { core.info(`Error renaming existing draft release.`); @@ -33346,7 +33489,7 @@ async function newDraftRelease(currentVersion, changelog, sha, prefix) { const nextPrereleaseVersion = currentVersion.nextPatch(); nextPrereleaseVersion.build = currentVersion.build; if (prefix === "dev") { - nextPrereleaseVersion.prerelease = `${prefix}001.${shortSha(sha)}`; + nextPrereleaseVersion.prerelease = `${prefix}001.${(0, bump_1.shortSha)(sha)}`; } else { nextPrereleaseVersion.prerelease = `${prefix}001`; @@ -33399,95 +33542,6 @@ function printNonCompliance(commits) { } } exports.printNonCompliance = printNonCompliance; -async function publishBump(nextVersion, releaseMode, headSha, changelog, isBranchAllowedToPublish, discussionCategoryName, updateDraftId) { - let releaseMetadata; - let tagMetadata; - const nv = nextVersion.toString(); - core.info(`ℹ️ Next version: ${nv}`); - core.endGroup(); - if (releaseMode !== "none") { - if (!isBranchAllowedToPublish) { - return {}; - } - if ((0, github_1.isPullRequestEvent)()) { - core.startGroup(`ℹ️ Not creating ${releaseMode} on a pull request event.`); - core.info("We cannot create a release or tag in a pull request context, due to " + - "potential parallelism (i.e. races) in pull request builds."); - return {}; - } - core.startGroup(`ℹ️ Creating ${releaseMode} ${nv}..`); - try { - if (releaseMode === "tag") { - tagMetadata = await (0, github_1.createTag)(nv, headSha); - } - else { - // If version is a prerelease, but not an RC, create a draft release - // If version is an RC, create a GitHub "pre-release" - const isRc = nextVersion.prerelease.startsWith(RC_PREFIX); - const isDev = nextVersion.prerelease !== "" && !isRc; - if (updateDraftId) { - releaseMetadata = await (0, github_1.updateDraftRelease)(updateDraftId, nv, nv, headSha, changelog, isDev, // draft - isRc // prerelease - ); - if (!releaseMetadata) { - core.info(`Error renaming existing draft release, ` + - `creating new draft release.`); - } - } - if (!releaseMetadata) { - releaseMetadata = await (0, github_1.createRelease)(nv, headSha, changelog, isDev, isRc, discussionCategoryName); - // Only set the tag information in case we created a release - // which implicitly creates a tag (i.e. not applicable for draft-releases). - if (releaseMetadata) { - tagMetadata = { - name: releaseMetadata.name, - ref: `refs/tags/${releaseMetadata.name}`, - sha: headSha, - }; - } - } - } - } - catch (ex) { - // The most likely failure is a preexisting tag, in which case - // a RequestError with statuscode 422 will be thrown - const commit = await (0, github_1.getShaForTag)(`refs/tags/${nv}`); - if (ex instanceof request_error_1.RequestError && ex.status === 422 && commit) { - core.setFailed(`Unable to create ${releaseMode}; the tag "${nv}" already exists in the repository, ` + - `it currently points to ${commit}.\n` + - "You can find the branch(es) associated with the tag with:\n" + - ` git fetch -t; git branch --contains ${nv}`); - } - else if (ex instanceof request_error_1.RequestError) { - core.setFailed(`Unable to create ${releaseMode} with the name "${nv}" due to ` + - `HTTP request error (status ${ex.status}):\n${ex.message}`); - } - else if (ex instanceof Error) { - core.setFailed(`Unable to create ${releaseMode} with the name "${nv}":\n${ex.message}`); - } - else { - core.setFailed(`Unknown error during ${releaseMode} creation`); - throw ex; - } - core.endGroup(); - return {}; - } - core.info("Succeeded"); - } - else { - core.startGroup(`ℹ️ Not creating tag or release for ${nv}..`); - core.info("To create a lightweight Git tag or GitHub release when the version is bumped, run this action with:\n" + - ' - "create-release" set to "true" to create a GitHub release, or\n' + - ' - "create-tag" set to "true" for a lightweight Git tag.\n' + - "Note that setting both options is not needed, since a GitHub release implicitly creates a Git tag."); - return {}; - } - return { - release: releaseMetadata, - tag: tagMetadata, - }; -} -exports.publishBump = publishBump; async function bumpSemVer(config, bumpInfo, releaseMode, branchName, headSha, isBranchAllowedToPublish, createChangelog) { const compliantCommits = bumpInfo.processedCommits .filter(c => c.message !== undefined) @@ -33528,7 +33582,7 @@ async function bumpSemVer(config, bumpInfo, releaseMode, branchName, headSha, is if (buildMetadata) { bumpMetadata.to.build = buildMetadata; } - const { release, tag } = await publishBump(bumpMetadata.to, releaseMode, headSha, changelog, isBranchAllowedToPublish, config.releaseDiscussionCategory); + const { release, tag } = await (0, bump_1.publishBump)(bumpMetadata.to, releaseMode, headSha, changelog, isBranchAllowedToPublish, config.releaseDiscussionCategory); versionMetadata = { bump: { from: bumpMetadata.from.toString(), @@ -33582,331 +33636,6 @@ async function bumpSemVer(config, bumpInfo, releaseMode, branchName, headSha, is return bumped ? versionMetadata : undefined; } exports.bumpSemVer = bumpSemVer; -function getNextSdkVer(currentVersion, sdkVerBumpType, isReleaseBranch, headMatchesTag, hasBreakingChange, devPrereleaseText, headSha, isInitialDevelopment) { - const currentIsRc = currentVersion.prerelease.startsWith(RC_PREFIX); - const currentIsRel = currentVersion.prerelease === ""; - const fatal = (msg) => { - throw new errors_1.BumpError(msg); - }; - const bumpOrError = (t) => { - const bumpResult = currentVersion.bump(t, isInitialDevelopment); - if (!bumpResult?.version) { - throw new errors_1.BumpError(`Bump ${t.toString()} for ${currentVersion} failed`); - } - return bumpResult.version; - }; - core.info(`Determining SDK bump for version ${currentVersion.toString()}:`); - core.info(` - current version type: ${currentIsRel ? "release" : currentIsRc ? "release candidate" : "dev"}`); - core.info(` - bump type: ${sdkVerBumpType}`); - core.info(` - branch type: ${isReleaseBranch ? "" : "not "}release`); - core.info(` - breaking changes: ${hasBreakingChange ? "yes" : "no"}`); - let nextVersion = null; - let nextBumpType = null; - if (isReleaseBranch) { - // If current branch HEAD is a release candidate: - // !createRel && !createRc = bump rc-val - // !createRel && createRc = bump rc-val - // createRel && !createRc = promote to full release - // Else if current branch HEAD is a full release: - // !createRel && !createRc = bump fix version (patch field) - // !createRel && createRc = error - // createRel && !createRc = bump fix version (patch field) - // Else - // error - if (!currentIsRc && !currentIsRel) { - fatal("Release branches can only contain release candidates or full releases. " + - `'${currentVersion.toString()}' is neither.`); - } - // Special case: we allow breaking changes on a release branch if that - // release branch still contains an RC for the next API version, in which - // case, the MINOR and PATCH fields will be 0 (1.2.3 -> 2.0.0-rc1) - if (hasBreakingChange && - !(currentIsRc && currentVersion.minor === 0 && currentVersion.patch === 0)) { - fatal("Breaking changes are not allowed on release branches."); - } - // Only bump if we need to; we don't want to generate a new RC or release - // when nothing has changed since the last RC or release, unless it is a - // promotion from RC to full release. - if (headMatchesTag && !(sdkVerBumpType === "rel" && currentIsRc)) { - core.info(` - head matches latest tag on release branch`); - } - else if (sdkVerBumpType === "rel") { - if (currentIsRel) { - // Pushes on release branches with a finalized release always - // bump PATCH, no exception. - nextVersion = bumpOrError(semver_1.SemVerType.PATCH); - nextBumpType = "rel"; - } - else if (currentIsRc) { - // A release bump on a release candidate results in a full release - const nv = semver_1.SemVer.copy(currentVersion); - nv.prerelease = ""; - nextVersion = nv; - nextBumpType = "rel"; - } - } - else { - // Bumps for "rc" and "dev" are identical on a release branch - if (currentIsRc) { - // We need to keep the pre intact (undefined), but the post needs to be - // cleared, as that contains the commit hash of the previous dev version. - // Also zero pad to at least two digits. - nextVersion = currentVersion.nextPrerelease(undefined, "", 2); - nextBumpType = "rc"; - if (!nextVersion) { - fatal(`Unable to bump RC version for: ${currentVersion.toString()}; ` + - `make sure it contains an index number.`); - } - } - else { - // Current version is a release, so bump patch - nextVersion = bumpOrError(semver_1.SemVerType.PATCH); - nextBumpType = "rel"; - } - } - } - else { - // !isReleaseBranch - // If current branch HEAD is a release candidate: - // dev bump = bump dev prerelease for next minor (do nothing here) - // rc bump = create new rc for _next_ version - // rel && rc_sha == head_sha = "promote" to new full release - // rel && rc_sha != head_sha = create full release for _next_ major - // Else if current branch HEAD is a full release: - // !createRel && !createRc = bump dev prerelease for next minor (do nothing here) - // !createRel && createRc = create new rc for _next_ version - // createRel && !createRc = create new full release - // Else - // !createRel && !createRc = bump dev prerelease (do nothing here) - // !createRel && createRc = create new rc for _next_ version - // createRel && !createRc = create new full release - const releaseBump = hasBreakingChange ? semver_1.SemVerType.MAJOR : semver_1.SemVerType.MINOR; - if (sdkVerBumpType === "rel") { - // Special case for release bumps if the current version is an RC: - // only promote (i.e. strip prerelease) if HEAD matches that RC's SHA. - // If not, get the next major/minor. - if (currentIsRel || (currentIsRc && !headMatchesTag)) { - nextVersion = bumpOrError(releaseBump); - } - else { - // Behavior for rc and dev is the same - nextVersion = semver_1.SemVer.copy(currentVersion); - nextVersion.prerelease = ""; - nextVersion.build = ""; - } - nextBumpType = "rel"; - } - else if (sdkVerBumpType === "rc") { - if (currentIsRel || currentIsRc) { - // ^^^^ - // This may be slightly counter-intuitive: RC increments can - // only be done on a release branch, so performing an RC bump - // on a non-release branch where the HEAD itself is an RC results - // in creating an RC for the _next_ version: - // 1.2.0-rc1 -> 1.3.0-rc1 (not 1.2.0-rc2). - nextVersion = bumpOrError(releaseBump); - } - else { - // Current HEAD is a dev prerelease - nextVersion = semver_1.SemVer.copy(currentVersion); - nextVersion.build = ""; - } - nextVersion.prerelease = `${RC_PREFIX}01`; - nextBumpType = "rc"; - } - else if (sdkVerBumpType === "dev") { - // TODO: decide on how best to handle hasBreakingChange in this case - if (currentIsRel || currentIsRc) { - nextVersion = bumpOrError(releaseBump); - nextVersion.prerelease = `${devPrereleaseText}001`; - nextBumpType = "dev"; - } - else { - // Keep prefix, clear postfix, zero pad to at least three digits - nextVersion = currentVersion.nextPrerelease(undefined, "", 3); - nextBumpType = "dev"; - if (!nextVersion) { - // This can only happen if the current version is something - // unexpected and invalid, like a prerelease without a number, e.g.: - // 1.2.3-rc 1.2.3-dev 1.2.3-testing - nextVersion = bumpOrError(semver_1.SemVerType.MINOR); - nextVersion.prerelease = `${devPrereleaseText}001`; - core.warning(`Failed to bump the prerelease for version ${currentVersion.toString()}` + - `; moving to next release version ${nextVersion.toString()}`); - } - } - } - } - core.info(` - next version: ${nextVersion?.toString() ?? "none"}`); - if (!nextVersion && !headMatchesTag) { - fatal(`Unable to bump version for: ${currentVersion.toString()}`); - } - const buildMetadata = core.getInput("build-metadata"); - nextVersion = nextVersion; - if (buildMetadata) { - nextVersion.build = buildMetadata; - } - if (nextBumpType === "dev") { - nextVersion.prerelease += `.${shortSha(headSha)}`; - } - if (nextVersion && nextBumpType) { - return { - from: currentVersion, - to: nextVersion, - type: nextBumpType, - }; - } -} -/** - * Bump and release/tag SDK versions - */ -async function bumpSdkVer(config, bumpInfo, releaseMode, sdkVerBumpType, headSha, branchName, isBranchAllowedToPublish, createChangelog) { - const isReleaseBranch = new RegExp(config.releaseBranches).test(branchName); - let hasBreakingChange = bumpInfo.processedCommits.some(c => c.message?.breakingChange); - if (!bumpInfo.foundVersion) - return; // should never happen - // SdkVer requires a prerelease, so apply the default if not set - config.prereleasePrefix = config.prereleasePrefix ?? "dev"; - let cv = semver_1.SemVer.copy(bumpInfo.foundVersion); - // Do not bump major version when breaking change is found in case - // the max configured major version is already reached - if (config.sdkverMaxMajor !== undefined && - config.sdkverMaxMajor > 0 && - cv.major >= config.sdkverMaxMajor) { - hasBreakingChange = false; - } - // Get the latest draft release matching our current version's prefix. - // Don't look at the draft version on a release branch; the current version - // should always reflect the version to be bumped (as no dev releases are - // allowed on a release branch) - const latestDraft = await (0, github_1.getRelease)({ - prefixToMatch: cv.prefix, - draftOnly: true, - fullReleasesOnly: false, - }); - const latestRelease = await (0, github_1.getRelease)({ - prefixToMatch: cv.prefix, - draftOnly: false, - fullReleasesOnly: true, - }); - core.info(`Current version: ${cv.toString()}, latest GitHub release draft: ${latestDraft?.name ?? "NONE"}, latest GitHub release: ${latestRelease?.name ?? "NONE"}`); - if (!isReleaseBranch && latestDraft) { - // If we're not on a release branch and a draft version exists that is - // newer than the latest tag, we continue with that - const draftVersion = semver_1.SemVer.fromString(latestDraft.name); - if (draftVersion && cv.lessThan(draftVersion)) { - cv = draftVersion; - } - } - // TODO: This is wasteful, as this info has already been available before - const headMatchesTag = await (0, github_1.currentHeadMatchesTag)(cv.toString()); - const bump = getNextSdkVer(cv, sdkVerBumpType, isReleaseBranch, headMatchesTag, hasBreakingChange, config.prereleasePrefix ?? "dev", headSha, config.initialDevelopment); - let bumped = false; - let changelog = ""; - let releaseBranchName; - let versionOutput; - if (bump?.to) { - // Since we want the changelog since the last _full_ release, we - // can only rely on the `bumpInfo` if the "current version" is a - // full release. In other cases, we need to gather some information - // to generate the proper changelog. - const previousRelease = await (0, github_1.getRelease)({ - prefixToMatch: bump.to.prefix, - draftOnly: false, - fullReleasesOnly: true, - constraint: { - major: bump.to.major, - minor: bump.to.minor, - }, - }); - core.info(`The full release preceding the current one is ${previousRelease?.name ?? "undefined"}`); - if (createChangelog) { - if (previousRelease && cv.prerelease) { - const toVersion = - // Since "dev" releases on non-release-branches result in a draft - // release, we'll need to use the commit sha. - bump.type === "dev" ? shortSha(headSha) : bump.to.toString(); - changelog = await (0, changelog_1.generateChangelogForCommits)(previousRelease.name, toVersion, await collectChangelogCommits(previousRelease.name, config)); - } - else { - changelog = await (0, changelog_1.generateChangelog)(bumpInfo); - } - } - const { release, tag } = await publishBump(bump.to, releaseMode, headSha, changelog, isBranchAllowedToPublish, config.releaseDiscussionCategory, - // Re-use the latest draft release only when not running on a release branch, - // otherwise we might randomly reset a `dev-N` number chain. - !isReleaseBranch ? latestDraft?.id : undefined); - versionOutput = { - tag, - release, - bump: { - from: bumpInfo.foundVersion.toString(), - to: bump.to.toString(), - type: bump.type, - }, - }; - // If we have a release and/or a tag, we consider the bump successful - bumped = release !== undefined || tag !== undefined; - } - if (!bumped) { - core.info("ℹ️ No bump was performed"); - } - else { - // Create a release branch for releases and RC's if we're configured to do so - // and are currently not running on a release branch. - if (config.sdkverCreateReleaseBranches !== undefined && - !isReleaseBranch && - bump?.type !== "dev" && - bump?.to) { - releaseBranchName = `${config.sdkverCreateReleaseBranches}${bump.to.major}.${bump.to.minor}`; - core.info(`Creating release branch ${releaseBranchName}..`); - try { - await (0, github_1.createBranch)(`refs/heads/${releaseBranchName}`, headSha); - } - catch (ex) { - if (ex instanceof request_error_1.RequestError && ex.status === 422) { - core.warning(`The branch '${releaseBranchName}' already exists` + - `${(0, github_1.getRunNumber)() !== 1 ? " (NOTE: this is a re-run)." : "."}`); - } - else if (ex instanceof request_error_1.RequestError) { - core.warning(`Unable to create release branch '${releaseBranchName}' due to ` + - `HTTP request error (status ${ex.status}):\n${ex.message}`); - } - else if (ex instanceof Error) { - core.warning(`Unable to create release branch '${releaseBranchName}':\n${ex.message}`); - } - else { - core.warning(`Unknown error during ${releaseMode} creation`); - throw ex; - } - } - } - } - core.endGroup(); - return bumped ? versionOutput : undefined; -} -exports.bumpSdkVer = bumpSdkVer; -/** - * For SdkVer, the latest tag (i.e. "current version") may not be the starting - * point we want for generating a changelog; in this context, we want to get a - * list of commits since the last _full_ release. - * - * Returns an object containing: - * - the name of the last full release reachable from our current version - * - the list of valid Conventional Commit objects since that release - */ -async function collectChangelogCommits(previousRelease, config) { - core.startGroup(`📜 Gathering changelog information`); - const commits = await (0, github_1.getCommitsBetweenRefs)(previousRelease); - core.info(`Processing commit list (since ${previousRelease}) ` + - `for changelog generation:\n-> ` + - `${commits.map(c => c.message.split("\n")[0]).join("\n-> ")}`); - const processedCommits = processCommitsForBump(commits, config); - core.endGroup(); - return processedCommits - .map(c => c.message) - .filter(c => c); -} /***/ }), @@ -36431,7 +36160,12 @@ class SemVer { const build = this.build ? `+${this.build}` : ""; return `${this.prefix}${this.major}.${this.minor}.${this.patch}${prerelease}${build}`; } - nextMajor() { + nextMajor(initialDevelopment) { + if (initialDevelopment && this.major <= 0) { + // Bumping major version during initial development is prohibited, + // bump the minor version instead. + return this.nextMinor(); + } return new SemVer({ major: this.major + 1, minor: 0, diff --git a/src/bump/bump.ts b/src/bump/bump.ts index 67c97ffa..3ee00a8a 100644 --- a/src/bump/bump.ts +++ b/src/bump/bump.ts @@ -1,3 +1,19 @@ +/** + * Copyright (C) 2025, TomTom (http://tomtom.com). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import * as core from "@actions/core"; import { RequestError } from "@octokit/request-error"; diff --git a/src/bump/sdkver.ts b/src/bump/sdkver.ts index 4288969a..9f73c349 100644 --- a/src/bump/sdkver.ts +++ b/src/bump/sdkver.ts @@ -1,5 +1,5 @@ /** - * Copyright (C) 2022, TomTom (http://tomtom.com). + * Copyright (C) 2025, TomTom (http://tomtom.com). * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/bump/semver.ts b/src/bump/semver.ts index fba95d63..e91477c6 100644 --- a/src/bump/semver.ts +++ b/src/bump/semver.ts @@ -1,5 +1,5 @@ /** - * Copyright (C) 2022, TomTom (http://tomtom.com). + * Copyright (C) 2025, TomTom (http://tomtom.com). * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. From feb2141aab787f25800a565e58e529d5b2721c96 Mon Sep 17 00:00:00 2001 From: Kevin de Jong Date: Tue, 24 Jun 2025 18:07:03 +0200 Subject: [PATCH 3/3] fixup! fix: handle breaking changes when current version is dev --- bump/action.yml | 13 +++++++++- dist/bump/index.js | 35 ++++++++++++++++++------- src/actions/bump.ts | 18 +++++++++++++ src/bump/sdkver.ts | 27 +++++++++++++------- test/bump.sdkver.test.ts | 55 +++++++++++++++++++++++++--------------- test/test_utils.ts | 2 ++ 6 files changed, 111 insertions(+), 39 deletions(-) diff --git a/bump/action.yml b/bump/action.yml index d2fc9db5..3c440cab 100644 --- a/bump/action.yml +++ b/bump/action.yml @@ -44,11 +44,22 @@ inputs: version-prefix: description: 'Optional version prefix (eg. "", "v", "componentX-"). If set, only tags with this exact prefix shall be considered. "*" is a special value, meaning the closest version is used, regardless of prefix".' required: false + increment-type-override: + description: >- + Optional override for the type of version increment to apply for this build. + If not set, the action will determine the increment type based on the commits since the last tag. + One of ["major", "minor", "patch"] + default: "" + required: false release-type: description: >- The type of version increment to apply for this build when using the SdkVer version scheme. - This has no effect when using the SemVer version scheme. + This has no effect when using the SemVer version scheme. One of ["rel", "rc", "dev"] [EXPERIMENTAL] + + To ensure a breaking change in SdkVer, either use a conventional commit with a breaking change, + or set the `increment-type-override` to `major`. Other values of `increment-type-override` + will be ignored. default: "" required: false create-changelog: diff --git a/dist/bump/index.js b/dist/bump/index.js index a2531d07..2b733b88 100644 --- a/dist/bump/index.js +++ b/dist/bump/index.js @@ -33045,6 +33045,7 @@ const sdkver_1 = __nccwpck_require__(6503); const semver_1 = __nccwpck_require__(2786); const config_1 = __nccwpck_require__(5354); const github_2 = __nccwpck_require__(9248); +const semver_2 = __nccwpck_require__(1475); /** * Bump action entrypoint * Finds out the current version based on SemVer Git tags, optionally creates a @@ -33082,6 +33083,16 @@ async function run() { } core.startGroup("🔍 Finding latest topological tag.."); const bumpInfo = await (0, semver_1.getVersionBumpTypeAndMessages)(github_1.context.sha, config); + let incrementTypeOverride = core.getInput("increment-type-override"); + if (incrementTypeOverride) { + if (Object.keys(semver_2.SemVerType).includes(incrementTypeOverride.toUpperCase())) { + bumpInfo.requiredBump = + semver_2.SemVerType[incrementTypeOverride.toUpperCase()]; + } + else { + core.warning(`The input 'increment-type-override' must be one of: [major, minor, patch]. Using default behavior.`); + } + } if (!bumpInfo.foundVersion) { // We haven't found a (matching) SemVer tag in the commit and tag list core.setOutput("current-version", ""); @@ -33415,25 +33426,30 @@ const versionUpdateCases = [ { currentType: "rel", bumpType: "rel", isReleaseBranch: true, updater: (params) => { return updateReleaseVersion(params); } }, ]; /** - * Increments a development version, i.e. 1.0.0-dev001.SHA -> 1.0.0-dev002.SHA + * Increments a development version, i.e. + * Non-breaking: 1.0.0-dev001.SHA -> 1.0.0-dev002.SHA + * Breaking: 1.0.0-dev001.SHA -> 2.0.0-dev001.SHA * * Exceptions: * - A new development version is created when the previous version is not using a SdkVer compatible prerelease pattern. */ function updateDevelopmentVersion(params) { - let nextVersion = params.currentVersion.nextPrerelease(undefined, "", 3); + let nextVersion = params.hasBreakingChange && !params.isInitialDevelopment + ? params.currentVersion.nextMajor(params.isInitialDevelopment) + : params.currentVersion.nextPrerelease(undefined, "", 3); if (!nextVersion) return newDevelopmentVersion(params); - nextVersion.prerelease = `${nextVersion.prerelease}.${(0, bump_1.shortSha)(params.headSha)}`; + nextVersion.prerelease = `${nextVersion.prerelease ? nextVersion.prerelease : "dev001"}.${(0, bump_1.shortSha)(params.headSha)}`; return { from: params.currentVersion, to: nextVersion, type: "dev" }; } /** - * Increments a release candidate version, i.e. 1.0.0-rc01 -> 1.0.0-rc02 + * Increments a release candidate version, i.e. + * Non-breaking: 1.0.0-rc01 -> 1.0.0-rc02 + * Breaking: 1.0.0-rc01 -> 1.0.0-rc02 * * Exceptions: * - No release candidate version is created when the head matches a tag. * - Release candidates can only be updated on a release branch. - * - Breaking changes are not allowed when updating a release candidate version. * - A new release candidate will not be created when the previous version is not using a SdkVer compatible prerelease pattern. */ function updateReleaseCandidateVersion(params) { @@ -33441,8 +33457,6 @@ function updateReleaseCandidateVersion(params) { throw new errors_1.BumpError("Do now update release candidate version when the head matches a tag."); if (!params.isReleaseBranch) throw new errors_1.BumpError("Cannot update release candidate version on a non-release branch."); - if (params.hasBreakingChange) - throw new errors_1.BumpError("Cannot update release candidates with a breaking change."); let nextVersion = params.currentVersion.nextPrerelease(undefined, "", 2); if (!nextVersion) throw new errors_1.BumpError(`Failed to determine next prerelease version from ${params.currentVersion.toString()}`); @@ -33517,7 +33531,7 @@ function promoteToReleaseCandidateVersion(params) { let nextVersion = params.hasBreakingChange ? params.currentVersion.nextMajor() : semver_1.SemVer.copy(params.currentVersion); - nextVersion.prerelease = `${bump_1.RC_PREFIX}01`; + nextVersion.prerelease = `rc01`; nextVersion.build = ""; return { from: params.currentVersion, to: nextVersion, type: "rc" }; } @@ -33582,6 +33596,7 @@ function getNextSdkVer(currentVersion, sdkVerBumpType, isReleaseBranch, headMatc isInitialDevelopment, }; const match = findVersionUpdateCase(params); + //console.log(match) if (match) { return match.updater(params); } @@ -33596,7 +33611,7 @@ function getNextSdkVer(currentVersion, sdkVerBumpType, isReleaseBranch, headMatc */ async function bumpSdkVer(config, bumpInfo, releaseMode, sdkVerBumpType, headSha, branchName, isBranchAllowedToPublish, createChangelog) { const isReleaseBranch = new RegExp(config.releaseBranches).test(branchName); - let hasBreakingChange = bumpInfo.processedCommits.some(c => c.message?.breakingChange); + let hasBreakingChange = bumpInfo.requiredBump === semver_1.SemVerType.MAJOR; if (!bumpInfo.foundVersion) return; // should never happen // SdkVer requires a prerelease, so apply the default if not set @@ -33622,6 +33637,8 @@ async function bumpSdkVer(config, bumpInfo, releaseMode, sdkVerBumpType, headSha if (draftVersion && cv.lessThan(draftVersion)) { cv = draftVersion; } + const releaseVersion = semver_1.SemVer.fromString(latestRelease?.name ?? "0.0.0"); + hasBreakingChange = hasBreakingChange && releaseVersion?.major === draftVersion?.major; } // TODO: This is wasteful, as this info has already been available before const headMatchesTag = await (0, github_1.currentHeadMatchesTag)(cv.toString()); diff --git a/src/actions/bump.ts b/src/actions/bump.ts index 5a3ee852..f06e7d99 100644 --- a/src/actions/bump.ts +++ b/src/actions/bump.ts @@ -31,6 +31,7 @@ import { SdkVerBumpType, IVersionOutput, } from "../interfaces"; +import { SemVerType } from "../semver"; /** * Bump action entrypoint @@ -59,6 +60,7 @@ export async function run(): Promise { } const release = core.getBooleanInput("create-release"); const tag = core.getBooleanInput("create-tag"); + let releaseMode: ReleaseMode = "none"; if (release) { releaseMode = "release"; @@ -77,6 +79,22 @@ export async function run(): Promise { const bumpInfo: IVersionBumpTypeAndMessages = await getVersionBumpTypeAndMessages(context.sha, config); + let incrementTypeOverride = core.getInput("increment-type-override"); + if (incrementTypeOverride) { + if ( + Object.keys(SemVerType).includes(incrementTypeOverride.toUpperCase()) + ) { + bumpInfo.requiredBump = + SemVerType[ + incrementTypeOverride.toUpperCase() as keyof typeof SemVerType + ]; + } else { + core.warning( + `The input 'increment-type-override' must be one of: [major, minor, patch]. Using default behavior.` + ); + } + } + if (!bumpInfo.foundVersion) { // We haven't found a (matching) SemVer tag in the commit and tag list core.setOutput("current-version", ""); diff --git a/src/bump/sdkver.ts b/src/bump/sdkver.ts index 9f73c349..b3e7ff41 100644 --- a/src/bump/sdkver.ts +++ b/src/bump/sdkver.ts @@ -21,7 +21,7 @@ import { generateChangelogForCommits, generateChangelog } from "../changelog"; import { Configuration } from "../config"; import { createBranch, currentHeadMatchesTag, getCommitsBetweenRefs, getRunNumber, getRelease } from "../github"; import { ConventionalCommitMessage } from "../commit"; -import { SemVer } from "../semver"; +import { SemVer, SemVerType } from "../semver"; import { BumpError } from "../errors"; import * as interfaces from "../interfaces"; import { processCommitsForBump, publishBump, RC_PREFIX, shortSha } from "./bump"; @@ -77,33 +77,38 @@ const versionUpdateCases: VersionUpdateCase[] = [ ]; /** - * Increments a development version, i.e. 1.0.0-dev001.SHA -> 1.0.0-dev002.SHA + * Increments a development version, i.e. + * Non-breaking: 1.0.0-dev001.SHA -> 1.0.0-dev002.SHA + * Breaking: 1.0.0-dev001.SHA -> 2.0.0-dev001.SHA * * Exceptions: * - A new development version is created when the previous version is not using a SdkVer compatible prerelease pattern. */ function updateDevelopmentVersion(params: VersionUpdateParams): interfaces.IBumpInfo { - let nextVersion = params.currentVersion.nextPrerelease(undefined, "", 3); + let nextVersion = + params.hasBreakingChange && !params.isInitialDevelopment + ? params.currentVersion.nextMajor(params.isInitialDevelopment) + : params.currentVersion.nextPrerelease(undefined, "", 3); if (!nextVersion) return newDevelopmentVersion(params); - nextVersion.prerelease = `${nextVersion.prerelease}.${shortSha(params.headSha)}`; + nextVersion.prerelease = `${nextVersion.prerelease ? nextVersion.prerelease : "dev001"}.${shortSha(params.headSha)}`; return { from: params.currentVersion, to: nextVersion, type: "dev" }; } /** - * Increments a release candidate version, i.e. 1.0.0-rc01 -> 1.0.0-rc02 + * Increments a release candidate version, i.e. + * Non-breaking: 1.0.0-rc01 -> 1.0.0-rc02 + * Breaking: 1.0.0-rc01 -> 1.0.0-rc02 * * Exceptions: * - No release candidate version is created when the head matches a tag. * - Release candidates can only be updated on a release branch. - * - Breaking changes are not allowed when updating a release candidate version. * - A new release candidate will not be created when the previous version is not using a SdkVer compatible prerelease pattern. */ function updateReleaseCandidateVersion(params: VersionUpdateParams): interfaces.IBumpInfo { if (params.headMatchesTag) throw new BumpError("Do now update release candidate version when the head matches a tag."); if (!params.isReleaseBranch) throw new BumpError("Cannot update release candidate version on a non-release branch."); - if (params.hasBreakingChange) throw new BumpError("Cannot update release candidates with a breaking change."); let nextVersion = params.currentVersion.nextPrerelease(undefined, "", 2); if (!nextVersion) @@ -185,7 +190,7 @@ function promoteToReleaseCandidateVersion(params: VersionUpdateParams): interfac let nextVersion: SemVer | null = params.hasBreakingChange ? params.currentVersion.nextMajor() : SemVer.copy(params.currentVersion); - nextVersion.prerelease = `${RC_PREFIX}01`; + nextVersion.prerelease = `rc01`; nextVersion.build = ""; return { from: params.currentVersion, to: nextVersion, type: "rc" }; @@ -268,6 +273,7 @@ function getNextSdkVer( }; const match = findVersionUpdateCase(params); + //console.log(match) if (match) { return match.updater(params); } @@ -295,7 +301,7 @@ export async function bumpSdkVer( createChangelog: boolean ): Promise { const isReleaseBranch = new RegExp(config.releaseBranches).test(branchName); - let hasBreakingChange = bumpInfo.processedCommits.some(c => c.message?.breakingChange); + let hasBreakingChange = bumpInfo.requiredBump === SemVerType.MAJOR; if (!bumpInfo.foundVersion) return; // should never happen // SdkVer requires a prerelease, so apply the default if not set @@ -328,6 +334,9 @@ export async function bumpSdkVer( if (draftVersion && cv.lessThan(draftVersion)) { cv = draftVersion; } + + const releaseVersion = SemVer.fromString(latestRelease?.name ?? "0.0.0"); + hasBreakingChange = hasBreakingChange && releaseVersion?.major === draftVersion?.major; } // TODO: This is wasteful, as this info has already been available before diff --git a/test/bump.sdkver.test.ts b/test/bump.sdkver.test.ts index 301d2635..d90675b5 100644 --- a/test/bump.sdkver.test.ts +++ b/test/bump.sdkver.test.ts @@ -50,6 +50,8 @@ const setInputSpyWith = (a: { [b: string]: string }): void => { return ".commisery.yml"; case "build-metadata": return ""; + case "increment-type-override": + return ""; } throw new Error(`getInput("${setting}") not mocked`); }); @@ -61,8 +63,6 @@ beforeEach(() => { jest.spyOn(github, "createTag").mockResolvedValue(undefined); jest.spyOn(github, "createRelease").mockResolvedValue(undefined); jest.spyOn(github, "getCommitsBetweenRefs").mockResolvedValue([]); - - const releaseTypeInput = core.getInput("release-type"); jest.spyOn(core, "getBooleanInput").mockImplementation(U.mockGetBooleanInput); jest @@ -167,8 +167,22 @@ const testFunction = async (p: SdkBumpTestParameters) => { .mockResolvedValue(p.testDescription.includes("HEADisTag")); jest .spyOn(github, "getRelease") - .mockResolvedValue( - p.latestDraftRelease ? { id: 1, name: p.latestDraftRelease } : undefined + .mockImplementation( + async (params: { + prefixToMatch: string; + draftOnly: boolean; + fullReleasesOnly: boolean; + constraint?: { major: number; minor: number }; + }) => { + if (params.fullReleasesOnly) { + return { id: 1, name: p.initialVersion, draft: false }; + } else if (params.draftOnly) { + return p.latestDraftRelease + ? { id: 1, name: p.latestDraftRelease, draft: true } + : undefined; + } + return undefined; + } ); gh.context.ref = `refs/heads/${p.branch}`; @@ -316,20 +330,21 @@ const testSuiteDefinitions = [ { suite: "Dev bumps with breaking changes", tests: [ - // [ test description , version , bump , latest draft , branch , breaking?, expected version , expected bump , initial development?, max major version ] - ["main branch, init" , "0.2.0" , "dev" , undefined , "master" , true , `0.3.0-dev001.${U.HEAD_SHA_ABBREV_8}`, "dev" , true , 0 ], - ["main branch, no init" , "0.2.0" , "dev" , undefined , "master" , true , `1.0.0-dev001.${U.HEAD_SHA_ABBREV_8}`, "dev" , false , 0 ], - ["main branch, max" , "1.2.0" , "dev" , undefined , "master" , true , `1.3.0-dev001.${U.HEAD_SHA_ABBREV_8}`, "dev" , false , 1 ], - ["main branch, max2" , "1.2.0" , "dev" , undefined , "master" , true , `2.0.0-dev001.${U.HEAD_SHA_ABBREV_8}`, "dev" , false , 2 ], - ["main branch" , "1.2.0" , "dev" , undefined , "master" , true , `2.0.0-dev001.${U.HEAD_SHA_ABBREV_8}`, "dev" , false , 0 ], - ["main branch, draft init", "0.2.0" , "dev" , "0.3.0-dev001.2" , "master" , true , `0.3.0-dev002.${U.HEAD_SHA_ABBREV_8}`, "dev" , true , 0 ], - ["main branch, draft max" , "1.2.0" , "dev" , "1.3.0-dev001.2" , "master" , true , `1.3.0-dev002.${U.HEAD_SHA_ABBREV_8}`, "dev" , false , 1 ], - ["main branch, draft max2", "1.2.0" , "dev" , "1.3.0-dev001.2" , "master" , true , `2.0.0-dev001.${U.HEAD_SHA_ABBREV_8}`, "dev" , false , 2 ], - ["main branch, draft" , "1.2.0" , "dev" , "1.3.0-dev001.2" , "master" , true , `1.3.0-dev002.${U.HEAD_SHA_ABBREV_8}`, "dev" , false , 0 ], - ["release branch" , "1.2.0" , "dev" , undefined , "release/1.2.0", true , undefined , "" , false , 0 ], - ["release branch, draft" , "1.2.0" , "dev" , "1.3.0-dev001.3" , "release/1.2.0", true , undefined , "" , false , 0 ], - ["release branch+RC" , "1.2.0-rc01" , "dev" , undefined , "release/1.2.0", true , undefined , "" , false , 0 ], - ["rel branch+RC, draft" , "1.2.0-rc01" , "dev" , "1.3.0-dev001.3" , "release/1.2.0", true , undefined , "" , false , 0 ], + // [ test description , version , bump , latest draft , branch , breaking?, expected version , expected bump , initial development?, max major version ] + ["main branch, init" , "0.2.0" , "dev" , undefined , "master" , true , `0.3.0-dev001.${U.HEAD_SHA_ABBREV_8}`, "dev" , true , 0 ], + ["main branch, no init" , "0.2.0" , "dev" , undefined , "master" , true , `1.0.0-dev001.${U.HEAD_SHA_ABBREV_8}`, "dev" , false , 0 ], + ["main branch, max" , "1.2.0" , "dev" , undefined , "master" , true , `1.3.0-dev001.${U.HEAD_SHA_ABBREV_8}`, "dev" , false , 1 ], + ["main branch, max2" , "1.2.0" , "dev" , undefined , "master" , true , `2.0.0-dev001.${U.HEAD_SHA_ABBREV_8}`, "dev" , false , 2 ], + ["main branch" , "1.2.0" , "dev" , undefined , "master" , true , `2.0.0-dev001.${U.HEAD_SHA_ABBREV_8}`, "dev" , false , 0 ], + ["main branch, draft init" , "0.2.0" , "dev" , "0.3.0-dev001.2" , "master" , true , `0.3.0-dev002.${U.HEAD_SHA_ABBREV_8}`, "dev" , true , 0 ], + ["main branch, draft" , "1.2.0" , "dev" , "1.3.0-dev001.2" , "master" , true , `2.0.0-dev001.${U.HEAD_SHA_ABBREV_8}`, "dev" , false , 0 ], + ["main branch, draft max" , "1.2.0" , "dev" , "1.3.0-dev001.2" , "master" , true , `1.3.0-dev002.${U.HEAD_SHA_ABBREV_8}`, "dev" , false , 1 ], + ["main branch, draft max2" , "1.2.0" , "dev" , "1.3.0-dev001.2" , "master" , true , `2.0.0-dev001.${U.HEAD_SHA_ABBREV_8}`, "dev" , false , 2 ], + ["main branch, draft double", "1.2.0" , "dev" , "2.0.0-dev001.2" , "master" , true , `2.0.0-dev002.${U.HEAD_SHA_ABBREV_8}`, "dev" , false , 3 ], + ["release branch" , "1.2.0" , "dev" , undefined , "release/1.2.0", true , undefined , "" , false , 0 ], + ["release branch, draft" , "1.2.0" , "dev" , "1.3.0-dev001.3" , "release/1.2.0", true , undefined , "" , false , 0 ], + ["release branch+RC" , "1.2.0-rc01" , "dev" , undefined , "release/1.2.0", true , `1.2.0-rc02` , "rc" , false , 0 ], + ["rel branch+RC, draft" , "1.2.0-rc01" , "dev" , "1.3.0-dev001.3" , "release/1.2.0", true , `1.2.0-rc02` , "rc" , false , 0 ], ], }, { @@ -346,8 +361,8 @@ const testSuiteDefinitions = [ ["main branch" , "1.2.0" , "rc" , undefined , "master" , true , "2.0.0-rc01" , "rc" , false , 0 ], ["main branch+RC" , "1.2.0-rc01" , "rc" , undefined , "master" , true , "2.0.0-rc01" , "rc" , false , 0 ], ["release branch" , "1.2.0" , "rc" , undefined , "release/1.2.0", true , undefined , "" , false , 0 ], - ["release branch+RC" , "1.2.0-rc01" , "rc" , undefined , "release/1.2.0", true , undefined , "" , false , 0 ], - ["RB+ RC for next major", "2.0.0-rc01" , "dev" , undefined , "release/2.0.0", true , undefined , "" , false , 0 ], + ["release branch+RC" , "1.2.0-rc01" , "rc" , undefined , "release/1.2.0", true , "1.2.0-rc02" , "rc" , false , 0 ], + ["RB+ RC for next major", "2.0.0-rc01" , "dev" , undefined , "release/2.0.0", true , "2.0.0-rc02" , "rc" , false , 0 ], ], }, { diff --git a/test/test_utils.ts b/test/test_utils.ts index 2885366c..6a6d038b 100644 --- a/test/test_utils.ts +++ b/test/test_utils.ts @@ -63,6 +63,8 @@ export const mockGetInput = (setting: string, _options?: unknown) => { return ""; case "release-type": return ""; + case "increment-type-override": + return ""; } throw new Error(`getInput("${setting}") not mocked`); };