From f5ba2f9a3a72a41a4d1fc176b29ba4fbc9d87368 Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Sat, 3 Oct 2026 14:28:23 -0700 Subject: [PATCH 1/6] feat(release): build, check, and publish every package to npm Follow fold's release setup. A `v*` tag builds every package in packages/, stages it in .release/ with real versions, checks it, and publishes it to npm through trusted publishing. `-rc.N` versions publish under the `rc` tag. - scripts/build/packages.ts bundles each package's JavaScript and emits its declarations, then adds `.js` to relative imports in them so NodeNext projects can read them. - scripts/release/prepare.ts writes publishable manifests: catalog and workspace ranges resolved, our packages pinned to the release, Effect and Alchemy as `^` peer ranges, exports pointing at dist/. - scripts/release/validate.ts checks each manifest, then installs the packed tarballs into a fresh project with npm, typechecks a sample app against them with NodeNext and skipLibCheck off, and imports every package under plain Node.js with one copy of Effect, no TypeScript, and no network. - scripts/release/publish.ts publishes in dependency order and skips versions already on npm, so a failed release can be resumed. - CI runs check, tests, both backend suites, and the release check with a dry-run publish on every pull request. Validate Publish runs that by hand. Effect and Alchemy are now peer dependencies; emulate moves to dev dependencies, since only tests use it. ChannelsCloudflare imports alchemy/Cloudflare so its declarations name DurableObjectState through a public path, not alchemy/Cloudflare/index. Add READMEs for the packages that had none. Replace the unused tools/packaging harness. Co-Authored-By: Claude Opus 5.5 HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a102d3-2fe4-7531-aff7-d6c62a5dc2e8 --- .github/workflows/ci.yml | 79 +++++++ .github/workflows/publish-validate.yml | 32 +++ .github/workflows/release.yml | 57 +++++ .gitignore | 1 + README.md | 17 ++ bun.lock | 42 +++- package.json | 15 +- packages/alchemy-cloudflare/README.md | 16 ++ packages/alchemy-cloudflare/package.json | 10 +- .../src/ChannelsCloudflare.ts | 1 + packages/delivery/README.md | 11 + packages/delivery/package.json | 9 +- packages/github/README.md | 2 +- packages/github/package.json | 7 +- packages/linear/README.md | 20 +- packages/linear/package.json | 5 +- packages/redis/README.md | 18 ++ packages/redis/package.json | 7 +- packages/slack/package.json | 7 +- packages/sql/README.md | 18 ++ packages/sql/package.json | 7 +- scripts/build/packages.ts | 89 ++++++++ scripts/release/fixtures/consumer.ts | 74 +++++++ .../release/fixtures}/guard.mjs | 19 +- scripts/release/fixtures/runtime.mjs | 22 ++ scripts/release/manifest.ts | 15 ++ scripts/release/prepare.ts | 95 ++++++++ scripts/release/publish.ts | 46 ++++ scripts/release/tag-info.ts | 19 ++ scripts/release/validate.ts | 166 ++++++++++++++ scripts/tsconfig.json | 8 + tools/packaging/backends.mjs | 29 --- tools/packaging/backends.ts.fixture | 8 - tools/packaging/browser-delivery.ts.fixture | 2 - .../browser-github-outbound.ts.fixture | 1 - tools/packaging/browser-github.ts.fixture | 2 - tools/packaging/browser-outbound.ts.fixture | 1 - tools/packaging/browser-slack.ts.fixture | 2 - tools/packaging/consumer.ts.fixture | 203 ------------------ tools/packaging/organizations.ts.fixture | 70 ------ tools/packaging/remote.mjs | 20 -- tools/packaging/runtime.mjs | 64 ------ tools/packaging/tsconfig.backends.json | 4 - tools/packaging/tsconfig.consumer.json | 15 -- tools/packaging/tsconfig.tool.json | 7 - typed-lint.config.ts | 2 +- vite.config.ts | 1 + 47 files changed, 881 insertions(+), 484 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/publish-validate.yml create mode 100644 .github/workflows/release.yml create mode 100644 packages/alchemy-cloudflare/README.md create mode 100644 packages/delivery/README.md create mode 100644 packages/redis/README.md create mode 100644 packages/sql/README.md create mode 100644 scripts/build/packages.ts create mode 100644 scripts/release/fixtures/consumer.ts rename {tools/packaging => scripts/release/fixtures}/guard.mjs (51%) create mode 100644 scripts/release/fixtures/runtime.mjs create mode 100644 scripts/release/manifest.ts create mode 100644 scripts/release/prepare.ts create mode 100644 scripts/release/publish.ts create mode 100644 scripts/release/tag-info.ts create mode 100644 scripts/release/validate.ts create mode 100644 scripts/tsconfig.json delete mode 100644 tools/packaging/backends.mjs delete mode 100644 tools/packaging/backends.ts.fixture delete mode 100644 tools/packaging/browser-delivery.ts.fixture delete mode 100644 tools/packaging/browser-github-outbound.ts.fixture delete mode 100644 tools/packaging/browser-github.ts.fixture delete mode 100644 tools/packaging/browser-outbound.ts.fixture delete mode 100644 tools/packaging/browser-slack.ts.fixture delete mode 100644 tools/packaging/consumer.ts.fixture delete mode 100644 tools/packaging/organizations.ts.fixture delete mode 100644 tools/packaging/remote.mjs delete mode 100644 tools/packaging/runtime.mjs delete mode 100644 tools/packaging/tsconfig.backends.json delete mode 100644 tools/packaging/tsconfig.consumer.json delete mode 100644 tools/packaging/tsconfig.tool.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2e334d8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,79 @@ +name: CI + +on: + workflow_dispatch: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + BUN_VERSION: 1.3.14 + NODE_VERSION: 24 + RELEASE_VERSION: 0.0.0-ci.0 + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ env.BUN_VERSION }} + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + - run: bun install --frozen-lockfile + - run: bun run check + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ env.BUN_VERSION }} + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + - run: bun install --frozen-lockfile + - run: bun run test + + backends: + runs-on: ubuntu-latest + strategy: + matrix: + backend: [postgres, redis] + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ env.BUN_VERSION }} + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + - run: bun install --frozen-lockfile + - run: bun run test:backend:${{ matrix.backend }} + + release-validation: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ env.BUN_VERSION }} + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + registry-url: https://registry.npmjs.org + - run: npm install --global npm@latest + - run: bun install --frozen-lockfile + - run: bun run build + - run: bun run release:prepare --version "$RELEASE_VERSION" + - run: bun run release:validate --version "$RELEASE_VERSION" + - run: bun run release:publish --version "$RELEASE_VERSION" --tag ci --dry-run diff --git a/.github/workflows/publish-validate.yml b/.github/workflows/publish-validate.yml new file mode 100644 index 0000000..3f00842 --- /dev/null +++ b/.github/workflows/publish-validate.yml @@ -0,0 +1,32 @@ +name: Validate Publish + +on: + workflow_dispatch: + inputs: + version: + description: Release version to stage + required: true + type: string + default: 0.1.0-rc.1 + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + - uses: actions/setup-node@v4 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + - run: npm install --global npm@latest + - run: bun install --frozen-lockfile + - run: bun run build + - run: bun run release:prepare --version "${{ inputs.version }}" + - run: bun run release:validate --version "${{ inputs.version }}" + - run: bun run release:publish --version "${{ inputs.version }}" --tag rc --dry-run diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4692d45 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,57 @@ +name: Release + +on: + push: + tags: ['v*'] + workflow_dispatch: + inputs: + version: + description: Existing release version to publish or resume + required: true + type: string + tag: + description: npm dist-tag + required: true + default: rc + type: string + +permissions: + contents: write + id-token: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + - uses: actions/setup-node@v4 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + - run: npm install --global npm@latest + - name: Derive release version + id: release + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + bun run release:tag-info --version "$MANUAL_VERSION" --tag "$MANUAL_TAG" + else + bun run release:tag-info + fi + env: + MANUAL_VERSION: ${{ inputs.version }} + MANUAL_TAG: ${{ inputs.tag }} + - run: bun install --frozen-lockfile + - run: bun run check + - run: bun run test + - run: bun run build + - run: bun run release:prepare --version "${{ steps.release.outputs.version }}" + - run: bun run release:validate --version "${{ steps.release.outputs.version }}" + - run: bun run release:publish --version "${{ steps.release.outputs.version }}" --tag "${{ steps.release.outputs.tag }}" + - name: Create GitHub release + if: github.event_name == 'push' + run: gh release create "$GITHUB_REF_NAME" --generate-notes ${{ contains(steps.release.outputs.version, '-') && '--prerelease' || '' }} + env: + GH_TOKEN: ${{ github.token }} diff --git a/.gitignore b/.gitignore index 59e2d53..1f932e3 100644 --- a/.gitignore +++ b/.gitignore @@ -81,6 +81,7 @@ out # Nuxt.js build / generate output .nuxt dist +.release/ .output # Gatsby files diff --git a/README.md b/README.md index b5ab0cd..977a685 100644 --- a/README.md +++ b/README.md @@ -564,3 +564,20 @@ bun run format `bun run test` needs no database, no credentials, and no network. Every store must pass one shared suite, [`packages/delivery/test/backend-contract.ts`](./packages/delivery/test/backend-contract.ts). The Postgres and Redis runs live in `packages/sql/test-backends` and `packages/redis/test-backends`, and are left out of `bun run test` because they need a real database. + +## Releasing + +Every package in `packages/` is published to npm together, at the same version. + +- **Check a release without publishing.** Run the _Validate Publish_ workflow in GitHub Actions. It builds every package, installs the packed files into a fresh project, typechecks a sample app against them, loads them in Node.js, and runs `npm publish --dry-run`. Pull requests run the same check. +- **Publish.** Push a tag: `v0.2.0` publishes as `latest`; `v0.2.0-rc.1` publishes under the `rc` tag, which people only get by asking for it. GitHub signs in to npm through trusted publishing, so no npm token is stored anywhere. +- **Resume a failed release.** Run the _Release_ workflow by hand with the same version. Packages already on npm are skipped. + +To run the same steps locally: + +```sh +bun run build +bun run release:prepare --version 0.2.0-rc.1 +bun run release:validate --version 0.2.0-rc.1 +bun run release:publish --version 0.2.0-rc.1 --tag rc --dry-run +``` diff --git a/bun.lock b/bun.lock index 989831f..de83528 100644 --- a/bun.lock +++ b/bun.lock @@ -49,8 +49,6 @@ "version": "0.0.0", "dependencies": { "@humanlayer/channels-delivery": "workspace:*", - "alchemy": "catalog:", - "effect": "catalog:", }, "devDependencies": { "@effect/platform-node": "catalog:", @@ -58,36 +56,46 @@ "@humanlayer/channels-github": "workspace:*", "@humanlayer/channels-linear": "workspace:*", "@humanlayer/channels-slack": "workspace:*", + "alchemy": "catalog:", + "effect": "catalog:", "typescript": "catalog:", }, + "peerDependencies": { + "alchemy": "catalog:", + "effect": "catalog:", + }, }, "packages/delivery": { "name": "@humanlayer/channels-delivery", "version": "0.0.0", - "dependencies": { - "effect": "catalog:", - "emulate": "^0.11.2", - }, "devDependencies": { "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", + "effect": "catalog:", + "emulate": "^0.11.2", "typescript": "catalog:", }, + "peerDependencies": { + "effect": "catalog:", + }, }, "packages/github": { "name": "@humanlayer/channels-github", "version": "0.0.0", "dependencies": { "@humanlayer/channels-delivery": "workspace:*", - "effect": "catalog:", }, "devDependencies": { "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", "@emulators/core": "0.11.2", "@emulators/github": "0.11.2", + "effect": "catalog:", "typescript": "catalog:", }, + "peerDependencies": { + "effect": "catalog:", + }, }, "packages/linear": { "name": "@humanlayer/channels-linear", @@ -95,7 +103,6 @@ "dependencies": { "@humanlayer/channels-delivery": "workspace:*", "@types/mdast": "^4.0.4", - "effect": "catalog:", "mdast-util-from-markdown": "^2.0.3", "mdast-util-gfm": "^3.1.0", "micromark-extension-gfm": "^3.0.0", @@ -103,51 +110,64 @@ "devDependencies": { "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", + "effect": "catalog:", "typescript": "catalog:", }, + "peerDependencies": { + "effect": "catalog:", + }, }, "packages/redis": { "name": "@humanlayer/channels-redis", "version": "0.0.0", "dependencies": { "@humanlayer/channels-delivery": "workspace:*", - "effect": "catalog:", }, "devDependencies": { "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", + "effect": "catalog:", "redis": "^5.11.0", "typescript": "catalog:", "vitest": "catalog:", }, + "peerDependencies": { + "effect": "catalog:", + }, }, "packages/slack": { "name": "@humanlayer/channels-slack", "version": "0.0.0", "dependencies": { "@humanlayer/channels-delivery": "workspace:*", - "effect": "catalog:", }, "devDependencies": { "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", "@emulators/core": "0.11.2", "@emulators/slack": "0.11.2", + "effect": "catalog:", "typescript": "catalog:", }, + "peerDependencies": { + "effect": "catalog:", + }, }, "packages/sql": { "name": "@humanlayer/channels-sql", "version": "0.0.0", "dependencies": { "@humanlayer/channels-delivery": "workspace:*", - "effect": "catalog:", }, "devDependencies": { "@effect/sql-pg": "catalog:", "@effect/vitest": "catalog:", + "effect": "catalog:", "typescript": "catalog:", }, + "peerDependencies": { + "effect": "catalog:", + }, }, }, "overrides": { diff --git a/package.json b/package.json index e518519..698ae2a 100644 --- a/package.json +++ b/package.json @@ -7,21 +7,26 @@ ], "type": "module", "scripts": { - "build": "vp run -r build", + "build": "bun run scripts/build/packages.ts", "dev": "vp run -r --parallel dev", "test": "vp test", "test:live:slack": "bun run --cwd examples/slack-thread-echo live", - "check": "bun run lint && bun run typecheck && bun run check:typed", + "check": "bun run lint && bun run typecheck && bun run typecheck:scripts && bun run check:typed", "lint": "vp lint", "lint:fix": "vp lint --fix", "format": "vp fmt --check", "format:write": "vp fmt --write", "typecheck": "vp run -r typecheck", + "typecheck:scripts": "tsc --noEmit -p scripts/tsconfig.json", "prepare": "effect-tsgo patch --oxlint", "test:backend:postgres": "PHASE2_BACKEND=postgres bun run tools/test-backends.ts", "test:backend:redis": "PHASE2_BACKEND=redis bun run tools/test-backends.ts", "check:typed": "node --import tsx tools/typed/src/cli.ts check --config typed-lint.config.ts", - "check:diff": "node --import tsx tools/diff-check/src/main.ts --config tools/diff-check/diff-check.config.json" + "check:diff": "node --import tsx tools/diff-check/src/main.ts --config tools/diff-check/diff-check.config.json", + "release:tag-info": "bun run scripts/release/tag-info.ts", + "release:prepare": "bun run scripts/release/prepare.ts", + "release:validate": "bun run scripts/release/validate.ts", + "release:publish": "bun run scripts/release/publish.ts" }, "devDependencies": { "@effect/language-service": "0.87.3", @@ -32,9 +37,9 @@ "@types/bun": "catalog:", "effect": "catalog:", "emulate": "catalog:", - "oxlint": "1.86.0", "eslint-plugin-better-tailwindcss": "4.7.0", "fast-glob": "3.3.3", + "oxlint": "1.86.0", "oxlint-tsgolint": "7.0.2003", "tailwindcss": "4.3.3", "tsx": "4.23.12", @@ -75,4 +80,4 @@ "typescript": "7.0.2", "@types/bun": "^1.3.10" } -} \ No newline at end of file +} diff --git a/packages/alchemy-cloudflare/README.md b/packages/alchemy-cloudflare/README.md new file mode 100644 index 0000000..0e5b9cf --- /dev/null +++ b/packages/alchemy-cloudflare/README.md @@ -0,0 +1,16 @@ +# Alchemy Cloudflare + +`@humanlayer/channels-alchemy-cloudflare` runs [Channels](https://github.com/humanlayer/effect-channels#readme) on Cloudflare with [Alchemy](https://alchemy.run): a Worker takes webhooks and serves the delivery API, and one Durable Object per mailbox stores its events and runs your callbacks from its alarm. + +```sh +npm install @humanlayer/channels-alchemy-cloudflare @humanlayer/channels-delivery effect alchemy +``` + +```ts +export const bot = ChannelsCloudflare.make({ namespace: 'my-app', providers: [slack], eventProcessing }) + +export class DeliveryMailbox extends Cloudflare.DurableObject()('DeliveryMailbox') {} +export const DeliveryMailboxLive = DeliveryMailbox.make(bot.mailbox({ rearmAfterMs: 1_000 })) +``` + +The Durable Object's alarm belongs to the mailbox; don't use it for anything else. See [`examples/alchemy-cloudflare`](https://github.com/humanlayer/effect-channels/tree/main/examples/alchemy-cloudflare) for a complete Slack, GitHub, and Linear app. diff --git a/packages/alchemy-cloudflare/package.json b/packages/alchemy-cloudflare/package.json index 0e84385..234a97b 100644 --- a/packages/alchemy-cloudflare/package.json +++ b/packages/alchemy-cloudflare/package.json @@ -13,9 +13,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@humanlayer/channels-delivery": "workspace:*", - "alchemy": "catalog:", - "effect": "catalog:" + "@humanlayer/channels-delivery": "workspace:*" }, "devDependencies": { "@effect/platform-node": "catalog:", @@ -23,6 +21,12 @@ "@humanlayer/channels-github": "workspace:*", "@humanlayer/channels-linear": "workspace:*", "@humanlayer/channels-slack": "workspace:*", + "alchemy": "catalog:", + "effect": "catalog:", "typescript": "catalog:" + }, + "peerDependencies": { + "alchemy": "catalog:", + "effect": "catalog:" } } diff --git a/packages/alchemy-cloudflare/src/ChannelsCloudflare.ts b/packages/alchemy-cloudflare/src/ChannelsCloudflare.ts index ce1e874..4b638cd 100644 --- a/packages/alchemy-cloudflare/src/ChannelsCloudflare.ts +++ b/packages/alchemy-cloudflare/src/ChannelsCloudflare.ts @@ -13,6 +13,7 @@ import { type ChannelsProviderRequirements, type MailboxSubscriptions, } from '@humanlayer/channels-delivery' +import type * as Cloudflare from 'alchemy/Cloudflare' import { RuntimeContext } from 'alchemy/RuntimeContext' import { Context, type Crypto, Effect, Layer } from 'effect' import * as HttpRouter from 'effect/http/HttpRouter' diff --git a/packages/delivery/README.md b/packages/delivery/README.md new file mode 100644 index 0000000..ded5812 --- /dev/null +++ b/packages/delivery/README.md @@ -0,0 +1,11 @@ +# Delivery + +`@humanlayer/channels-delivery` is the core of [Channels](https://github.com/humanlayer/effect-channels#readme). It saves provider webhooks into mailboxes, runs your callbacks in order with retries, and lets a callback hand its delivery to a remote worker that finishes it over the delivery API. + +```sh +npm install @humanlayer/channels-delivery effect +``` + +It includes `Channels.make`, the delivery modes, in-memory storage for tests (`ChannelsMemory.make`), the delivery API routes, and `makeDeliveryClient` for remote workers. Add a provider ([Slack](https://www.npmjs.com/package/@humanlayer/channels-slack), [GitHub](https://www.npmjs.com/package/@humanlayer/channels-github), [Linear](https://www.npmjs.com/package/@humanlayer/channels-linear)) and a store ([Postgres](https://www.npmjs.com/package/@humanlayer/channels-sql), [Redis](https://www.npmjs.com/package/@humanlayer/channels-redis), [Cloudflare](https://www.npmjs.com/package/@humanlayer/channels-alchemy-cloudflare)). + +See the [main README](https://github.com/humanlayer/effect-channels#readme) for a full walkthrough. diff --git a/packages/delivery/package.json b/packages/delivery/package.json index 3c925fe..7098220 100644 --- a/packages/delivery/package.json +++ b/packages/delivery/package.json @@ -12,13 +12,14 @@ "scripts": { "typecheck": "tsc --noEmit -p tsconfig.json" }, - "dependencies": { - "effect": "catalog:", - "emulate": "^0.11.2" - }, "devDependencies": { "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", + "effect": "catalog:", + "emulate": "^0.11.2", "typescript": "catalog:" + }, + "peerDependencies": { + "effect": "catalog:" } } diff --git a/packages/github/README.md b/packages/github/README.md index 76f9aa4..91eb92b 100644 --- a/packages/github/README.md +++ b/packages/github/README.md @@ -264,7 +264,7 @@ Every callback receives a `DeliveryContext` and may hand its delivery to a remot | `messages.create` | comment | | `messages.update` / `delete` | edit or delete the comment | | `links.add` | nothing | -| `reactions.set` | the bot's reaction on what started the delivery, or on a comment the delivery posted: `thumbs_up` is `+1`, `thumbs_down` is `-1`; the rest keep their names | +| `reactions.set` | the bot's reaction on what started the delivery, or on a comment the delivery posted: `thumbs_up` is `+1`, `thumbs_down` is `-1`; the rest keep their names | | `plan.put` | one plan comment: the first plan comments it, each later plan edits it, and an unchanged plan makes no call; a deleted plan comment is commented again | What started the delivery (its activation target) is the mentioning comment or inline review comment, or the issue or pull request itself when it was opened or mentioned the bot in its body. A subscribed batch has none, so its delivery does not list `SetActivity`, and `activity.set` answers 409. `GET /deliveries/` lists what the delivery supports in `supportedOperations`. diff --git a/packages/github/package.json b/packages/github/package.json index 638ed74..af3425c 100644 --- a/packages/github/package.json +++ b/packages/github/package.json @@ -13,14 +13,17 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@humanlayer/channels-delivery": "workspace:*", - "effect": "catalog:" + "@humanlayer/channels-delivery": "workspace:*" }, "devDependencies": { "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", "@emulators/core": "0.11.2", "@emulators/github": "0.11.2", + "effect": "catalog:", "typescript": "catalog:" + }, + "peerDependencies": { + "effect": "catalog:" } } diff --git a/packages/linear/README.md b/packages/linear/README.md index 1166091..2f104c4 100644 --- a/packages/linear/README.md +++ b/packages/linear/README.md @@ -72,16 +72,16 @@ Linear keeps three representations separate: Every callback receives a `DeliveryContext` and may hand its delivery to a remote worker. The remote worker then drives the output through the delivery API; `LinearBot` sends it to Linear. -| Operation | Agent Session (`onAgentSessionCreated`, `onAgentSessionPrompted`) | Issue (the other callbacks) | -| --- | --- | --- | -| `complete` / `fail` (`PresentOutcome`) | one `response`, `error`, or `elicitation` (with `select` choices for `awaitingInput` options); a short default text without Markdown | a comment when there is Markdown, otherwise nothing | -| `activity.set` `Working` | ephemeral thought, replaced by the next activity | not supported (409) | -| `activity.set` `Idle` | nothing: Linear sets the session's state from its last activity | not supported (409) | -| `messages.create` | lasting thought | comment | -| `messages.update` / `delete` | not supported (409): activities cannot change | edit or delete the comment | -| `links.add` | a labeled link on the session (`agentSessionUpdate`) | nothing | -| `reactions.set` | on the comment that started the session, else the issue; not on the session's messages (409) | on the mentioning comment, else the issue, or on a comment the delivery posted | -| `plan.put` | the whole Agent Plan (`agentSessionUpdate` with `plan`), replaced each time | one plan comment, edited for each later plan | +| Operation | Agent Session (`onAgentSessionCreated`, `onAgentSessionPrompted`) | Issue (the other callbacks) | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | +| `complete` / `fail` (`PresentOutcome`) | one `response`, `error`, or `elicitation` (with `select` choices for `awaitingInput` options); a short default text without Markdown | a comment when there is Markdown, otherwise nothing | +| `activity.set` `Working` | ephemeral thought, replaced by the next activity | not supported (409) | +| `activity.set` `Idle` | nothing: Linear sets the session's state from its last activity | not supported (409) | +| `messages.create` | lasting thought | comment | +| `messages.update` / `delete` | not supported (409): activities cannot change | edit or delete the comment | +| `links.add` | a labeled link on the session (`agentSessionUpdate`) | nothing | +| `reactions.set` | on the comment that started the session, else the issue; not on the session's messages (409) | on the mentioning comment, else the issue, or on a comment the delivery posted | +| `plan.put` | the whole Agent Plan (`agentSessionUpdate` with `plan`), replaced each time | one plan comment, edited for each later plan | `GET /deliveries/` lists what the delivery supports in `supportedOperations`. The final activity replaces any ephemeral thought, so a session turn needs no separate step to clear its activity. Linear marks a session stale after 30 minutes without an activity, and any later activity revives it; on long turns, send `Working` every few minutes. diff --git a/packages/linear/package.json b/packages/linear/package.json index b68704e..b91a0f5 100644 --- a/packages/linear/package.json +++ b/packages/linear/package.json @@ -17,7 +17,6 @@ "dependencies": { "@humanlayer/channels-delivery": "workspace:*", "@types/mdast": "^4.0.4", - "effect": "catalog:", "mdast-util-from-markdown": "^2.0.3", "mdast-util-gfm": "^3.1.0", "micromark-extension-gfm": "^3.0.0" @@ -25,6 +24,10 @@ "devDependencies": { "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", + "effect": "catalog:", "typescript": "catalog:" + }, + "peerDependencies": { + "effect": "catalog:" } } diff --git a/packages/redis/README.md b/packages/redis/README.md new file mode 100644 index 0000000..b3ac3f9 --- /dev/null +++ b/packages/redis/README.md @@ -0,0 +1,18 @@ +# Redis + +`@humanlayer/channels-redis` stores [Channels](https://github.com/humanlayer/effect-channels#readme) mailboxes in Redis. + +```sh +npm install @humanlayer/channels-redis @humanlayer/channels-delivery effect +``` + +```ts +const bot = Channels.make({ + namespace: 'my-app', + providers: [slack], + eventProcessing: { concurrency: 8, maxAttempts: 5, leaseMs: 30_000 }, + storage: ChannelsRedis.make({ claimLimit: 50, polling: { intervalMs: 1_000 } }), +}) +``` + +Provide Effect's `Redis` service, such as `NodeRedis.layer(...)` from `@effect/platform-node`. diff --git a/packages/redis/package.json b/packages/redis/package.json index 7198b81..2ada633 100644 --- a/packages/redis/package.json +++ b/packages/redis/package.json @@ -13,14 +13,17 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@humanlayer/channels-delivery": "workspace:*", - "effect": "catalog:" + "@humanlayer/channels-delivery": "workspace:*" }, "devDependencies": { "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", + "effect": "catalog:", "redis": "^5.11.0", "typescript": "catalog:", "vitest": "catalog:" + }, + "peerDependencies": { + "effect": "catalog:" } } diff --git a/packages/slack/package.json b/packages/slack/package.json index 51e1c50..c26df0c 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -14,14 +14,17 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@humanlayer/channels-delivery": "workspace:*", - "effect": "catalog:" + "@humanlayer/channels-delivery": "workspace:*" }, "devDependencies": { "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", "@emulators/core": "0.11.2", "@emulators/slack": "0.11.2", + "effect": "catalog:", "typescript": "catalog:" + }, + "peerDependencies": { + "effect": "catalog:" } } diff --git a/packages/sql/README.md b/packages/sql/README.md new file mode 100644 index 0000000..64140b9 --- /dev/null +++ b/packages/sql/README.md @@ -0,0 +1,18 @@ +# SQL + +`@humanlayer/channels-sql` stores [Channels](https://github.com/humanlayer/effect-channels#readme) mailboxes in Postgres. + +```sh +npm install @humanlayer/channels-sql @humanlayer/channels-delivery effect @effect/sql-pg +``` + +```ts +const bot = Channels.make({ + namespace: 'my-app', + providers: [slack], + eventProcessing: { concurrency: 8, maxAttempts: 5, leaseMs: 30_000 }, + storage: ChannelsSql.make({ claimLimit: 50, runMigrations: true, polling: { intervalMs: 1_000 } }), +}) +``` + +Provide a `SqlClient`, such as `PgClient.layerConfig(...)` from `@effect/sql-pg`. With `runMigrations: true` the store creates its tables on start. Postgres has nothing to wake processing, so it polls. diff --git a/packages/sql/package.json b/packages/sql/package.json index 30452bf..c3808d4 100644 --- a/packages/sql/package.json +++ b/packages/sql/package.json @@ -13,12 +13,15 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@humanlayer/channels-delivery": "workspace:*", - "effect": "catalog:" + "@humanlayer/channels-delivery": "workspace:*" }, "devDependencies": { "@effect/sql-pg": "catalog:", "@effect/vitest": "catalog:", + "effect": "catalog:", "typescript": "catalog:" + }, + "peerDependencies": { + "effect": "catalog:" } } diff --git a/scripts/build/packages.ts b/scripts/build/packages.ts new file mode 100644 index 0000000..5423291 --- /dev/null +++ b/scripts/build/packages.ts @@ -0,0 +1,89 @@ +/** + * Builds every package in `packages/` into its own `dist/`: one ESM file per export, and declaration + * files that resolve under both `bundler` and `NodeNext` module resolution. + */ +import { readdir, rm, stat } from 'node:fs/promises' +import { dirname, join } from 'node:path' + +import { json, libraries, root } from '../release/manifest' + +const exists = (path: string) => + stat(path).then( + () => true, + () => false, + ) + +/** + * The source keeps relative imports without extensions, which `NodeNext` refuses in declaration files. + * Point each one at its emitted `.js` (or `/index.js`); TypeScript reads the `.d.ts` beside it. + */ +const relativeSpecifier = /((?:from|import)\s*\(?\s*['"])(\.{1,2}\/[^'"]*?)(['"])/g + +const rewriteDeclarations = async (directory: string) => { + for (const entry of await readdir(directory, { recursive: true })) { + if (!entry.endsWith('.d.ts')) continue + const file = join(directory, entry) + const text = await Bun.file(file).text() + const replacements = new Map() + for (const [, , specifier] of text.matchAll(relativeSpecifier)) { + if (specifier === undefined || /\.[cm]?js$/.test(specifier) || replacements.has(specifier)) continue + const target = join(dirname(file), specifier) + if (await exists(`${target}.d.ts`)) replacements.set(specifier, `${specifier}.js`) + else if (await exists(join(target, 'index.d.ts'))) replacements.set(specifier, `${specifier}/index.js`) + else throw new Error(`${file} imports ${specifier}, which has no declaration file`) + } + await Bun.write( + file, + text.replace(relativeSpecifier, (match, before: string, specifier: string, after: string) => { + const replacement = replacements.get(specifier) + return replacement === undefined ? match : `${before}${replacement}${after}` + }), + ) + } +} + +for (const name of libraries) { + const directory = join(root, 'packages', name) + const manifest = await json<{ name: string; exports: Record }>(join(directory, 'package.json')) + const entrypoints = Object.values(manifest.exports).filter((entry) => /^\.\/src\/.*\.ts$/.test(entry)) + if (entrypoints.length === 0) throw new Error(`${manifest.name} exports no TypeScript entrypoint`) + const outdir = join(directory, 'dist') + await rm(outdir, { recursive: true, force: true }) + + const result = await Bun.build({ + entrypoints: entrypoints.map((entry) => join(directory, entry)), + outdir, + root: join(directory, 'src'), + target: 'browser', + format: 'esm', + packages: 'external', + sourcemap: 'external', + }) + if (!result.success) throw new AggregateError(result.logs, `Failed to build ${manifest.name}`) + + const config = join(directory, 'tsconfig.release.json') + await Bun.write( + config, + `${JSON.stringify( + { + extends: './tsconfig.json', + compilerOptions: { + noEmit: false, + declaration: true, + emitDeclarationOnly: true, + outDir: './dist', + rootDir: './src', + }, + include: ['src'], + }, + null, + 2, + )}\n`, + ) + const declarations = Bun.spawn(['bunx', 'tsc', '-p', config], { cwd: root, stdout: 'inherit', stderr: 'inherit' }) + const exitCode = await declarations.exited + await rm(config, { force: true }) + if (exitCode !== 0) throw new Error(`Failed to emit declarations for ${manifest.name}`) + await rewriteDeclarations(outdir) + console.log(`built ${manifest.name}`) +} diff --git a/scripts/release/fixtures/consumer.ts b/scripts/release/fixtures/consumer.ts new file mode 100644 index 0000000..944530b --- /dev/null +++ b/scripts/release/fixtures/consumer.ts @@ -0,0 +1,74 @@ +/** + * A small application written only against the published packages. The release check typechecks it + * with `NodeNext` resolution and `skipLibCheck: false`, so it fails on any declaration file that a + * strict Node.js project cannot read. + */ +import { ChannelsCloudflare } from '@humanlayer/channels-alchemy-cloudflare' +import { + Channels, + ChannelsMemory, + DeliveryActivity, + QueueDeliveryMode, + makeDeliveryClient, + type DeliveryContext, +} from '@humanlayer/channels-delivery' +import { GitHubBot, GitHubId } from '@humanlayer/channels-github' +import { LinearAuth, LinearBot, LinearOrganizationId, LinearUserId } from '@humanlayer/channels-linear' +import { ChannelsRedis } from '@humanlayer/channels-redis' +import { SlackBot, SlackContent } from '@humanlayer/channels-slack' +import { ChannelsSql } from '@humanlayer/channels-sql' +import { Config, Effect, Redacted } from 'effect' +import { FetchHttpClient } from 'effect/http' + +const handOff = (delivery: DeliveryContext) => delivery.handoff({ failAfter: '1 hour' }) + +const slack = SlackBot.make({ + signingSecret: Config.Redacted('SLACK_SIGNING_SECRET'), + deliveryMode: QueueDeliveryMode.make({}), + handlers: { + onNewMention: (event, delivery) => + Effect.gen(function* () { + yield* event.thread.post(SlackContent.make({ markdown: 'On it.' })) + return yield* handOff(delivery) + }), + }, +}) + +const github = GitHubBot.make({ + webhookSecret: Config.Redacted('GITHUB_WEBHOOK_SECRET'), + deliveryMode: QueueDeliveryMode.make({}), + bot: { mentionNames: ['my-bot'], botUserId: GitHubId.make(1) }, + handlers: { onMentioned: (_event, delivery) => handOff(delivery) }, +}) + +const linear = LinearBot.make({ + webhookSecret: Config.Redacted('LINEAR_WEBHOOK_SECRET'), + bot: Config.all({ + organizationId: Config.schema(LinearOrganizationId, 'LINEAR_ORGANIZATION_ID'), + appUserId: Config.schema(LinearUserId, 'LINEAR_APP_USER_ID'), + }), + auth: LinearAuth.fromEnvironment, + handlers: { onAgentSessionCreated: (event) => event.session.respond('Hello.').pipe(Effect.asVoid) }, +}) + +const eventProcessing = { concurrency: 1, maxAttempts: 3, leaseMs: 30_000 } + +export const memoryBot = Channels.make({ + namespace: 'consumer', + providers: [slack, github, linear], + eventProcessing, + storage: ChannelsMemory.make({ polling: { intervalMs: 1_000 } }), +}) +export const postgresStorage = ChannelsSql.make({ claimLimit: 10, runMigrations: true, polling: { intervalMs: 1_000 } }) +export const redisStorage = ChannelsRedis.make({ claimLimit: 10, polling: { intervalMs: 1_000 } }) +export const cloudflareBot = ChannelsCloudflare.make({ namespace: 'consumer', providers: [slack], eventProcessing }) + +export const reportProgress = (deliveryId: string, accessToken: string) => + Effect.gen(function* () { + const api = yield* makeDeliveryClient({ baseUrl: 'https://agent.example.com' }) + yield* api.activity.set({ + deliveryId, + accessToken: Redacted.make(accessToken), + activity: DeliveryActivity.cases.Working.make({ message: 'Working' }), + }) + }).pipe(Effect.provide(FetchHttpClient.layer)) diff --git a/tools/packaging/guard.mjs b/scripts/release/fixtures/guard.mjs similarity index 51% rename from tools/packaging/guard.mjs rename to scripts/release/fixtures/guard.mjs index 8401be0..e0696d9 100644 --- a/tools/packaging/guard.mjs +++ b/scripts/release/fixtures/guard.mjs @@ -1,10 +1,11 @@ +// Loaded before the runtime check. Fails if importing a package opens a network connection, loads +// TypeScript source, resolves outside the throwaway project, or brings in a second copy of Effect. import assert from 'node:assert/strict' import { createHook } from 'node:async_hooks' import { registerHooks } from 'node:module' const consumer = new URL('./', import.meta.url).href const effectRoots = new Set() -export const resolvedModules = new Set() const networkResources = new Set([ 'TCPWRAP', 'TCPCONNECTWRAP', @@ -19,7 +20,7 @@ const networkResources = new Set([ createHook({ init(_id, type) { - assert.ok(!networkResources.has(type), 'Package import attempted to acquire a network resource') + assert.ok(!networkResources.has(type), `Importing a package opened a network resource (${type})`) }, }).enable() @@ -27,9 +28,8 @@ registerHooks({ resolve(specifier, context, nextResolve) { const resolved = nextResolve(specifier, context) if (resolved.url.startsWith('file:')) { - resolvedModules.add(resolved.url) - assert.ok(resolved.url.startsWith(consumer), 'Module resolution escaped the isolated consumer') - assert.ok(!resolved.url.endsWith('.ts'), 'Node loaded TypeScript instead of built JavaScript') + assert.ok(resolved.url.startsWith(consumer), `Module resolution escaped the consumer: ${resolved.url}`) + assert.ok(!/\.[cm]?ts$/.test(resolved.url), `Node loaded TypeScript instead of JavaScript: ${resolved.url}`) const marker = '/node_modules/effect/' const index = resolved.url.lastIndexOf(marker) if (index !== -1) effectRoots.add(resolved.url.slice(0, index + marker.length)) @@ -38,10 +38,5 @@ registerHooks({ }, }) -export const assertOneEffect = () => assert.equal(effectRoots.size, 1, 'Expected exactly one resolved Effect runtime') - -export const assertBuiltEntry = (specifier, entry) => { - const resolved = import.meta.resolve(specifier) - assert.ok(resolved.startsWith(consumer), 'Package resolved outside consumer') - assert.ok(resolved.endsWith(`/dist/${entry}.js`), 'Package did not resolve its built ESM entry') -} +export const assertOneEffect = () => + assert.equal(effectRoots.size, 1, `Expected one copy of Effect, found ${[...effectRoots].join(', ')}`) diff --git a/scripts/release/fixtures/runtime.mjs b/scripts/release/fixtures/runtime.mjs new file mode 100644 index 0000000..cba1eb5 --- /dev/null +++ b/scripts/release/fixtures/runtime.mjs @@ -0,0 +1,22 @@ +// Imports every published package under plain Node.js, with the guard loaded first. +import assert from 'node:assert/strict' + +import { assertOneEffect } from './guard.mjs' + +const packages = { + '@humanlayer/channels-delivery': ['Channels', 'ChannelsMemory', 'makeDeliveryClient', 'DeliveryHttpApi'], + '@humanlayer/channels-slack': ['SlackBot', 'SlackContent'], + '@humanlayer/channels-github': ['GitHubBot', 'GitHubContent'], + '@humanlayer/channels-linear': ['LinearBot', 'LinearAuth'], + '@humanlayer/channels-sql': ['ChannelsSql'], + '@humanlayer/channels-redis': ['ChannelsRedis'], + '@humanlayer/channels-alchemy-cloudflare': ['ChannelsCloudflare'], +} + +for (const [name, exports] of Object.entries(packages)) { + const module = await import(name) + for (const exported of exports) assert.ok(module[exported] !== undefined, `${name} does not export ${exported}`) + assert.ok(import.meta.resolve(name).includes(`/node_modules/${name}/dist/`), `${name} did not resolve to dist/`) + console.log(`imported ${name}`) +} +assertOneEffect() diff --git a/scripts/release/manifest.ts b/scripts/release/manifest.ts new file mode 100644 index 0000000..0373cca --- /dev/null +++ b/scripts/release/manifest.ts @@ -0,0 +1,15 @@ +import { join } from 'node:path' + +export const root = join(import.meta.dirname, '../..') +export const stage = join(root, '.release') +export const scope = '@humanlayer' +export const repository = { type: 'git', url: 'git+https://github.com/humanlayer/effect-channels.git' } + +/** Every directory in `packages/`, in publish order: a package comes after the packages it depends on. */ +export const libraries = ['delivery', 'slack', 'github', 'linear', 'sql', 'redis', 'alchemy-cloudflare'] as const + +export const versionPattern = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/ + +export async function json>(path: string): Promise { + return Bun.file(path).json() +} diff --git a/scripts/release/prepare.ts b/scripts/release/prepare.ts new file mode 100644 index 0000000..7c46d25 --- /dev/null +++ b/scripts/release/prepare.ts @@ -0,0 +1,95 @@ +/** + * Copies every built package into `.release/packages/` with a publishable `package.json`: + * the release version, `catalog:` and `workspace:` ranges replaced by real ones, and exports + * pointing at `dist/` instead of `src/`. + */ +import { cp, mkdir, readdir, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { parseArgs } from 'node:util' + +import { json, libraries, repository, root, scope, stage, versionPattern } from './manifest' + +const version = parseArgs({ options: { version: { type: 'string' } } }).values.version +if (version === undefined || !versionPattern.test(version)) throw new Error('A valid --version is required') + +type DependencyMap = Record +type ExportTarget = { types: string; import: string; default: string } +type PackageManifest = { + [key: string]: unknown + name: string + exports: Record + dependencies?: DependencyMap + peerDependencies?: DependencyMap +} + +const directories = (await readdir(join(root, 'packages'), { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort() +if (JSON.stringify(directories) !== JSON.stringify([...libraries].sort())) + throw new Error( + `scripts/release/manifest.ts lists ${libraries.join(', ')}, but packages/ has ${directories.join(', ')}`, + ) + +const { catalog } = await json<{ catalog: Record }>(join(root, 'package.json')) +const fromCatalog = (name: string) => { + const range = catalog[name] + if (range === undefined) throw new Error(`Missing catalog entry ${name}`) + return range +} + +/** + * Our own packages are pinned to this release. Peers such as `effect` accept any compatible version, + * so an application on a later patch can install them without a conflict. + */ +const resolveRanges = (dependencies: DependencyMap | undefined, peer: boolean) => { + if (dependencies === undefined) return + for (const [name, range] of Object.entries(dependencies)) { + if (range === 'catalog:') dependencies[name] = peer ? `^${fromCatalog(name)}` : fromCatalog(name) + else if (range.startsWith('workspace:')) dependencies[name] = version + } +} + +const toDist = (source: string) => source.replace(/^\.\/src\//, './dist/').replace(/\.ts$/, '.js') +const toTypes = (source: string) => toDist(source).replace(/\.js$/, '.d.ts') + +await rm(stage, { recursive: true, force: true }) + +for (const directory of libraries) { + const source = join(root, 'packages', directory) + const dest = join(stage, 'packages', directory) + const manifest = structuredClone(await json(join(source, 'package.json'))) + if (!manifest.name.startsWith(`${scope}/`)) throw new Error(`${manifest.name} is outside the ${scope} scope`) + + manifest.version = version + manifest.private = false + manifest.license = 'MIT' + manifest.publishConfig = { access: 'public' } + manifest.repository = { ...repository, directory: `packages/${directory}` } + manifest.homepage = 'https://github.com/humanlayer/effect-channels#readme' + manifest.bugs = { url: 'https://github.com/humanlayer/effect-channels/issues' } + manifest.files = ['dist', 'README.md', 'LICENSE'] + delete manifest.devDependencies + delete manifest.scripts + resolveRanges(manifest.dependencies, false) + resolveRanges(manifest.peerDependencies, true) + + const sources = Object.entries(manifest.exports).map(([key, value]) => { + if (typeof value !== 'string') throw new Error(`${manifest.name} export ${key} is not a source path`) + return [key, value] as const + }) + const main = sources.find(([key]) => key === '.')?.[1] + if (main === undefined) throw new Error(`${manifest.name} has no root export`) + manifest.exports = Object.fromEntries( + sources.map(([key, value]) => [key, { types: toTypes(value), import: toDist(value), default: toDist(value) }]), + ) + manifest.module = toDist(main) + manifest.types = toTypes(main) + + await mkdir(dest, { recursive: true }) + await cp(join(source, 'dist'), join(dest, 'dist'), { recursive: true }) + if (await Bun.file(join(source, 'README.md')).exists()) await cp(join(source, 'README.md'), join(dest, 'README.md')) + await cp(join(root, 'LICENSE'), join(dest, 'LICENSE')) + await Bun.write(join(dest, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`) + console.log(`staged ${manifest.name}@${version}`) +} diff --git a/scripts/release/publish.ts b/scripts/release/publish.ts new file mode 100644 index 0000000..3fed3c7 --- /dev/null +++ b/scripts/release/publish.ts @@ -0,0 +1,46 @@ +/** + * Publishes the staged packages in `.release/`, one at a time, in dependency order. + * + * A version already on npm is skipped, so a release that failed halfway can be run again. In GitHub + * Actions npm signs in through trusted publishing; from a laptop it uses your `npm login`. + */ +import { join } from 'node:path' +import { parseArgs } from 'node:util' + +import { libraries, stage } from './manifest' + +const values = parseArgs({ + options: { + version: { type: 'string' }, + tag: { type: 'string', default: 'latest' }, + 'dry-run': { type: 'boolean', default: false }, + }, +}).values +if (values.version === undefined) throw new Error('--version is required') +const dryRun = values['dry-run'] + +const alreadyPublished = async (name: string, version: string) => { + const child = Bun.spawn(['npm', 'view', `${name}@${version}`, 'version', '--json'], { + stdout: 'pipe', + stderr: 'ignore', + }) + const output = await new Response(child.stdout).text() + return (await child.exited) === 0 && output.trim().length > 0 +} + +for (const directory of libraries) { + const path = join(stage, 'packages', directory) + const manifest: { name: string; version: string } = await Bun.file(join(path, 'package.json')).json() + if (manifest.version !== values.version) + throw new Error(`${manifest.name} is staged at ${manifest.version}, expected ${values.version}`) + if (!dryRun && (await alreadyPublished(manifest.name, manifest.version))) { + console.log(`skipping ${manifest.name}@${manifest.version}: already published`) + continue + } + console.log(`${dryRun ? 'dry run of' : 'publishing'} ${manifest.name}@${manifest.version} as ${values.tag}`) + const child = Bun.spawn( + ['npm', 'publish', '--access', 'public', '--tag', values.tag, ...(dryRun ? ['--dry-run'] : [])], + { cwd: path, stdin: 'inherit', stdout: 'inherit', stderr: 'inherit' }, + ) + if ((await child.exited) !== 0) throw new Error(`Publish failed for ${manifest.name}`) +} diff --git a/scripts/release/tag-info.ts b/scripts/release/tag-info.ts new file mode 100644 index 0000000..218adaf --- /dev/null +++ b/scripts/release/tag-info.ts @@ -0,0 +1,19 @@ +import { parseArgs } from 'node:util' + +const values = parseArgs({ + options: { + version: { type: 'string' }, + tag: { type: 'string' }, + }, + allowPositionals: true, +}).values +const gitTag = process.env.GITHUB_REF_NAME ?? process.argv[2] +const version = values.version ?? (gitTag?.startsWith('v') ? gitTag.slice(1) : undefined) +if (!version?.match(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/)) + throw new Error(`Invalid release version: ${version ?? gitTag}`) +const prerelease = version.split('-')[1] +const distTag = values.tag ?? prerelease?.split('.')[0] ?? 'latest' +if (!distTag.match(/^[a-zA-Z][0-9a-zA-Z._-]*$/)) throw new Error(`Invalid npm tag: ${distTag}`) +console.log(`version=${version}`) +console.log(`tag=${distTag}`) +if (process.env.GITHUB_OUTPUT) await Bun.write(process.env.GITHUB_OUTPUT, `version=${version}\ntag=${distTag}\n`) diff --git a/scripts/release/validate.ts b/scripts/release/validate.ts new file mode 100644 index 0000000..ec98520 --- /dev/null +++ b/scripts/release/validate.ts @@ -0,0 +1,166 @@ +/** + * Checks the staged packages in `.release/` before anything is published. + * + * 1. Each `package.json` is publishable: the release version, public, no `workspace:` or `catalog:` + * ranges, Effect and Alchemy only as peers, our own packages pinned to this release, and exports that + * point at built files that exist. + * 2. The packed tarballs work for a real user: npm installs them into a throwaway project, a sample app + * typechecks against them with strict Node.js settings, and plain Node.js imports every package. + */ +import { mkdtemp, readdir, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { parseArgs } from 'node:util' + +import { json, libraries, root, scope, stage, versionPattern } from './manifest' + +const version = parseArgs({ options: { version: { type: 'string' } } }).values.version +if (version === undefined || !versionPattern.test(version)) throw new Error('A valid --version is required') + +type ExportTarget = { types: string; import: string; default: string } +type StagedManifest = { + name: string + version: string + private?: boolean + publishConfig?: { access?: string } + dependencies?: Record + peerDependencies?: Record + exports: Record +} + +const peerOnly = (name: string) => name === 'effect' || name.startsWith('@effect/') || name === 'alchemy' + +const exists = (path: string) => + stat(path).then( + () => true, + () => false, + ) + +const run = async (command: Array, cwd: string) => { + const child = Bun.spawn(command, { + cwd, + stdout: 'pipe', + stderr: 'pipe', + env: { + ...process.env, + npm_config_update_notifier: 'false', + npm_config_fund: 'false', + npm_config_audit: 'false', + }, + }) + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]) + if (exitCode !== 0) throw new Error(`Command failed in ${cwd}: ${command.join(' ')}\n${stdout}${stderr}`) + return stdout +} + +const peers = new Map() + +for (const directory of libraries) { + const path = join(stage, 'packages', directory) + const manifest = await json(join(path, 'package.json')) + const fail = (message: string): never => { + throw new Error(`${manifest.name}: ${message}`) + } + if (manifest.version !== version) fail(`is ${manifest.version}, expected ${version}`) + if (manifest.private === true) fail('is private') + if (manifest.publishConfig?.access !== 'public') fail('is not public') + if (await exists(join(path, 'src'))) fail('would publish its source files') + for (const file of ['README.md', 'LICENSE']) if (!(await exists(join(path, file)))) fail(`is missing ${file}`) + + for (const [name, range] of Object.entries(manifest.dependencies ?? {})) { + if (peerOnly(name)) fail(`must declare ${name} as a peer dependency`) + if (range.includes('workspace:') || range === 'catalog:') fail(`has unresolved dependency ${name}`) + if (name.startsWith(`${scope}/`) && range !== version) fail(`does not pin ${name} to ${version}`) + } + for (const [name, range] of Object.entries(manifest.peerDependencies ?? {})) { + if (range.includes('workspace:') || range === 'catalog:') fail(`has unresolved peer ${name}`) + peers.set(name, range) + } + if (manifest.peerDependencies?.effect === undefined) fail('must declare effect as a peer dependency') + + for (const [key, target] of Object.entries(manifest.exports)) { + for (const file of [target.types, target.import, target.default]) { + if (file.includes('/src/')) fail(`export ${key} points at source: ${file}`) + if (!(await exists(join(path, file)))) fail(`export ${key} points at a missing file: ${file}`) + } + } + const files = await readdir(join(path, 'dist'), { recursive: true }) + if (files.some((file) => /(?() + for (const directory of libraries) { + const output = await run( + ['npm', 'pack', '--json', '--pack-destination', archives], + join(stage, 'packages', directory), + ) + const [packed] = JSON.parse(output) as Array<{ filename: string }> + if (packed === undefined) throw new Error(`npm pack produced nothing for ${directory}`) + tarballs.push(join(archives, packed.filename)) + } + + const fixtures = join(root, 'scripts/release/fixtures') + await Bun.write( + join(consumer, 'package.json'), + `${JSON.stringify({ name: 'channels-release-consumer', private: true, type: 'module' }, null, 2)}\n`, + ) + await Bun.write( + join(consumer, 'tsconfig.json'), + `${JSON.stringify( + { + compilerOptions: { + target: 'ES2023', + lib: ['ESNext', 'DOM', 'DOM.Iterable'], + module: 'NodeNext', + moduleResolution: 'NodeNext', + strict: true, + skipLibCheck: false, + noEmit: true, + types: [], + }, + files: ['consumer.ts'], + }, + null, + 2, + )}\n`, + ) + for (const file of ['consumer.ts', 'guard.mjs', 'runtime.mjs']) + await Bun.write(join(consumer, file), Bun.file(join(fixtures, file))) + + /** Importing `alchemy/Cloudflare` loads `@effect/platform-node`, an optional peer every Alchemy app installs. */ + const alchemyPeers = peers.has('alchemy') ? [`@effect/platform-node@${peers.get('effect')}`] : [] + const peerSpecs = [...[...peers].map(([name, range]) => `${name}@${range}`), ...alchemyPeers] + await run(['npm', 'install', '--ignore-scripts', '--no-package-lock', ...peerSpecs, ...tarballs], consumer) + console.log(`installed ${tarballs.length} packages with ${peerSpecs.join(', ')}`) + + /** + * `skipLibCheck: false` reads every declaration file, including Alchemy's, which need Cloudflare and + * Node.js globals this project does not install. Only errors in the sample app or our packages count. + */ + const typecheck = Bun.spawn([join(root, 'node_modules/.bin/tsc'), '-p', 'tsconfig.json'], { + cwd: consumer, + stdout: 'pipe', + stderr: 'pipe', + }) + const ours = (await new Response(typecheck.stdout).text()) + .split('\n') + .filter((line) => line.startsWith('consumer.ts') || line.startsWith(`node_modules/${scope}/`)) + await typecheck.exited + if (ours.length > 0) throw new Error(`The sample app does not typecheck against the packages:\n${ours.join('\n')}`) + console.log('typechecked the sample app with NodeNext resolution') + + process.stdout.write(await run(['node', 'runtime.mjs'], consumer)) + console.log(`validated ${libraries.length} packages at ${version}`) +} finally { + await rm(workspace, { recursive: true, force: true }) +} diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json new file mode 100644 index 0000000..02647f1 --- /dev/null +++ b/scripts/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "types": ["bun"] + }, + "include": ["."], + "exclude": ["release/fixtures"] +} diff --git a/tools/packaging/backends.mjs b/tools/packaging/backends.mjs deleted file mode 100644 index 74ad8d0..0000000 --- a/tools/packaging/backends.mjs +++ /dev/null @@ -1,29 +0,0 @@ -import assert from 'node:assert/strict' - -import * as DeliveryPostgres from '@humanlayer/channels-delivery/postgres' -import * as DeliveryPostgresClient from '@humanlayer/channels-delivery/postgres/client' -import * as DeliveryRedis from '@humanlayer/channels-delivery/redis' -import * as DeliveryRedisClient from '@humanlayer/channels-delivery/redis/client' -import * as SlackPostgres from '@humanlayer/channels-slack/postgres' -import * as SlackPostgresClient from '@humanlayer/channels-slack/postgres/client' -import * as SlackRedis from '@humanlayer/channels-slack/redis' -import * as SlackRedisClient from '@humanlayer/channels-slack/redis/client' -import { Layer } from 'effect' - -import { assertBuiltEntry, assertOneEffect } from './guard' - -for (const name of ['@humanlayer/channels-delivery', '@humanlayer/channels-slack']) { - for (const entry of ['postgres', 'redis', 'postgres/client', 'redis/client']) { - assertBuiltEntry(`${name}/${entry}`, entry) - } -} - -for (const backend of [DeliveryPostgres, DeliveryRedis, SlackPostgres, SlackRedis]) { - assert.ok(Layer.isLayer(backend.layer)) -} - -assert.equal(DeliveryPostgresClient.layer, SlackPostgresClient.layer) -assert.equal(DeliveryPostgresClient.layerConfig, SlackPostgresClient.layerConfig) -assert.equal(DeliveryRedisClient.layer, SlackRedisClient.layer) -assert.equal(DeliveryRedisClient.layerConfig, SlackRedisClient.layerConfig) -assertOneEffect() diff --git a/tools/packaging/backends.ts.fixture b/tools/packaging/backends.ts.fixture deleted file mode 100644 index 9a224e9..0000000 --- a/tools/packaging/backends.ts.fixture +++ /dev/null @@ -1,8 +0,0 @@ -export * as deliveryPostgres from '@humanlayer/channels-delivery/postgres' -export * as deliveryPostgresClient from '@humanlayer/channels-delivery/postgres/client' -export * as deliveryRedis from '@humanlayer/channels-delivery/redis' -export * as deliveryRedisClient from '@humanlayer/channels-delivery/redis/client' -export * as slackPostgres from '@humanlayer/channels-slack/postgres' -export * as slackPostgresClient from '@humanlayer/channels-slack/postgres/client' -export * as slackRedis from '@humanlayer/channels-slack/redis' -export * as slackRedisClient from '@humanlayer/channels-slack/redis/client' diff --git a/tools/packaging/browser-delivery.ts.fixture b/tools/packaging/browser-delivery.ts.fixture deleted file mode 100644 index 789007e..0000000 --- a/tools/packaging/browser-delivery.ts.fixture +++ /dev/null @@ -1,2 +0,0 @@ -export * from '@humanlayer/channels-delivery' -export * as memory from '@humanlayer/channels-delivery/memory' diff --git a/tools/packaging/browser-github-outbound.ts.fixture b/tools/packaging/browser-github-outbound.ts.fixture deleted file mode 100644 index 1925eaa..0000000 --- a/tools/packaging/browser-github-outbound.ts.fixture +++ /dev/null @@ -1 +0,0 @@ -export { GitHub, GitHubCredentials, GitHubCrypto, GitHubEmoji, GitHubReaction, GitHubReactionData, GitHubReactionRef, GitHubReactionContent, GitHubReactionTarget, AddReactionInput, ListReactionsInput, RemoveReactionInput } from '@humanlayer/channels-github' diff --git a/tools/packaging/browser-github.ts.fixture b/tools/packaging/browser-github.ts.fixture deleted file mode 100644 index 53d6805..0000000 --- a/tools/packaging/browser-github.ts.fixture +++ /dev/null @@ -1,2 +0,0 @@ -export * from '@humanlayer/channels-github' -export * as GitHubMemory from '@humanlayer/channels-github/memory' diff --git a/tools/packaging/browser-outbound.ts.fixture b/tools/packaging/browser-outbound.ts.fixture deleted file mode 100644 index f856f79..0000000 --- a/tools/packaging/browser-outbound.ts.fixture +++ /dev/null @@ -1 +0,0 @@ -export { Slack, SlackClient, SlackTenantCredentials } from '@humanlayer/channels-slack' diff --git a/tools/packaging/browser-slack.ts.fixture b/tools/packaging/browser-slack.ts.fixture deleted file mode 100644 index 62bcb4e..0000000 --- a/tools/packaging/browser-slack.ts.fixture +++ /dev/null @@ -1,2 +0,0 @@ -export * from '@humanlayer/channels-slack' -export * as memory from '@humanlayer/channels-slack/memory' diff --git a/tools/packaging/consumer.ts.fixture b/tools/packaging/consumer.ts.fixture deleted file mode 100644 index 9064601..0000000 --- a/tools/packaging/consumer.ts.fixture +++ /dev/null @@ -1,203 +0,0 @@ -import { emptyMailbox, MailboxReadiness, MailboxStore } from '@humanlayer/channels-delivery' -import * as DeliveryMemory from '@humanlayer/channels-delivery/memory' -import { MarkdownContent, resolveSlackDelivery, Slack, SlackConnectionStore, SlackSubscriptions, SlackTeamId, ThreadId } from '@humanlayer/channels-slack' -import * as SlackMemory from '@humanlayer/channels-slack/memory' -import { Context, Effect, Layer, Redacted, Schema } from 'effect' -import { GitHub, GitHubCredentials, GitHubCrypto, GitHubIssueRef, GitHubEmoji, GitHubReactionContent, resolveGitHubDelivery, type GitHubReaction, type GitHubReactionRef, type GitHubReactionTarget, type GitHubError, type AddReactionInput, type ListReactionsInput, type RemoveReactionInput } from '@humanlayer/channels-github' -import { FetchHttpClient } from 'effect/unstable/http' -import { GitHubSubscriptions } from '@humanlayer/channels-github' -import * as GitHubMemory from '@humanlayer/channels-github/memory' -import { GitHubBot, GitHubIngress, GitHubIngressError, GitHubSubscriptionStore, type GitHubHandlerRegistration, type GitHubActivityEvent } from '@humanlayer/channels-github' -import { type DeliveryPolicy, type ResolvedDelivery } from '@humanlayer/channels-delivery' -import { makeDeliveryClient } from '@humanlayer/channels-delivery/client' -import { DeliveryContract } from '@humanlayer/channels-delivery/contract' -import { DeliveryId, DeliveryNotFound } from '@humanlayer/channels-delivery/protocol' -import { HttpClient } from 'effect/unstable/http' -// @ts-expect-error Removed registration name; there is no compatibility export. -import type { GitHubActivityRegistration } from '@humanlayer/channels-github' -// @ts-expect-error Removed legacy delivery definition; the native parser is not an ingress pipeline. -import type { issueEventDefinition } from '@humanlayer/channels-github' - -class FirstHandlerDependency extends Context.Service()('packed/FirstHandlerDependency') {} -class SecondHandlerDependency extends Context.Service()('packed/SecondHandlerDependency') {} - -export const remoteContractConsumer = { - contract: DeliveryContract, - client: makeDeliveryClient, - deliveryId: DeliveryId.make('delivery:v1:eA:eA'), - notFound: DeliveryNotFound.make({ - deliveryId: DeliveryId.make('delivery:v1:eA:eA'), - message: 'The requested delivery was not found.', - }), -} - -export const providerNarrowingConsumer = (delivery: ResolvedDelivery) => ({ - slack: resolveSlackDelivery(delivery).pipe( - Effect.map((native) => { - const provider: 'slack' = native.provider - return { provider, event: native.event, threadId: native.resource.threadId } - }), - ), - github: resolveGitHubDelivery(delivery).pipe( - Effect.map((native) => { - const provider: 'github' = native.provider - return { provider, event: native.event, resource: native.resource } - }), - ), -}) - -export const githubHandlers = (policy: DeliveryPolicy) => { - const bot = GitHubBot.make({ - namespace: 'packed', policy, - handlers: [{ - id: 'first', - onCreation: (event, context) => { - const action: 'opened' = event.action - const organizationId: string = context.organizationId - const skipped: ReadonlyArray = context.skipped - if (event.event === 'pull_request') { - const kind: 'github.pull-request' = event.resource.kind - const body: string | null = event.pull_request.body - // @ts-expect-error Native PR events do not fabricate an issue snapshot. - event.issue - } - return Effect.andThen(FirstHandlerDependency, Effect.fail('first' as const)) - }, - onMention: (event) => { - const action: 'opened' | 'created' | 'edited' | 'submitted' = event.action - return Effect.andThen(SecondHandlerDependency, Effect.fail(42 as const)) - }, - }, { - id: 'second', - onSubscribedEvent: (event) => { - if (event.event === 'pull_request_review_thread') { - const nodeId: string = event.thread.node_id - // @ts-expect-error A review thread is not a discussion comment. - event.comment - } - return Effect.void - }, - }], - }) - type Requirements = FirstHandlerDependency | SecondHandlerDependency | MailboxStore | MailboxReadiness | GitHubSubscriptionStore | GitHubCredentials | HttpClient.HttpClient - const services: Layer.Layer = bot - // @ts-expect-error Required callback services must not disappear or become optional. - const missing: Layer.Layer> = bot - // @ts-expect-error Ingress configuration failures are typed, not never. - const noErrors: Layer.Layer = bot - const partial = GitHubBot.make({ namespace: 'partial', policy, handlers: [] }) - const empty: Layer.Layer> = partial - const registration: GitHubHandlerRegistration<'first' | 42, FirstHandlerDependency | SecondHandlerDependency> = { - id: 'typed', - onCreation: () => Effect.andThen(FirstHandlerDependency, Effect.fail('first' as const)), - onMention: () => Effect.andThen(SecondHandlerDependency, Effect.fail(42 as const)), - } - const typed = GitHubBot.make({ namespace: 'typed', policy, handlers: [registration] }) - const typedServices: typeof services = typed - return { services, empty, typedServices } -} - -export const rejectedGitHubForms = (policy: DeliveryPolicy, ingress: GitHubIngress['Service']) => { - // @ts-expect-error The old property is not an alias, even alongside canonical handlers. - GitHubBot.make({ namespace: 'old', policy, handlers: [], activityHandlers: [] }) - // @ts-expect-error Bare issue-event handlers are removed. - GitHubBot.make({ namespace: 'old', policy, handlers: [{ id: 'old', handler: () => Effect.void }] }) - // @ts-expect-error Mixing old and new callback fields is rejected too. - GitHubBot.make({ namespace: 'old', policy, handlers: [{ id: 'old', onCreation: () => Effect.void, handler: () => Effect.void }] }) - // @ts-expect-error Ingress has the same sole registration API. - GitHubIngress.layer({ namespace: 'old', policy, handlers: [{ id: 'old', handler: () => Effect.void }] }) - // @ts-expect-error No alternate activityHandlers ingress mode. - GitHubIngress.layer({ namespace: 'old', policy, handlers: [], activityHandlers: [] }) - // @ts-expect-error Removed legacy admission operation. - ingress.accept - // @ts-expect-error Removed legacy processing operation. - ingress.process - // @ts-expect-error Routes cannot select an old pipeline. - ingress.activityEnabled -} - -export const githubSubscriptionRoundTrip = Effect.gen(function* () { - const subscriptions = yield* GitHubSubscriptions - const input = { namespace: 'packed', resource: GitHubIssueRef.make({ kind: 'github.issue', repository: { kind: 'github.repository', installationId: 100, id: 1, owner: 'owner', name: 'repo' }, number: 1 }) } - yield* subscriptions.subscribe(input) - const subscribed = yield* subscriptions.isSubscribed(input) - yield* subscriptions.unsubscribe(input) - return { subscribed, removed: !(yield* subscriptions.isSubscribed(input)) } -}).pipe(Effect.provide(GitHubMemory.subscriptions())) - -export const githubEmojiRoundTrip = Effect.all({ - decoded: Effect.forEach(GitHubEmoji.literals, (emoji) => Schema.decodeEffect(GitHubEmoji)(emoji)), - encoded: Effect.forEach(GitHubEmoji.literals, (emoji) => Schema.encodeEffect(GitHubEmoji)(emoji)), -}) - -const readyForHost = (program: Effect.Effect) => program -const githubLayer = GitHub.layer.pipe(Layer.provide(GitHubCredentials.layer({ appId: 42, privateKey: Redacted.make('not-used-during-construction'), installationIds: [100], botUserId: 99, apiUrl: 'https://api.github.com' }).pipe(Layer.provide(GitHubCrypto.layerWebCrypto), Layer.provide(FetchHttpClient.layer))), Layer.provide(FetchHttpClient.layer)) -export const githubOutbound = readyForHost(Effect.flatMap(GitHub, (github) => github.getIssue({ issue: GitHubIssueRef.make({ kind: 'github.issue', repository: { kind: 'github.repository', installationId: 100, id: 1, owner: 'owner', name: 'repo' }, number: 1 }) })).pipe(Effect.provide(githubLayer))) -export const githubIdentity = Effect.scoped(Layer.build(githubLayer).pipe(Effect.map((context) => Context.get(context, GitHub)))) -export const githubRejectsSlack = (github: GitHub['Service'], threadId: ThreadId) => { - // @ts-expect-error Slack IDs are not GitHub issue handles. - return github.getIssue({ issue: threadId }) -} -export const githubReactions = (github: GitHub['Service'], target: GitHubReactionTarget, reaction: GitHubReactionRef) => { - const emoji: GitHubEmoji = GitHubEmoji.Eyes - const native: GitHubReactionContent = '+1' - const add: AddReactionInput = { target, content: emoji } - const list: ListReactionsInput = { target, page: 1, perPage: 100, content: GitHubEmoji.ThumbsUp } - GitHubReactionContent.make(native) - // @ts-expect-error GitHub has no custom emoji constructor. - GitHubEmoji.custom('party_parrot') - // @ts-expect-error Named values preserve their exact native literal types. - const wrong: typeof GitHubEmoji.ThumbsUp = 'eyes' - const remove: RemoveReactionInput = { reaction } - const added: Effect.Effect = github.addReaction(add) - const listed: Effect.Effect, GitHubError> = github.listReactions(list) - const removed: Effect.Effect = github.removeReaction(remove) - // @ts-expect-error No Slack reaction aliases or arbitrary emoji. - github.addReaction({ target, content: 'thumbsup' }) - // @ts-expect-error Bounded pagination must be explicit. - github.listReactions({ target }) - // @ts-expect-error Removing requires a target-scoped native reaction ref, not a number. - github.removeReaction({ reaction: 70 }) - // @ts-expect-error PR review comments are outside this slice. - github.addReaction({ target: { kind: 'github.review-comment', id: 1 }, content: GitHubEmoji.Eyes }) - // @ts-expect-error Commit reactions are outside this slice. - github.addReaction({ target: { kind: 'github.commit', sha: 'abc' }, content: GitHubEmoji.Eyes }) - return { added, listed, removed } -} -export const outbound = readyForHost(Effect.flatMap(Slack, (slack) => slack.post({ - threadId: ThreadId.make('slack:v1:T_PACKAGING:C_PACKAGING:100.1'), - content: MarkdownContent.make({ markdown: 'Hello' }), -})).pipe(Effect.provide(Slack.layerFromStore.pipe( - Layer.provide(SlackMemory.connections()), - Layer.provide(FetchHttpClient.layer), -)))) - -export const deliveryIdentity = Effect.scoped( - Effect.gen(function* () { - const context = yield* Layer.build(DeliveryMemory.layer({ maxMailboxes: 2 })) - const store = Context.get(context, MailboxStore) - const readiness = Context.get(context, MailboxReadiness) - const committed = yield* store.commitMailbox({ key: 'packaging', expectedRevision: null, nextState: emptyMailbox() }) - const snapshot = yield* store.loadMailbox({ key: 'packaging' }) - const ready = yield* readiness.scanReady({ prefix: '', now: 0, limit: 1 }) - return { committed, snapshot, ready } - }), -) - -export const slackIdentity = Effect.scoped( - Effect.gen(function* () { - const context = yield* Layer.build(SlackMemory.layer({ maxMailboxes: 2 })) - const store = Context.get(context, MailboxStore) - const readiness = Context.get(context, MailboxReadiness) - const connections = Context.get(context, SlackConnectionStore) - const subscriptions = Context.get(context, SlackSubscriptions) - const threadId = ThreadId.make('packaging-thread') - const committed = yield* store.commitMailbox({ key: 'slack', expectedRevision: null, nextState: emptyMailbox() }) - const snapshot = yield* store.loadMailbox({ key: 'slack' }) - const ready = yield* readiness.scanReady({ prefix: '', now: 0, limit: 1 }) - const connection = yield* connections.get({ workspaceId: SlackTeamId.make('T_PACKAGING') }) - yield* subscriptions.subscribe({ threadId }) - const subscribed = yield* subscriptions.isSubscribed({ threadId }) - return { committed, snapshot, ready, connection, subscribed } - }), -) diff --git a/tools/packaging/organizations.ts.fixture b/tools/packaging/organizations.ts.fixture deleted file mode 100644 index 92b2acf..0000000 --- a/tools/packaging/organizations.ts.fixture +++ /dev/null @@ -1,70 +0,0 @@ -import { GitHubId, GitHubOrganizationLookup, GitHubOrganizationLookupError, GitHubOrganizations } from '@humanlayer/channels-github' -import { SlackOrganizationLookup, SlackOrganizationLookupError, SlackOrganizations, SlackTeamId } from '@humanlayer/channels-slack' -import { Context, Effect, Layer, SchemaIssue } from 'effect' -import { SaveIngressAttribution, saveIngressAttribution, MailboxStore, type MailboxStoreError, type IngressAttribution } from '@humanlayer/channels-delivery' - -export const saveAttribution = (input: SaveIngressAttribution): Effect.Effect => saveIngressAttribution(input) - -class Directory extends Context.Service Effect.Effect<{ readonly organizationId: string } | null, SlackOrganizationLookupError> - readonly github: (input: GitHubOrganizationLookup) => Effect.Effect<{ readonly organizationId: string } | null, GitHubOrganizationLookupError> -}>()('packed/ApplicationDirectory') {} - -export const slack: Layer.Layer = Layer.effect(SlackOrganizations, Effect.gen(function* () { - const directory = yield* Directory - return SlackOrganizations.of({ resolve: (input) => { - const workspaceId: SlackTeamId = input.workspaceId - // @ts-expect-error Slack's native workspace is not a GitHub installation number. - const invalid: number = input.workspaceId - return directory.slack({ workspaceId }) - } }) -})) - -export const github: Layer.Layer = Layer.effect(GitHubOrganizations, Effect.gen(function* () { - const directory = yield* Directory - return GitHubOrganizations.of({ resolve: (input) => { - const installationId: typeof GitHubId.Type = input.installationId - // @ts-expect-error GitHub installations retain their native number type. - const invalid: SlackTeamId = input.installationId - return directory.github({ installationId }) - } }) -})) - -export const nativeInputs = () => { - SlackOrganizationLookup.make({ workspaceId: SlackTeamId.make('T_PACKED') }) - GitHubOrganizationLookup.make({ installationId: 100 }) - // @ts-expect-error Raw unparsed workspace strings are not branded SlackTeamId values. - SlackOrganizationLookup.make({ workspaceId: 'T_PACKED' }) - // @ts-expect-error Do not replace native GitHub installation IDs with strings. - GitHubOrganizationLookup.make({ installationId: '100' }) -} - -export const fixedSlack: Layer.Layer = SlackOrganizations.fixed({ organizationId: 'single' }) -export const fixedGitHub: Layer.Layer = GitHubOrganizations.fixed({ organizationId: 'single' }) - -class ApplicationLookup extends Context.Service()('packed/Lookup') {} - -export const inferredSlack = SlackOrganizations.layer(({ workspaceId }) => Effect.gen(function* () { - const native: SlackTeamId = workspaceId - // @ts-expect-error Native Slack identity is not numeric. - const wrong: number = workspaceId - return { organizationId: (yield* ApplicationLookup).organizationId } -})) -export const inferredGitHub = GitHubOrganizations.layer(({ installationId }) => Effect.gen(function* () { - const native: typeof GitHubId.Type = installationId - // @ts-expect-error Native GitHub identity is not a string. - const wrong: string = installationId - if (installationId === 0) return yield* Effect.fail({ applicationFailure: true }) - return { organizationId: (yield* ApplicationLookup).organizationId } -})) -export const slackRequired: Layer.Layer = inferredSlack -export const githubRequired: Layer.Layer = inferredGitHub -// @ts-expect-error The callback dependency must be provided. -export const missingSlack: Layer.Layer = inferredSlack -// @ts-expect-error The callback dependency must be provided. -export const missingGitHub: Layer.Layer = inferredGitHub -const dependency = Layer.succeed(ApplicationLookup, { organizationId: 'packed' }) -export const fulfilledSlack: Layer.Layer = inferredSlack.pipe(Layer.provide(dependency)) -export const fulfilledGitHub: Layer.Layer = inferredGitHub.pipe(Layer.provide(dependency)) -export const noLeakSlack = Effect.flatMap(SlackOrganizations, (service): Effect.Effect => service.resolve({ workspaceId: SlackTeamId.make('T_PACKED') })) -export const noLeakGitHub = Effect.flatMap(GitHubOrganizations, (service): Effect.Effect => service.resolve({ installationId: 100 })) diff --git a/tools/packaging/remote.mjs b/tools/packaging/remote.mjs deleted file mode 100644 index a3af301..0000000 --- a/tools/packaging/remote.mjs +++ /dev/null @@ -1,20 +0,0 @@ -import assert from 'node:assert/strict' - -import { assertBuiltEntry, assertOneEffect, resolvedModules } from './guard' - -await import('@humanlayer/channels-delivery/protocol') -await import('@humanlayer/channels-delivery/contract') -await import('@humanlayer/channels-delivery/client') - -assertBuiltEntry('@humanlayer/channels-delivery/protocol', 'protocol') -assertBuiltEntry('@humanlayer/channels-delivery/contract', 'contract') -assertBuiltEntry('@humanlayer/channels-delivery/client', 'client') -for (const forbidden of ['/DeliveryControl.js', '/Mailbox.js', '/MailboxStore.js', '/server.js', '/memory.js']) { - assert.ok( - ![...resolvedModules].some( - (url) => url.includes('/@humanlayer/channels-delivery/dist') && url.endsWith(forbidden), - ), - `Contract/client import loaded private implementation module ${forbidden}`, - ) -} -assertOneEffect() diff --git a/tools/packaging/runtime.mjs b/tools/packaging/runtime.mjs deleted file mode 100644 index 2387aa4..0000000 --- a/tools/packaging/runtime.mjs +++ /dev/null @@ -1,64 +0,0 @@ -import assert from 'node:assert/strict' - -import * as Delivery from '@humanlayer/channels-delivery' -import * as GitHub from '@humanlayer/channels-github' -import * as Slack from '@humanlayer/channels-slack' -import { Effect } from 'effect' - -import { - deliveryIdentity, - slackIdentity, - githubEmojiRoundTrip, - githubIdentity, - githubSubscriptionRoundTrip, -} from './compiled/consumer' -import { assertBuiltEntry, assertOneEffect } from './guard' - -for (const name of ['@humanlayer/channels-delivery', '@humanlayer/channels-slack']) { - assertBuiltEntry(name, 'index') - assertBuiltEntry(`${name}/memory`, 'memory') -} - -const deliveryModule = await import(new URL('./MailboxStore.js', import.meta.resolve('@humanlayer/channels-delivery'))) -const connectionModule = await import( - new URL('./SlackConnectionStore.js', import.meta.resolve('@humanlayer/channels-slack')) -) -const subscriptionModule = await import( - new URL('./SlackSubscriptions.js', import.meta.resolve('@humanlayer/channels-slack')) -) -assert.equal(Delivery.MailboxStore, deliveryModule.MailboxStore) -assert.equal(Delivery.MailboxReadiness, deliveryModule.MailboxReadiness) -assert.equal(Slack.SlackConnectionStore, connectionModule.SlackConnectionStore) -assert.equal(Slack.SlackSubscriptions, subscriptionModule.SlackSubscriptions) - -const delivery = Effect.runSync(deliveryIdentity) -assert.equal(delivery.committed, 'committed') -assert.equal(delivery.snapshot?.revision, 0) -assert.deepEqual(delivery.ready, []) - -const slack = Effect.runSync(slackIdentity) -assert.equal(slack.committed, 'committed') -assert.equal(slack.snapshot?.revision, 0) -assert.deepEqual(slack.ready, []) -assert.equal(slack.connection, undefined) -assert.equal(slack.subscribed, true) -assertBuiltEntry('@humanlayer/channels-github', 'index') -assertBuiltEntry('@humanlayer/channels-github/memory', 'memory') -assert.deepEqual(await Effect.runPromise(githubSubscriptionRoundTrip), { subscribed: true, removed: true }) -const githubModule = await import(new URL('./GitHub.js', import.meta.resolve('@humanlayer/channels-github'))) -assert.equal(GitHub.GitHub, githubModule.GitHub) -assert.ok(Effect.runSync(githubIdentity).createIssue) -assert.ok(Effect.runSync(githubIdentity).addReaction) -assert.ok(Effect.runSync(githubIdentity).listReactions) -assert.ok(Effect.runSync(githubIdentity).removeReaction) -assert.ok(GitHub.GitHubReaction) -assert.ok(GitHub.GitHubReactionData) -assert.ok(GitHub.GitHubReactionRef) -assert.ok(GitHub.GitHubReactionContent) -assert.equal(GitHub.GitHubEmoji.Eyes, 'eyes') -assert.equal(GitHub.GitHubEmoji.ThumbsUp, '+1') -assert.equal(GitHub.GitHubReactionContent, GitHub.GitHubEmoji) -const emoji = Effect.runSync(githubEmojiRoundTrip) -assert.deepEqual(emoji.decoded, GitHub.GitHubEmoji.literals) -assert.deepEqual(emoji.encoded, GitHub.GitHubEmoji.literals) -assertOneEffect() diff --git a/tools/packaging/tsconfig.backends.json b/tools/packaging/tsconfig.backends.json deleted file mode 100644 index dc6ad81..0000000 --- a/tools/packaging/tsconfig.backends.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "./tsconfig.json", - "files": ["consumer.ts", "backends.ts"] -} diff --git a/tools/packaging/tsconfig.consumer.json b/tools/packaging/tsconfig.consumer.json deleted file mode 100644 index 650cb7e..0000000 --- a/tools/packaging/tsconfig.consumer.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2023", - "lib": ["ESNext", "DOM", "DOM.Iterable"], - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "skipLibCheck": false, - "types": [], - "verbatimModuleSyntax": true, - "noEmitOnError": true, - "outDir": "compiled" - }, - "files": ["consumer.ts", "organizations.ts"] -} diff --git a/tools/packaging/tsconfig.tool.json b/tools/packaging/tsconfig.tool.json deleted file mode 100644 index b5ddc99..0000000 --- a/tools/packaging/tsconfig.tool.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "types": ["bun"] - }, - "include": ["../verify-package-exports.ts", "../test-backends.ts"] -} diff --git a/typed-lint.config.ts b/typed-lint.config.ts index 5aa3205..d5950ea 100644 --- a/typed-lint.config.ts +++ b/typed-lint.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from './tools/typed/src/config' export default defineConfig({ projects: ['tsconfig.json'], projectExcludes: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/coverage/**'], - sourceExcludes: ['**/*.d.ts', '**/node_modules/**', '**/dist/**', '**/build/**', '**/coverage/**', 'tools/**'], + sourceExcludes: ['**/*.d.ts', '**/node_modules/**', '**/dist/**', '**/build/**', '**/coverage/**', 'tools/**', 'scripts/**'], rules: { 'no-svg-files': [ 'error', diff --git a/vite.config.ts b/vite.config.ts index 5fceafc..0801fae 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -58,6 +58,7 @@ export default defineConfig({ 'tools/oxlint/automation/**', 'tools/diff-check/**', 'tools/typed/**', + 'scripts/**', ], categories: { correctness: 'error', From 0fed6a1367421c3f0f49ce3654adf1458e51c4fb Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Sat, 3 Oct 2026 14:30:16 -0700 Subject: [PATCH 2/6] fix(release): read npm pack output from npm 10 and 11 Co-Authored-By: Claude Opus 5.5 HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a102d3-2fe4-7531-aff7-d6c62a5dc2e8 --- scripts/release/validate.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/scripts/release/validate.ts b/scripts/release/validate.ts index ec98520..5b3628c 100644 --- a/scripts/release/validate.ts +++ b/scripts/release/validate.ts @@ -104,8 +104,21 @@ try { ['npm', 'pack', '--json', '--pack-destination', archives], join(stage, 'packages', directory), ) - const [packed] = JSON.parse(output) as Array<{ filename: string }> - if (packed === undefined) throw new Error(`npm pack produced nothing for ${directory}`) + /** npm 10 prints a list of packed packages; npm 11 and later print an object keyed by name. */ + const parsed: unknown = JSON.parse(output) + const entries: Array = Array.isArray(parsed) + ? parsed + : typeof parsed === 'object' && parsed !== null + ? Object.values(parsed) + : [] + const packed = entries.find( + (entry): entry is { filename: string } => + typeof entry === 'object' && + entry !== null && + 'filename' in entry && + typeof entry.filename === 'string', + ) + if (packed === undefined) throw new Error(`npm pack produced nothing for ${directory}:\n${output}`) tarballs.push(join(archives, packed.filename)) } From 2552ee6ef74fcc96dfc07e1b99c86ced26b9d453 Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Sat, 3 Oct 2026 14:33:05 -0700 Subject: [PATCH 3/6] fix(release): compile packages with tsc instead of Bun's bundler Bun 1.3.14's bundler emits a broken export for `export * as Channels`, which the release check caught under Node.js. tsc emits one file per source file, the same on every Bun version. Co-Authored-By: Claude Opus 5.5 HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a102d3-2fe4-7531-aff7-d6c62a5dc2e8 --- scripts/build/packages.ts | 43 +++++++++++++++------------------------ 1 file changed, 16 insertions(+), 27 deletions(-) diff --git a/scripts/build/packages.ts b/scripts/build/packages.ts index 5423291..9aa0981 100644 --- a/scripts/build/packages.ts +++ b/scripts/build/packages.ts @@ -1,6 +1,6 @@ /** - * Builds every package in `packages/` into its own `dist/`: one ESM file per export, and declaration - * files that resolve under both `bundler` and `NodeNext` module resolution. + * Builds every package in `packages/` into its own `dist/` with TypeScript: one `.js`, `.js.map`, and + * `.d.ts` per source file, which Node.js and `NodeNext` projects can both read. */ import { readdir, rm, stat } from 'node:fs/promises' import { dirname, join } from 'node:path' @@ -14,23 +14,23 @@ const exists = (path: string) => ) /** - * The source keeps relative imports without extensions, which `NodeNext` refuses in declaration files. - * Point each one at its emitted `.js` (or `/index.js`); TypeScript reads the `.d.ts` beside it. + * The source keeps relative imports without extensions, which Node.js refuses in ESM and `NodeNext` + * refuses in declaration files. Point each one at its emitted `.js` (or `/index.js`). */ const relativeSpecifier = /((?:from|import)\s*\(?\s*['"])(\.{1,2}\/[^'"]*?)(['"])/g -const rewriteDeclarations = async (directory: string) => { +const addExtensions = async (directory: string) => { for (const entry of await readdir(directory, { recursive: true })) { - if (!entry.endsWith('.d.ts')) continue + if (!entry.endsWith('.js') && !entry.endsWith('.d.ts')) continue const file = join(directory, entry) const text = await Bun.file(file).text() const replacements = new Map() for (const [, , specifier] of text.matchAll(relativeSpecifier)) { if (specifier === undefined || /\.[cm]?js$/.test(specifier) || replacements.has(specifier)) continue const target = join(dirname(file), specifier) - if (await exists(`${target}.d.ts`)) replacements.set(specifier, `${specifier}.js`) - else if (await exists(join(target, 'index.d.ts'))) replacements.set(specifier, `${specifier}/index.js`) - else throw new Error(`${file} imports ${specifier}, which has no declaration file`) + if (await exists(`${target}.js`)) replacements.set(specifier, `${specifier}.js`) + else if (await exists(join(target, 'index.js'))) replacements.set(specifier, `${specifier}/index.js`) + else throw new Error(`${file} imports ${specifier}, which was not emitted`) } await Bun.write( file, @@ -45,22 +45,11 @@ const rewriteDeclarations = async (directory: string) => { for (const name of libraries) { const directory = join(root, 'packages', name) const manifest = await json<{ name: string; exports: Record }>(join(directory, 'package.json')) - const entrypoints = Object.values(manifest.exports).filter((entry) => /^\.\/src\/.*\.ts$/.test(entry)) - if (entrypoints.length === 0) throw new Error(`${manifest.name} exports no TypeScript entrypoint`) + if (!Object.values(manifest.exports).every((entry) => /^\.\/src\/.*\.ts$/.test(entry))) + throw new Error(`${manifest.name} must export TypeScript files in src/`) const outdir = join(directory, 'dist') await rm(outdir, { recursive: true, force: true }) - const result = await Bun.build({ - entrypoints: entrypoints.map((entry) => join(directory, entry)), - outdir, - root: join(directory, 'src'), - target: 'browser', - format: 'esm', - packages: 'external', - sourcemap: 'external', - }) - if (!result.success) throw new AggregateError(result.logs, `Failed to build ${manifest.name}`) - const config = join(directory, 'tsconfig.release.json') await Bun.write( config, @@ -70,7 +59,7 @@ for (const name of libraries) { compilerOptions: { noEmit: false, declaration: true, - emitDeclarationOnly: true, + sourceMap: true, outDir: './dist', rootDir: './src', }, @@ -80,10 +69,10 @@ for (const name of libraries) { 2, )}\n`, ) - const declarations = Bun.spawn(['bunx', 'tsc', '-p', config], { cwd: root, stdout: 'inherit', stderr: 'inherit' }) - const exitCode = await declarations.exited + const compile = Bun.spawn(['bunx', 'tsc', '-p', config], { cwd: root, stdout: 'inherit', stderr: 'inherit' }) + const exitCode = await compile.exited await rm(config, { force: true }) - if (exitCode !== 0) throw new Error(`Failed to emit declarations for ${manifest.name}`) - await rewriteDeclarations(outdir) + if (exitCode !== 0) throw new Error(`Failed to compile ${manifest.name}`) + await addExtensions(outdir) console.log(`built ${manifest.name}`) } From 9e68c4e0af333e154bb05509ef5c4ea13cef2a0d Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Mon, 5 Oct 2026 11:09:00 -0700 Subject: [PATCH 4/6] feat(release): connect packages to trusted publishing from the CLI Add `bun run release:trust`, which runs `npm trust github` for every package against release.yml in humanlayer/effect-channels and skips packages that are already connected. Document the first-release steps in the README. Co-Authored-By: Claude Opus 5.5 HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a102d3-2fe4-7531-aff7-d6c62a5dc2e8 --- README.md | 2 ++ package.json | 7 ++++--- scripts/release/trust.ts | 39 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 scripts/release/trust.ts diff --git a/README.md b/README.md index 977a685..80aa488 100644 --- a/README.md +++ b/README.md @@ -581,3 +581,5 @@ bun run release:prepare --version 0.2.0-rc.1 bun run release:validate --version 0.2.0-rc.1 bun run release:publish --version 0.2.0-rc.1 --tag rc --dry-run ``` + +The first release of a new package has to come from a laptop, because npm only lets GitHub publish a package that already exists. Sign in with `npm login` (your account needs two-factor login on), run the four commands above without `--dry-run`, then connect every package to GitHub with `bun run release:trust`. It skips packages that are already connected. diff --git a/package.json b/package.json index 698ae2a..7774153 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "release:tag-info": "bun run scripts/release/tag-info.ts", "release:prepare": "bun run scripts/release/prepare.ts", "release:validate": "bun run scripts/release/validate.ts", - "release:publish": "bun run scripts/release/publish.ts" + "release:publish": "bun run scripts/release/publish.ts", + "release:trust": "bun run scripts/release/trust.ts" }, "devDependencies": { "@effect/language-service": "0.87.3", @@ -50,7 +51,6 @@ "vitest": "catalog:" }, "overrides": { - "workerd": "1.20261002.1", "@effect/opentelemetry": "4.0.0", "@effect/platform-browser": "4.0.0", "@effect/platform-bun": "4.0.0", @@ -61,7 +61,8 @@ "@effect/sql-sqlite-do": "4.0.0", "@effect/vitest": "4.0.0", "effect": "4.0.0", - "vite": "npm:@voidzero-dev/vite-plus-core@1.0.0" + "vite": "npm:@voidzero-dev/vite-plus-core@1.0.0", + "workerd": "1.20261002.1" }, "packageManager": "bun@1.3.14", "catalog": { diff --git a/scripts/release/trust.ts b/scripts/release/trust.ts new file mode 100644 index 0000000..f4f0d03 --- /dev/null +++ b/scripts/release/trust.ts @@ -0,0 +1,39 @@ +/** + * Connects every package to GitHub Actions trusted publishing, so `release.yml` can publish without an + * npm token. Run it once, from a laptop signed in with `npm login`, after each package exists on npm. + * + * A package that already trusts `release.yml` in this repository is skipped, so it is safe to run again. + * Pass `--dry-run` to see what it would do. + */ +import { join } from 'node:path' +import { parseArgs } from 'node:util' + +import { json, libraries, root } from './manifest' + +const repository = 'humanlayer/effect-channels' +const workflow = 'release.yml' +const dryRun = parseArgs({ options: { 'dry-run': { type: 'boolean', default: false } } }).values['dry-run'] + +const npm = async (args: Array, capture: boolean) => { + const child = Bun.spawn(['npm', ...args], { + stdin: 'inherit', + stdout: capture ? 'pipe' : 'inherit', + stderr: 'inherit', + }) + const output = capture ? await new Response(child.stdout).text() : '' + return { exitCode: await child.exited, output } +} + +for (const directory of libraries) { + const { name } = await json<{ name: string }>(join(root, 'packages', directory, 'package.json')) + const existing = await npm(['trust', 'list', name, '--json'], true) + if (existing.exitCode !== 0) throw new Error(`Could not list trusted publishers for ${name}; is it on npm yet?`) + if (existing.output.includes(repository) && existing.output.includes(workflow)) { + console.log(`skipping ${name}: already trusts ${repository} ${workflow}`) + continue + } + console.log(`${dryRun ? 'dry run of' : 'connecting'} ${name} to ${repository} ${workflow}`) + const args = ['trust', 'github', name, '--file', workflow, '--repo', repository, '--allow-publish', '--yes'] + const { exitCode } = await npm(dryRun ? [...args, '--dry-run'] : args, false) + if (exitCode !== 0) throw new Error(`npm trust failed for ${name}`) +} From 91222d7f864646c079a791725826486674430f49 Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Mon, 5 Oct 2026 11:09:11 -0700 Subject: [PATCH 5/6] docs: realign README tables after the package rename Co-Authored-By: Claude Opus 5.5 HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a102d3-2fe4-7531-aff7-d6c62a5dc2e8 --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 80aa488..cc413c7 100644 --- a/README.md +++ b/README.md @@ -45,10 +45,10 @@ flowchart LR | Package | What it is | | --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | -| [`packages/delivery`](./packages/delivery/) | The core: `Channels.make`, mailboxes, delivery modes, handoff, the delivery API and its client. | -| [`packages/slack`](./packages/slack/) | `SlackBot.make`: Slack events, threads, messages, reactions, files, streaming, status line. | -| [`packages/github`](./packages/github/) | `GitHubBot.make`: GitHub App events, issues, pull requests, reviews, checks, and job logs. | -| [`packages/linear`](./packages/linear/) | `LinearBot.make`: Linear Agent Sessions, issues, comments, reactions, and files. | +| [`packages/delivery`](./packages/delivery/) | The core: `Channels.make`, mailboxes, delivery modes, handoff, the delivery API and its client. | +| [`packages/slack`](./packages/slack/) | `SlackBot.make`: Slack events, threads, messages, reactions, files, streaming, status line. | +| [`packages/github`](./packages/github/) | `GitHubBot.make`: GitHub App events, issues, pull requests, reviews, checks, and job logs. | +| [`packages/linear`](./packages/linear/) | `LinearBot.make`: Linear Agent Sessions, issues, comments, reactions, and files. | | [`packages/sql`](./packages/sql/) | Mailbox storage on Postgres. | | [`packages/redis`](./packages/redis/) | Mailbox storage on Redis. | | [`packages/alchemy-cloudflare`](./packages/alchemy-cloudflare/) | Mailbox storage on Cloudflare Durable Objects, one object per mailbox, woken by its alarm. | @@ -352,7 +352,7 @@ Nothing runs until one of these is built, and a program that uses more than one | ---------- | --------------------------------------------------------------- | ------------------------- | | Postgres | [`packages/sql`](./packages/sql/) | `ChannelsSql.make` | | Redis | [`packages/redis`](./packages/redis/) | `ChannelsRedis.make` | -| Memory | [`packages/delivery`](./packages/delivery/) | `ChannelsMemory.make` | +| Memory | [`packages/delivery`](./packages/delivery/) | `ChannelsMemory.make` | | Cloudflare | [`packages/alchemy-cloudflare`](./packages/alchemy-cloudflare/) | `ChannelsCloudflare.make` | Every store supports handoff. Memory is for tests and local work: it forgets everything when the process stops. From 5775d34ae2121f7d7a5463c83ff18687f3082fa7 Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Mon, 5 Oct 2026 11:17:09 -0700 Subject: [PATCH 6/6] revert(release): drop the release:trust script Co-Authored-By: Claude Opus 5.5 HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a102d3-2fe4-7531-aff7-d6c62a5dc2e8 --- README.md | 2 -- package.json | 3 +-- scripts/release/trust.ts | 39 --------------------------------------- 3 files changed, 1 insertion(+), 43 deletions(-) delete mode 100644 scripts/release/trust.ts diff --git a/README.md b/README.md index cc413c7..f314513 100644 --- a/README.md +++ b/README.md @@ -581,5 +581,3 @@ bun run release:prepare --version 0.2.0-rc.1 bun run release:validate --version 0.2.0-rc.1 bun run release:publish --version 0.2.0-rc.1 --tag rc --dry-run ``` - -The first release of a new package has to come from a laptop, because npm only lets GitHub publish a package that already exists. Sign in with `npm login` (your account needs two-factor login on), run the four commands above without `--dry-run`, then connect every package to GitHub with `bun run release:trust`. It skips packages that are already connected. diff --git a/package.json b/package.json index 7774153..2fa1901 100644 --- a/package.json +++ b/package.json @@ -26,8 +26,7 @@ "release:tag-info": "bun run scripts/release/tag-info.ts", "release:prepare": "bun run scripts/release/prepare.ts", "release:validate": "bun run scripts/release/validate.ts", - "release:publish": "bun run scripts/release/publish.ts", - "release:trust": "bun run scripts/release/trust.ts" + "release:publish": "bun run scripts/release/publish.ts" }, "devDependencies": { "@effect/language-service": "0.87.3", diff --git a/scripts/release/trust.ts b/scripts/release/trust.ts deleted file mode 100644 index f4f0d03..0000000 --- a/scripts/release/trust.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Connects every package to GitHub Actions trusted publishing, so `release.yml` can publish without an - * npm token. Run it once, from a laptop signed in with `npm login`, after each package exists on npm. - * - * A package that already trusts `release.yml` in this repository is skipped, so it is safe to run again. - * Pass `--dry-run` to see what it would do. - */ -import { join } from 'node:path' -import { parseArgs } from 'node:util' - -import { json, libraries, root } from './manifest' - -const repository = 'humanlayer/effect-channels' -const workflow = 'release.yml' -const dryRun = parseArgs({ options: { 'dry-run': { type: 'boolean', default: false } } }).values['dry-run'] - -const npm = async (args: Array, capture: boolean) => { - const child = Bun.spawn(['npm', ...args], { - stdin: 'inherit', - stdout: capture ? 'pipe' : 'inherit', - stderr: 'inherit', - }) - const output = capture ? await new Response(child.stdout).text() : '' - return { exitCode: await child.exited, output } -} - -for (const directory of libraries) { - const { name } = await json<{ name: string }>(join(root, 'packages', directory, 'package.json')) - const existing = await npm(['trust', 'list', name, '--json'], true) - if (existing.exitCode !== 0) throw new Error(`Could not list trusted publishers for ${name}; is it on npm yet?`) - if (existing.output.includes(repository) && existing.output.includes(workflow)) { - console.log(`skipping ${name}: already trusts ${repository} ${workflow}`) - continue - } - console.log(`${dryRun ? 'dry run of' : 'connecting'} ${name} to ${repository} ${workflow}`) - const args = ['trust', 'github', name, '--file', workflow, '--repo', repository, '--allow-publish', '--yes'] - const { exitCode } = await npm(dryRun ? [...args, '--dry-run'] : args, false) - if (exitCode !== 0) throw new Error(`npm trust failed for ${name}`) -}