Skip to content
Merged
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
136 changes: 83 additions & 53 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ Use `pnpm build:macos-helper:clean` if a Swift cache was created in another work

## Prepare the npm package

`pnpm publish` and package-manager pack commands run `prepack`, which first checks synchronized MCP
Package-manager pack commands run `prepack`, which first checks synchronized MCP
metadata and then runs `pnpm package:npm`. This is the one completeness-oriented aggregate: it
builds the TypeScript distribution and all four Apple runner targets (into a scratch directory under
`.tmp/` that it removes afterwards, not into `~/.agent-device`), clean-builds the macOS helper,
Expand All @@ -83,72 +83,102 @@ so name the version the CI lanes install and the published helper matches the CI
`pnpm package:npm` is a release guard, not a routine development command. Use the specific commands
above while iterating.

### Release the core and public workspace packages together
### Release through the Release workflow

Every release publishes from `.github/workflows/release.yml`, through npm trusted publishing: no
npm token or one-time password is involved, and every release version carries provenance. The one
exception is the empty `0.0.0` placeholder a maintainer publishes by hand to onboard a new package,
described below. A local `npm publish` from the repository root refuses to run.

The core and every public workspace package release together at one version. Private packages
stay unpublished. There are three channels:

- **Nightly** (`npm install -g agent-device@nightly`): every few hours the workflow checks `main`.
It publishes `X.Y.Z-nightly.<YYYYMMDD>.<run>` under the `nightly` dist-tag when `main`'s HEAD has
changed since the last nightly, its CI passed, and no nightly shipped that UTC day. It then tags
the commit `nightly/v<nightly version>`. To publish one now, run **Release** on `main`.
- **Stable** (`latest`): a repository admin publishes a GitHub release with a new `vX.Y.Z` tag
targeting `main`. Use the version `main` builds towards (its `X.Y.Z-dev`), or a later one, and
write the notes or generate them in the release form. The tag starts a **Release** run. It waits
up to 20 minutes for that commit's CI to pass, then waits for approval in the `release`
environment. Once approved, it publishes the packages, attaches the iOS runner and Android helper
assets to the release, publishes the MCP Registry entry, and commits `main`'s next `-dev` version.
If you reject the run, delete the release and its tag. Pushing the tag instead (`git push origin
main:refs/tags/vX.Y.Z`) starts the same run, which then drafts the release itself.
- **Dry run**: a pull request that changes release tooling builds and verifies every package with a
nightly version, and publishes nothing.

To retry a failed run, re-run its failed jobs, or run **Release** on the release tag. The
publisher skips package versions that are already on npm, so a partial publish completes on the
next attempt. To preview the packages locally, run
`pnpm release:prepare`. It builds, verifies, and packs every public package into `.tmp/release`.

The root `package.json` owns the release version. Every public workspace package uses that version;
private internal packages stay unpublished. There is one Git tag and GitHub release, `v<version>`,
for the whole release. No changeset files or per-package tags are needed.
A public package owns its `files`, published `exports`, license, repository metadata, README,
and `prepack` build (including any prerequisites). Use `publishConfig.exports` for source-only
workspace test exports; pnpm applies the overrides when packing. Keep plugin SDK imports and
bundled workspace helpers in `devDependencies`. Plugins must not depend on the core at runtime
or through a peer dependency. Their factories receive the host from `agent-device`.

From a clean release checkout, bump the version once:
When adding a public package, initialize its version from the root `package.json`; the release
workflow stamps the release version into every public package. An optional plugin must have no
production import from the core build or production dependency in the root manifest. Run its
packed-install smoke before releasing. npm can only trust a package that exists, so a maintainer
publishes an empty `0.0.0` placeholder once and grants the trust before the next nightly:

```bash
npm version patch
# or npm version minor / npm version 0.22.0
cd "$(mktemp -d)"
echo '{"name":"@agent-device/<name>","version":"0.0.0","license":"MIT"}' > package.json
npm publish --access public --tag reserved
npm trust github @agent-device/<name> --file release.yml --repository callstack/agent-device \
--environment npm-publish --allow-publish --yes
npm access set mfa=publish @agent-device/<name>
```

The version hook synchronizes all public workspace manifests and MCP metadata, stages them, and
includes them in npm's version commit and `v<version>` tag. Do not bump plugin versions separately.
### One-time repository setup

Publish and push using the normal npm workflow:
npm trusts every run of `release.yml` that reaches the `npm-publish` environment, so GitHub
settings, not the workflow, decide who can publish. The plan job refuses to publish until the first
three are in place. Apply them before granting npm trust:

```bash
npm publish && git push && git push --tags
```
1. **Environment `npm-publish`** (Settings → Environments): deployment branches and tags limited to
the branch `main` and the tag pattern `v*`. No reviewers and no secrets, so nightlies run
unattended.
2. **Environment `release`**: required reviewers. Turn on "Prevent self-review" only when another
maintainer can approve, because the admin who creates the tag cannot approve their own run.
3. **Tag ruleset** (Settings → Rules → Rulesets → New tag ruleset): active, targeting
`refs/tags/v*` with no exclusions, restricting creations, updates, and deletions, with only the
Repository admin role allowed to bypass. Nightly tags live under `nightly/` and stay outside it.
4. **Actions** (Settings → Actions → General): workflow permissions set to read repository contents,
and "Allow GitHub Actions to create and approve pull requests" turned off.
5. **Releases** (Settings → General): leave release immutability off, because the run attaches its
assets after the release is published.

The publish hooks prepare and validate every public package before uploading the core, then
publish the remaining workspace packages at the same version. Private packages stay unpublished.
After every package succeeds, the hook commits all public manifests and MCP metadata at the next
`-dev` version. Your `git push` includes that commit; the version bump created the single release
tag. Write one GitHub release covering the core and plugins.

After setting the version, preview the full release with `npm publish --dry-run`. It builds and
checks the packages without uploading them, changing versions, or creating commits. The dry-run
publish hook makes no additional registry requests. Dependency installation during preparation
still requires registry access unless the dependencies are cached. Publishing requires npm registry access for
`agent-device` and the `@agent-device` scope.

If core publication succeeds but a later package fails, run `pnpm release:publish` to finish the
release. It skips versions already published and commits the development marker once every package
succeeds. Plain `npm publish` cannot retry an already-published core version.
If every upload succeeds but development-marker synchronization or its Git commit fails, fix the
reported error and run `pnpm release:mark-dev` to finish any interrupted version synchronization, then commit the
changed public manifests and `server.json` before pushing. No package needs republishing.

Use `npm pack` to build and validate a development-version package locally. The normal npm and
pnpm publishers repack the prepared files when uploading; the checks validate package contents,
not the byte identity of the uploaded archive.
Confirm the result, then grant npm trust to each public package and, once a nightly has published,
refuse token-based publishing:

A public package owns its `files`, published `exports`, license, repository metadata, README,
and `prepack` build (including any prerequisites). Use `publishConfig.exports` for source-only
workspace test exports; pnpm applies the overrides when packing. Keep plugin SDK imports and
bundled workspace helpers in `devDependencies`. Plugins must not depend on the core at runtime
or through a peer dependency. Their factories receive the host from `agent-device`.
```bash
gh api repos/callstack/agent-device/environments/npm-publish/deployment-branch-policies \
--jq '.branch_policies[] | "\(.type) \(.name)"' # branch main, tag v*
gh api repos/callstack/agent-device/environments/release \
--jq '.protection_rules[] | select(.type == "required_reviewers")'
gh api repos/callstack/agent-device/rulesets --jq '.[] | select(.target == "tag") | .name'
npm trust github <package> --file release.yml --repository callstack/agent-device \
--environment npm-publish --allow-publish --yes
npm access set mfa=publish <package>
```

When adding a public package, initialize its version from the root `package.json`; subsequent
`npm version` and `release:mark-dev` runs keep it synchronized automatically. An optional plugin
must have no production import from the core build or production dependency in the root manifest.
Run its packed-install smoke before releasing.
Anyone who can push to `main` can still change what the next nightly publishes; `main` does not
require pull requests.

### The version on main never equals a published version

`postpublish` runs `release:mark-dev` after all packages publish, moving all public manifests
and synchronized `server.json` to the next patch with a `-dev` prerelease marker (for example
`0.20.11-dev`) and committing that bump as part of the release. The invariant it protects: MCP registry
scanners diff the repository's tool surface per version string, so a released number left on `main`
while `main` keeps changing is indistinguishable from a republished ("rug-pull") version.
`prepublishOnly` and the retry command refuse to publish while the `-dev` marker is
still in place — set the real release version first (for example `npm version patch`, which strips
the prerelease marker), commit, then publish.
`main` carries `X.Y.Z-dev`, naming the release it is building towards. Nightlies preview it as
`X.Y.Z-nightly.*`, and only a stable release publishes `X.Y.Z`. After a stable release, the workflow
moves `main` to the next patch's `-dev` version, unless `main` already builds towards a later
release. To plan a minor or major release, commit the new `-dev` version to `main` (for example
`0.22.0-dev`); nightlies then preview it. The invariant protects MCP Registry scanners, which diff
the repository's tool surface per version string: a released number left on `main` while `main`
keeps changing is indistinguishable from a republished ("rug-pull") version.

### Released-surface baselines roll forward on publish

Expand Down
6 changes: 1 addition & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,6 @@
"build:package": "pnpm build && pnpm package:xcuitest && pnpm build:macos-helper:clean && pnpm prepare:publish-assets",
"package:npm": "pnpm build:package && pnpm check:package",
"release:prepare": "rm -rf .tmp/release && pnpm check:mcp-metadata && pnpm build:package && pnpm check:package -- --pack-destination .tmp/release && node scripts/release-workspace.mjs pack",
"release:publish": "node scripts/release-mark-dev.mjs --check-release-version && pnpm release:prepare && node scripts/release-workspace.mjs publish",
"release:mark-dev": "node scripts/release-mark-dev.mjs",
"ad": "node bin/agent-device.mjs",
"bench:help-conformance": "node scripts/help-conformance-bench.mjs",
"maestro:conformance": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/maestro-conformance/format-generated-json.test.mjs packages/maestro/test/conformance/verify.test.ts packages/maestro/test/conformance/differential/engine-process.test.ts packages/maestro/test/conformance/differential/report-output.test.ts packages/maestro/test/conformance/differential/run.test.ts packages/maestro/test/conformance/differential/invariants.test.ts",
Expand Down Expand Up @@ -173,7 +171,6 @@
"check:quick": "pnpm lint && pnpm typecheck",
"sync:mcp-metadata": "node scripts/sync-mcp-metadata.mjs",
"check:mcp-metadata": "node scripts/sync-mcp-metadata.mjs --check",
"version": "node scripts/release-workspace.mjs sync --stage && pnpm sync:mcp-metadata && git add server.json",
"check:tooling": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm check:layering && pnpm depgraph:test && pnpm check:gate-manifest:test && pnpm check:gate-manifest && pnpm check:production-exports && pnpm check:tmpdir-leaks:test && pnpm check:xctest-selection && pnpm check:packaged-runner-swift && pnpm check:mcp-metadata && pnpm build && pnpm check:bundle-owner-files && pnpm check:package",
"check:unit": "pnpm test:unit && pnpm check:tmpdir-leaks && pnpm test:smoke",
"check": "pnpm check:tooling && pnpm check:fallow && pnpm check:unit",
Expand Down Expand Up @@ -214,8 +211,7 @@
"test:replay:macos": "node --experimental-strip-types src/bin.ts test test/integration/replays/macos",
"test:replay:linux": "node --experimental-strip-types src/bin.ts test test/integration/replays/linux",
"test:linux:command-evidence": "node --experimental-strip-types test/integration/linux-e2e/live-runner.ts",
"postpublish": "node scripts/release-workspace.mjs publish",
"prepublishOnly": "node scripts/release-mark-dev.mjs --check-release-version"
"prepublishOnly": "echo 'agent-device publishes from .github/workflows/release.yml; see CONTRIBUTING.md.' >&2 && exit 1"
},
"files": [
"bin",
Expand Down
2 changes: 1 addition & 1 deletion packages/host-kit/src/internal/version.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ test('isNewerVersion orders releases numerically per segment', () => {
});

test('isNewerVersion ranks a release above the prerelease of the same base', () => {
// main carries `-dev` between releases (scripts/release-mark-dev.mjs): a released client meeting
// main carries `-dev` between releases (scripts/release-version.mjs): a released client meeting
// a `-dev` daemon of the same base is the upgrade, and the reverse is the downgrade.
assert.equal(isNewerVersion('0.21.13', '0.21.13-dev'), true);
assert.equal(isNewerVersion('0.21.13-dev', '0.21.13'), false);
Expand Down
75 changes: 0 additions & 75 deletions scripts/release-mark-dev.mjs

This file was deleted.

56 changes: 4 additions & 52 deletions scripts/release-workspace.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,8 @@ import path from 'node:path';

const root = process.cwd();
const [command, ...options] = process.argv.slice(2);
if (
!['sync', 'check', 'pack', 'publish'].includes(command) ||
options.some((option) => option !== '--stage')
) {
throw new Error('Usage: node scripts/release-workspace.mjs <sync|check|pack|publish> [--stage]');
if (!['sync', 'pack'].includes(command) || options.length > 0) {
throw new Error('Usage: node scripts/release-workspace.mjs <sync|pack>');
}
const packages = JSON.parse(
execFileSync('pnpm', ['list', '--recursive', '--depth', '-1', '--json'], {
Expand All @@ -34,12 +31,11 @@ if (command === 'sync') {
for (const pkg of publicPackages) {
if (pkg.manifest.version !== version) {
throw new Error(
`${pkg.manifest.name}@${pkg.manifest.version} must match agent-device@${version}. Run npm version first.`,
`${pkg.manifest.name}@${pkg.manifest.version} must match agent-device@${version}. Run release-version.mjs stamp first.`,
);
}
}
if (command === 'pack') packWorkspacePackages();
if (command === 'publish') publishWorkspacePackages();
packWorkspacePackages();
}

function syncVersions() {
Expand All @@ -48,12 +44,6 @@ function syncVersions() {
pkg.manifest.version = version;
fs.writeFileSync(pkg.manifestPath, `${JSON.stringify(pkg.manifest, null, 2)}\n`);
}
if (options.includes('--stage')) {
execFileSync('git', ['add', '--', ...publicPackages.map((pkg) => pkg.manifestPath)], {
cwd: root,
stdio: 'inherit',
});
}
}

function packWorkspacePackages() {
Expand Down Expand Up @@ -101,41 +91,3 @@ function checkPackedIdentity(packed, name) {
throw new Error('Packed package identity differs from the synchronized public package.');
}
}

function publishWorkspacePackages() {
if (process.env.npm_config_dry_run === 'true') {
if (process.env.npm_lifecycle_event !== 'postpublish') {
throw new Error(
'Use npm publish --dry-run to preview a release; the retry command publishes.',
);
}
return;
}
const args = [
'--recursive',
'--include-workspace-root',
'publish',
'--access',
'public',
'--no-git-checks',
'--ignore-scripts',
];
if (process.env.npm_lifecycle_event === 'postpublish') {
args.push('--filter', `!${rootPackage.manifest.name}`);
}
execFileSync('pnpm', args, { cwd: root, stdio: 'inherit' });
execFileSync(process.execPath, ['scripts/release-mark-dev.mjs'], { cwd: root, stdio: 'inherit' });
execFileSync(
'git',
[
'commit',
'--only',
'-m',
`chore: mark ${version} as released`,
'--',
...publicPackages.map((pkg) => pkg.manifestPath),
path.join(root, 'server.json'),
],
{ cwd: root, stdio: 'inherit' },
);
}
Loading
Loading