From fdc02ad842a7a2b1b91ae4bf5531262bbe626961 Mon Sep 17 00:00:00 2001 From: CosmoX Date: Mon, 6 Jul 2026 21:15:22 -0700 Subject: [PATCH] Automate monthly beta prereleases (#658) --- .../scripts/resolve-scheduled-prerelease.js | 334 ++++++++++++++++++ .github/workflows/release.yml | 79 +++++ .github/workflows/scheduled-prerelease.yml | 116 ++++++ tests/utilities/scheduledPrerelease.test.js | 129 +++++++ 4 files changed, 658 insertions(+) create mode 100644 .github/scripts/resolve-scheduled-prerelease.js create mode 100644 .github/workflows/scheduled-prerelease.yml create mode 100644 tests/utilities/scheduledPrerelease.test.js diff --git a/.github/scripts/resolve-scheduled-prerelease.js b/.github/scripts/resolve-scheduled-prerelease.js new file mode 100644 index 00000000..157d3374 --- /dev/null +++ b/.github/scripts/resolve-scheduled-prerelease.js @@ -0,0 +1,334 @@ +const fs = require('fs') +const { execFileSync } = require('child_process') + +const SEMVER_TAG_PATTERN = /^v?([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z.-]+))?$/ +const BETA_PRERELEASE_PATTERN = /^beta\.([0-9]+)$/ + +function parseSemverTag (tagName) { + if (typeof tagName !== 'string') return null + + const match = SEMVER_TAG_PATTERN.exec(tagName.trim()) + if (!match) return null + + const prerelease = match[4] || '' + return { + tagName, + version: `${match[1]}.${match[2]}.${match[3]}${prerelease ? `-${prerelease}` : ''}`, + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease, + prereleaseParts: prerelease ? prerelease.split('.') : [] + } +} + +function compareIdentifiers (left, right) { + const leftNumeric = /^[0-9]+$/.test(left) + const rightNumeric = /^[0-9]+$/.test(right) + + if (leftNumeric && rightNumeric) return Number(left) - Number(right) + if (leftNumeric) return -1 + if (rightNumeric) return 1 + + if (left < right) return -1 + if (left > right) return 1 + return 0 +} + +function compareParsedVersions (left, right) { + for (const key of ['major', 'minor', 'patch']) { + if (left[key] !== right[key]) return left[key] - right[key] + } + + if (!left.prerelease && !right.prerelease) return 0 + if (!left.prerelease) return 1 + if (!right.prerelease) return -1 + + const length = Math.max(left.prereleaseParts.length, right.prereleaseParts.length) + for (let index = 0; index < length; index += 1) { + const leftPart = left.prereleaseParts[index] + const rightPart = right.prereleaseParts[index] + + if (leftPart === undefined) return -1 + if (rightPart === undefined) return 1 + + const comparison = compareIdentifiers(leftPart, rightPart) + if (comparison !== 0) return comparison + } + + return 0 +} + +function normalizeRelease (release) { + if (!release || release.isDraft) return null + + const version = parseSemverTag(release.tagName) + if (!version) return null + + return { + ...release, + version + } +} + +function getSemverReleases (releases) { + return releases + .map(normalizeRelease) + .filter(Boolean) +} + +function getLatestRelease (releases) { + return [...releases].sort((left, right) => ( + compareParsedVersions(right.version, left.version) + ))[0] || null +} + +function getPreviousReleaseTag (releases, currentTag) { + return getLatestRelease(getSemverReleases(releases).filter(release => ( + release.tagName !== currentTag + )))?.tagName || '' +} + +function getNextBetaVersion (latestRelease) { + if (!latestRelease) { + throw new Error('No non-draft semver GitHub releases were found.') + } + + const { major, minor, patch, prerelease } = latestRelease.version + if (!prerelease) { + return `${major}.${minor}.${patch + 1}-beta.1` + } + + const betaMatch = BETA_PRERELEASE_PATTERN.exec(prerelease) + if (!betaMatch) { + throw new Error(`Latest semver release ${latestRelease.tagName} uses unsupported prerelease label "${prerelease}".`) + } + + return `${major}.${minor}.${patch}-beta.${Number(betaMatch[1]) + 1}` +} + +function normalizeSha (sha) { + return typeof sha === 'string' ? sha.trim().toLowerCase() : '' +} + +function resolveScheduledPrerelease ({ releases, masterSha, resolveTagSha }) { + const sourceSha = normalizeSha(masterSha) + if (!sourceSha) throw new Error('A master commit SHA is required.') + if (typeof resolveTagSha !== 'function') throw new Error('A tag SHA resolver is required.') + + const semverReleases = getSemverReleases(releases).map(release => ({ + ...release, + commitSha: normalizeSha(resolveTagSha(release.tagName)) + })) + + const releasedMaster = semverReleases.find(release => release.commitSha === sourceSha) + if (releasedMaster) { + return { + shouldRelease: false, + skipReason: `master ${sourceSha} is already released as ${releasedMaster.tagName}`, + releasedTag: releasedMaster.tagName, + sourceSha + } + } + + const latestRelease = getLatestRelease(semverReleases) + const nextVersion = getNextBetaVersion(latestRelease) + const nextTag = `v${nextVersion}` + + return { + shouldRelease: true, + nextVersion, + nextTag, + previousTag: latestRelease.tagName, + sourceSha + } +} + +function buildReleaseNotes ({ version, tag, sourceSha, previousTag, repository, runUrl, sourceBranch = 'master' }) { + const lines = [ + '## Traceability', + `- Version: ${version}`, + `- Source commit: ${sourceSha}`, + `- Source branch: ${sourceBranch}` + ] + + if (runUrl) lines.push(`- Workflow run: ${runUrl}`) + if (previousTag) lines.push(`- Previous release: ${previousTag}`) + if (repository && previousTag) { + lines.push(`- Changes: https://github.com/${repository}/compare/${previousTag}...${tag}`) + } + + return `${lines.join('\n')}\n` +} + +function buildTagMessage ({ version, tag, sourceSha, previousTag, repository, runUrl }) { + return buildReleaseNotes({ + version, + tag, + sourceSha, + previousTag, + repository, + runUrl + }).replace('## Traceability\n', `${tag}\n\n`) +} + +function runCommand (command, args) { + return execFileSync(command, args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }).trim() +} + +function readGithubReleases () { + const output = runCommand('gh', [ + 'release', + 'list', + '--limit', + '100', + '--json', + 'tagName,isDraft,isPrerelease,publishedAt' + ]) + + return JSON.parse(output) +} + +function resolveGitTagSha (tagName) { + return runCommand('git', ['rev-list', '-n', '1', tagName]) +} + +function getRunUrl (options) { + if (options.runUrl) return options.runUrl + + const serverUrl = process.env.GITHUB_SERVER_URL + const repository = options.repository || process.env.GITHUB_REPOSITORY + const runId = process.env.GITHUB_RUN_ID + + if (!serverUrl || !repository || !runId) return '' + return `${serverUrl}/${repository}/actions/runs/${runId}` +} + +function appendGithubOutput (name, value) { + const outputPath = process.env.GITHUB_OUTPUT + const text = String(value || '') + + if (!outputPath) { + console.log(`${name}=${text}`) + return + } + + if (!text.includes('\n')) { + fs.appendFileSync(outputPath, `${name}=${text}\n`) + return + } + + const delimiter = `EOF_${name}_${Date.now()}` + fs.appendFileSync(outputPath, `${name}<<${delimiter}\n${text}\n${delimiter}\n`) +} + +function parseArgs (argv) { + const args = [...argv] + const command = args[0] && !args[0].startsWith('--') ? args.shift() : 'resolve' + const options = {} + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] + if (!arg.startsWith('--')) continue + + const key = arg.slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()) + options[key] = args[index + 1] + index += 1 + } + + return { command, options } +} + +function writeResolveOutputs (result, options) { + appendGithubOutput('should_release', result.shouldRelease ? 'true' : 'false') + appendGithubOutput('source_sha', result.sourceSha) + + if (!result.shouldRelease) { + appendGithubOutput('skip_reason', result.skipReason) + appendGithubOutput('released_tag', result.releasedTag) + return + } + + const runUrl = getRunUrl(options) + const repository = options.repository || process.env.GITHUB_REPOSITORY || '' + const releaseNotes = buildReleaseNotes({ + version: result.nextVersion, + tag: result.nextTag, + sourceSha: result.sourceSha, + previousTag: result.previousTag, + repository, + runUrl + }) + + appendGithubOutput('next_version', result.nextVersion) + appendGithubOutput('next_tag', result.nextTag) + appendGithubOutput('previous_tag', result.previousTag) + appendGithubOutput('release_notes', releaseNotes) + appendGithubOutput('tag_message', buildTagMessage({ + version: result.nextVersion, + tag: result.nextTag, + sourceSha: result.sourceSha, + previousTag: result.previousTag, + repository, + runUrl + })) +} + +function main () { + const { command, options } = parseArgs(process.argv.slice(2)) + + if (command === 'resolve') { + const result = resolveScheduledPrerelease({ + releases: readGithubReleases(), + masterSha: options.masterSha, + resolveTagSha: resolveGitTagSha + }) + writeResolveOutputs(result, options) + return + } + + if (command === 'previous-release-tag') { + const previousTag = getPreviousReleaseTag(readGithubReleases(), options.currentTag) + appendGithubOutput('previous_tag', previousTag) + process.stdout.write(`${previousTag}\n`) + return + } + + if (command === 'release-notes') { + process.stdout.write(buildReleaseNotes({ + version: options.version, + tag: options.tag, + sourceSha: options.sourceSha, + previousTag: options.previousTag, + repository: options.repository || process.env.GITHUB_REPOSITORY || '', + runUrl: getRunUrl(options) + })) + return + } + + throw new Error(`Unsupported command: ${command}`) +} + +if (require.main === module) { + try { + main() + } catch (error) { + console.error(error.message) + process.exit(1) + } +} + +module.exports = { + buildReleaseNotes, + buildTagMessage, + compareParsedVersions, + getLatestRelease, + getNextBetaVersion, + getPreviousReleaseTag, + getSemverReleases, + parseSemverTag, + resolveScheduledPrerelease +} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 36a87d2a..2fa26261 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,6 +61,8 @@ jobs: publish_policy: ${{ steps.context.outputs.publish_policy }} snap_channels: ${{ steps.context.outputs.snap_channels }} checkout_ref: ${{ steps.context.outputs.checkout_ref }} + release_version: ${{ steps.context.outputs.release_version }} + release_tag: ${{ steps.context.outputs.release_tag }} build_macos: ${{ steps.context.outputs.build_macos }} build_windows: ${{ steps.context.outputs.build_windows }} build_linux: ${{ steps.context.outputs.build_linux }} @@ -80,6 +82,8 @@ jobs: build_macos="true" build_windows="true" build_linux="true" + release_version="" + release_tag="" if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then tag="${GITHUB_REF_NAME}" @@ -88,6 +92,8 @@ jobs: exit 1 fi + release_version="${tag#v}" + release_tag="${tag}" publish_policy="always" if [[ "${tag}" == *-* ]]; then github_release_type="prerelease" @@ -106,6 +112,12 @@ jobs: checkout_ref="${MANUAL_RELEASE_REF}" fi + checkout_tag="${checkout_ref#refs/tags/}" + if [[ "${checkout_tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + release_version="${checkout_tag#v}" + release_tag="${checkout_tag}" + fi + case "${MANUAL_PLATFORM}" in all) ;; @@ -140,6 +152,8 @@ jobs: echo "publish_policy=${publish_policy}" echo "snap_channels=${snap_channels}" echo "checkout_ref=${checkout_ref}" + echo "release_version=${release_version}" + echo "release_tag=${release_tag}" echo "build_macos=${build_macos}" echo "build_windows=${build_windows}" echo "build_linux=${build_linux}" @@ -164,6 +178,10 @@ jobs: - name: Install npm 11 run: npm install -g npm@11 + - name: Apply release version + if: needs.release-context.outputs.release_version != '' + run: npm version ${{ needs.release-context.outputs.release_version }} --no-git-tag-version --allow-same-version --ignore-scripts + - name: Install dependencies run: npm ci @@ -201,6 +219,10 @@ jobs: - name: Install npm 11 run: npm install -g npm@11 + - name: Apply release version + if: needs.release-context.outputs.release_version != '' + run: npm version ${{ needs.release-context.outputs.release_version }} --no-git-tag-version --allow-same-version --ignore-scripts + - name: Install dependencies run: npm ci @@ -262,6 +284,10 @@ jobs: - name: Install npm 11 run: npm install -g npm@11 + - name: Apply release version + if: needs.release-context.outputs.release_version != '' + run: npm version ${{ needs.release-context.outputs.release_version }} --no-git-tag-version --allow-same-version --ignore-scripts + - name: Install dependencies run: npm ci @@ -324,6 +350,10 @@ jobs: - name: Install npm 11 run: npm install -g npm@11 + - name: Apply release version + if: needs.release-context.outputs.release_version != '' + run: npm version ${{ needs.release-context.outputs.release_version }} --no-git-tag-version --allow-same-version --ignore-scripts + - name: Install dependencies run: npm ci @@ -378,3 +408,52 @@ jobs: dist/*.snap dist/*.yml if-no-files-found: error + + update-release-metadata: + name: Update GitHub release metadata + runs-on: ubuntu-latest + needs: + - release-context + - build-macos + - build-windows + - build-linux + if: | + always() && + needs.release-context.outputs.publish_policy == 'always' && + needs.release-context.outputs.release_tag != '' && + (needs.release-context.outputs.build_macos != 'true' || needs.build-macos.result == 'success') && + (needs.release-context.outputs.build_windows != 'true' || needs.build-windows.result == 'success') && + (needs.release-context.outputs.build_linux != 'true' || needs.build-linux.result == 'success') + steps: + - name: Checkout release tag + uses: actions/checkout@v5 + with: + ref: ${{ needs.release-context.outputs.release_tag }} + fetch-depth: 0 + + - name: Set up Node + uses: actions/setup-node@v5 + with: + node-version-file: .nvmrc + + - name: Update release notes + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ needs.release-context.outputs.release_tag }} + RELEASE_VERSION: ${{ needs.release-context.outputs.release_version }} + run: | + set -euo pipefail + + git fetch origin --tags --force + source_sha="$(git rev-list -n 1 "${RELEASE_TAG}")" + previous_tag="$(node ./.github/scripts/resolve-scheduled-prerelease.js previous-release-tag --current-tag "${RELEASE_TAG}")" + + node ./.github/scripts/resolve-scheduled-prerelease.js release-notes \ + --version "${RELEASE_VERSION}" \ + --tag "${RELEASE_TAG}" \ + --source-sha "${source_sha}" \ + --previous-tag "${previous_tag}" \ + --repository "${GITHUB_REPOSITORY}" \ + > release-notes.md + + gh release edit "${RELEASE_TAG}" --notes-file release-notes.md --verify-tag diff --git a/.github/workflows/scheduled-prerelease.yml b/.github/workflows/scheduled-prerelease.yml new file mode 100644 index 00000000..c3f6466c --- /dev/null +++ b/.github/workflows/scheduled-prerelease.yml @@ -0,0 +1,116 @@ +name: Scheduled Prerelease + +on: + schedule: + - cron: '0 9 * * 1' + workflow_dispatch: + +permissions: + actions: write + contents: write + +concurrency: + group: scheduled-prerelease + cancel-in-progress: false + +jobs: + prerelease: + name: Create scheduled prerelease + runs-on: ubuntu-latest + steps: + - name: Check monthly cadence + id: cadence + shell: bash + run: | + set -euo pipefail + + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + echo "should_run=true" >> "${GITHUB_OUTPUT}" + echo "Manual dispatch bypasses the monthly cadence gate." + exit 0 + fi + + day_of_month="$(date -u +%d)" + if (( 10#${day_of_month} <= 7 )); then + echo "should_run=true" >> "${GITHUB_OUTPUT}" + echo "This is the first Monday of the month." + else + echo "should_run=false" >> "${GITHUB_OUTPUT}" + echo "This is not the first Monday of the month." + fi + + - name: Checkout master + if: steps.cadence.outputs.should_run == 'true' + uses: actions/checkout@v5 + with: + ref: master + fetch-depth: 0 + + - name: Fetch release tags + if: steps.cadence.outputs.should_run == 'true' + shell: bash + run: git fetch origin master:refs/remotes/origin/master --tags --force + + - name: Resolve prerelease + if: steps.cadence.outputs.should_run == 'true' + id: prerelease + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + source_sha="$(git rev-parse origin/master)" + node ./.github/scripts/resolve-scheduled-prerelease.js \ + --master-sha "${source_sha}" \ + --repository "${GITHUB_REPOSITORY}" + + - name: Report skipped prerelease + if: steps.cadence.outputs.should_run == 'true' && steps.prerelease.outputs.should_release == 'false' + shell: bash + run: echo "${{ steps.prerelease.outputs.skip_reason }}" + + - name: Create beta tag + if: steps.prerelease.outputs.should_release == 'true' + env: + NEXT_TAG: ${{ steps.prerelease.outputs.next_tag }} + SOURCE_SHA: ${{ steps.prerelease.outputs.source_sha }} + TAG_MESSAGE: ${{ steps.prerelease.outputs.tag_message }} + shell: bash + run: | + set -euo pipefail + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + if git rev-parse -q --verify "refs/tags/${NEXT_TAG}" > /dev/null; then + tag_sha="$(git rev-list -n 1 "${NEXT_TAG}")" + if [[ "${tag_sha}" != "${SOURCE_SHA}" ]]; then + echo "Tag ${NEXT_TAG} already exists on ${tag_sha}, not ${SOURCE_SHA}." >&2 + exit 1 + fi + + echo "Tag ${NEXT_TAG} already exists on ${SOURCE_SHA}; reusing it." + else + printf '%s\n' "${TAG_MESSAGE}" > tag-message.txt + git tag -a "${NEXT_TAG}" "${SOURCE_SHA}" -F tag-message.txt + git push origin "refs/tags/${NEXT_TAG}" + fi + + - name: Dispatch release workflow + if: steps.prerelease.outputs.should_release == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NEXT_TAG: ${{ steps.prerelease.outputs.next_tag }} + shell: bash + run: | + set -euo pipefail + + gh workflow run release.yml \ + --ref master \ + -f publish=always \ + -f release_type=prerelease \ + -f snap_channels=edge \ + -f release_ref="${NEXT_TAG}" \ + -f platform=all + + echo "Dispatched release workflow for ${NEXT_TAG}." diff --git a/tests/utilities/scheduledPrerelease.test.js b/tests/utilities/scheduledPrerelease.test.js new file mode 100644 index 00000000..2f55c76a --- /dev/null +++ b/tests/utilities/scheduledPrerelease.test.js @@ -0,0 +1,129 @@ +import { createRequire } from 'node:module' +import { describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const { + buildReleaseNotes, + getLatestRelease, + getNextBetaVersion, + getPreviousReleaseTag, + getSemverReleases, + resolveScheduledPrerelease +} = require('../../.github/scripts/resolve-scheduled-prerelease') + +function release (tagName, options = {}) { + return { + tagName, + isDraft: false, + isPrerelease: tagName.includes('-'), + ...options + } +} + +describe('scheduled prerelease resolver', () => { + it('starts the next patch beta after a stable latest release', () => { + const latestRelease = getLatestRelease(getSemverReleases([ + release('v2.0.0'), + release('v2.0.0-beta.15') + ])) + + expect(getNextBetaVersion(latestRelease)).toBe('2.0.1-beta.1') + }) + + it('increments only the beta number after a beta latest release', () => { + const result = resolveScheduledPrerelease({ + masterSha: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + releases: [ + release('v2.0.1-beta.3'), + release('v2.0.0') + ], + resolveTagSha: tagName => ({ + 'v2.0.1-beta.3': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'v2.0.0': '9999999999999999999999999999999999999999' + })[tagName] + }) + + expect(result).toMatchObject({ + shouldRelease: true, + nextVersion: '2.0.1-beta.4', + nextTag: 'v2.0.1-beta.4', + previousTag: 'v2.0.1-beta.3' + }) + }) + + it('skips when the master commit already has a release', () => { + const result = resolveScheduledPrerelease({ + masterSha: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + releases: [ + release('v2.0.1-beta.3'), + release('v2.0.0') + ], + resolveTagSha: tagName => ({ + 'v2.0.1-beta.3': 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + 'v2.0.0': '9999999999999999999999999999999999999999' + })[tagName] + }) + + expect(result).toMatchObject({ + shouldRelease: false, + releasedTag: 'v2.0.1-beta.3' + }) + }) + + it('ignores draft releases when deciding whether master is released', () => { + const result = resolveScheduledPrerelease({ + masterSha: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + releases: [ + release('v2.0.1-beta.3', { isDraft: true }), + release('v2.0.0') + ], + resolveTagSha: tagName => ({ + 'v2.0.1-beta.3': 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + 'v2.0.0': '9999999999999999999999999999999999999999' + })[tagName] + }) + + expect(result).toMatchObject({ + shouldRelease: true, + nextVersion: '2.0.1-beta.1' + }) + }) + + it('fails clearly for unsupported latest prerelease labels', () => { + expect(() => resolveScheduledPrerelease({ + masterSha: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + releases: [ + release('v2.0.1-rc.1'), + release('v2.0.0') + ], + resolveTagSha: tagName => ({ + 'v2.0.1-rc.1': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'v2.0.0': '9999999999999999999999999999999999999999' + })[tagName] + })).toThrow('unsupported prerelease label "rc.1"') + }) + + it('finds the previous release tag by semver precedence', () => { + expect(getPreviousReleaseTag([ + release('v2.0.1-beta.4'), + release('v2.0.1-beta.3'), + release('v2.0.0') + ], 'v2.0.1-beta.4')).toBe('v2.0.1-beta.3') + }) + + it('includes version and commit traceability in release notes', () => { + const notes = buildReleaseNotes({ + version: '2.0.1-beta.4', + tag: 'v2.0.1-beta.4', + sourceSha: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + previousTag: 'v2.0.1-beta.3', + repository: 'hackjutsu/Lepton', + runUrl: 'https://github.com/hackjutsu/Lepton/actions/runs/123' + }) + + expect(notes).toContain('Version: 2.0.1-beta.4') + expect(notes).toContain('Source commit: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb') + expect(notes).toContain('https://github.com/hackjutsu/Lepton/compare/v2.0.1-beta.3...v2.0.1-beta.4') + expect(notes).toContain('https://github.com/hackjutsu/Lepton/actions/runs/123') + }) +})