Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
126 changes: 123 additions & 3 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -36832,9 +36832,9 @@ var semver = /*@__PURE__*/getDefaultExportFromCjs(semverExports);

// const bot_id = 6071159 // TODO: DEBUG: Remove This
const bot_id = 41898282;
const script_id = '<!-- cssnr/draft-release-action -->'
const script_id = '<!-- cssnr/draft-release-action -->';

;(async () => {
void (async () => {
try {
info(`🏳️ Starting Draft Release Action`);

Expand Down Expand Up @@ -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}`)
Expand Down Expand Up @@ -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
Expand All @@ -37050,10 +37064,116 @@ 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 }),
previous_tag_name: getInput('previous_tag_name'),
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 = /^(\d{4})\.(\d{2})\.(\d{2,})(?:-([^.+\s]+)\.(\d+))?/.exec(rest);
if (!match) {
return null
}
return {
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 ? Number.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?.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)}`
}
124 changes: 122 additions & 2 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
const bot_id = 41898282
const script_id = '<!-- cssnr/draft-release-action -->'

;(async () => {
void (async () => {
try {
core.info(`🏳️ Starting Draft Release Action`)

Expand Down Expand Up @@ -102,7 +102,19 @@
: (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}`)
Expand All @@ -128,10 +140,10 @@
)
for (const draft of drafts) {
core.info(`⛔ Deleting Previous Draft: \u001b[31;1m${draft.tag_name}`)
const response = await octokit.rest.repos.deleteRelease({
...github.context.repo,
release_id: draft.id,
})

Check warning on line 146 in src/index.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected `await` inside a loop.

See more on https://sonarcloud.io/project/issues?id=cssnr_draft-release-action&issues=AaD-YteytzyrLpRUJkyS&open=AaD-YteytzyrLpRUJkyS&pullRequest=11
console.log('response.status:', response.status)
}

Expand Down Expand Up @@ -210,6 +222,8 @@
* @property {string} semver
* @property {string} identifier
* @property {boolean} prerelease
* @property {boolean} calver
* @property {string} suffix
* @property {string} prefix
* @property {boolean} summary
* @property {string} token
Expand All @@ -222,10 +236,116 @@
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 }),
previous_tag_name: core.getInput('previous_tag_name'),
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 = /^(\d{4})\.(\d{2})\.(\d{2,})(?:-([^.+\s]+)\.(\d+))?/.exec(rest)
if (!match) {
return null
}
return {
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 ? Number.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?.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)}`
}
Loading