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);