From 7b700616014a9d5e07a3590c6cb19421f7fc05bd Mon Sep 17 00:00:00 2001 From: Janghoon Lee <44862514+savagemanage@users.noreply.github.com> Date: Tue, 22 Sep 2026 01:37:47 +0000 Subject: [PATCH] build(sdk): add a publish path for @redrob-code/sdk, separate from the signing release The SDK has never been published. `publish.ts` was already written and complete; nothing called it, and `release.yml` mentions npm nowhere. So a consumer outside this repository cannot depend on the fork's own SDK at all -- which is why the desktop app still consumes the upstream `@opencode-ai/sdk` and cannot see types added here. A separate workflow rather than a step in `release.yml`, deliberately. That workflow reaches the Apple and Windows signing credentials, and bolting an npm publish onto it means a missing or expired token turns a signed binary release red AFTER the artifacts are already out. Here a token problem fails one run that has published nothing else. `workflow_dispatch` only, dry-run by default. The first publish of a public package under this scope is not reversible: npm allows unpublishing a new version only within 72 hours, and a name once taken stays taken. The version comes from the release, not from `package.json`, which holds `0.0.0` on purpose. This package is generated from the CLI's own API surface, so a version committed beside it would be a second number to bump and would drift from the CLI it describes. Taking it from the release tag also makes an SDK build answerable -- a consumer can tell which CLI generated it -- and `publish.ts` restores the file afterwards, so a release leaves no diff behind. The repository-id guard matches `release.yml`: a fork must never publish under this scope, and a rename must not silently switch the guard off. --- .github/workflows/publish-sdk.yml | 115 ++++++++++++++++++++++++++++++ packages/sdk/js/script/publish.ts | 19 ++++- 2 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/publish-sdk.yml diff --git a/.github/workflows/publish-sdk.yml b/.github/workflows/publish-sdk.yml new file mode 100644 index 0000000000..881c2888a2 --- /dev/null +++ b/.github/workflows/publish-sdk.yml @@ -0,0 +1,115 @@ +# Publish `@redrob-code/sdk` to npm. +# +# DELIBERATELY SEPARATE from release.yml. That workflow reaches the Apple and Windows signing +# credentials, and bolting an npm publish onto it would mean a missing or expired npm token turns a +# signed binary release red after the artifacts are already out. Here a token problem fails one run +# that has published nothing else. +# +# `workflow_dispatch` only, and dry-run by default, because the first publish of a public package +# under this scope is not reversible: npm allows unpublishing a new version only within 72 hours, +# and a name, once taken, stays taken. +# +# The version is NOT read from packages/sdk/js/package.json, which holds 0.0.0 on purpose. It comes +# from `version` below and reaches `publish.ts` as REDROB_VERSION, so an SDK build is answerable to +# the CLI release it was generated from. +name: publish-sdk + +on: + workflow_dispatch: + inputs: + version: + description: "Version to publish, without the leading v (e.g. 0.4.1). Defaults to the latest release tag." + required: false + type: string + dry_run: + description: "Build and pack, but publish nothing." + required: false + default: true + type: boolean + +concurrency: + group: publish-sdk + cancel-in-progress: false + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + # Pinned to this repository's immutable id, matching release.yml: a fork must never publish under + # this scope, and a rename must not silently switch the guard off. + if: github.repository_id == '1371675259' + steps: + # Tags are the version state when `version` is not given, and a shallow checkout carries none. + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: develop + fetch-depth: 0 + + - uses: ./.github/actions/setup-bun + + - id: resolve + env: + REQUESTED: ${{ inputs.version }} + run: | + set -euo pipefail + if [ -n "$REQUESTED" ]; then + version="$REQUESTED" + else + # Same tag discipline as release.yml: only plain vMAJOR.MINOR.PATCH counts, so a + # prerelease or a legacy upstream-derived tag cannot become the SDK's version. + latest="$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname \ + | grep -v -- '-' | head -n1 || true)" + if [ -z "$latest" ]; then + echo "::error::no release tag to take a version from, and none was given" + exit 1 + fi + version="${latest#v}" + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "publishing version $version" >> "$GITHUB_STEP_SUMMARY" + + # The package ships `dist` only, so this is what there is to publish. + - name: Build the SDK + working-directory: packages/sdk/js + run: bun run build + + # Fails loudly rather than letting `npm publish` fail after the pack: an absent token is a + # configuration answer, and the run should say so in one line instead of in npm's output. + - name: Check the npm token is present + if: ${{ !inputs.dry_run }} + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + set -euo pipefail + if [ -z "${NPM_TOKEN:-}" ]; then + echo "::error::NPM_TOKEN is not set for this repository or its organization" + exit 1 + fi + + - name: Pack only + if: ${{ inputs.dry_run }} + working-directory: packages/sdk/js + env: + REDROB_VERSION: ${{ steps.resolve.outputs.version }} + REDROB_CHANNEL: latest + run: | + set -euo pipefail + bun pm pack + ls -la ./*.tgz + echo "dry run: packed but published nothing" >> "$GITHUB_STEP_SUMMARY" + + - name: Publish + if: ${{ !inputs.dry_run }} + working-directory: packages/sdk/js + env: + REDROB_VERSION: ${{ steps.resolve.outputs.version }} + REDROB_CHANNEL: latest + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + set -euo pipefail + # npm reads the token from the registry-scoped line, not from the environment name alone. + echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc + bun run ./script/publish.ts diff --git a/packages/sdk/js/script/publish.ts b/packages/sdk/js/script/publish.ts index ed5d8d620e..926e8b7d42 100755 --- a/packages/sdk/js/script/publish.ts +++ b/packages/sdk/js/script/publish.ts @@ -17,6 +17,19 @@ const pkg = JSON.parse(originalText) as { version: string exports: Record } +/* + The version comes from the RELEASE, not from the committed literal. + + `package.json` holds `0.0.0` on purpose: this package is generated from the CLI's own API surface, so a + version committed here would be a second number to remember to bump and would drift from the CLI it + describes. `Script.version` is the figure the release computed, which also makes an SDK version answerable + -- a consumer can tell which CLI a given SDK build was generated from. + + Falls back to whatever is in the file, so running this script outside a release still does something + predictable rather than publishing `undefined`. +*/ +const version = Script.version || pkg.version +pkg.version = version function transformExports(exports: Record) { return Object.fromEntries( Object.entries(exports).map(([key, value]) => { @@ -31,15 +44,17 @@ function transformExports(exports: Record) { }), ) } -if (await published(pkg.name, pkg.version)) { - console.log(`already published ${pkg.name}@${pkg.version}`) +if (await published(pkg.name, version)) { + console.log(`already published ${pkg.name}@${version}`) } else { + console.log(`publishing ${pkg.name}@${version} on tag ${Script.channel}`) pkg.exports = transformExports(pkg.exports) await Bun.write("package.json", JSON.stringify(pkg, null, 2)) try { await $`bun pm pack` await $`npm publish *.tgz --tag ${Script.channel} --access public` } finally { + /* The committed 0.0.0 and the untransformed exports go back, so a release leaves no diff behind. */ await Bun.write("package.json", originalText) } }