Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 53 additions & 77 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
175 changes: 63 additions & 112 deletions docs/release.md
Original file line number Diff line number Diff line change
@@ -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.