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
145 changes: 17 additions & 128 deletions actions/version-bump/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,156 +16,45 @@ inputs:
description: "Optional feature tag for pre-releases; e.g. `3ds` produces `3.135.0-beta-3ds.1`"
required: false
dry-run:
description: "Skip creating the GitHub release (extract and log notes only)"
description: "Simulate the version bump without making any changes"
required: false
default: false
default: "false"

outputs:
new-version:
description: "The new version number (without v prefix)"
value: ${{ steps.bump.outputs.new_version || steps.dry-run.outputs.new_version }}
value: ${{ steps.bump.outputs.new_version }}

runs:
using: "composite"
steps:
- name: "Configure git credentials"
run: |
git config user.name 'github-actions[bot]'
git config user.email 'github-actions[bot]@users.noreply.github.com'
shell: bash

- name: "Validate CHANGELOG"
run: |
if ! grep -i -q "## UNRELEASED" CHANGELOG.md; then
echo "::error::UNRELEASED section not found in CHANGELOG.md"
exit 1
fi
shell: bash
- uses: ${{ github.action_path }}/../setup-git

- name: "Setup Node.js"
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc

- name: "Validate CHANGELOG"
run: node '${{ github.action_path }}/../../dist/validate-changelog.js'
shell: bash

- name: "Bump version and update CHANGELOG"
if: ${{ inputs.dry-run != 'true' }}
id: bump
run: node '${{ github.action_path }}/../../dist/bump-version.js'
shell: bash
env:
VERSION_TYPE: ${{ inputs.version-type }}
FEATURE_TAG: ${{ inputs.feature-tag }}
run: |
case "$VERSION_TYPE" in
beta|alpha|rc)
if [ -n "$FEATURE_TAG" ]; then
preid="${VERSION_TYPE}-${FEATURE_TAG}"
else
preid="$VERSION_TYPE"
fi
current_version=$(node -p "require('./package.json').version")
if [[ "$current_version" == *"-"* ]]; then
new_version=$(npm version prerelease --preid="$preid" --no-git-tag-version)
else
new_version=$(npm version preminor --preid="$preid" --no-git-tag-version)
fi
;;
*)
new_version=$(npm version "$VERSION_TYPE" --no-git-tag-version)
;;
esac
new_version=${new_version#v}
echo "new_version=$new_version" >> "$GITHUB_OUTPUT"

today=$(date +'%Y-%m-%d')
sed -i "s/## unreleased/## ${new_version} (${today})/i" CHANGELOG.md

git add package.json package-lock.json CHANGELOG.md
git commit -m "Release v${new_version}"

echo "### Version Bump" >> "$GITHUB_STEP_SUMMARY"
echo "${VERSION_TYPE} → **${new_version}**" >> "$GITHUB_STEP_SUMMARY"
shell: bash

- name: "Create release branch and PR"
if: ${{ inputs.dry-run != 'true' }}
env:
BASE_BRANCH: ${{ inputs.base-branch }}
GITHUB_TOKEN: ${{ inputs.github-token }}
run: |
new_version="${{ steps.bump.outputs.new_version }}"
git checkout -b "release/v${new_version}"
git push -u origin "release/v${new_version}"

pr_url=$(gh pr create \
--title "Release v${new_version}" \
--body "Updated changelog for release v${new_version}" \
--base "$BASE_BRANCH" \
--head "release/v${new_version}")

pr_number=$(echo "$pr_url" | awk -F/ '{print $NF}')
gh pr merge "$pr_number" --auto --squash --delete-branch
DRY_RUN: ${{ inputs.dry-run }}

echo "Waiting for PR to merge..."
for i in $(seq 1 20); do
state=$(gh pr view "$pr_number" --json state --jq '.state')
if [ "$state" = "MERGED" ]; then
echo "PR merged"
break
fi
if [ "$i" -eq 20 ]; then
echo "::error::Timed out waiting for PR to merge"
exit 1
fi
sleep 30
done

git checkout "$BASE_BRANCH"
git pull origin "$BASE_BRANCH"
git tag -m "v${new_version}" "v${new_version}"
git push origin "v${new_version}"

echo "#### Merged release PR: $pr_url" >> "$GITHUB_STEP_SUMMARY"
- name: "Create release branch, PR, and tag"
if: ${{ inputs.dry-run != 'true' }}
run: node '${{ github.action_path }}/../../dist/release-pr.js'
shell: bash

# simulates version bump and publishes theoretical output
- name: "Dry Run Report"
if: ${{ inputs.dry-run == 'true' }}
id: dry-run
env:
VERSION_TYPE: ${{ inputs.version-type }}
FEATURE_TAG: ${{ inputs.feature-tag }}
NEW_VERSION: ${{ steps.bump.outputs.new_version }}
BASE_BRANCH: ${{ inputs.base-branch }}
run: |
current_version=$(node -p "require('./package.json').version")
case "$VERSION_TYPE" in
beta|alpha|rc)
if [ -n "$FEATURE_TAG" ]; then
PREID="${VERSION_TYPE}-${FEATURE_TAG}"
else
PREID="$VERSION_TYPE"
fi
if [[ "$current_version" == *"-"* ]]; then
new_version=$(npx semver "$current_version" --preid="$PREID" -i prerelease)
else
new_version=$(npx semver "$current_version" --preid="$PREID" -i preminor)
fi
;;
*)
new_version=$(npx semver "$current_version" -i "$VERSION_TYPE")
;;
esac

new_version=${new_version#v}
echo "new_version=$new_version" >> "$GITHUB_OUTPUT"
echo "${VERSION_TYPE} → **${new_version}**" >> "$GITHUB_STEP_SUMMARY"

echo "=================================="
echo " DRY RUN — no changes were made"
echo "=================================="
echo "Repository : ${{ github.repository }}"
echo "Actor : ${{ github.actor }}"
echo "Base branch : ${{ env.BASE_BRANCH }}"
echo "Bump type : ${{ env.VERSION_TYPE }}"
echo "Feature tag : ${{ env.FEATURE_TAG }}"
echo "Expected version : $new_version"
echo "=================================="
shell: bash
GITHUB_TOKEN: ${{ inputs.github-token }}
GITHUB_REPOSITORY: ${{ github.repository }}
149 changes: 149 additions & 0 deletions dist/bump-version.js

Large diffs are not rendered by default.

168 changes: 168 additions & 0 deletions dist/release-pr.js

Large diffs are not rendered by default.

80 changes: 80 additions & 0 deletions src/bump-version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import * as core from '@actions/core';
import * as exec from '@actions/exec';
import * as semver from 'semver';
import { readFileSync, writeFileSync } from 'node:fs';
import { getRequiredEnv } from './utils';

const versionType = getRequiredEnv('VERSION_TYPE');
const featureTag = process.env.FEATURE_TAG ?? '';
const isDryRun = process.env.DRY_RUN === 'true';

const PRE_RELEASE_TYPES = ['beta', 'alpha', 'rc'] as const;
type PreReleaseType = (typeof PRE_RELEASE_TYPES)[number];

function isPreRelease(type: string): type is PreReleaseType {
return PRE_RELEASE_TYPES.includes(type as PreReleaseType);
}

async function run(): Promise<void> {
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'));
const currentVersion: string = packageJson.version;

let newVersion: string;

if (isPreRelease(versionType)) {
const preid = featureTag ? `${versionType}-${featureTag}` : versionType;

if (isDryRun) {
const bumpType = currentVersion.includes('-') ? 'prerelease' : 'preminor';
newVersion = semver.inc(currentVersion, bumpType, preid) ?? '';
} else {
const bumpType = currentVersion.includes('-') ? 'prerelease' : 'preminor';
const { stdout } = await exec.getExecOutput('npm', [
'version',
bumpType,
`--preid=${preid}`,
'--no-git-tag-version',
]);
newVersion = stdout.trim().replace(/^v/, '');
}
} else {
if (isDryRun) {
newVersion = semver.inc(currentVersion, versionType as semver.ReleaseType) ?? '';
} else {
const { stdout } = await exec.getExecOutput('npm', [
'version',
versionType,
'--no-git-tag-version',
]);
newVersion = stdout.trim().replace(/^v/, '');
}
}

if (!newVersion) {
core.setFailed(`Failed to calculate new version from ${currentVersion} with type ${versionType}`);
return;
}

if (!isDryRun) {
const today = new Date().toISOString().slice(0, 10);
const changelog = readFileSync('CHANGELOG.md', 'utf8');
writeFileSync('CHANGELOG.md', changelog.replace(/^## UNRELEASED/im, `## ${newVersion} (${today})`));

await exec.exec('git', ['add', 'package.json', 'package-lock.json', 'CHANGELOG.md']);
await exec.exec('git', ['commit', '-m', `Release v${newVersion}`]);
} else {
core.info('DRY RUN — no files modified, no git commit created');
core.info(` Repository : ${process.env.GITHUB_REPOSITORY ?? ''}`);
core.info(` Actor : ${process.env.GITHUB_ACTOR ?? ''}`);
core.info(` Base branch : ${process.env.BASE_BRANCH ?? ''}`);
core.info(` Bump type : ${versionType}`);
core.info(` Feature tag : ${featureTag}`);
core.info(` Would release : v${newVersion}`);
}

core.setOutput('new_version', newVersion);
core.summary.addRaw(`### Version Bump\n${versionType} → **${newVersion}**`);
await core.summary.write();
}

run().catch((error: Error) => core.setFailed(error.message));
90 changes: 90 additions & 0 deletions src/release-pr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import * as core from '@actions/core';
import * as exec from '@actions/exec';
import * as github from '@actions/github';
import { getRequiredEnv } from './utils';

const newVersion = getRequiredEnv('NEW_VERSION');
const baseBranch = getRequiredEnv('BASE_BRANCH');
const githubToken = getRequiredEnv('GITHUB_TOKEN');
const [owner, repo] = getRequiredEnv('GITHUB_REPOSITORY').split('/');
const isDryRun = process.env.DRY_RUN === 'true';

const POLL_INITIAL_DELAY_MS = 10_000;
const POLL_MAX_DELAY_MS = 60_000;
const POLL_TIMEOUT_MS = 10 * 60 * 1000;

async function run(): Promise<void> {
if (isDryRun) {
core.info('DRY RUN — skipping release branch, PR creation, and tagging');
return;
}

const releaseBranch = `release/v${newVersion}`;
const octokit = github.getOctokit(githubToken);

await exec.exec('git', ['checkout', '-b', releaseBranch]);
await exec.exec('git', ['push', '-u', 'origin', releaseBranch]);

const { data: pr } = await octokit.rest.pulls.create({
owner,
repo,
title: `Release v${newVersion}`,
body: `Updated changelog for release v${newVersion}`,
base: baseBranch,
head: releaseBranch,
});

await octokit.graphql(
`mutation EnableAutoMerge($pullRequestId: ID!) {
enablePullRequestAutoMerge(input: { pullRequestId: $pullRequestId, mergeMethod: SQUASH }) {
pullRequest { number }
}
}`,
{ pullRequestId: pr.node_id }
);

core.info(`Waiting for PR #${pr.number} to merge...`);
await waitForMerge(octokit, pr.number);

await octokit.rest.git.deleteRef({ owner, repo, ref: `heads/${releaseBranch}` });

await exec.exec('git', ['checkout', baseBranch]);
await exec.exec('git', ['pull', 'origin', baseBranch]);
await exec.exec('git', ['tag', '-m', `v${newVersion}`, `v${newVersion}`]);
await exec.exec('git', ['push', 'origin', `v${newVersion}`]);
Comment on lines +51 to +54

core.setOutput('pr_url', pr.html_url);
core.summary.addRaw(`#### Merged release PR: ${pr.html_url}`);
await core.summary.write();
}

async function waitForMerge(
octokit: ReturnType<typeof github.getOctokit>,
prNumber: number
): Promise<void> {
const deadline = Date.now() + POLL_TIMEOUT_MS;
let delay = POLL_INITIAL_DELAY_MS;

while (Date.now() < deadline) {
await sleep(delay);
const { data: current } = await octokit.rest.pulls.get({ owner, repo, pull_number: prNumber });

if (current.merged) {
core.info('PR merged');
return;
}
if (current.state === 'closed') {
throw new Error(`PR #${prNumber} was closed without merging`);
}

delay = Math.min(delay * 2, POLL_MAX_DELAY_MS);
}

throw new Error(`Timed out waiting for PR #${prNumber} to merge`);
}

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

run().catch((error: Error) => core.setFailed(error.message));