diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d3285c464e..e2860a1d30 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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, @@ -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..` 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`. 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`, -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/","version":"0.0.0","license":"MIT"}' > package.json +npm publish --access public --tag reserved +npm trust github @agent-device/ --file release.yml --repository callstack/agent-device \ + --environment npm-publish --allow-publish --yes +npm access set mfa=publish @agent-device/ ``` -The version hook synchronizes all public workspace manifests and MCP metadata, stages them, and -includes them in npm's version commit and `v` 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 --file release.yml --repository callstack/agent-device \ + --environment npm-publish --allow-publish --yes +npm access set mfa=publish +``` -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 diff --git a/package.json b/package.json index 31d81c8bea..c265826a29 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", @@ -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", diff --git a/packages/host-kit/src/internal/version.test.ts b/packages/host-kit/src/internal/version.test.ts index e46f17e3f3..51d4276a8a 100644 --- a/packages/host-kit/src/internal/version.test.ts +++ b/packages/host-kit/src/internal/version.test.ts @@ -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); diff --git a/scripts/release-mark-dev.mjs b/scripts/release-mark-dev.mjs deleted file mode 100644 index 8a186bc3cc..0000000000 --- a/scripts/release-mark-dev.mjs +++ /dev/null @@ -1,75 +0,0 @@ -import { spawnSync } from 'node:child_process'; -import fs from 'node:fs'; -import path from 'node:path'; -import process from 'node:process'; - -// The version on main must never equal a published version: registry scanners -// diff the repository's tool surface per version string, so a released number -// left in place while main keeps moving reads as a rug-pull republish -// (AS-012). `postpublish` runs this after all packages publish to move -// main to the next patch with a `-dev` prerelease marker; `--check-release-version` -// is the inverse guard in `prepublishOnly`, refusing to publish a `-dev` -// version because the maintainer has not set the release version yet. -const root = process.cwd(); -const checkReleaseVersion = process.argv.includes('--check-release-version'); -const packagePath = path.join(root, 'package.json'); - -const raw = fs.readFileSync(packagePath, 'utf8'); -const pkg = JSON.parse(raw); -const version = pkg.version; - -if (typeof version !== 'string' || version.length === 0) { - fail('package.json must define version.'); -} - -if (checkReleaseVersion) { - if (version.includes('-')) { - fail( - `package.json version ${version} is a prerelease marker. Set the release version first ` + - '(e.g. `npm version patch`), commit, then publish.', - ); - } - runWorkspaceRelease('check'); - process.exit(0); -} - -const nextVersion = version.includes('-') ? version : markRootAsDevelopment(); -runWorkspaceRelease('sync'); - -const sync = spawnSync(process.execPath, [path.join(root, 'scripts', 'sync-mcp-metadata.mjs')], { - stdio: 'inherit', -}); -if (sync.status !== 0) { - fail('sync-mcp-metadata failed after the version bump.'); -} - -process.stdout.write( - `Synchronized development version: ${nextVersion} (public packages + server.json).\n` + - `Commit and push this so main stays marked as unreleased.\n`, -); - -function fail(message) { - process.stderr.write(`${message}\n`); - process.exit(1); -} - -function runWorkspaceRelease(command) { - const result = spawnSync( - process.execPath, - [path.join(root, 'scripts', 'release-workspace.mjs'), command], - { stdio: 'inherit' }, - ); - if (result.status !== 0) fail(`Workspace release ${command} failed.`); -} - -function markRootAsDevelopment() { - const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version); - if (!match) fail(`Unsupported version format: ${version}`); - const nextVersion = `${match[1]}.${match[2]}.${Number(match[3]) + 1}-dev`; - const versionField = `"version": "${version}"`; - if (raw.split(versionField).length !== 2) { - fail(`Expected exactly one ${versionField} in package.json.`); - } - fs.writeFileSync(packagePath, raw.replace(versionField, `"version": "${nextVersion}"`)); - return nextVersion; -} diff --git a/scripts/release-workspace.mjs b/scripts/release-workspace.mjs index ea35077ee9..d526775a92 100644 --- a/scripts/release-workspace.mjs +++ b/scripts/release-workspace.mjs @@ -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 [--stage]'); +if (!['sync', 'pack'].includes(command) || options.length > 0) { + throw new Error('Usage: node scripts/release-workspace.mjs '); } const packages = JSON.parse( execFileSync('pnpm', ['list', '--recursive', '--depth', '-1', '--json'], { @@ -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() { @@ -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() { @@ -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' }, - ); -} diff --git a/website/docs/docs/installation.md b/website/docs/docs/installation.md index f6dc687d98..8c3f78f8f5 100644 --- a/website/docs/docs/installation.md +++ b/website/docs/docs/installation.md @@ -55,6 +55,17 @@ agent-device --version Set `AGENT_DEVICE_NO_UPDATE_NOTIFIER=1` to disable the notice. +## Nightly builds + +Nightly builds publish `main` after its CI passes, at most once per UTC day: + +```bash +npm install -g agent-device@nightly +``` + +A nightly install's update notice recommends a newer nightly when an update check finds one. To +return to stable releases, install `agent-device@latest`. + ## Without installing ```bash