From 299a563684f07b765036f4b12d77fb6f9f765af6 Mon Sep 17 00:00:00 2001 From: Facundo Date: Mon, 6 Apr 2026 01:31:23 -0700 Subject: [PATCH] chore(ci): tag-driven release workflow Replace push-to-main auto-bump release with a two-workflow setup: version-bump.yml (manual, computes + commits + tags) and release.yml (tag-driven publish, idempotent, validates tag matches package.json, preserves workspace:* substitution at publish time only). Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/README.md | 59 +++++++++++++++ .github/workflows/release.yml | 110 ++++++++++++++-------------- .github/workflows/version-bump.yml | 111 +++++++++++++++++++++++++++++ 3 files changed, 223 insertions(+), 57 deletions(-) create mode 100644 .github/workflows/README.md create mode 100644 .github/workflows/version-bump.yml diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 0000000..367261d --- /dev/null +++ b/.github/workflows/README.md @@ -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..(-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 @` 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. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 031a8fc..b415b7d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,7 +2,8 @@ name: Release on: push: - branches: [main] + tags: + - 'v*' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -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: @@ -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 }} diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml new file mode 100644 index 0000000..fb274de --- /dev/null +++ b/.github/workflows/version-bump.yml @@ -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."