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
225 changes: 196 additions & 29 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -1,68 +1,235 @@
name: Publish

on:
workflow_run:
workflows: ["Release"]
types: [completed]
workflow_dispatch:
inputs:
action:
description: "Action to perform"
channel:
description: "Release channel, or publish an existing tag/ref"
required: true
type: choice
options:
- publish-next
- publish-latest
default: publish-next
- next
- finalize
- stable
- existing
default: next
bump:
description: "Version bump (next and stable only)"
required: false
type: choice
options:
- patch
- minor
- major
default: patch
prerelease_tag:
description: "Prerelease tag to finalize (defaults to latest v*-next.* tag)"
required: false
type: string
ref:
description: "Existing release tag/ref to publish (required when channel=existing)"
required: false
type: string

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
group: ${{ github.workflow }}-${{ inputs.channel }}-${{ inputs.ref || inputs.prerelease_tag || inputs.bump || github.ref }}
cancel-in-progress: false

permissions:
contents: read
id-token: write
permissions: {}

jobs:
prepare:
name: Prepare release
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
publish_action: ${{ steps.context.outputs.publish_action }}
release_tag: ${{ steps.context.outputs.release_tag }}
version: ${{ steps.context.outputs.version }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-tags: true
ref: ${{ inputs.channel == 'existing' && inputs.ref || github.ref }}

- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
if [ "${{ inputs.channel }}" != "existing" ]; then
git checkout -B main origin/main
fi

- name: Validate existing ref input
if: inputs.channel == 'existing' && inputs.ref == ''
run: |
echo "::error::ref is required when channel=existing"
exit 1

- name: Resolve finalize ref
if: inputs.channel == 'finalize'
id: finalize_ref
env:
PRERELEASE_TAG: ${{ inputs.prerelease_tag }}
run: |
set -euo pipefail

if [ -n "$PRERELEASE_TAG" ]; then
TAG="$PRERELEASE_TAG"
else
TAG=$(git tag --list 'v*-next.*' --sort=-version:refname | head -n 1)
fi

if [ -z "$TAG" ]; then
echo "::error::No prerelease tag found to finalize"
exit 1
fi

case "$TAG" in
v*) ;;
*) TAG="v$TAG" ;;
esac

echo "tag=$TAG" >> "$GITHUB_OUTPUT"
git checkout --detach "$TAG"

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: package.json

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Bump version, commit, and tag
if: inputs.channel != 'existing'
env:
CHANNEL: ${{ inputs.channel }}
BUMP: ${{ inputs.bump }}
FINALIZE_TAG: ${{ steps.finalize_ref.outputs.tag }}
run: |
set -euo pipefail

if [ "$CHANNEL" = "next" ]; then
bun run release next "$BUMP"
elif [ "$CHANNEL" = "finalize" ]; then
bun run release finalize "$FINALIZE_TAG"
else
bun run release "$BUMP"
fi

- name: Resolve publish context
id: context
run: |
set -euo pipefail

VERSION=$(node -p "require('./package.json').version")
RELEASE_TAG="v${VERSION}"
CHECKOUT_TAG=$(git tag --points-at HEAD | grep -Fx "$RELEASE_TAG" | head -n 1 || true)

if [ -z "$CHECKOUT_TAG" ]; then
echo "::error::Publish checkout must point at $RELEASE_TAG; tags at HEAD are:"
git tag --points-at HEAD || true
exit 1
fi

if [[ "$VERSION" == *"-next."* ]]; then
PUBLISH_ACTION="publish-next"
else
PUBLISH_ACTION="publish-latest"
fi

echo "Publishing $RELEASE_TAG with $PUBLISH_ACTION"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "release_tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT"
echo "publish_action=$PUBLISH_ACTION" >> "$GITHUB_OUTPUT"

- name: Ensure GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail

VERSION="${{ steps.context.outputs.version }}"
RELEASE_TAG="${{ steps.context.outputs.release_tag }}"

if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
echo "GitHub Release $RELEASE_TAG already exists"
exit 0
fi

if [[ "$VERSION" == *"-next."* ]]; then
gh release create "$RELEASE_TAG" --generate-notes --prerelease
else
gh release create "$RELEASE_TAG" --generate-notes
fi

publish:
if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success'
name: Publish npm package
needs: prepare
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-tags: true
ref: ${{ needs.prepare.outputs.release_tag }}

- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"

- name: Upgrade npm
run: npm install -g npm@latest

- uses: oven-sh/setup-bun@v2
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: package.json

- name: Install dependencies
run: bun install
run: bun install --frozen-lockfile

- name: Detect channel
id: channel
- name: Validate publish checkout
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "action=${{ inputs.action }}" >> $GITHUB_OUTPUT
set -euo pipefail

VERSION=$(node -p "require('./package.json').version")
RELEASE_TAG="v${VERSION}"
CHECKOUT_TAG=$(git tag --points-at HEAD | grep -Fx "$RELEASE_TAG" | head -n 1 || true)

if [ -z "$CHECKOUT_TAG" ]; then
echo "::error::Publish checkout must point at $RELEASE_TAG; tags at HEAD are:"
git tag --points-at HEAD || true
exit 1
fi

if [[ "$VERSION" == *"-next."* ]]; then
if [ "${{ needs.prepare.outputs.publish_action }}" != "publish-next" ]; then
echo "::error::Cannot publish prerelease $VERSION with ${{ needs.prepare.outputs.publish_action }}"
exit 1
fi
else
VERSION=$(node -p "require('./package.json').version")
if [[ "$VERSION" == *"-next."* ]]; then
echo "action=publish-next" >> $GITHUB_OUTPUT
else
echo "action=publish-latest" >> $GITHUB_OUTPUT
if [ "${{ needs.prepare.outputs.publish_action }}" != "publish-latest" ]; then
echo "::error::Cannot publish stable version $VERSION with ${{ needs.prepare.outputs.publish_action }}"
exit 1
fi
fi

echo "Publishing $RELEASE_TAG with ${{ needs.prepare.outputs.publish_action }}"

- name: Publish to npm
env:
NPM_CONFIG_PROVENANCE: true
run: |
if [ "${{ steps.channel.outputs.action }}" = "publish-next" ]; then
if [ "${{ needs.prepare.outputs.publish_action }}" = "publish-next" ]; then
bun run publish:next
else
bun run publish:latest
fi
env:
NPM_CONFIG_PROVENANCE: true
71 changes: 0 additions & 71 deletions .github/workflows/release.yml

This file was deleted.

7 changes: 3 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,10 +178,9 @@ bun run build

## Publishing
- Never run `npm publish` directly.
- Use the guarded two-step workflow:
- `bun run publish:next`
- `bun run promote:latest`
- `prepublishOnly` exists to prevent untested direct publishes to `latest`.
- Use the GitHub Actions `Publish` workflow for release tagging and npm publishing.
- For recovery, dispatch `Publish` with `channel=existing` and the release tag/ref.
- `prepublishOnly` exists to prevent untested direct publishes outside the workflow.

## Interactive Testing

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"release": "bun run scripts/release.ts",
"release:next": "bun run scripts/release.ts next",
"release:finalize": "bun run scripts/release.ts finalize",
"prepublishOnly": "test \"$ALLOW_PUBLISH\" = '1' || (echo 'ERROR: Use bun run publish:next, then bun run promote:latest' && exit 1)",
"prepublishOnly": "test \"$ALLOW_PUBLISH\" = '1' || (echo 'ERROR: Use the GitHub Actions Publish workflow' && exit 1)",
"publish:latest": "bun run build && ALLOW_PUBLISH=1 npm publish",
"publish:next": "bun run build && ALLOW_PUBLISH=1 npm publish --tag next",
"promote:latest": "bun scripts/tag-channel.ts latest"
Expand Down
Loading