Skip to content
168 changes: 150 additions & 18 deletions .github/scripts/before-beta-release.cjs
Original file line number Diff line number Diff line change
@@ -1,33 +1,165 @@
/* eslint-disable no-console */
const { execSync } = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');

const PKG_JSON_PATH = path.join(__dirname, '..', '..', 'package.json');

const pkgJson = require(PKG_JSON_PATH); // eslint-disable-line import/no-dynamic-require
function execCommand(command, options = {}) {
try {
return execSync(command, { encoding: 'utf8', ...options }).trim();
} catch (error) {
console.error(`Command failed: ${command}`);
console.error(error.message);
process.exit(1);
}

return null;
}

function getPackageInfo() {
const pkgJson = JSON.parse(fs.readFileSync(PKG_JSON_PATH, 'utf8'));
return {
name: pkgJson.name,
version: pkgJson.version,
pkgJson,
};
}

function getBaseVersionFromGit() {
try {
// Get the base version from the latest commit that updated package.json
// This ensures we use the version that was set by the release_metadata step
const gitShow = execCommand('git show HEAD:package.json');
const gitPackageJson = JSON.parse(gitShow);
return gitPackageJson.version;
} catch (error) {
console.error('Could not get base version from git');
throw error;
}
}

const PACKAGE_NAME = pkgJson.name;
const VERSION = pkgJson.version;
console.log(`before-deploy: Current version is ${VERSION}`); // eslint-disable-line no-console
function incrementVersion(version, type = 'patch') {
const [major, minor, patch] = version.split('.').map(Number);

const nextVersion = getNextVersion(VERSION);
console.log(`before-deploy: Setting version to ${nextVersion}`); // eslint-disable-line no-console
pkgJson.version = nextVersion;
switch (type) {
case 'major':
return `${major + 1}.0.0`;
case 'minor':
return `${major}.${minor + 1}.0`;
case 'patch':
default:
return `${major}.${minor}.${patch + 1}`;
}
}

function findNextAvailableVersion(packageName, baseVersion) {
console.log(`Finding next available version starting from: ${baseVersion}`);

fs.writeFileSync(PKG_JSON_PATH, `${JSON.stringify(pkgJson, null, 2)}\n`);
try {
const versionString = execCommand(`npm show ${packageName} versions --json`);
const versions = JSON.parse(versionString);

function getNextVersion(version) {
const versionString = execSync(`npm show ${PACKAGE_NAME} versions --json`, { encoding: 'utf8' });
const versions = JSON.parse(versionString);
let currentVersion = baseVersion;

if (versions.some((v) => v === VERSION)) {
console.error(`before-deploy: A release with version ${VERSION} already exists. Please increment version accordingly.`); // eslint-disable-line no-console
// Keep incrementing patch version until we find one that doesn't exist
while (versions.includes(currentVersion)) {
console.log(`Version ${currentVersion} already exists as stable, incrementing...`);
currentVersion = incrementVersion(currentVersion, 'patch');
}

console.log(`Next available base version: ${currentVersion}`);
return currentVersion;
} catch {
console.log('Could not check NPM versions, using provided base version');
return baseVersion;
}
}

function getNextBetaVersion(packageName, baseVersion) {
console.log(`Calculating next beta version for base: ${baseVersion}`);

// Validate base version format
if (!/^\d+\.\d+\.\d+$/.test(baseVersion)) {
console.error(`Invalid base version format: ${baseVersion}`);
process.exit(1);
}

const prereleaseNumbers = versions
.filter((v) => (v.startsWith(VERSION) && v.includes('-')))
.map((v) => Number(v.match(/\.(\d+)$/)[1]));
const lastPrereleaseNumber = Math.max(-1, ...prereleaseNumbers);
return `${version}-beta.${lastPrereleaseNumber + 1}`;
// Find the next available base version if current one exists as stable
const availableBaseVersion = findNextAvailableVersion(packageName, baseVersion);

let npmBetaNumber = 0;
let gitBetaNumber = 0;

// Check NPM for existing beta versions of the available base version
try {
const versionString = execCommand(`npm show ${packageName} versions --json`);
const versions = JSON.parse(versionString);

const versionPrefix = `${availableBaseVersion}-beta.`;
const npmBetas = versions
.filter((v) => v.startsWith(versionPrefix))
.map((v) => {
const match = v.match(/^.+-beta\.(\d+)$/);
return match ? parseInt(match[1], 10) : 0;
});

npmBetaNumber = npmBetas.length > 0 ? Math.max(...npmBetas) : 0;
console.log(`Latest beta on NPM for ${availableBaseVersion}: ${npmBetaNumber}`);
} catch {
console.log('No existing beta versions found on NPM');
}

// Check Git tags for existing beta versions of the available base version
try {
const tagPattern = `v${availableBaseVersion}-beta.*`;
const tags = execCommand(`git tag -l "${tagPattern}" --sort=-version:refname`);

if (tags) {
const tagList = tags.split('\n').filter((tag) => tag.trim());
if (tagList.length > 0) {
const latestTag = tagList[0];
const match = latestTag.match(/v\d+\.\d+\.\d+-beta\.(\d+)$/);
if (match) {
gitBetaNumber = parseInt(match[1], 10);
console.log(`Latest beta in Git for ${availableBaseVersion}: ${gitBetaNumber}`);
}
}
}
} catch {
console.log('No existing beta tags found in Git');
}

// Use the higher number to avoid conflicts
const nextBetaNumber = Math.max(npmBetaNumber, gitBetaNumber) + 1;
const nextVersion = `${availableBaseVersion}-beta.${nextBetaNumber}`;

console.log(`Next beta version: ${nextVersion}`);
return nextVersion;
}

function saveBetaVersionToFile(version) {
// Save version to temporary file for workflow to read
fs.writeFileSync('/tmp/beta_version.txt', version);
console.log(`Saved beta version to /tmp/beta_version.txt: ${version}`);
}

function main() {
console.log('🚀 Starting beta version calculation...');

const { name: packageName } = getPackageInfo();

// Get the base version from Git (what was committed by release_metadata)
const baseVersion = getBaseVersionFromGit();
console.log(`Base version from Git: ${baseVersion}`);

// Calculate next beta version (will auto-increment if base version exists as stable)
const nextBetaVersion = getNextBetaVersion(packageName, baseVersion);

// Only calculate and save to file, don't update package.json
saveBetaVersionToFile(nextBetaVersion);
console.log('✅ Beta version calculation completed!');
console.log(`Beta version calculated: ${nextBetaVersion}`);
}

main();
129 changes: 95 additions & 34 deletions .github/workflows/pre_release.yaml
Original file line number Diff line number Diff line change
@@ -1,21 +1,18 @@
name: Create a pre-release

on:
workflow_dispatch:
# Push to master will deploy a beta version
push:
branches:
- master
tags-ignore:
- "**" # Ignore all tags to prevent duplicate builds when tags are pushed.
# Only trigger on PRs with "beta" label
pull_request:
types: [labeled, synchronize, reopened]

concurrency:
group: release
cancel-in-progress: false

jobs:
release_metadata:
if: "!startsWith(github.event.head_commit.message, 'docs') && !startsWith(github.event.head_commit.message, 'ci') && startsWith(github.repository, 'apify/')"
# Run ONLY when PR has the "beta" label
if: contains(github.event.pull_request.labels.*.name, 'beta')
name: Prepare release metadata
runs-on: ubuntu-latest
outputs:
Expand All @@ -30,76 +27,140 @@ jobs:
existing_changelog_path: CHANGELOG.md

wait_for_checks:
# Run ONLY when PR has the "beta" label
if: contains(github.event.pull_request.labels.*.name, 'beta')
name: Wait for code checks to pass
runs-on: ubuntu-latest
steps:
- uses: lewagon/[email protected]
- name: Wait for existing checks or skip if none
uses: lewagon/[email protected]
with:
ref: ${{ github.ref }}
ref: ${{ github.event.pull_request.head.sha }}
repo-token: ${{ secrets.GITHUB_TOKEN }}
check-regexp: (Code checks)
wait-interval: 5
wait-interval: 10
running-workflow-name: 'Wait for code checks to pass'
allowed-conclusions: success,neutral,skipped
continue-on-error: true

- name: Run checks if none were found
if: failure()
uses: actions/checkout@v4

- name: Use Node.js 22
if: failure()
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
cache-dependency-path: 'package-lock.json'

- name: Install Dependencies
if: failure()
run: npm ci

- name: Lint
if: failure()
run: npm run lint

- name: Build
if: failure()
run: npm run build

- name: Test
if: failure()
run: npm run test

- name: Type checks
if: failure()
run: npm run type-check

update_changelog:
calculate_beta_version:
needs: [ release_metadata, wait_for_checks ]
name: Update changelog
name: Calculate beta version
runs-on: ubuntu-latest
outputs:
changelog_commitish: ${{ steps.commit.outputs.commit_long_sha || github.sha }}
beta_version: ${{ steps.beta_version.outputs.version }}

steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.APIFY_SERVICE_ACCOUNT_GITHUB_TOKEN }}
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}

- name: Use Node.js 22
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
cache-dependency-path: 'package-lock.json'

- name: Update package version in package.json
- name: Install dependencies
run: npm ci

- name: Update package version to base version
run: npm version --no-git-tag-version --allow-same-version ${{ needs.release_metadata.outputs.version_number }}

- name: Update CHANGELOG.md
uses: DamianReeves/write-file-action@master
with:
path: CHANGELOG.md
write-mode: overwrite
contents: ${{ needs.release_metadata.outputs.changelog }}
- name: Calculate beta version (without updating package.json)
id: beta_version
run: |
# Use the improved beta script to calculate version only
node ./.github/scripts/before-beta-release.cjs
# The script will output the beta version without updating package.json
BETA_VERSION=$(cat /tmp/beta_version.txt)
echo "version=$BETA_VERSION" >> $GITHUB_OUTPUT
echo "Beta version calculated: $BETA_VERSION"

- name: Commit changes
id: commit
uses: EndBug/add-and-commit@v9
with:
author_name: Apify Release Bot
author_email: [email protected]
message: "chore(release): Update changelog and package version [skip ci]"
- name: Create and push beta tag
run: |
git tag "v${{ steps.beta_version.outputs.version }}"
git push origin "v${{ steps.beta_version.outputs.version }}"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

publish_to_npm:
name: Publish to NPM
needs: [ release_metadata, wait_for_checks ]
needs: [ calculate_beta_version ]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.update_changelog.changelog_commitish }}
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}

- name: Use Node.js 22
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
cache-dependency-path: 'package-lock.json'

- name: Install dependencies
run: |
echo "access=public" >> .npmrc
echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" >> .npmrc
npm ci
- # Check version consistency and increment pre-release version number for beta only.
name: Bump pre-release version
run: node ./.github/scripts/before-beta-release.cjs

- name: Set beta version in package.json
run: |
BETA_VERSION="${{ needs.calculate_beta_version.outputs.beta_version }}"
echo "Setting package.json version to: $BETA_VERSION"
npm version --no-git-tag-version --allow-same-version "$BETA_VERSION"

- name: Verify version is set correctly
run: |
PACKAGE_VERSION=$(node -p "require('./package.json').version")
EXPECTED_VERSION="${{ needs.calculate_beta_version.outputs.beta_version }}"
if [ "$PACKAGE_VERSION" != "$EXPECTED_VERSION" ]; then
echo "Version mismatch! Package: $PACKAGE_VERSION, Expected: $EXPECTED_VERSION"
exit 1
fi
echo "Version verified: $PACKAGE_VERSION"

- name: Build module
run: npm run build

- name: Publish to NPM
run: npm publish --tag beta

Expand Down