diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 152c745..e43bdfa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,50 +1,40 @@ -name: Release +name: Release on NPM -# Publishes @sidebase/streamctl to the public npm registry under the @sidebase -# scope. workflow_dispatch only, and scoped to the `release` environment. +# Publishes @sidebase/streamctl to the public npm registry, tokenless. # -# That environment is only a gate once it exists and carries reviewers plus -# NPM_TOKEN. GitHub auto-creates a missing environment unprotected and -# secretless, so an unconfigured dispatch skips the approval pause and dies at -# publish on auth. docs/release.md has the setup checklist and how to check it. -# -# The two packages version independently, no lockstep. Publish the CLI before -# the config package on a first publish: the config repo's validate-presets -# consumes the schema this package exports. See docs/release.md. +# Same shape as sidebase/ssm-secrets and sidebase/nuxt-auth: publishing a +# GitHub Release for a vX.Y.Z tag is the trigger, and the OIDC token minted by +# `id-token: write` authenticates to npm. No NPM_TOKEN exists, so there is no +# publish secret to leak or rotate. docs/release.md has the checklist. on: - workflow_dispatch: - inputs: - version: - description: "Release version X.Y.Z (no leading v)." - required: true - type: string + release: + types: [published] # runs when a GitHub Release is published permissions: - contents: write # create + push the vX.Y.Z tag - id-token: write # OIDC for npm provenance (--provenance) + contents: read + id-token: write # OIDC: npm trusted publishing + provenance concurrency: group: release cancel-in-progress: false jobs: - release: - # Belt-and-braces: never release off a non-default branch. - if: github.ref == 'refs/heads/main' + publish: + name: Publish package from release tag + # Only tags produced by the release process, `vX.Y.Z` or a `-` prerelease. + if: startsWith(github.event.release.tag_name, 'v') runs-on: ubuntu-latest timeout-minutes: 15 - # Protected environment: require a reviewer + hold the NPM_TOKEN secret here. - # A compromised release propagates to every consuming repo on the next - # `streamctl upgrade`, so this gate matters. - environment: release - # Bind the dispatch input to an env var and reference "$VERSION" in run blocks, - # rather than interpolating `${{ inputs.version }}` straight into shell (which is - # a script-injection shape even though the input is maintainer-only here). + # Bind the tag to an env var and reference "$TAG" in run blocks rather than + # interpolating `${{ }}` into shell, which is a script-injection shape even + # though only a maintainer can publish a release here. env: - VERSION: ${{ inputs.version }} + TAG: ${{ github.event.release.tag_name }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Check out the tag referenced by this release + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event.release.tag_name }} fetch-depth: 0 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 @@ -53,69 +43,55 @@ jobs: with: node-version: 24 cache: pnpm + # Required for setup-node to write the registry into .npmrc. registry-url: https://registry.npmjs.org - scope: "@sidebase" - - - run: pnpm install --frozen-lockfile - # Fail fast if this version was already tagged. A re-release would publish - # a tarball that diverges from the existing tag. - - name: Guard, release tag must not already exist + # The tag is the source of truth for the version now, so a tag that + # disagrees with package.json would publish a version nobody asked for. + # build.config.ts also injects package.json "version" into the CLI's + # `--version` string, so a mismatch ships a CLI that misreports itself. + - name: Verify tag matches package.json version run: | - tag="v${VERSION}" - if git ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then - echo "::error::Tag ${tag} already exists on origin, refusing to re-release." + PKG_VERSION=$(node -p "require('./package.json').version") + if [ "v$PKG_VERSION" != "$TAG" ]; then + echo "::error::Tag ($TAG) does not match package.json version (v$PKG_VERSION)" exit 1 fi - echo "Tag ${tag} is free." + echo "Tag $TAG matches package.json." - # Set the version FIRST: build.config.ts injects package.json "version" into - # the CLI's `--version` string at build time, so the bump must precede build. - - name: Set version - run: npm pkg set version="${VERSION}" + - run: pnpm install --frozen-lockfile - # Full quality gate before anything is published. + # Full quality gate before anything is published. The siblings skip this + # because their PR CI covers it; kept here because publishing is the only + # thing that runs against the tagged tree specifically. - run: pnpm typecheck - run: pnpm test - run: pnpm lint - run: pnpm build - # Verify the PACKAGED artifact before publishing. publint checks the - # published package.json (exports / files / bin correctness); attw checks type - # resolution. `--profile esm-only` acknowledges this is an intentionally - # ESM-only package, so the CJS/node10 "failures" are expected, not real. + # Verify the PACKAGED artifact. publint checks the published package.json + # (exports / files / bin); attw checks type resolution. `--profile + # esm-only` acknowledges this is an intentionally ESM-only package, so the + # CJS/node10 "failures" are expected, not real. - name: Lint the package (publint) run: pnpm exec publint - name: Check type resolution (are-the-types-wrong) run: pnpm exec attw --pack . --profile esm-only - # Commit the version bump and tag it BEFORE publishing, so the tagged tree - # is the published artifact. The tag is annotated, so - # `git push --follow-tags` carries it with the commit. - - name: Commit version bump + tag + # `npm publish`, not `pnpm publish`: the OIDC exchange for trusted + # publishing is implemented in the npm CLI, and it is what the sibling + # repos publish with. `prepublishOnly` rebuilds, which is redundant after + # the build above and cheap enough to leave as a backstop. + - name: Publish to npm with provenance run: | - tag="v${VERSION}" - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git commit -m "release: ${tag}" package.json - git tag -a "${tag}" -m "Release ${tag}" + # Stable release (vX.Y.Z) + if echo "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + npm publish --provenance --access public - # Public npm publish WITH provenance: `--provenance` attaches a signed - # build-attestation (requires `id-token: write` + npm CLI >=9.5 on a public - # repo). `--access public` is required for a first-time scoped publish. - - name: Publish to npm (public, with provenance) - run: pnpm publish --no-git-checks --access public --provenance - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + # Pre-release (vX.Y.Z-*) + elif echo "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+-'; then + npm publish --provenance --access public --tag next - # Push the release commit + tag ONLY after a successful publish, so a failed - # publish leaves origin untouched (no orphan tag for an unpublished version). - - name: Push release commit + tag - run: git push --follow-tags origin HEAD:main - - # Surface the tag as a GitHub Release with notes generated from the commit - # subjects since the previous tag. Runs after the tag is on origin. - - name: Create GitHub Release - run: gh release create "v${VERSION}" --title "v${VERSION}" --generate-notes - env: - GITHUB_TOKEN: ${{ github.token }} + else + echo "Not a valid release tag ($TAG), skipping publish." + fi diff --git a/docs/release.md b/docs/release.md index 35bff72..028ce8e 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,128 +1,79 @@ # Release runbook (`@sidebase/streamctl`) -> **Status: `0.1.0` is on the registry, published by hand.** The `Release` -> workflow (`.github/workflows/release.yml`) has never published, and the -> one-time setup below is not confirmed done. It stays `workflow_dispatch`-only -> and scoped to the `release` environment, so every publish is a deliberate -> manual dispatch. - -`streamctl` publishes to the public npm registry under the `@sidebase` scope. It -is an intentionally ESM-only package; the published tarball ships only `dist/`. - -## Compatibility contract: `schemaVersion` - -What couples the CLI to a payload is the payload manifest's integer -**`schemaVersion`** (currently `2`), not the package version. - -- A CLI major supports exactly one `schemaVersion`. The supported value is exported - at the CLI's `./manifest` subpath — the same zod schema a payload validates its - presets against. -- If the running CLI does not support a payload's `schemaVersion`, the user gets a - dedicated "payload requires a newer/older streamctl" error rather than a generic - `CONFIG_INVALID`. The loader leaves room for per-version migrations later. -- The config file's `version` pin governs the payload package only, and - `CONFIG_VERSION_MISMATCH` compares the installed payload against that pin. - `upgrade` moves the payload pin and its devDep, and leaves the CLI version alone. - -**Bumping `schemaVersion` is a CLI major.** Ship a CLI major that supports the new -schema before any payload adopts it, or existing installs break. - -## How `--version` is produced - -The CLI's `--version` is injected at build time from `package.json` -(`build.config.ts` rollup replace of the `__STREAMCTL_VERSION__` token). Set the -version, then build, then publish — the workflow already orders these correctly. -The JSON envelope (`--json`) is **append-only** (new fields, never renamed or -removed) so consumer CI that parses it survives CLI upgrades. - -## One-time setup - -**Not verified as done.** `0.1.0` reached the registry by a manual publish that -bypassed this workflow, so its presence says nothing about whether the workflow -can publish. As of writing, `gh api repos/sidebase/streamctl/environments` -returns zero environments and `gh secret list` is empty, so at minimum step 3 is -outstanding. Step 2's token may exist as an **org** secret, which is not readable -without org admin. Confirm before the first dispatch. - -A missing environment does not fail loudly: GitHub creates one on demand with no -protection rules and no secrets, so the run loses its approval gate and reaches -the publish step with an empty `NODE_AUTH_TOKEN`. It then fails on auth, before -the push, leaving origin untouched. - -1. **npm org / scope.** Create/claim the `@sidebase` org on npmjs.com and add the - release machine account. Confirm the package name `@sidebase/streamctl` is free - (or owned). `publishConfig.access` is already `public` in `package.json`. -2. **Token / secret.** Mint an npm **automation** token (bypasses 2FA for CI) with - publish rights on `@sidebase`, and store it as the `NPM_TOKEN` secret **on the - protected `release` environment** (not repo-wide). The workflow uses OIDC - (`id-token: write`) for `--provenance`; provenance additionally requires the - repository to be **public**. -3. **Environment protection.** Add required reviewer(s) to the `release` - environment so a dispatch pauses for approval before publish. +> **`0.1.0` is on npm, published by hand.** The `Release on NPM` workflow has +> never run. One setup item below still blocks it. + +ESM-only, published public under the `@sidebase` scope, tarball ships only `dist/`. + +## Blocking: register the trusted publisher + +There is no publish secret. The workflow authenticates with the OIDC token from +`id-token: write`, the same tokenless setup as `sidebase/ssm-secrets` and +`sidebase/nuxt-auth`. npm still has to be told which workflow may publish. + +On npmjs.com, package settings for `@sidebase/streamctl`, add a trusted publisher +for repository `sidebase/streamctl`, workflow `release.yml`. Until then a run +fails at publish on auth, having changed nothing. ## Cutting a release -Dispatch the `Release` workflow with the target `X.Y.Z` (no leading `v`) and -approve the environment gate. The workflow: - -1. Refuses if the `vX.Y.Z` tag already exists on origin. -2. Sets `package.json` version to the input, then runs the full gate: - `typecheck` → `test` → `lint` → `build`, then `publint` and - `attw --pack . --profile esm-only`. -3. Commits `release: vX.Y.Z` and an annotated `vX.Y.Z` tag. -4. Publishes with `pnpm publish --access public --provenance`. -5. Pushes the release commit + tag to `main` **only after** a successful publish, - so a failed publish leaves origin untouched. -6. Creates a GitHub Release for the tag with notes generated from the commit - subjects since the previous tag (`gh release create --generate-notes`). - -After the run, verify the published tarball on npm, the `vX.Y.Z` tag, and the -generated GitHub Release notes. - -Step 6 diffs against the previous tag, so every release needs its predecessor -tagged or the notes cover the whole history. **`0.1.0` was published outside this -workflow and left no tag.** `v0.1.0` has since been backfilled onto -`265809b` (`chore: bump deps (#6)`), the last commit carrying that version, so -the next release diffs against the right point. Nothing else needs backfilling. - -The version is an input, not something you edit first. Do not bump -`package.json` by hand before dispatching: step 2 sets it, and a pre-bumped -working tree just means the release commit contains no version change. +1. Bump `package.json` to `X.Y.Z` and merge to `main`. +2. `git tag -a vX.Y.Z -m vX.Y.Z && git push origin vX.Y.Z` +3. `gh release create vX.Y.Z --title vX.Y.Z --generate-notes`, then **publish** + it. A draft triggers nothing. + +Publishing runs the workflow: checks out the tag, refuses if the tag and +`package.json` version disagree, runs `typecheck` / `test` / `lint` / `build` +plus `publint` and `attw`, then `npm publish --provenance`. A `vX.Y.Z-rc.1` tag +goes to the `next` dist-tag instead of `latest`. + +Nothing ships until you publish the release, and a failed run leaves the tag and +release intact, so re-publishing the same release re-runs it. Afterwards, check +the tarball on npm and that provenance is attached. + +Two things worth knowing: + +- `--generate-notes` diffs against the previous tag. That is why `v0.1.0` was + backfilled onto `265809b`; nothing else needs backfilling. +- `--version` is baked in at build time from `package.json`, so a tag that + disagrees would ship a CLI that misreports itself. Hence the check. + +## Versioning + +What couples the CLI to a payload is the manifest's `schemaVersion` (currently +`2`), not the package version. A CLI major supports exactly one, exported at the +`./manifest` subpath. **Bumping it is a CLI major**, and the supporting CLI has +to ship before any payload adopts it. + +The config file's `version` pin governs the payload package only. `upgrade` moves +that pin and leaves the CLI version alone. The CLI and the payload release on +their own schedules: **no lockstep and no shared version number**, which is why +`init` writes the two devDep pins from separate values. + +The `--json` envelope is append-only: new fields, never renamed or removed, so +consumer CI survives upgrades. Use Conventional Commit subjects, with `!` for +anything that moves that envelope, the exit codes, or `schemaVersion`. ## Notes for the next release -Include these in the release notes; the rest is generated from commit subjects. +Generated notes only cover commit subjects, so add these by hand. -- **The config file's default location moved** to `streamctl.config.ts` at the repo +- **The default config location moved** to `streamctl.config.ts` in the repo root. `init` writes it there. -- **`.streamctl/config.*` keeps working, permanently.** Not deprecated, no warning, - no removal planned. Existing repos need to do nothing. A repo that *does* move its - config needs this CLI version or newer. -- **One breaking edge:** a config at `.config/.streamctl/config.ts` resolved before - this release and does not now — it raises `NOT_INITIALIZED`. Measured against - c12 3.3.4: the old `configFile: ".streamctl/config"` spelling made c12 probe - `.config/.streamctl/config`, and the new spelling does not. The form is - undocumented and nested, so realistically nobody is on it, but the fix is one - command: +- **`.streamctl/config.*` keeps working, permanently.** Not deprecated, no + warning, no removal planned. Existing repos need to do nothing. +- **One break:** `.config/.streamctl/config.ts` used to resolve through c12's + `.config/` convention and now raises `NOT_INITIALIZED`. Fix: ```sh git mv .config/.streamctl/config.ts streamctl.config.ts ``` - Nothing else under `.config/` is read by streamctl, before or after this release. - - The affected population is narrower than it reads: below c12 3.2.0 there is no - `_configFile`, so the old loader raised `NOT_INITIALIZED` from any location. A - repo on this layout was only ever working if its tree resolved c12 >= 3.2.0. - The `git mv` is worth doing either way, so the instruction above is not - conditional on that. -- **New warning:** two extensions of the same config at one location - (`streamctl.config.js` next to `streamctl.config.ts`) now warn on stderr that one is - *shadowed by* the other, naming the one being read. c12's order puts `.js` ahead of - `.ts`, which surprises most people. **Nothing is read differently than before** — this - is a new diagnostic, not new behaviour, so a repo that sees it needs no migration. + Nothing else under `.config/` was ever read. Realistically nobody is affected: + the layout is undocumented, and it only ever worked on trees resolving + c12 >= 3.2.0. +- **New warning:** two extensions at one location (`streamctl.config.js` beside + `streamctl.config.ts`) now warn on stderr which one is being read, since c12 + orders `.js` ahead of `.ts`. Nothing is read differently than before, so a repo + that sees it needs no migration. - Minor bump: new default, no removals. - -Use Conventional Commit subjects (and `!` / `BREAKING CHANGE:` for anything that -moves the `--json` envelope, exit codes, or the manifest `schemaVersion`) so the -history reads clearly for consumers.