From fc00a912b4c08f9dcd67f78b822ac0f8cc01c618 Mon Sep 17 00:00:00 2001 From: Ming Yoo Date: Tue, 14 Jul 2026 00:30:01 +0800 Subject: [PATCH] Keep coordinated releases from silently omitting public packages OpenTag's CLI installation closes over every public workspace package, so the 0.5.0 family must be released atomically from one discovered dependency graph. The release gate now validates that graph, exercises the packed CLI in a clean npm project, and records the migration and staged rollout contract. Constraint: The CLI runtime dependency closure requires all 15 public packages at one lockstep version. Rejected: Maintain another hand-written package list | the existing check and publish lists had already drifted from the manifests. Confidence: high Scope-risk: moderate Reversibility: clean Directive: Treat publishConfig.access=public as the sole release-set source and promote immutable versions with dist-tags only. Tested: build, lint, typecheck, 1510 unit/integration tests, governance matrix, privacy scan, 9 release-plan tests, publication-set checks, 15-package pack and clean npm install, installed CLI help and doctor. Not-tested: Public-registry install, foreground start, live provider callback, and npm dist-tag promotion remain post-publish gates. --- .github/workflows/ci.yml | 8 +- CHANGELOG.md | 159 ++++++++++++++ README.md | 2 +- README.zh-CN.md | 2 +- docs/npm-release.md | 207 +++++++++++++----- docs/versioning.md | 71 ++++-- package.json | 1 + packages/cli/package.json | 2 +- .../cli/test/release-package-plan.test.ts | 194 ++++++++++++++++ packages/client/package.json | 2 +- packages/core/package.json | 2 +- packages/discord/package.json | 2 +- packages/dispatcher/package.json | 2 +- packages/github/package.json | 2 +- packages/gitlab/package.json | 2 +- packages/lark/package.json | 2 +- packages/linear/package.json | 2 +- packages/local-runtime/package.json | 2 +- packages/runner/package.json | 2 +- packages/slack/package.json | 2 +- packages/store/package.json | 2 +- packages/teams/package.json | 2 +- packages/telegram/package.json | 2 +- scripts/release/check-cli-package.mjs | 77 +++++-- scripts/release/check-publication-set.mjs | 17 ++ scripts/release/package-plan.mjs | 164 ++++++++++++++ scripts/release/publish-local.mjs | 67 +++--- 27 files changed, 867 insertions(+), 132 deletions(-) create mode 100644 packages/cli/test/release-package-plan.test.ts create mode 100644 scripts/release/check-publication-set.mjs create mode 100644 scripts/release/package-plan.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8454deb..a07ee4ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ permissions: jobs: check: - name: Build, lint, typecheck, and test + name: Build, lint, typecheck, test, and release checks runs-on: ubuntu-latest steps: @@ -46,3 +46,9 @@ jobs: - name: Test run: pnpm test + + - name: Check publication set consistency + run: pnpm release:publication-set + + - name: Check release packages + run: pnpm release:check diff --git a/CHANGELOG.md b/CHANGELOG.md index d8966b13..d9f8b813 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,68 @@ ## Unreleased +No changes yet. + +## v0.5.0 - 2026-07-14 + +OpenTag 0.5.0 adds Discord, Linear, and Microsoft Teams adapters and moves +agent execution onto durable, governed Attempts. It is a coordinated release +of all 15 public packages and contains breaking Client and Runner contract +changes described below. + +### Added + +- Published `@opentag/discord`, `@opentag/linear`, and `@opentag/teams` as + first-class members of the coordinated package family. +- Discord Gateway and interactions-webhook ingest, channel replies, runtime + readiness checks, and local-first CLI setup. +- Linear webhook ingest, issue comments, OAuth/API-key setup, workspace + discovery, issue creation and mutation application, and source-thread action + receipts. +- Microsoft Teams Bot Framework webhook ingest, tenant-aware authentication, + channel replies, action application, and CLI/runtime setup. +- A generic stdio ACP host backed by the official ACP SDK. Named ACP agents can + execute repository Attempts in isolated worktrees or ordinary non-repository + Attempts in attempt-scoped scratch workspaces. +- Durable Attempt records with monotonically numbered claims, lease recovery, + opaque fencing tokens, and attempt-scoped cancellation. +- Provider-neutral Channel protocol objects for normalized inbound messages, + Run Card updates, approval prompts, action receipts, and final summaries. +- Governed ACP permission requests with `allow_once`, `allow_run`, and `deny` + decisions delivered through the existing source-thread approval path. +- Material Action records for external side effects such as push, deploy, + publish, and connector writes. Actions now carry stable IDs and idempotency + keys, store normalized receipts, prevent duplicate execution, and require an + explicit administrative reconciliation when an outcome remains `unknown`. +- Source-thread runtime controls and richer action receipts across supported + chat and repository providers, plus local ledger/status evidence for runs, + Attempts, artifacts, callbacks, and apply outcomes. + +### Changed + +- Every mutating runner operation now belongs to the active Attempt. Claims + return `attemptId`, `attemptNumber`, and `fencingToken`; mark-running, + heartbeat, progress, completion, permission, and material-action receipt + calls must send the active Attempt lease. +- Lease expiry interrupts the prior Attempt before a new claim is issued. A + stale worker receives `409 { "error": "stale_attempt" }` and can no longer + append progress, complete the Run, resolve permissions, or write receipts. +- Agent integrations now use ACP instead of the unshipped + `opentag.executor.v1` / `stdio-jsonl-basic` protocol. ACP sessions are + disposable runtime state below durable Runs and Attempts. +- Repository execution uses OpenTag-owned isolation while non-repository ACP + work uses scratch isolation. Failed, refused, cancelled, and interrupted ACP + Attempts retain their workspace evidence instead of publishing a commit. +- Source-thread presentation is quieter: routine ACP/tool progress remains in + audit evidence, while approvals, blockers, material-action receipts, and + final summaries remain visible to people. +- CLI setup, start, status, doctor, pairing, service, and capability discovery + now cover the expanded adapter family, ACP agent profiles, runtime readiness, + secret readiness, and the installed CLI version. +- GitHub and GitLab source threads can run status/doctor/stop controls without + creating a new Run, and supported source-thread actions reuse the governed + approval/apply path. + ### Security - Run admission on public GitHub/GitLab repositories now requires @@ -28,6 +90,103 @@ `permissionMode` is configured. - Enabling `dangerouslySkipPermissions` for Claude Code now emits an audit warning on every run so the bypass stays visible in the run timeline. +- Fencing tokens are accepted only on authenticated runner mutation requests. + They are redacted from Attempt records, audit events, callbacks, errors, + snapshots, logs, and material-action receipts. +- ACP children receive a scrubbed environment, an explicit contained workspace, + strict NDJSON framing, bounded cancellation, and no dispatcher/channel + credentials. Child stderr and raw ACP frames do not enter durable Run results + or source-thread messages. +- Credential-like values, local absolute paths, hidden reasoning, and provider + secrets are sanitized at progress, completion, receipt, callback, and + control-plane boundaries. +- Channel roles and ACP agent roles use separate credentials, lifecycle, and + capability grants; a channel integration cannot silently inherit executor + authority. +- Attempt lifecycle state and its audit evidence commit atomically so a failed + evidence write cannot leave a partially advanced lease or terminal Run. + +### Migration: Client and custom runners + +`@opentag/client` consumers that claim Runs must keep the lease returned by +`claim` and pass it to every mutation: + +```ts +import { createOpenTagClient } from "@opentag/client"; + +const runnerId = "runner_custom"; +const client = createOpenTagClient({ + dispatcherUrl: "http://localhost:3030", + pairingToken: process.env.OPENTAG_PAIRING_TOKEN +}); + +const claimed = await client.claim({ runnerId }); +if (!claimed) throw new Error("No Run available"); + +const lease = { + attemptId: claimed.attemptId, + fencingToken: claimed.fencingToken +}; + +await client.markRunning({ + runnerId, + runId: claimed.run.id, + ...lease, + executor: "custom" +}); +await client.heartbeat({ runnerId, runId: claimed.run.id, ...lease }); +await client.progress({ + runnerId, + runId: claimed.run.id, + ...lease, + type: "executor.progress", + message: "Working on the request" +}); +await client.complete({ + runnerId, + runId: claimed.run.id, + ...lease, + result: { conclusion: "success", summary: "Done" } +}); +``` + +- `createDispatcherClient` callers must pass the lease as the new argument to + `markRunning(runId, executor, lease, options)`, `heartbeat(runId, lease)`, + `progress(runId, lease, input)`, and + `complete(runId, lease, result, options)`. +- Direct HTTP runners must use the runner-scoped `/v1/runners/:runnerId/runs/*` + endpoints and include `attemptId` plus `fencingToken` in every mutation body. + The old unscoped running/progress/complete endpoints now return `410`. +- Treat `stale_attempt` as loss of ownership: cancel local execution and do not + retry the mutation with the expired lease. Never log or persist a fencing + token outside the active runner process. +- Custom `@opentag/runner` adapters must replace `input.workspacePath` with + `input.workspace.path` (or `executorWorkspacePath(input)`). The workspace is + now explicitly `{ kind: "repository" | "scratch", path }`, and + `cancel(runId, attemptId?)` may receive the Attempt to cancel. +- Adapters that execute material side effects should use the injected + `permissionResolver` before execution and `materialActionReporter` afterward + so the action remains fenced, receipted, and retry-safe. +- Integrations built on the removed custom stdio protocol must migrate their + manifest to an ACP agent role and use `createAcpExecutor`. + +### Packages + +- `@opentag/core` +- `@opentag/client` +- `@opentag/discord` +- `@opentag/github` +- `@opentag/gitlab` +- `@opentag/lark` +- `@opentag/linear` +- `@opentag/runner` +- `@opentag/slack` +- `@opentag/store` +- `@opentag/teams` +- `@opentag/telegram` +- `@opentag/dispatcher` +- `@opentag/local-runtime` +- `@opentag/cli` ## v0.3.0 - 2026-06-30 diff --git a/README.md b/README.md index cefd105d..2ca20e7e 100644 --- a/README.md +++ b/README.md @@ -266,7 +266,7 @@ opentag-dev setup ## Packages -Current public release: `v0.4.0`. The npm package family is published under the `@opentag` scope. +Current public release: `v0.5.0`. The npm package family is published under the `@opentag` scope. | Package | Purpose | | --- | --- | diff --git a/README.zh-CN.md b/README.zh-CN.md index c8ec3060..24de5429 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -261,7 +261,7 @@ opentag-dev setup ## 软件包 -当前公开发布版本:`v0.4.0`。OpenTag 的 npm 包发布在 `@opentag` scope 下。 +当前公开发布版本:`v0.5.0`。OpenTag 的 npm 包发布在 `@opentag` scope 下。 | 包 | 用途 | | --- | --- | diff --git a/docs/npm-release.md b/docs/npm-release.md index 1c3a8c35..eafef48f 100644 --- a/docs/npm-release.md +++ b/docs/npm-release.md @@ -1,94 +1,203 @@ # Publishing OpenTag to npm -OpenTag npm packages are published manually from a local checkout until a release pipeline exists. +OpenTag npm packages are published manually from a clean local checkout until +a trusted release pipeline exists. All public packages ship as one coordinated +version. -## What gets published +## Current release -Publish all public packages together with the same version. The CLI depends on local runtime and adapter packages, so publishing only one package is not enough. +```text +0.5.0 +``` -Current release version: +The release is first published on the npm `next` dist-tag, tested from the +registry, then promoted to `latest`. The exact commit that produced the npm +artifacts must also receive the matching `v0.5.0` git tag and GitHub Release. -```text -0.4.0 +## Public package discovery and order + +The release helpers discover packages from `packages/*/package.json`. A package +is in the publication set only when its manifest contains: + +```json +{ + "publishConfig": { + "access": "public" + } +} ``` -Package publish order: +The helpers validate that every `@opentag/*` runtime dependency of a public +package is also in that set, build the internal dependency graph, and publish +it in topological order. Do not add a second hand-maintained package list to a +release script or this guide. -1. `@opentag/core` -2. `@opentag/client` -3. `@opentag/telegram` -4. `@opentag/runner` -5. `@opentag/store` -6. `@opentag/github` -7. `@opentag/gitlab` -8. `@opentag/lark` -9. `@opentag/slack` -10. `@opentag/dispatcher` -11. `@opentag/local-runtime` -12. `@opentag/cli` +Run `corepack pnpm release:publication-set` whenever the exact set or order is +needed. Its live, dependency-first output is the authoritative publication +plan. -## Preflight +## Release gate -Use the repository package manager through Corepack: +Start from the intended release commit with a clean working tree. Confirm that +all 15 public manifests use `0.5.0` and that the frozen lockfile is current, +then run the verification ladder in this order: ```bash corepack pnpm install --frozen-lockfile +corepack pnpm release:publication-set +corepack pnpm build +corepack pnpm lint +corepack pnpm typecheck +corepack pnpm test +corepack pnpm smoke:governance -- --all --report .omx/governance-matrix/all.json +corepack pnpm smoke:privacy -- --allow-missing --report .omx/governance-matrix/privacy.json corepack pnpm release:check ``` -`release:check` builds the workspace, packs every publishable package, installs those tarballs into a clean npm project, and verifies that the installed `opentag` command runs. +`release:check` repeats the build as a packaging precondition, packs every +automatically discovered public package, installs all tarballs into a clean npm +project, and runs the installed CLI's help and doctor checks. It fails before +publish when the public-package set is inconsistent, the dependency graph is +cyclic, a tarball is missing, the clean install cannot resolve the complete +package family, or the installed CLI checks fail. + +Do not use `--skip-check` for a release. Keep the release-gate outputs with the +release record, but do not commit reports that contain local paths or live +provider identifiers. -## Publish +## Publish the `next` canary -Log in to npm first: +Confirm npm access immediately before publishing: ```bash npm whoami +npm org ls opentag +corepack pnpm release:publish -- --tag next ``` -Then publish from the repo root: +The publish command uses the same automatic publication set and topological +order as `release:check`. A coordinated release is incomplete until all 15 +packages exist at `0.5.0`; do not promote a partial package family. + +If npm asks for a two-factor one-time password, do not pass `--otp` by default +for OpenTag releases. Refresh the local npm browser login, then rerun the normal +publish command: ```bash -corepack pnpm release:publish +npm login --auth-type=web +npm whoami +corepack pnpm release:publish -- --tag next ``` -For a dry run: +If npm still requires a per-publish code after a fresh browser login, stop and +continue from a trusted interactive terminal or adjust the npm account/session +policy. Never paste a one-time password into shared logs or automation. + +## Verify from the npm registry + +Do not smoke-test the workspace build for this gate. Install the exact canary +version into a new directory so npm must resolve every dependency from the +registry: ```bash -corepack pnpm release:publish -- --dry-run +smoke_root="$(mktemp -d)" +npm install --prefix "$smoke_root" --no-audit --no-fund @opentag/cli@0.5.0 +"$smoke_root/node_modules/.bin/opentag" --version +"$smoke_root/node_modules/.bin/opentag" --help ``` -If npm asks for a two-factor one-time password, do not pass `--otp` by default for OpenTag releases. -Refresh the local npm browser login instead, then rerun the normal publish command: +The version must be `0.5.0`. With isolated config and state directories, run +the setup, doctor, and foreground-start path for one platform that has real +test credentials: ```bash -npm login --auth-type=web -npm whoami -corepack pnpm release:publish +export OPENTAG_CONFIG_HOME="$smoke_root/config" +export OPENTAG_STATE_DIR="$smoke_root/state" +export PATH="$smoke_root/node_modules/.bin:$PATH" + +opentag setup +opentag doctor +opentag start ``` -If npm still requires a per-publish one-time password after a fresh browser login, stop and publish from a trusted interactive terminal or adjust the npm account/session policy. Do not paste one-time passwords into shared logs or release automation. +Use the relevant platform setup guide for the credentialed `opentag setup` +answers. While `opentag start` is running, send one real provider event and +verify the complete loop: provider ingest, Run creation, local execution, +source-thread reply, and the expected audit/action receipt. Stop the foreground +process after the receipt is visible. Record which platform was tested and the +redacted evidence in the release notes; never record provider tokens, fencing +tokens, raw ACP frames, or full private message IDs. -## User install check +Also verify every package and its canary tag before promotion: -After publishing, verify the global CLI and no-install paths: +```bash +for manifest in packages/*/package.json; do + [ "$(jq -r '.publishConfig.access // ""' "$manifest")" = "public" ] || continue + package="$(jq -r '.name' "$manifest")" + test "$(npm view "$package@0.5.0" version)" = "0.5.0" + npm view "$package" dist-tags --json +done +``` + +## Promote the same artifacts to `latest` + +Promotion changes dist-tags only; it must not rebuild or republish. After all +registry and live-platform checks pass: ```bash -npm install -g @opentag/cli -opentag --help -opentag doctor -npx @opentag/cli --help -npx @opentag/cli doctor +for manifest in packages/*/package.json; do + [ "$(jq -r '.publishConfig.access // ""' "$manifest")" = "public" ] || continue + package="$(jq -r '.name' "$manifest")" + npm dist-tag add "$package@0.5.0" latest +done ``` -The `@opentag/cli` package exposes this binary: +Rerun the package loop from the registry-verification section and confirm both +`next` and `latest` point at `0.5.0` for all 15 packages. -```json -{ - "bin": { - "opentag": "./dist/index.js" - } -} +## Create the matching source release + +Create the source tag from the exact clean commit used for `release:publish`. +Copy the `v0.5.0` section of `CHANGELOG.md` into a temporary release-notes file, +then run: + +```bash +git tag -a v0.5.0 -m "OpenTag v0.5.0" +git push origin v0.5.0 +gh release create v0.5.0 \ + --verify-tag \ + --title "OpenTag v0.5.0" \ + --notes-file /tmp/opentag-v0.5.0-release-notes.md +``` + +Verify that the GitHub Release tag resolves to the same commit that produced +the npm tarballs. The release is not complete until npm, git, and GitHub all +identify version `0.5.0`. + +## Dist-tag rollback + +Do not unpublish immutable package versions during rollback. + +- If canary validation fails before promotion, leave `latest` untouched. After + preserving diagnostics, remove the failed canary tag from every package with + `npm dist-tag rm next`, or move `next` to a corrected version. +- If `latest` promotion fails partway, first finish or retry the idempotent + promotion loop. If 0.5.0 itself must be withdrawn, restore `0.4.0` where it + exists. The Discord, Linear, and Teams adapters are first published in 0.5.0, + so remove their `latest` tags instead. Leave 0.5.0 on `next` for diagnosis: + +```bash +for manifest in packages/*/package.json; do + [ "$(jq -r '.publishConfig.access // ""' "$manifest")" = "public" ] || continue + package="$(jq -r '.name' "$manifest")" + if npm view "$package@0.4.0" version >/dev/null 2>&1; then + npm dist-tag add "$package@0.4.0" latest + else + npm dist-tag rm "$package" latest || true + fi + npm dist-tag add "$package@0.5.0" next +done ``` -That means a normal npm install creates an `opentag` command for the user. +Verify all dist-tags after rollback and publish a clear incident note. A later +fix must use a new version; never overwrite `0.5.0`. diff --git a/docs/versioning.md b/docs/versioning.md index 41594b83..ee27962f 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -4,20 +4,23 @@ OpenTag packages are versioned and published as a coordinated package family. ## Package Family -Public packages: +Public packages, shown in one valid dependency order: -- `@opentag/cli` -- `@opentag/local-runtime` - `@opentag/core` - `@opentag/client` -- `@opentag/dispatcher` +- `@opentag/discord` - `@opentag/github` - `@opentag/gitlab` - `@opentag/lark` -- `@opentag/slack` -- `@opentag/telegram` +- `@opentag/linear` - `@opentag/runner` +- `@opentag/slack` - `@opentag/store` +- `@opentag/teams` +- `@opentag/telegram` +- `@opentag/dispatcher` +- `@opentag/local-runtime` +- `@opentag/cli` Private runnable apps are not published: @@ -28,7 +31,7 @@ Private runnable apps are not published: ## Pre-1.0 Policy -The current public release is `0.4.0`. The public API is still settling, so all releases remain in the `0.x` line until the package contracts are stable enough for `1.0.0`. +The current public release is `0.5.0`. The public API is still settling, so all releases remain in the `0.x` line until the package contracts are stable enough for `1.0.0`. The first npm release was published as the coordinated `0.1.0` package family. The `0.2.0` release added the published CLI, local runtime package, and Lark and Telegram packages. @@ -36,13 +39,29 @@ The `0.3.0` release improved CLI setup flexibility, source-thread approval rende The `0.3.4` release improves service startup reliability, Lark status updates, final-card readability, and read-only executor result summaries. The `0.3.5` release adds Linux user-service support and keeps unsupported platforms on terminal startup by default. The `0.4.0` release adds GitLab source-thread ingestion, note callbacks, and merge request action application. +The `0.5.0` release adds Discord, Linear, and Microsoft Teams adapters, ACP-first agent execution, durable Attempt leases and fencing, governed material-action receipts and reconciliation, and the corresponding Client/Runner migration. For each npm release: - Set every public package to the same version. - Keep `private: true` only on runnable apps and the root workspace. +- Discover the public package family from `packages/*/package.json` entries with + `publishConfig.access=public`; do not maintain separate package arrays in + release scripts. +- Validate that every public package's `@opentag/*` runtime dependency is in + the discovered publication set, reject dependency cycles, and build and + publish packages in topological order. - Verify `pnpm lint`, `pnpm typecheck`, `pnpm test`, and `pnpm build`. -- Run `npm pack --dry-run --json` in every public package directory and inspect included files. +- Run the governance and privacy smoke suites. +- Pack every discovered public package, install the tarballs into a clean npm + project, and run the installed CLI help and doctor checks. +- Publish the coordinated version to the npm `next` dist-tag first. +- Install the exact CLI version from the public registry and complete setup, + doctor, start, and one credentialed real-platform smoke before promotion. +- Promote the same immutable package versions to `latest`; never rebuild or + republish between canary and promotion. +- Create a matching annotated git tag and GitHub Release from the exact commit + used for npm publication. For `0.x` releases: @@ -69,12 +88,30 @@ After `1.0.0`, follow SemVer: ## Release Checklist 1. Update package versions consistently across public packages. -2. Update changelog or release notes with package-specific changes. -3. Run `pnpm install` to refresh `pnpm-lock.yaml`. -4. Run `pnpm lint`. -5. Run `pnpm typecheck`. -6. Run `pnpm test`. -7. Run `pnpm build`. -8. Run `npm pack --dry-run --json` in each public package directory. -9. Publish public packages with `publishConfig.access=public`. -10. Create a matching GitHub Release, for example `v0.3.0`, pointing at the commit that produced the npm packages. +2. Run `pnpm release:publication-set` and verify that automatic discovery + returns all expected public packages, their versions match, and every + internal runtime dependency belongs to the publication set. +3. Update changelog and migration notes with package-specific changes and every + breaking public contract. +4. Run `pnpm install` to refresh `pnpm-lock.yaml`. +5. Run build, lint, typecheck, and tests sequentially. +6. Run governance and privacy smoke validation. +7. Run `release:check` to pack the complete publication set, install it in a + clean npm project, and verify the installed CLI. +8. Publish all public packages to `next` in the computed topological order. +9. Confirm the exact version and `next` dist-tag for every package from the npm + registry. +10. Install `@opentag/cli@` from the registry in a clean directory. +11. Run CLI version/help, setup, doctor, start, and at least one real-platform + ingest-to-receipt smoke from that registry installation. +12. Promote the same package versions to `latest` by changing dist-tags only. +13. Confirm `latest` and `next` for the complete package family. +14. Create and push the matching annotated git tag, for example `v0.5.0`, from + the exact publication commit. +15. Create the matching GitHub Release with the changelog notes and verify its + tag target. + +If canary validation fails, leave `latest` unchanged and remove or move the +`next` tags after preserving diagnostics. If a promoted release must be rolled +back, move `latest` for every package back to the previous coordinated version; +do not unpublish or overwrite an immutable npm version. diff --git a/package.json b/package.json index 1307a2ea..198583a1 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "test": "vitest run", "typecheck": "tsc -b", "lint": "corepack pnpm -r lint", + "release:publication-set": "node scripts/release/check-publication-set.mjs", "release:check": "node scripts/release/check-cli-package.mjs", "release:publish": "node scripts/release/publish-local.mjs", "opentag-dev": "node scripts/dev/install-opentag-dev.mjs", diff --git a/packages/cli/package.json b/packages/cli/package.json index 498fc54b..18beab05 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@opentag/cli", - "version": "0.4.0", + "version": "0.5.0", "description": "OpenTag command line interface.", "type": "module", "engines": { diff --git a/packages/cli/test/release-package-plan.test.ts b/packages/cli/test/release-package-plan.test.ts new file mode 100644 index 00000000..54c465af --- /dev/null +++ b/packages/cli/test/release-package-plan.test.ts @@ -0,0 +1,194 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { buildPublicPackagePlan } from "../../../scripts/release/package-plan.mjs"; + +type PackageManifest = { + name: string; + version?: string; + private?: boolean; + publishConfig?: { access?: string }; + dependencies?: Record; +}; + +const temporaryRoots: string[] = []; + +function createPackagesDirectory(): string { + const root = mkdtempSync(join(tmpdir(), "opentag-release-package-plan-")); + temporaryRoots.push(root); + const packagesDirectory = join(root, "packages"); + mkdirSync(packagesDirectory); + return packagesDirectory; +} + +function writePackage(packagesDirectory: string, directory: string, manifest: PackageManifest): void { + const packageDirectory = join(packagesDirectory, directory); + mkdirSync(packageDirectory, { recursive: true }); + writeFileSync( + join(packageDirectory, "package.json"), + `${JSON.stringify({ version: "0.5.0", ...manifest }, null, 2)}\n` + ); +} + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe("public release package plan", () => { + it("rejects an empty public package set", () => { + const packagesDirectory = createPackagesDirectory(); + + expect(() => buildPublicPackagePlan(packagesDirectory)).toThrow(/no public packages/i); + }); + + it("discovers only packages whose publish access is public", () => { + const packagesDirectory = createPackagesDirectory(); + writePackage(packagesDirectory, "core", { + name: "@opentag/core", + publishConfig: { access: "public" } + }); + writePackage(packagesDirectory, "internal", { + name: "@opentag/internal", + private: true + }); + writePackage(packagesDirectory, "restricted", { + name: "@opentag/restricted", + publishConfig: { access: "restricted" } + }); + + const plan = buildPublicPackagePlan(packagesDirectory); + + expect(plan.map((entry) => entry.packageJson.name)).toEqual(["@opentag/core"]); + expect(plan.map((entry) => entry.directory)).toEqual(["core"]); + }); + + it("rejects a public package whose runtime OpenTag dependency is absent from the public set", () => { + const packagesDirectory = createPackagesDirectory(); + writePackage(packagesDirectory, "cli", { + name: "@opentag/cli", + publishConfig: { access: "public" }, + dependencies: { "@opentag/internal-runtime": "workspace:*" } + }); + writePackage(packagesDirectory, "internal-runtime", { + name: "@opentag/internal-runtime", + private: true + }); + + expect(() => buildPublicPackagePlan(packagesDirectory)).toThrow( + /@opentag\/cli.*@opentag\/internal-runtime.*public/i + ); + }); + + it("orders public packages after their runtime OpenTag dependencies", () => { + const packagesDirectory = createPackagesDirectory(); + writePackage(packagesDirectory, "cli", { + name: "@opentag/cli", + publishConfig: { access: "public" }, + dependencies: { "@opentag/client": "workspace:*" } + }); + writePackage(packagesDirectory, "client", { + name: "@opentag/client", + publishConfig: { access: "public" }, + dependencies: { "@opentag/core": "workspace:*" } + }); + writePackage(packagesDirectory, "core", { + name: "@opentag/core", + publishConfig: { access: "public" } + }); + + const plan = buildPublicPackagePlan(packagesDirectory); + + expect(plan.map((entry) => entry.packageJson.name)).toEqual([ + "@opentag/core", + "@opentag/client", + "@opentag/cli" + ]); + }); + + it("rejects cycles in public runtime dependencies", () => { + const packagesDirectory = createPackagesDirectory(); + writePackage(packagesDirectory, "client", { + name: "@opentag/client", + publishConfig: { access: "public" }, + dependencies: { "@opentag/runner": "workspace:*" } + }); + writePackage(packagesDirectory, "runner", { + name: "@opentag/runner", + publishConfig: { access: "public" }, + dependencies: { "@opentag/client": "workspace:*" } + }); + + expect(() => buildPublicPackagePlan(packagesDirectory)).toThrow(/cycle/i); + }); + + it("rejects duplicate public package names", () => { + const packagesDirectory = createPackagesDirectory(); + writePackage(packagesDirectory, "core-one", { + name: "@opentag/core", + publishConfig: { access: "public" } + }); + writePackage(packagesDirectory, "core-two", { + name: "@opentag/core", + publishConfig: { access: "public" } + }); + + expect(() => buildPublicPackagePlan(packagesDirectory)).toThrow(/duplicate.*@opentag\/core/i); + }); + + it("rejects public packages with non-lockstep versions", () => { + const packagesDirectory = createPackagesDirectory(); + writePackage(packagesDirectory, "core", { + name: "@opentag/core", + version: "0.5.0", + publishConfig: { access: "public" } + }); + writePackage(packagesDirectory, "client", { + name: "@opentag/client", + version: "0.5.1", + publishConfig: { access: "public" }, + dependencies: { "@opentag/core": "workspace:*" } + }); + + expect(() => buildPublicPackagePlan(packagesDirectory)).toThrow(/lockstep.*0\.5\.0.*0\.5\.1/i); + }); + + it("reports malformed package manifests with their package directory", () => { + const packagesDirectory = createPackagesDirectory(); + const malformedDirectory = join(packagesDirectory, "malformed"); + mkdirSync(malformedDirectory, { recursive: true }); + writeFileSync(join(malformedDirectory, "package.json"), "{ not-json\n"); + + expect(() => buildPublicPackagePlan(packagesDirectory)).toThrow(/malformed.*package\.json/i); + }); + + it("discovers the repository's complete 15-package public release set", () => { + const repositoryPackagesDirectory = resolve( + dirname(fileURLToPath(import.meta.url)), + "../../../packages" + ); + + const plan = buildPublicPackagePlan(repositoryPackagesDirectory); + + expect(plan.map((entry) => entry.packageJson.name).sort()).toEqual([ + "@opentag/cli", + "@opentag/client", + "@opentag/core", + "@opentag/discord", + "@opentag/dispatcher", + "@opentag/github", + "@opentag/gitlab", + "@opentag/lark", + "@opentag/linear", + "@opentag/local-runtime", + "@opentag/runner", + "@opentag/slack", + "@opentag/store", + "@opentag/teams", + "@opentag/telegram" + ]); + }); +}); diff --git a/packages/client/package.json b/packages/client/package.json index dc6672a3..024fc844 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@opentag/client", - "version": "0.4.0", + "version": "0.5.0", "description": "HTTP client SDK for creating, claiming, and updating OpenTag dispatcher runs.", "type": "module", "engines": { diff --git a/packages/core/package.json b/packages/core/package.json index cfc5fd25..283659ae 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@opentag/core", - "version": "0.4.0", + "version": "0.5.0", "description": "Core OpenTag protocol schemas, types, JSON Schema, and mention parsing.", "type": "module", "engines": { diff --git a/packages/discord/package.json b/packages/discord/package.json index d8a8dc98..5e0ded48 100644 --- a/packages/discord/package.json +++ b/packages/discord/package.json @@ -1,6 +1,6 @@ { "name": "@opentag/discord", - "version": "0.4.0", + "version": "0.5.0", "description": "Discord interactions normalization and callback rendering for OpenTag.", "type": "module", "engines": { diff --git a/packages/dispatcher/package.json b/packages/dispatcher/package.json index 90d2da86..c85feed0 100644 --- a/packages/dispatcher/package.json +++ b/packages/dispatcher/package.json @@ -1,6 +1,6 @@ { "name": "@opentag/dispatcher", - "version": "0.4.0", + "version": "0.5.0", "description": "Embeddable OpenTag dispatcher Hono app and callback sinks.", "type": "module", "engines": { diff --git a/packages/github/package.json b/packages/github/package.json index 261731ac..31ca23c1 100644 --- a/packages/github/package.json +++ b/packages/github/package.json @@ -1,6 +1,6 @@ { "name": "@opentag/github", - "version": "0.4.0", + "version": "0.5.0", "description": "GitHub event normalization and callback rendering for OpenTag.", "type": "module", "engines": { diff --git a/packages/gitlab/package.json b/packages/gitlab/package.json index 4d00daa6..7801d009 100644 --- a/packages/gitlab/package.json +++ b/packages/gitlab/package.json @@ -1,6 +1,6 @@ { "name": "@opentag/gitlab", - "version": "0.4.0", + "version": "0.5.0", "description": "GitLab webhook normalization and callback rendering for OpenTag.", "type": "module", "engines": { diff --git a/packages/lark/package.json b/packages/lark/package.json index 2e63f765..c0c96061 100644 --- a/packages/lark/package.json +++ b/packages/lark/package.json @@ -1,6 +1,6 @@ { "name": "@opentag/lark", - "version": "0.4.0", + "version": "0.5.0", "description": "Lark/Feishu message normalization and callback helpers for OpenTag.", "type": "module", "engines": { diff --git a/packages/linear/package.json b/packages/linear/package.json index eac6ad8a..02161dce 100644 --- a/packages/linear/package.json +++ b/packages/linear/package.json @@ -1,6 +1,6 @@ { "name": "@opentag/linear", - "version": "0.4.0", + "version": "0.5.0", "description": "Linear webhook normalization, callback rendering, and issue mutation helpers for OpenTag.", "type": "module", "engines": { diff --git a/packages/local-runtime/package.json b/packages/local-runtime/package.json index f430b4d7..c1c9e045 100644 --- a/packages/local-runtime/package.json +++ b/packages/local-runtime/package.json @@ -1,6 +1,6 @@ { "name": "@opentag/local-runtime", - "version": "0.4.0", + "version": "0.5.0", "description": "Local OpenTag dispatcher, daemon, and diagnostics runtime helpers.", "type": "module", "engines": { diff --git a/packages/runner/package.json b/packages/runner/package.json index 35a76d63..09842498 100644 --- a/packages/runner/package.json +++ b/packages/runner/package.json @@ -1,6 +1,6 @@ { "name": "@opentag/runner", - "version": "0.4.0", + "version": "0.5.0", "description": "Executor contracts and built-in runner adapters for OpenTag.", "type": "module", "engines": { diff --git a/packages/slack/package.json b/packages/slack/package.json index 58dfd40a..72548af5 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opentag/slack", - "version": "0.4.0", + "version": "0.5.0", "description": "Slack app mention normalization and callback helpers for OpenTag.", "type": "module", "engines": { diff --git a/packages/store/package.json b/packages/store/package.json index bca5a5bf..d0dd44a3 100644 --- a/packages/store/package.json +++ b/packages/store/package.json @@ -1,6 +1,6 @@ { "name": "@opentag/store", - "version": "0.4.0", + "version": "0.5.0", "description": "SQLite and Drizzle persistence primitives for OpenTag runs and leases.", "type": "module", "engines": { diff --git a/packages/teams/package.json b/packages/teams/package.json index 64fc8a97..73e836ca 100644 --- a/packages/teams/package.json +++ b/packages/teams/package.json @@ -1,6 +1,6 @@ { "name": "@opentag/teams", - "version": "0.4.0", + "version": "0.5.0", "description": "Microsoft Teams activity normalization and callback rendering for OpenTag.", "type": "module", "engines": { "node": ">=20" }, diff --git a/packages/telegram/package.json b/packages/telegram/package.json index 09465837..0e0432df 100644 --- a/packages/telegram/package.json +++ b/packages/telegram/package.json @@ -1,6 +1,6 @@ { "name": "@opentag/telegram", - "version": "0.4.0", + "version": "0.5.0", "description": "Telegram message normalization and callback helpers for OpenTag.", "type": "module", "engines": { diff --git a/scripts/release/check-cli-package.mjs b/scripts/release/check-cli-package.mjs index 56320300..fd431eae 100644 --- a/scripts/release/check-cli-package.mjs +++ b/scripts/release/check-cli-package.mjs @@ -4,23 +4,10 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { spawnSync } from "node:child_process"; +import { buildPublicPackagePlan } from "./package-plan.mjs"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); -const packageDirs = [ - "core", - "client", - "telegram", - "discord", - "runner", - "store", - "github", - "gitlab", - "lark", - "slack", - "dispatcher", - "local-runtime", - "cli" -]; +const packagePlan = buildPublicPackagePlan(path.join(repoRoot, "packages")); function run(command, args, options = {}) { const result = spawnSync(command, args, { @@ -31,14 +18,15 @@ function run(command, args, options = {}) { ...process.env, npm_config_audit: "false", npm_config_fund: "false" - } + }, + encoding: options.stdio === "pipe" ? "utf8" : undefined }); if (result.error) { throw result.error; } - if (result.status !== 0) { - process.exit(result.status ?? 1); + if (result.status !== 0 && !options.allowFailure) { + throw new Error(`${command} failed with exit code ${result.status ?? 1}.`); } return result; } @@ -57,6 +45,56 @@ function commandPath(cwd, command) { return path.join(cwd, "node_modules", ".bin", process.platform === "win32" ? `${command}.cmd` : command); } +function checkInstalledDoctorCommand(installDir) { + const opentagCommand = commandPath(installDir, "opentag"); + run(opentagCommand, ["doctor", "--help"], { cwd: installDir }); + + const stateDirectory = path.join(installDir, "doctor-state"); + const configPath = path.join(installDir, "doctor-config.json"); + mkdirSync(stateDirectory, { recursive: true }); + writeFileSync( + configPath, + `${JSON.stringify( + { + schemaVersion: 1, + state: { + directory: stateDirectory, + databasePath: path.join(stateDirectory, "opentag.db"), + worktreeRoot: path.join(stateDirectory, "worktrees") + }, + runtime: { mode: "local" }, + daemon: { + runnerId: "runner_release_check", + dispatcherUrl: "http://127.0.0.1:9", + repositories: [], + pairingToken: "release_check_pairing_token", + pollIntervalMs: 5_000, + heartbeatIntervalMs: 15_000 + }, + platforms: {} + }, + null, + 2 + )}\n`, + { mode: 0o600 } + ); + + const doctor = run(opentagCommand, ["doctor", "--config", configPath], { + cwd: installDir, + stdio: "pipe", + allowFailure: true + }); + const doctorOutput = `${doctor.stdout ?? ""}\n${doctor.stderr ?? ""}`; + if (doctor.status !== 1) { + throw new Error(`Expected the intentionally incomplete doctor config to exit 1, received ${doctor.status ?? "no status"}.`); + } + for (const expected of ["OpenTag doctor", "FAIL repository config: No repositories are configured."]) { + if (!doctorOutput.includes(expected)) { + throw new Error(`Installed opentag doctor output did not contain ${JSON.stringify(expected)}.`); + } + } +} + const tempRoot = mkdtempSync(path.join(tmpdir(), "opentag-release-check-")); const packDir = path.join(tempRoot, "packs"); const installDir = path.join(tempRoot, "install"); @@ -67,7 +105,7 @@ try { console.log("Packing publishable packages..."); mkdirSync(packDir, { recursive: true }); - const tarballs = packageDirs.map((packageDir) => packPackage(packageDir, packDir)); + const tarballs = packagePlan.map((entry) => packPackage(entry.directory, packDir)); console.log("Installing packed packages into a clean npm project..."); mkdirSync(installDir, { recursive: true }); @@ -77,6 +115,7 @@ try { console.log("Checking the installed opentag command..."); run(commandPath(installDir, "opentag"), ["--help"], { cwd: installDir }); run("npx", ["--no-install", "opentag", "--help"], { cwd: installDir }); + checkInstalledDoctorCommand(installDir); console.log(""); console.log("OpenTag CLI package check passed."); diff --git a/scripts/release/check-publication-set.mjs b/scripts/release/check-publication-set.mjs new file mode 100644 index 00000000..c8794423 --- /dev/null +++ b/scripts/release/check-publication-set.mjs @@ -0,0 +1,17 @@ +#!/usr/bin/env node +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { buildPublicPackagePlan } from "./package-plan.mjs"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + +try { + const plan = buildPublicPackagePlan(path.join(repoRoot, "packages")); + console.log(`Publication set is consistent (${plan.length} public packages).`); + for (const entry of plan) { + console.log(`- ${entry.packageJson.name}@${entry.packageJson.version} (packages/${entry.directory})`); + } +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +} diff --git a/scripts/release/package-plan.mjs b/scripts/release/package-plan.mjs new file mode 100644 index 00000000..877c75e7 --- /dev/null +++ b/scripts/release/package-plan.mjs @@ -0,0 +1,164 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; + +const runtimeDependencyFields = ["dependencies", "optionalDependencies", "peerDependencies"]; + +function compareStrings(left, right) { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + +function readPackageManifest(packagePath) { + let packageJson; + try { + packageJson = JSON.parse(readFileSync(packagePath, "utf8")); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`Malformed package manifest at ${packagePath}: ${detail}`); + } + + if (!packageJson || typeof packageJson !== "object" || Array.isArray(packageJson)) { + throw new Error(`Malformed package manifest at ${packagePath}: expected a JSON object.`); + } + if ( + packageJson.publishConfig !== undefined && + (!packageJson.publishConfig || typeof packageJson.publishConfig !== "object" || Array.isArray(packageJson.publishConfig)) + ) { + throw new Error(`Malformed package manifest at ${packagePath}: publishConfig must be an object.`); + } + for (const field of runtimeDependencyFields) { + const dependencies = packageJson[field]; + if (dependencies !== undefined && (!dependencies || typeof dependencies !== "object" || Array.isArray(dependencies))) { + throw new Error(`Malformed package manifest at ${packagePath}: ${field} must be an object.`); + } + } + return packageJson; +} + +function runtimeOpenTagDependencies(packageJson) { + const dependencies = new Set(); + for (const field of runtimeDependencyFields) { + for (const dependencyName of Object.keys(packageJson[field] ?? {})) { + if (dependencyName.startsWith("@opentag/")) { + dependencies.add(dependencyName); + } + } + } + return [...dependencies].sort(); +} + +function insertSorted(values, value) { + const index = values.findIndex((candidate) => candidate > value); + if (index === -1) { + values.push(value); + } else { + values.splice(index, 0, value); + } +} + +export function buildPublicPackagePlan(packagesDirectory) { + const packageEntries = readdirSync(packagesDirectory, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => { + const packagePath = path.join(packagesDirectory, entry.name, "package.json"); + if (!existsSync(packagePath)) { + return undefined; + } + return { + directory: entry.name, + packageJson: readPackageManifest(packagePath) + }; + }) + .filter((entry) => entry !== undefined) + .sort((left, right) => compareStrings(left.directory, right.directory)); + + const directoryByName = new Map(); + for (const entry of packageEntries) { + const packageName = entry.packageJson.name; + if (typeof packageName !== "string" || packageName.trim() === "") { + if (entry.packageJson.publishConfig?.access === "public") { + throw new Error(`Malformed package manifest at packages/${entry.directory}/package.json: public packages require a name.`); + } + continue; + } + const existingDirectory = directoryByName.get(packageName); + if (existingDirectory) { + throw new Error( + `Duplicate package name ${packageName} in packages/${existingDirectory}/package.json and packages/${entry.directory}/package.json.` + ); + } + directoryByName.set(packageName, entry.directory); + } + + const publicEntries = packageEntries.filter((entry) => entry.packageJson.publishConfig?.access === "public"); + if (publicEntries.length === 0) { + throw new Error(`No public packages found in ${packagesDirectory}.`); + } + const publicEntryByName = new Map(); + const packageNamesByVersion = new Map(); + for (const entry of publicEntries) { + const packageName = entry.packageJson.name; + if (typeof entry.packageJson.version !== "string" || entry.packageJson.version.trim() === "") { + throw new Error(`Malformed package manifest at packages/${entry.directory}/package.json: public packages require a version.`); + } + publicEntryByName.set(packageName, entry); + const packageNames = packageNamesByVersion.get(entry.packageJson.version) ?? []; + packageNames.push(packageName); + packageNamesByVersion.set(entry.packageJson.version, packageNames); + } + if (packageNamesByVersion.size > 1) { + const versions = [...packageNamesByVersion.entries()] + .sort(([left], [right]) => compareStrings(left, right)) + .map(([version, packageNames]) => `${version} (${packageNames.sort().join(", ")})`); + throw new Error(`Public packages must use a lockstep version; found ${versions.join("; ")}.`); + } + + const dependenciesByName = new Map(); + const dependentsByName = new Map(publicEntries.map((entry) => [entry.packageJson.name, []])); + for (const entry of publicEntries) { + const packageName = entry.packageJson.name; + const dependencies = runtimeOpenTagDependencies(entry.packageJson); + for (const dependencyName of dependencies) { + if (!publicEntryByName.has(dependencyName)) { + throw new Error( + `Public package ${packageName} has runtime dependency ${dependencyName}, which is not in the public package set.` + ); + } + dependentsByName.get(dependencyName).push(packageName); + } + dependenciesByName.set(packageName, dependencies); + } + + for (const dependents of dependentsByName.values()) { + dependents.sort(); + } + + const ready = [...publicEntryByName.keys()] + .filter((packageName) => dependenciesByName.get(packageName).length === 0) + .sort(); + const orderedNames = []; + + while (ready.length > 0) { + const packageName = ready.shift(); + orderedNames.push(packageName); + for (const dependentName of dependentsByName.get(packageName)) { + const remainingDependencies = dependenciesByName + .get(dependentName) + .filter((dependencyName) => dependencyName !== packageName); + dependenciesByName.set(dependentName, remainingDependencies); + if (remainingDependencies.length === 0) { + insertSorted(ready, dependentName); + } + } + } + + if (orderedNames.length !== publicEntries.length) { + const cycleMembers = [...publicEntryByName.keys()] + .filter((packageName) => dependenciesByName.get(packageName).length > 0) + .sort(); + throw new Error(`Cycle detected in public package runtime dependencies: ${cycleMembers.join(", ")}.`); + } + + return orderedNames.map((packageName) => publicEntryByName.get(packageName)); +} diff --git a/scripts/release/publish-local.mjs b/scripts/release/publish-local.mjs index 75ca20f3..ba10f4b1 100644 --- a/scripts/release/publish-local.mjs +++ b/scripts/release/publish-local.mjs @@ -1,32 +1,18 @@ #!/usr/bin/env node -import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { spawnSync } from "node:child_process"; +import { buildPublicPackagePlan } from "./package-plan.mjs"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); -const packageDirs = [ - "core", - "client", - "telegram", - "discord", - "runner", - "store", - "github", - "gitlab", - "lark", - "slack", - "dispatcher", - "local-runtime", - "cli" -]; +const packagePlan = buildPublicPackagePlan(path.join(repoRoot, "packages")); function parseArgs(argv) { const options = { dryRun: false, skipCheck: false, otp: undefined, - tag: "latest" + tag: "next" }; for (let index = 0; index < argv.length; index += 1) { @@ -87,7 +73,7 @@ Options: --dry-run Run pnpm publish without publishing to npm. --skip-check Skip corepack pnpm release:check. --otp Pass an npm two-factor one-time password. - --tag Publish dist-tag. Defaults to latest. + --tag Publish dist-tag. Defaults to next. -h, --help Show this help. `); } @@ -125,22 +111,38 @@ function runOutput(command, args, options = {}) { }; } -function readPackage(packageDir) { - const packagePath = path.join(repoRoot, "packages", packageDir, "package.json"); - return JSON.parse(readFileSync(packagePath, "utf8")); +function registryVersion(packageSpec) { + const result = runOutput("npm", ["view", packageSpec, "version"]); + if (result.ok) { + return result.stdout || undefined; + } + if (/\bE404\b/.test(result.stderr)) { + return undefined; + } + + throw new Error( + `npm registry lookup failed for ${packageSpec}:\n${result.stderr || `npm exited with status ${result.status ?? "unknown"}`}` + ); } function publishedVersionExists(packageName, version) { - const result = runOutput("npm", ["view", `${packageName}@${version}`, "version"]); - return result.ok && result.stdout === version; + return registryVersion(`${packageName}@${version}`) === version; +} + +function publishedVersionForTag(packageName, tag) { + return registryVersion(`${packageName}@${tag}`); } -function printGitContext() { +function printGitContext({ dryRun }) { const branch = runOutput("git", ["branch", "--show-current"]); const status = runOutput("git", ["status", "--short"]); if (branch.ok && branch.stdout) { console.log(`Git branch: ${branch.stdout}`); } + if (!dryRun && branch.stdout !== "main") { + console.error(`Release refused: npm publication must run from main, not ${branch.stdout || "a detached or unknown ref"}.`); + process.exit(1); + } if (status.ok && status.stdout) { console.error("Release refused: the git working tree has local changes."); console.error("Commit or stash the changes, then rerun release:publish from the intended commit."); @@ -162,7 +164,7 @@ function checkNpmAccess() { const options = parseArgs(process.argv.slice(2)); console.log("OpenTag local npm publish"); -printGitContext(); +printGitContext(options); checkNpmAccess(); if (!options.skipCheck) { @@ -174,20 +176,27 @@ if (!options.skipCheck) { console.log(""); console.log(options.dryRun ? "Dry-run publishing packages..." : "Publishing packages..."); -for (const packageDir of packageDirs) { - const packageJson = readPackage(packageDir); +for (const { directory: packageDir, packageJson } of packagePlan) { const packageName = packageJson.name; const version = packageJson.version; if (publishedVersionExists(packageName, version)) { - console.log(`Skipping ${packageName}@${version}; it is already published.`); + const taggedVersion = publishedVersionForTag(packageName, options.tag); + if (taggedVersion !== version) { + console.error( + `Release refused: ${packageName}@${version} already exists, but dist-tag ${options.tag} points to ${taggedVersion ?? "nothing"}.` + ); + console.error(`Repair it explicitly with: npm dist-tag add ${packageName}@${version} ${options.tag}`); + process.exit(1); + } + console.log(`Skipping ${packageName}@${version}; it is already published with dist-tag ${options.tag}.`); continue; } console.log(`${options.dryRun ? "Dry-run publishing" : "Publishing"} ${packageName}@${version}...`); const args = ["pnpm", "publish", "--access", "public", "--tag", options.tag]; if (options.dryRun) { - args.push("--dry-run"); + args.push("--dry-run", "--no-git-checks"); } if (options.otp) { args.push("--otp", options.otp);