From efcc2d5d97e000aadef89d41feda9c915cebdc50 Mon Sep 17 00:00:00 2001 From: Shane <6071159+smashedr@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:50:07 -0700 Subject: [PATCH 1/2] Add Calendar Version and Suffix --- README.md | 13 ++++++ action.yml | 5 +++ dist/index.js | 122 +++++++++++++++++++++++++++++++++++++++++++++++++- src/index.js | 122 +++++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 260 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a9cad16..f127b7c 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ Want to see more feature? [Request one](https://github.com/cssnr/draft-release-a | semver | - | `prerelease` | Semantaic Version to Incriment | | identifier | - | `beta` | Prerelease Tag to Append | | prerelease | - | `true` | Set Draft as Prerelease | +| calver | - | `false` | Enable Calendar Versioning | +| suffix | - | - | Suffix Appended to Tag Name | | prefix | - | - | Release Tag Prefix | | previous_tag_name | - | - | Previous Tag or SHA for Comparison | | notes_prefix | - | - | Prefix for Release Notes Tag | @@ -49,6 +51,17 @@ Want to see more feature? [Request one](https://github.com/cssnr/draft-release-a **semver:** This is the string passed to `semver.inc()` to determine which version to increment. For more details, see the [docs](https://github.com/npm/node-semver?tab=readme-ov-file#functions). +**calver:** Enable calendar versioning with `YYYY.MM.NN` in UTC. `MM`/`NN` are zero-padded, +`NN` starts at `00` and resets on UTC month rollover. Bypasses `semver.inc()`. +With `prerelease: true` this drafts `YYYY.MM.NN-identifier.0` (e.g. `2026.10.01-beta.0`), +incrementing the beta counter only on published prereleases. With `prerelease: false` +it drafts the stable `YYYY.MM.NN` (e.g. `2026.10.01`). + +**suffix:** Opaque string appended last: `prefix + YYYY.MM.NN[-identifier.N] + suffix`. +A leading `-` or `+` is added when missing, so `abc1234` and `-abc1234` both yield `-abc1234`. +Use this for a short SHA (e.g. `suffix: -${{ github.sha }}` truncated in the workflow). +Applies in both `semver` and `calver` modes. Stripped before version comparison. + **previous_tag_name:** Override the previous tag used as the starting point for generating release notes. This can be a tag name or a commit SHA. Use this if your release tag was moved after publication (e.g., by a force-push to a release branch) and the auto-detected tag name no longer produces diff --git a/action.yml b/action.yml index 23c3507..0ab8e24 100644 --- a/action.yml +++ b/action.yml @@ -21,6 +21,11 @@ inputs: prerelease: description: "Mark Release as prerelease" default: "true" + calver: + description: "Enable Calendar Versioning YYYY.MM.NN" + default: "false" + suffix: + description: "Suffix Appended to Tag Name" prefix: description: "Release Tag Prefix" summary: diff --git a/dist/index.js b/dist/index.js index 9269511..bc06515 100644 --- a/dist/index.js +++ b/dist/index.js @@ -36930,7 +36930,19 @@ async function processRelease(inputs) { : (releases.data.find((r) => !r.draft && !r.prerelease) ?? latest); console.log('base.tag_name:', base?.tag_name); - const new_name = semver.inc(base.tag_name, inputs.semver, inputs.identifier); + const suffix = normalizeSuffix(inputs.suffix); + console.log('suffix:', suffix); + + let new_name; + if (inputs.calver) { + new_name = `${getNextCalver(releases.data, inputs)}${suffix}`; + } else { + const inc = semver.inc(base.tag_name, inputs.semver, inputs.identifier); + if (!inc) { + throw new Error(`Unable to parse ${inputs.semver} from ${base.tag_name}`) + } + new_name = suffix ? `${inc}${suffix}` : inc; + } console.log('new_name:', new_name); if (!new_name) { throw new Error(`Unable to parse ${inputs.semver} from ${base.tag_name}`) @@ -37038,6 +37050,8 @@ async function addSummary(inputs, response) { * @property {string} semver * @property {string} identifier * @property {boolean} prerelease + * @property {boolean} calver + * @property {string} suffix * @property {string} prefix * @property {boolean} summary * @property {string} token @@ -37050,6 +37064,8 @@ function getInputs() { semver: getInput('semver', { required: true }), identifier: getInput('identifier'), prerelease: getBooleanInput('prerelease'), + calver: getBooleanInput('calver'), + suffix: getInput('suffix'), prefix: getInput('prefix'), summary: getBooleanInput('summary'), token: getInput('token', { required: true }), @@ -37057,3 +37073,107 @@ function getInputs() { notes_prefix: getInput('notes_prefix'), } } + +/** + * Normalize Suffix + * Ensures a leading "-" or "+" separator so "abc1234" becomes "-abc1234". + * Empty string stays empty. Caller passes the "-" if they want it. + * @param {string} suffix + * @return {string} + */ +function normalizeSuffix(suffix) { + if (!suffix) { + return '' + } + const trimmed = suffix.trim(); + if (!trimmed) { + return '' + } + if (trimmed.startsWith('-') || trimmed.startsWith('+')) { + return trimmed + } + return `-${trimmed}` +} + +/** + * Parse CalVer Tag + * Strips prefix, then matches YYYY.MM.NN[-identifier.N], ignoring any + * trailing suffix (e.g. short SHA). Returns null when not a CalVer tag. + * @param {string} tag_name + * @param {string} prefix + * @return {{year:number,month:number,micro:number,identifier:string|null,prerelease:number|null}|null} + */ +function parseCalver(tag_name, prefix) { + let rest = tag_name; + if (prefix) { + if (!rest.startsWith(prefix)) { + return null + } + rest = rest.slice(prefix.length); + } + const match = rest.match(/^(\d{4})\.(\d{2})\.(\d{2,})(?:-([^.+\s]+)\.(\d+))?/); + if (!match) { + return null + } + return { + year: parseInt(match[1], 10), + month: parseInt(match[2], 10), + micro: parseInt(match[3], 10), + identifier: match[4] ?? null, + prerelease: match[5] !== undefined ? parseInt(match[5], 10) : null, + } +} + +/** + * Get Next CalVer Version (without prefix/suffix) + * NN resets to 00 on UTC month rollover. Beta counter resets to 0 on + * every NN bump and only increments on published prereleases. + * @param {Array} releases + * @param {Inputs} inputs + * @return {string} + */ +function getNextCalver(releases, inputs) { + const now = new Date(); + const year = now.getUTCFullYear(); + const month = now.getUTCMonth() + 1; + const current = `${year}.${String(month).padStart(2, '0')}`; + console.log('current:', current); + + const candidates = releases + .filter((r) => !r.draft && (inputs.prerelease ? true : !r.prerelease)) + .map((r) => parseCalver(r.tag_name, inputs.prefix)) + .filter((p) => p && p.year === year && p.month === month) + .filter((p) => { + if (p.identifier === null) { + return true + } + return p.identifier === inputs.identifier + }); + console.log('calver candidates:', candidates.length); + + const pad = (n) => String(n).padStart(2, '0'); + if (!candidates.length) { + if (inputs.prerelease) { + return `${current}.00-${inputs.identifier}.0` + } + return `${current}.00` + } + + const maxMicro = Math.max(...candidates.map((p) => p.micro)); + const atMax = candidates.filter((p) => p.micro === maxMicro); + const hasStable = atMax.some((p) => p.identifier === null); + const maxBeta = Math.max( + -1, + ...atMax.filter((p) => p.identifier !== null).map((p) => p.prerelease ?? -1), + ); + if (inputs.prerelease) { + if (hasStable) { + return `${current}.${pad(maxMicro + 1)}-${inputs.identifier}.0` + } + return `${current}.${pad(maxMicro)}-${inputs.identifier}.${maxBeta + 1}` + } + if (hasStable) { + return `${current}.${pad(maxMicro + 1)}` + } + return `${current}.${pad(maxMicro)}` +} diff --git a/src/index.js b/src/index.js index d1c2e6b..e21dbcc 100644 --- a/src/index.js +++ b/src/index.js @@ -102,7 +102,19 @@ async function processRelease(inputs) { : (releases.data.find((r) => !r.draft && !r.prerelease) ?? latest) console.log('base.tag_name:', base?.tag_name) - const new_name = semver.inc(base.tag_name, inputs.semver, inputs.identifier) + const suffix = normalizeSuffix(inputs.suffix) + console.log('suffix:', suffix) + + let new_name + if (inputs.calver) { + new_name = `${getNextCalver(releases.data, inputs)}${suffix}` + } else { + const inc = semver.inc(base.tag_name, inputs.semver, inputs.identifier) + if (!inc) { + throw new Error(`Unable to parse ${inputs.semver} from ${base.tag_name}`) + } + new_name = suffix ? `${inc}${suffix}` : inc + } console.log('new_name:', new_name) if (!new_name) { throw new Error(`Unable to parse ${inputs.semver} from ${base.tag_name}`) @@ -210,6 +222,8 @@ async function addSummary(inputs, response) { * @property {string} semver * @property {string} identifier * @property {boolean} prerelease + * @property {boolean} calver + * @property {string} suffix * @property {string} prefix * @property {boolean} summary * @property {string} token @@ -222,6 +236,8 @@ function getInputs() { semver: core.getInput('semver', { required: true }), identifier: core.getInput('identifier'), prerelease: core.getBooleanInput('prerelease'), + calver: core.getBooleanInput('calver'), + suffix: core.getInput('suffix'), prefix: core.getInput('prefix'), summary: core.getBooleanInput('summary'), token: core.getInput('token', { required: true }), @@ -229,3 +245,107 @@ function getInputs() { notes_prefix: core.getInput('notes_prefix'), } } + +/** + * Normalize Suffix + * Ensures a leading "-" or "+" separator so "abc1234" becomes "-abc1234". + * Empty string stays empty. Caller passes the "-" if they want it. + * @param {string} suffix + * @return {string} + */ +function normalizeSuffix(suffix) { + if (!suffix) { + return '' + } + const trimmed = suffix.trim() + if (!trimmed) { + return '' + } + if (trimmed.startsWith('-') || trimmed.startsWith('+')) { + return trimmed + } + return `-${trimmed}` +} + +/** + * Parse CalVer Tag + * Strips prefix, then matches YYYY.MM.NN[-identifier.N], ignoring any + * trailing suffix (e.g. short SHA). Returns null when not a CalVer tag. + * @param {string} tag_name + * @param {string} prefix + * @return {{year:number,month:number,micro:number,identifier:string|null,prerelease:number|null}|null} + */ +function parseCalver(tag_name, prefix) { + let rest = tag_name + if (prefix) { + if (!rest.startsWith(prefix)) { + return null + } + rest = rest.slice(prefix.length) + } + const match = rest.match(/^(\d{4})\.(\d{2})\.(\d{2,})(?:-([^.+\s]+)\.(\d+))?/) + if (!match) { + return null + } + return { + year: parseInt(match[1], 10), + month: parseInt(match[2], 10), + micro: parseInt(match[3], 10), + identifier: match[4] ?? null, + prerelease: match[5] !== undefined ? parseInt(match[5], 10) : null, + } +} + +/** + * Get Next CalVer Version (without prefix/suffix) + * NN resets to 00 on UTC month rollover. Beta counter resets to 0 on + * every NN bump and only increments on published prereleases. + * @param {Array} releases + * @param {Inputs} inputs + * @return {string} + */ +function getNextCalver(releases, inputs) { + const now = new Date() + const year = now.getUTCFullYear() + const month = now.getUTCMonth() + 1 + const current = `${year}.${String(month).padStart(2, '0')}` + console.log('current:', current) + + const candidates = releases + .filter((r) => !r.draft && (inputs.prerelease ? true : !r.prerelease)) + .map((r) => parseCalver(r.tag_name, inputs.prefix)) + .filter((p) => p && p.year === year && p.month === month) + .filter((p) => { + if (p.identifier === null) { + return true + } + return p.identifier === inputs.identifier + }) + console.log('calver candidates:', candidates.length) + + const pad = (n) => String(n).padStart(2, '0') + if (!candidates.length) { + if (inputs.prerelease) { + return `${current}.00-${inputs.identifier}.0` + } + return `${current}.00` + } + + const maxMicro = Math.max(...candidates.map((p) => p.micro)) + const atMax = candidates.filter((p) => p.micro === maxMicro) + const hasStable = atMax.some((p) => p.identifier === null) + const maxBeta = Math.max( + -1, + ...atMax.filter((p) => p.identifier !== null).map((p) => p.prerelease ?? -1), + ) + if (inputs.prerelease) { + if (hasStable) { + return `${current}.${pad(maxMicro + 1)}-${inputs.identifier}.0` + } + return `${current}.${pad(maxMicro)}-${inputs.identifier}.${maxBeta + 1}` + } + if (hasStable) { + return `${current}.${pad(maxMicro + 1)}` + } + return `${current}.${pad(maxMicro)}` +} From 3de37ac19d270dc260cfecc8fbd7cd9f8ee44ea1 Mon Sep 17 00:00:00 2001 From: Shane <6071159+smashedr@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:57:12 -0700 Subject: [PATCH 2/2] Lint Fixes for SonarCrap --- dist/index.js | 16 ++++++++-------- src/index.js | 14 +++++++------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/dist/index.js b/dist/index.js index bc06515..819b685 100644 --- a/dist/index.js +++ b/dist/index.js @@ -36832,9 +36832,9 @@ var semver = /*@__PURE__*/getDefaultExportFromCjs(semverExports); // const bot_id = 6071159 // TODO: DEBUG: Remove This const bot_id = 41898282; -const script_id = '' +const script_id = ''; -;(async () => { +void (async () => { try { info(`🏳️ Starting Draft Release Action`); @@ -37111,16 +37111,16 @@ function parseCalver(tag_name, prefix) { } rest = rest.slice(prefix.length); } - const match = rest.match(/^(\d{4})\.(\d{2})\.(\d{2,})(?:-([^.+\s]+)\.(\d+))?/); + const match = /^(\d{4})\.(\d{2})\.(\d{2,})(?:-([^.+\s]+)\.(\d+))?/.exec(rest); if (!match) { return null } return { - year: parseInt(match[1], 10), - month: parseInt(match[2], 10), - micro: parseInt(match[3], 10), + year: Number.parseInt(match[1], 10), + month: Number.parseInt(match[2], 10), + micro: Number.parseInt(match[3], 10), identifier: match[4] ?? null, - prerelease: match[5] !== undefined ? parseInt(match[5], 10) : null, + prerelease: match[5] !== undefined ? Number.parseInt(match[5], 10) : null, } } @@ -37142,7 +37142,7 @@ function getNextCalver(releases, inputs) { const candidates = releases .filter((r) => !r.draft && (inputs.prerelease ? true : !r.prerelease)) .map((r) => parseCalver(r.tag_name, inputs.prefix)) - .filter((p) => p && p.year === year && p.month === month) + .filter((p) => p?.year === year && p?.month === month) .filter((p) => { if (p.identifier === null) { return true diff --git a/src/index.js b/src/index.js index e21dbcc..0c564bb 100644 --- a/src/index.js +++ b/src/index.js @@ -6,7 +6,7 @@ import semver from 'semver' const bot_id = 41898282 const script_id = '' -;(async () => { +void (async () => { try { core.info(`🏳️ Starting Draft Release Action`) @@ -283,16 +283,16 @@ function parseCalver(tag_name, prefix) { } rest = rest.slice(prefix.length) } - const match = rest.match(/^(\d{4})\.(\d{2})\.(\d{2,})(?:-([^.+\s]+)\.(\d+))?/) + const match = /^(\d{4})\.(\d{2})\.(\d{2,})(?:-([^.+\s]+)\.(\d+))?/.exec(rest) if (!match) { return null } return { - year: parseInt(match[1], 10), - month: parseInt(match[2], 10), - micro: parseInt(match[3], 10), + year: Number.parseInt(match[1], 10), + month: Number.parseInt(match[2], 10), + micro: Number.parseInt(match[3], 10), identifier: match[4] ?? null, - prerelease: match[5] !== undefined ? parseInt(match[5], 10) : null, + prerelease: match[5] !== undefined ? Number.parseInt(match[5], 10) : null, } } @@ -314,7 +314,7 @@ function getNextCalver(releases, inputs) { const candidates = releases .filter((r) => !r.draft && (inputs.prerelease ? true : !r.prerelease)) .map((r) => parseCalver(r.tag_name, inputs.prefix)) - .filter((p) => p && p.year === year && p.month === month) + .filter((p) => p?.year === year && p?.month === month) .filter((p) => { if (p.identifier === null) { return true