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
59 changes: 59 additions & 0 deletions .github/workflows/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# GitHub Actions workflows

| Workflow | Trigger | Purpose |
| --- | --- | --- |
| `ci.yml` | push to `main`, PRs | Build + test on every change |
| `version-bump.yml` | manual (`workflow_dispatch`) | Bump versions in all three publishable packages, commit, and push a `vX.Y.Z` tag |
| `release.yml` | push of a `v*` tag | Build, test, verify, publish to npm in dependency order, and create a GitHub Release |
| `binaries.yml` | GitHub Release published | Build standalone CLI binaries for linux/darwin and attach them to the Release |

## How to cut a release

Standard path (recommended):

1. Open the **Actions** tab on GitHub.
2. Choose **Version Bump** -> **Run workflow**.
3. Pick the bump `level`: `patch`, `minor`, `major`, or `prerelease`.
- For `prerelease`, you can override the identifier (default: `rc`). Example output: `0.2.0-rc.0`, `0.2.0-rc.1`, ...
4. The workflow will:
- Compute the new version from `packages/core/package.json`.
- Write it to all three packages (`core`, `cli`, `mcp`).
- Commit `chore: bump version to vX.Y.Z` to `main`.
- Push tag `vX.Y.Z`.
5. The tag push triggers `release.yml`, which builds, tests, publishes to npm, and creates the GitHub Release. `binaries.yml` then runs and uploads CLI binaries to the Release.

That's it. Don't touch version files by hand on `main`.

## Emergency: cut a release manually

If the Actions UI is unavailable, you can tag locally — but only after you've already bumped the version in all three `package.json` files via a normal PR:

```bash
git checkout main && git pull
# Edit packages/{core,cli,mcp}/package.json to the new version, commit, push
git tag v1.2.3
git push origin v1.2.3
```

`release.yml` will refuse to publish if the tag and the `package.json` versions don't match.

## Rollback

You **cannot** unpublish from npm (after the 72-hour window, and even within it you shouldn't). The recovery story is:

1. `npm deprecate @sowdb/core@1.2.3 "broken release, use 1.2.4"` (and the same for `@sowdb/cli` and `@sowdb/mcp`).
2. Fix forward: bump again via the Version Bump workflow and ship `1.2.4`.

If a release.yml run fails partway through (e.g. `@sowdb/core` published but `@sowdb/cli` failed), you can safely re-run the workflow on the same tag — it checks `npm view` for each package and skips the ones already published.

## What NOT to do

- Do **not** manually edit `version` in `packages/*/package.json` and push directly to `main`. The previous workflow auto-bumped on every push; the new workflow does not, so a manual bump without a tag will silently do nothing.
- Do **not** push a tag whose name doesn't match `v<MAJOR>.<MINOR>.<PATCH>(-prerelease)?`. `release.yml` validates the format and will fail loudly, but please don't rely on that.
- Do **not** commit `workspace:*` -> concrete-version rewrites back to `main`. The release workflow does that transformation in-memory at publish time only.

## Idempotency guarantees

- `release.yml` checks `npm view <pkg>@<version>` before publishing each package and skips already-published versions. Re-running on the same tag is safe.
- The GitHub Release creation step also no-ops if the release already exists.
- `version-bump.yml` aborts if the computed tag already exists locally.
110 changes: 53 additions & 57 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ name: Release

on:
push:
branches: [main]
tags:
- 'v*'

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
Expand All @@ -11,14 +12,21 @@ concurrency:
jobs:
release:
runs-on: ubuntu-latest
# Skip release commits to avoid infinite loop
if: "!startsWith(github.event.head_commit.message, 'release:')"
permissions:
contents: write
steps:
- name: Validate tag format
run: |
TAG="${GITHUB_REF#refs/tags/}"
echo "Tag: $TAG"
if ! echo "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-.+)?$'; then
echo "::error::Tag '$TAG' does not match semver pattern v\\d+.\\d+.\\d+(-.*)?. Refusing to publish."
exit 1
fi
echo "VERSION=${TAG#v}" >> $GITHUB_ENV
echo "TAG=$TAG" >> $GITHUB_ENV

- uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}

- uses: oven-sh/setup-bun@v2
with:
Expand All @@ -30,80 +38,68 @@ jobs:

- run: bun run test

- name: Configure npm auth
run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc

- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"

- name: Bump version and publish
- name: Verify package versions match tag
run: |
set -e
ROOT_DIR=$(pwd)

VERSION=$(node -p "require('./packages/core/package.json').version")
NEW_VERSION=$(node -p "const v='$VERSION'.split('.'); v[2]=+v[2]+1; v.join('.')")
echo "Publishing v$NEW_VERSION"

# Bump version in all publishable packages
for pkg in core cli mcp; do
node -e "
const fs = require('fs');
const p = JSON.parse(fs.readFileSync('./packages/$pkg/package.json', 'utf8'));
p.version = '$NEW_VERSION';
fs.writeFileSync('./packages/$pkg/package.json', JSON.stringify(p, null, 2) + '\n');
"
PKG_VERSION=$(node -p "require('./packages/$pkg/package.json').version")
if [ "$PKG_VERSION" != "$VERSION" ]; then
echo "::error::refusing to publish v$VERSION: packages/$pkg/package.json is at $PKG_VERSION. Run the Version Bump workflow first."
exit 1
fi
done
echo "All package versions match tag v$VERSION"

# Replace workspace:* with real version for publishing
- name: Configure npm auth
run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc

- name: Replace workspace:* with concrete versions (publish-time only, not committed)
run: |
set -e
for pkg in cli mcp; do
node -e "
const fs = require('fs');
const p = JSON.parse(fs.readFileSync('./packages/$pkg/package.json', 'utf8'));
const path = './packages/$pkg/package.json';
const p = JSON.parse(fs.readFileSync(path, 'utf8'));
if (p.dependencies && p.dependencies['@sowdb/core'] === 'workspace:*') {
p.dependencies['@sowdb/core'] = '^$NEW_VERSION';
p.dependencies['@sowdb/core'] = '^${VERSION}';
fs.writeFileSync(path, JSON.stringify(p, null, 2) + '\n');
console.log('Rewrote @sowdb/core dep in', path, '->', '^${VERSION}');
} else if (!p.dependencies || !p.dependencies['@sowdb/core']) {
console.error('Expected @sowdb/core dep in', path);
process.exit(1);
}
fs.writeFileSync('./packages/$pkg/package.json', JSON.stringify(p, null, 2) + '\n');
"
done
# Sanity: fail loudly if any workspace:* references remain in the three packages
if grep -R "workspace:\*" packages/core/package.json packages/cli/package.json packages/mcp/package.json; then
echo "::error::workspace:* substitution failed; refusing to publish"
exit 1
fi

# Publish in dependency order, skipping already-published versions
- name: Publish packages (idempotent, dependency order)
run: |
set -e
ROOT_DIR=$(pwd)
for pkg in core cli mcp; do
cd "$ROOT_DIR/packages/$pkg"
PKG_NAME=$(node -p "require('./package.json').name")
if npm view "$PKG_NAME@$NEW_VERSION" version 2>/dev/null; then
echo " $PKG_NAME@$NEW_VERSION already published, skipping"
if npm view "$PKG_NAME@$VERSION" version >/dev/null 2>&1; then
echo "skip: $PKG_NAME@$VERSION already published"
else
npm pkg fix 2>/dev/null || true
npm publish --access public
echo "✅ Published $PKG_NAME@$NEW_VERSION"
echo "published: $PKG_NAME@$VERSION"
fi
done
cd "$ROOT_DIR"

# Restore workspace:* for the committed version
for pkg in cli mcp; do
node -e "
const fs = require('fs');
const p = JSON.parse(fs.readFileSync('./packages/$pkg/package.json', 'utf8'));
if (p.dependencies && p.dependencies['@sowdb/core']) {
p.dependencies['@sowdb/core'] = 'workspace:*';
}
fs.writeFileSync('./packages/$pkg/package.json', JSON.stringify(p, null, 2) + '\n');
"
done

# Commit version bump and tag
git add -A
git commit -m "release: v$NEW_VERSION"
git tag "v$NEW_VERSION"
git push origin main --tags

echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_ENV

- name: Create GitHub release
run: gh release create "v$NEW_VERSION" --generate-notes
- name: Create GitHub Release
run: |
if gh release view "$TAG" >/dev/null 2>&1; then
echo "GitHub Release $TAG already exists, skipping"
else
gh release create "$TAG" --generate-notes
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
111 changes: 111 additions & 0 deletions .github/workflows/version-bump.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
name: Version Bump

on:
workflow_dispatch:
inputs:
level:
description: 'Semver bump level'
required: true
type: choice
options:
- patch
- minor
- major
- prerelease
prerelease_tag:
description: 'Prerelease identifier (only used when level=prerelease)'
required: false
default: 'rc'
type: string

concurrency:
group: version-bump
cancel-in-progress: false

jobs:
bump:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0

- uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- run: bun install --frozen-lockfile

- name: Compute new version
id: bump
env:
LEVEL: ${{ inputs.level }}
PRE_TAG: ${{ inputs.prerelease_tag }}
run: |
set -e
NEW_VERSION=$(node -e "
const fs = require('fs');
const cur = JSON.parse(fs.readFileSync('./packages/core/package.json', 'utf8')).version;
const level = process.env.LEVEL;
const preTag = process.env.PRE_TAG || 'rc';
const m = cur.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?\$/);
if (!m) { console.error('Cannot parse version: ' + cur); process.exit(1); }
let [_, MA, MI, PA, PRE] = m;
MA = +MA; MI = +MI; PA = +PA;
let out;
if (level === 'major') { out = \`\${MA+1}.0.0\`; }
else if (level === 'minor') { out = \`\${MA}.\${MI+1}.0\`; }
else if (level === 'patch') { out = \`\${MA}.\${MI}.\${PA+1}\`; }
else if (level === 'prerelease') {
if (PRE) {
const pm = PRE.match(/^(.*?)\.(\d+)\$/);
if (pm && pm[1] === preTag) {
out = \`\${MA}.\${MI}.\${PA}-\${preTag}.\${+pm[2]+1}\`;
} else {
out = \`\${MA}.\${MI}.\${PA}-\${preTag}.0\`;
}
} else {
out = \`\${MA}.\${MI}.\${PA+1}-\${preTag}.0\`;
}
} else {
console.error('Unknown level: ' + level); process.exit(1);
}
console.log(out);
")
echo "Current -> New: $NEW_VERSION"
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_ENV
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT

- name: Write new version to all packages
run: |
set -e
for pkg in core cli mcp; do
node -e "
const fs = require('fs');
const path = './packages/$pkg/package.json';
const p = JSON.parse(fs.readFileSync(path, 'utf8'));
p.version = '${NEW_VERSION}';
fs.writeFileSync(path, JSON.stringify(p, null, 2) + '\n');
"
done

- name: Verify tag does not already exist
run: |
if git rev-parse "v${NEW_VERSION}" >/dev/null 2>&1; then
echo "::error::Tag v${NEW_VERSION} already exists. Aborting."
exit 1
fi

- name: Commit, tag, and push
run: |
set -e
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add packages/core/package.json packages/cli/package.json packages/mcp/package.json
git commit -m "chore: bump version to v${NEW_VERSION}"
git tag "v${NEW_VERSION}"
git push origin HEAD:${GITHUB_REF_NAME} "v${NEW_VERSION}"
echo "Pushed v${NEW_VERSION}. release.yml will now publish."
Loading