Skip to content

Commit 50bf9c6

Browse files
Pijukatelclaude
andauthored
feat: Add custom releasing for runtime (#1399)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6435152 commit 50bf9c6

15 files changed

Lines changed: 701 additions & 40 deletions

File tree

.github/scripts/before-beta-release.ts

Lines changed: 0 additions & 38 deletions
This file was deleted.
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { execSync } from 'node:child_process';
2+
import { readFile, writeFile } from 'node:fs/promises';
3+
import path from 'node:path';
4+
import { parseArgs } from 'node:util';
5+
6+
const PKG_JSON_PATH = path.join(import.meta.dirname, '..', '..', 'package.json');
7+
8+
const { values } = parseArgs({
9+
options: {
10+
'tag': { type: 'string', default: 'beta' },
11+
// Side channels are published from branches whose package.json version is usually the last
12+
// released one, so the base version has to be moved forward instead of failing the release.
13+
'bump-base-if-published': { type: 'boolean', default: false },
14+
},
15+
});
16+
17+
const PRERELEASE_TAG = values.tag!;
18+
const BUMP_BASE_IF_PUBLISHED = values['bump-base-if-published'];
19+
20+
// The tag ends up both as an npm dist-tag and as a semver prerelease identifier.
21+
if (!/^[a-z][a-z0-9-]*$/.test(PRERELEASE_TAG)) {
22+
console.error(
23+
`before-prerelease: '${PRERELEASE_TAG}' is not a usable prerelease tag - use lowercase letters, digits and hyphens, starting with a letter.`,
24+
);
25+
process.exit(1);
26+
}
27+
28+
if (PRERELEASE_TAG === 'latest') {
29+
console.error(`before-prerelease: 'latest' is the stable dist-tag and cannot be used for a prerelease.`);
30+
process.exit(1);
31+
}
32+
33+
const pkgJson = JSON.parse(await readFile(PKG_JSON_PATH, { encoding: 'utf8' }));
34+
35+
const PACKAGE_NAME = pkgJson.name;
36+
const VERSION = pkgJson.version;
37+
38+
const nextVersion = getNextVersion(VERSION);
39+
console.log(`before-prerelease: Setting version to ${nextVersion}`);
40+
pkgJson.version = nextVersion;
41+
42+
await writeFile(PKG_JSON_PATH, `${JSON.stringify(pkgJson, null, 4)}\n`);
43+
44+
function getPublishedVersions() {
45+
const versionString = execSync(`npm show ${PACKAGE_NAME} versions --json`, {
46+
encoding: 'utf8',
47+
stdio: ['ignore', 'pipe', 'ignore'],
48+
});
49+
50+
const parsed = JSON.parse(versionString) as string[] | string;
51+
52+
// npm returns a bare string when the package has exactly one published version.
53+
return Array.isArray(parsed) ? parsed : [parsed];
54+
}
55+
56+
function nextFreeBaseVersion(version: string, publishedVersions: string[]) {
57+
const [major, minor, patch] = version.split('-')[0].split('.').map(Number);
58+
59+
if ([major, minor, patch].some((part) => !Number.isInteger(part))) {
60+
console.error(`before-prerelease: Cannot parse '${version}' in package.json as a semver version.`);
61+
process.exit(1);
62+
}
63+
64+
let candidate = `${major}.${minor}.${patch}`;
65+
let nextPatch = patch;
66+
67+
while (publishedVersions.includes(candidate)) {
68+
nextPatch += 1;
69+
candidate = `${major}.${minor}.${nextPatch}`;
70+
}
71+
72+
return candidate;
73+
}
74+
75+
function getNextVersion(version: string) {
76+
const versions = getPublishedVersions();
77+
78+
let baseVersion = version;
79+
80+
if (versions.includes(baseVersion)) {
81+
if (!BUMP_BASE_IF_PUBLISHED) {
82+
console.error(
83+
`before-prerelease: A release with version ${baseVersion} already exists. Please increment version accordingly.`,
84+
);
85+
process.exit(1);
86+
}
87+
88+
baseVersion = nextFreeBaseVersion(baseVersion, versions);
89+
console.log(`before-prerelease: ${version} is already published, basing the prerelease on ${baseVersion}`);
90+
}
91+
92+
const prereleasePattern = new RegExp(`^${baseVersion.replace(/\./g, '\\.')}-${PRERELEASE_TAG}\\.(\\d+)$`);
93+
94+
const prereleaseNumbers = versions
95+
.map((v) => v.match(prereleasePattern)?.[1])
96+
.filter((number) => number !== undefined)
97+
.map(Number);
98+
99+
const lastPrereleaseNumber = Math.max(-1, ...prereleaseNumbers);
100+
101+
return `${baseVersion}-${PRERELEASE_TAG}.${lastPrereleaseNumber + 1}`;
102+
}

.github/workflows/pre_release.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ jobs:
8080
- name: Get pre-release version
8181
id: get-pre-release-version
8282
run: |
83-
pnpm exec tsx ./.github/scripts/before-beta-release.ts
83+
pnpm exec tsx ./.github/scripts/before-prerelease.ts --tag beta
8484
echo "pre_release_version=$(cat package.json | jq -r '.version')" >> $GITHUB_OUTPUT
8585
8686
build-bundles:

.github/workflows/publish_to_npm.yaml

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ on:
1515
options:
1616
- latest
1717
- beta
18+
- runtime
19+
allow_unmerged_latest:
20+
description: "Allow 'latest' from a ref that is not contained in master (deliberate releases only)"
21+
required: false
22+
type: boolean
23+
default: false
1824

1925
permissions:
2026
id-token: write # Required for OIDC
@@ -28,6 +34,27 @@ jobs:
2834
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
2935
with:
3036
ref: ${{ inputs.ref }}
37+
# The master containment check below needs real history, not a shallow clone.
38+
fetch-depth: 0
39+
40+
# 'latest' is what plain `npm install -g apify-cli` resolves to, so it must not come from a
41+
# side channel branch by accident. Side channels (beta, runtime) are opt-in and unrestricted.
42+
- name: Check the ref is in master before publishing 'latest'
43+
if: ${{ inputs.tag == 'latest' }}
44+
env:
45+
ALLOW_UNMERGED_LATEST: ${{ inputs.allow_unmerged_latest }}
46+
run: |
47+
if [ "$ALLOW_UNMERGED_LATEST" = "true" ]; then
48+
echo "allow_unmerged_latest is set, skipping the master containment check."
49+
exit 0
50+
fi
51+
52+
git fetch --no-tags origin master
53+
54+
if ! git merge-base --is-ancestor HEAD origin/master; then
55+
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."
56+
exit 1
57+
fi
3158
3259
- name: Use Node.js
3360
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@@ -40,7 +67,13 @@ jobs:
4067

4168
- name: Check version consistency and bump pre-release version (beta only)
4269
if: ${{ inputs.tag == 'beta' }}
43-
run: pnpm exec tsx ./.github/scripts/before-beta-release.ts
70+
run: pnpm exec tsx ./.github/scripts/before-prerelease.ts --tag beta
71+
72+
# Side channels are published off long-lived branches whose package.json still carries the
73+
# last released version, so the base version is moved forward instead of failing the build.
74+
- name: Bump pre-release version (side channels)
75+
if: ${{ inputs.tag != 'beta' && inputs.tag != 'latest' }}
76+
run: pnpm exec tsx ./.github/scripts/before-prerelease.ts --tag "${{ inputs.tag }}" --bump-base-if-published
4477

4578
- name: Build module
4679
run: pnpm run build
@@ -53,3 +86,9 @@ jobs:
5386

5487
- name: Publish to NPM
5588
run: pnpm publish --provenance --access public --no-git-checks --tag ${{ inputs.tag }}
89+
90+
- name: Report what was published
91+
run: |
92+
echo "### Published \`apify-cli@$(jq -r .version package.json)\` under the \`${{ inputs.tag }}\` dist-tag" >> $GITHUB_STEP_SUMMARY
93+
echo "" >> $GITHUB_STEP_SUMMARY
94+
echo "Install it with \`npm install -g apify-cli@${{ inputs.tag }}\`" >> $GITHUB_STEP_SUMMARY

CONTRIBUTING.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,32 @@ Releases are fully automated via GitHub Actions — **do not bump the version in
184184
- **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.
185185

186186
Only users with publish access to the [`apify-cli` npm package](https://www.npmjs.com/package/apify-cli) can trigger the stable release workflow.
187+
188+
### Side channels (publishing a branch to npm)
189+
190+
A feature that needs real-world testing before it lands on `master` can be published from its own branch
191+
under its own npm dist-tag. Users on `latest` are unaffected — npm only installs a dist-tag when it is
192+
asked for explicitly:
193+
194+
```bash
195+
npm install -g apify-cli@runtime
196+
```
197+
198+
The `runtime` channel exists for [Actor runtime](https://docs.apify.com/cli) development. To publish one:
199+
200+
1. Merge `master` into your branch. The workflow definition comes from the ref you dispatch from, but
201+
`.github/scripts/` comes from the ref you publish, so a stale branch fails the version-bump step.
202+
2. Run the **Publish to NPM** workflow (`.github/workflows/publish_to_npm.yaml`) from the Actions UI,
203+
dispatching it **from `master`**, with `ref` set to your branch and `tag` set to `runtime`. Dispatching
204+
from `master` also keeps the OIDC claim npm's trusted publisher sees stable.
205+
3. The workflow derives the version itself: the base version moves forward to the first unpublished patch
206+
and the channel name becomes the prerelease identifier, so the result looks like `1.10.1-runtime.0`,
207+
then `.1`, `.2` on later publishes. Each channel counts independently of `beta`.
208+
209+
Side channels publish to npm only — no GitHub release, no changelog entry, no standalone bundles.
210+
211+
To add another channel, add its name to the `tag` input's `options` in the workflow. Channel names must be
212+
lowercase and cannot be valid semver (they become both a dist-tag and a semver prerelease identifier).
213+
214+
Publishing `latest` is refused unless the ref is contained in `origin/master`; `allow_unmerged_latest`
215+
overrides that for a deliberate release from another branch.

docs/reference.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,69 @@ ARGUMENTS
453453
validates all schemas in '.actor/actor.json'.
454454
```
455455
456+
##### `apify runtime`
457+
458+
```sh
459+
DESCRIPTION
460+
Manages the Actor runtime, a self-contained local Apify platform running as a
461+
Docker container.
462+
463+
SUBCOMMANDS
464+
runtime install Installs the Actor runtime: verifies this
465+
machine can run Docker images and downloads the Actor runtime
466+
Docker image ('actor-runtime:latest').
467+
runtime start Starts the Actor runtime, a local Apify
468+
platform running as a Docker container.
469+
runtime stop Stops the Actor runtime container started with
470+
'apify runtime start --detach'.
471+
```
472+
473+
##### `apify runtime install`
474+
475+
```sh
476+
DESCRIPTION
477+
Installs the Actor runtime: verifies this machine can run Docker images and
478+
downloads the Actor runtime Docker image ('actor-runtime:latest').
479+
480+
USAGE
481+
$ apify runtime install [-f]
482+
483+
FLAGS
484+
-f, --force Download the Actor runtime image even when it is already
485+
available locally.
486+
```
487+
488+
##### `apify runtime start`
489+
490+
```sh
491+
DESCRIPTION
492+
Starts the Actor runtime, a local Apify platform running as a Docker
493+
container.
494+
Installs the runtime first when needed (like 'apify runtime install'). The
495+
runtime API listens on http://localhost:3333 and the console on
496+
http://localhost:3000.
497+
498+
USAGE
499+
$ apify runtime start [--data-dir <value>] [-d]
500+
501+
FLAGS
502+
--data-dir=<value> Host directory mounted as the runtime
503+
data directory (storages, builds and run records).
504+
Defaults to ~/.apify/actor-runtime/data.
505+
-d, --detach Run the runtime container in the
506+
background. Stop it with 'apify runtime stop'.
507+
```
508+
509+
##### `apify runtime stop`
510+
511+
```sh
512+
DESCRIPTION
513+
Stops the Actor runtime container started with 'apify runtime start --detach'.
514+
515+
USAGE
516+
$ apify runtime stop
517+
```
518+
456519
##### `apify actor`
457520
458521
```sh

scripts/generate-cli-docs.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ const categories: Record<string, CommandsInCategory[]> = {
2727
{ command: Commands.run },
2828
{ command: Commands.validateSchema },
2929

30+
{ command: Commands.runtime },
31+
{ command: Commands.runtimeInstall },
32+
{ command: Commands.runtimeStart },
33+
{ command: Commands.runtimeStop },
34+
3035
{ command: Commands.actor },
3136
{ command: Commands.actorCalculateMemory },
3237
{ command: Commands.actorCharge },

src/commands/_register.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { ToplevelPushCommand } from './push.js';
3131
import { RequestQueuesIndexCommand } from './request-queues/_index.js';
3232
import { RunCommand } from './run.js';
3333
import { RunsIndexCommand } from './runs/_index.js';
34+
import { RuntimeIndexCommand } from './runtime/_index.js';
3435
import { SecretsIndexCommand } from './secrets/_index.js';
3536
import { TasksIndexCommand } from './task/_index.js';
3637
import { TelemetryIndexCommand } from './telemetry/_index.js';
@@ -48,6 +49,7 @@ export const apifyCommands = [
4849
MCPIndexCommand,
4950
RequestQueuesIndexCommand,
5051
RunsIndexCommand,
52+
RuntimeIndexCommand,
5153
SecretsIndexCommand,
5254
TasksIndexCommand,
5355
TelemetryIndexCommand,

src/commands/runtime/_index.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { ApifyCommand } from '../../lib/command-framework/apify-command.js';
2+
import { RuntimeInstallCommand } from './install.js';
3+
import { RuntimeStartCommand } from './start.js';
4+
import { RuntimeStopCommand } from './stop.js';
5+
6+
export class RuntimeIndexCommand extends ApifyCommand<typeof RuntimeIndexCommand> {
7+
static override name = 'runtime' as const;
8+
9+
static override description =
10+
'Manages the Actor runtime, a self-contained local Apify platform running as a Docker container.';
11+
12+
static override group = 'Local Actor Development';
13+
14+
static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-runtime';
15+
16+
static override subcommands = [RuntimeInstallCommand, RuntimeStartCommand, RuntimeStopCommand];
17+
18+
async run() {
19+
this.printHelp();
20+
}
21+
}

0 commit comments

Comments
 (0)