Skip to content
Closed
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
38 changes: 0 additions & 38 deletions .github/scripts/before-beta-release.ts

This file was deleted.

102 changes: 102 additions & 0 deletions .github/scripts/before-prerelease.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { execSync } from 'node:child_process';
import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { parseArgs } from 'node:util';

const PKG_JSON_PATH = path.join(import.meta.dirname, '..', '..', 'package.json');

const { values } = parseArgs({
options: {
'tag': { type: 'string', default: 'beta' },
// Side channels are published from branches whose package.json version is usually the last
// released one, so the base version has to be moved forward instead of failing the release.
'bump-base-if-published': { type: 'boolean', default: false },
},
});

const PRERELEASE_TAG = values.tag!;
const BUMP_BASE_IF_PUBLISHED = values['bump-base-if-published'];

// The tag ends up both as an npm dist-tag and as a semver prerelease identifier.
if (!/^[a-z][a-z0-9-]*$/.test(PRERELEASE_TAG)) {
console.error(
`before-prerelease: '${PRERELEASE_TAG}' is not a usable prerelease tag - use lowercase letters, digits and hyphens, starting with a letter.`,
);
process.exit(1);
}

if (PRERELEASE_TAG === 'latest') {
console.error(`before-prerelease: 'latest' is the stable dist-tag and cannot be used for a prerelease.`);
process.exit(1);
}

const pkgJson = JSON.parse(await readFile(PKG_JSON_PATH, { encoding: 'utf8' }));

const PACKAGE_NAME = pkgJson.name;
const VERSION = pkgJson.version;

const nextVersion = getNextVersion(VERSION);
console.log(`before-prerelease: Setting version to ${nextVersion}`);
pkgJson.version = nextVersion;

await writeFile(PKG_JSON_PATH, `${JSON.stringify(pkgJson, null, 4)}\n`);

function getPublishedVersions() {
const versionString = execSync(`npm show ${PACKAGE_NAME} versions --json`, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
});

const parsed = JSON.parse(versionString) as string[] | string;

// npm returns a bare string when the package has exactly one published version.
return Array.isArray(parsed) ? parsed : [parsed];
}

function nextFreeBaseVersion(version: string, publishedVersions: string[]) {
const [major, minor, patch] = version.split('-')[0].split('.').map(Number);

if ([major, minor, patch].some((part) => !Number.isInteger(part))) {
console.error(`before-prerelease: Cannot parse '${version}' in package.json as a semver version.`);
process.exit(1);
}

let candidate = `${major}.${minor}.${patch}`;
let nextPatch = patch;

while (publishedVersions.includes(candidate)) {
nextPatch += 1;
candidate = `${major}.${minor}.${nextPatch}`;
}

return candidate;
}

function getNextVersion(version: string) {
const versions = getPublishedVersions();

let baseVersion = version;

if (versions.includes(baseVersion)) {
if (!BUMP_BASE_IF_PUBLISHED) {
console.error(
`before-prerelease: A release with version ${baseVersion} already exists. Please increment version accordingly.`,
);
process.exit(1);
}

baseVersion = nextFreeBaseVersion(baseVersion, versions);
console.log(`before-prerelease: ${version} is already published, basing the prerelease on ${baseVersion}`);
}

const prereleasePattern = new RegExp(`^${baseVersion.replace(/\./g, '\\.')}-${PRERELEASE_TAG}\\.(\\d+)$`);

const prereleaseNumbers = versions
.map((v) => v.match(prereleasePattern)?.[1])
.filter((number) => number !== undefined)
.map(Number);

const lastPrereleaseNumber = Math.max(-1, ...prereleaseNumbers);

return `${baseVersion}-${PRERELEASE_TAG}.${lastPrereleaseNumber + 1}`;
}
2 changes: 1 addition & 1 deletion .github/workflows/pre_release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ jobs:
- name: Get pre-release version
id: get-pre-release-version
run: |
pnpm exec tsx ./.github/scripts/before-beta-release.ts
pnpm exec tsx ./.github/scripts/before-prerelease.ts --tag beta
echo "pre_release_version=$(cat package.json | jq -r '.version')" >> $GITHUB_OUTPUT

build-bundles:
Expand Down
41 changes: 40 additions & 1 deletion .github/workflows/publish_to_npm.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ on:
options:
- latest
- beta
- runtime
allow_unmerged_latest:
description: "Allow 'latest' from a ref that is not contained in master (deliberate releases only)"
required: false
type: boolean
default: false

permissions:
id-token: write # Required for OIDC
Expand All @@ -28,6 +34,27 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref }}
# The master containment check below needs real history, not a shallow clone.
fetch-depth: 0

# 'latest' is what plain `npm install -g apify-cli` resolves to, so it must not come from a
# side channel branch by accident. Side channels (beta, runtime) are opt-in and unrestricted.
- name: Check the ref is in master before publishing 'latest'
if: ${{ inputs.tag == 'latest' }}
env:
ALLOW_UNMERGED_LATEST: ${{ inputs.allow_unmerged_latest }}
run: |
if [ "$ALLOW_UNMERGED_LATEST" = "true" ]; then
echo "allow_unmerged_latest is set, skipping the master containment check."
exit 0
fi

git fetch --no-tags origin master

if ! git merge-base --is-ancestor HEAD origin/master; then
echo "::error::Refusing to publish the 'latest' dist-tag from $(git rev-parse HEAD) - it is not contained in origin/master. Publish a side channel (tag 'beta' or 'runtime') instead, or re-run with allow_unmerged_latest to release from another branch on purpose."
exit 1
fi

- name: Use Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
Expand All @@ -40,7 +67,13 @@ jobs:

- name: Check version consistency and bump pre-release version (beta only)
if: ${{ inputs.tag == 'beta' }}
run: pnpm exec tsx ./.github/scripts/before-beta-release.ts
run: pnpm exec tsx ./.github/scripts/before-prerelease.ts --tag beta

# Side channels are published off long-lived branches whose package.json still carries the
# last released version, so the base version is moved forward instead of failing the build.
- name: Bump pre-release version (side channels)
if: ${{ inputs.tag != 'beta' && inputs.tag != 'latest' }}
run: pnpm exec tsx ./.github/scripts/before-prerelease.ts --tag "${{ inputs.tag }}" --bump-base-if-published

- name: Build module
run: pnpm run build
Expand All @@ -53,3 +86,9 @@ jobs:

- name: Publish to NPM
run: pnpm publish --provenance --access public --no-git-checks --tag ${{ inputs.tag }}

- name: Report what was published
run: |
echo "### Published \`apify-cli@$(jq -r .version package.json)\` under the \`${{ inputs.tag }}\` dist-tag" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Install it with \`npm install -g apify-cli@${{ inputs.tag }}\`" >> $GITHUB_STEP_SUMMARY
29 changes: 29 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,32 @@ Releases are fully automated via GitHub Actions — **do not bump the version in
- **Stable releases:** trigger the **Create a release** workflow (`.github/workflows/release.yaml`) manually from the GitHub Actions UI. It computes the next version (auto / patch / minor / major / custom), updates `CHANGELOG.md`, builds standalone bundles for Linux / macOS / Windows (x64 + ARM64), creates a GitHub release with the bundles attached, publishes to npm under `latest`, and opens a PR against the Homebrew formula.

Only users with publish access to the [`apify-cli` npm package](https://www.npmjs.com/package/apify-cli) can trigger the stable release workflow.

### Side channels (publishing a branch to npm)

A feature that needs real-world testing before it lands on `master` can be published from its own branch
under its own npm dist-tag. Users on `latest` are unaffected — npm only installs a dist-tag when it is
asked for explicitly:

```bash
npm install -g apify-cli@runtime
```

The `runtime` channel exists for [Actor runtime](https://docs.apify.com/cli) development. To publish one:

1. Merge `master` into your branch. The workflow definition comes from the ref you dispatch from, but
`.github/scripts/` comes from the ref you publish, so a stale branch fails the version-bump step.
2. Run the **Publish to NPM** workflow (`.github/workflows/publish_to_npm.yaml`) from the Actions UI,
dispatching it **from `master`**, with `ref` set to your branch and `tag` set to `runtime`. Dispatching
from `master` also keeps the OIDC claim npm's trusted publisher sees stable.
3. The workflow derives the version itself: the base version moves forward to the first unpublished patch
and the channel name becomes the prerelease identifier, so the result looks like `1.10.1-runtime.0`,
then `.1`, `.2` on later publishes. Each channel counts independently of `beta`.

Side channels publish to npm only — no GitHub release, no changelog entry, no standalone bundles.

To add another channel, add its name to the `tag` input's `options` in the workflow. Channel names must be
lowercase and cannot be valid semver (they become both a dist-tag and a semver prerelease identifier).

Publishing `latest` is refused unless the ref is contained in `origin/master`; `allow_unmerged_latest`
overrides that for a deliberate release from another branch.
Loading