diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml new file mode 100644 index 0000000..6b04c84 --- /dev/null +++ b/.github/workflows/release-pr.yml @@ -0,0 +1,256 @@ +name: Create Release PR + +on: + workflow_dispatch: + inputs: + version_type: + description: 'Version bump type' + required: true + type: choice + options: + - patch + - minor + - major + - auto + default: 'auto' + custom_version: + description: 'Custom version (overrides version_type if set, e.g., 1.2.3)' + required: false + type: string + +permissions: + contents: write + pull-requests: write + +jobs: + create-release-pr: + runs-on: ubuntu-latest + + steps: + - name: Checkout main branch + uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + + - name: Get current version + id: current + run: | + CURRENT_VERSION=$(jq -r '.version' package.json) + echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT + echo "Current version: $CURRENT_VERSION" + + - name: Determine version bump from commits + id: analyze + if: inputs.version_type == 'auto' && inputs.custom_version == '' + run: | + # Get commits since last tag + LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + + if [ -z "$LAST_TAG" ]; then + COMMITS=$(git log --oneline) + else + COMMITS=$(git log --oneline ${LAST_TAG}..HEAD) + fi + + echo "Commits since last tag:" + echo "$COMMITS" + + # Analyze commits for version bump (conventional commits) + BUMP="patch" + + if echo "$COMMITS" | grep -qiE "^[a-f0-9]+ (feat|feature)(\(.+\))?!:|BREAKING CHANGE"; then + BUMP="major" + elif echo "$COMMITS" | grep -qiE "^[a-f0-9]+ (feat|feature)(\(.+\))?:"; then + BUMP="minor" + fi + + echo "bump=$BUMP" >> $GITHUB_OUTPUT + echo "Determined bump type: $BUMP" + + - name: Calculate new version + id: version + run: | + CURRENT="${{ steps.current.outputs.version }}" + + # Use custom version if provided + if [ -n "${{ inputs.custom_version }}" ]; then + NEW_VERSION="${{ inputs.custom_version }}" + else + # Determine bump type + if [ "${{ inputs.version_type }}" == "auto" ]; then + BUMP="${{ steps.analyze.outputs.bump }}" + else + BUMP="${{ inputs.version_type }}" + fi + + # Split version into parts + IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT" + + # Validate no prerelease suffix (e.g., 1.0.0-alpha.1) + if [[ ! "$MAJOR" =~ ^[0-9]+$ ]] || [[ ! "$MINOR" =~ ^[0-9]+$ ]] || [[ ! "$PATCH" =~ ^[0-9]+$ ]]; then + echo "::error::Prerelease versions not supported (got: $CURRENT). Use explicit version input instead." + exit 1 + fi + + case "$BUMP" in + major) + NEW_VERSION="$((MAJOR + 1)).0.0" + ;; + minor) + NEW_VERSION="${MAJOR}.$((MINOR + 1)).0" + ;; + patch) + NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))" + ;; + esac + fi + + echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT + echo "New version: $NEW_VERSION" + + - name: Create release branch + run: | + BRANCH_NAME="release-v${{ steps.version.outputs.new_version }}" + git checkout -b "$BRANCH_NAME" + echo "Created branch: $BRANCH_NAME" + + - name: Update package.json version + run: | + NEW_VERSION="${{ steps.version.outputs.new_version }}" + jq --arg v "$NEW_VERSION" '.version = $v' package.json > package.json.tmp + mv package.json.tmp package.json + echo "Updated package.json to version $NEW_VERSION" + + - name: Generate changelog entry + id: changelog + run: | + NEW_VERSION="${{ steps.version.outputs.new_version }}" + TODAY=$(date +%Y-%m-%d) + LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + + # Get commits grouped by type + if [ -z "$LAST_TAG" ]; then + COMMITS=$(git log --pretty=format:"%s" HEAD) + else + COMMITS=$(git log --pretty=format:"%s" ${LAST_TAG}..HEAD) + fi + + # Build changelog entry + ENTRY="## [$NEW_VERSION] - $TODAY"$'\n\n' + + # Features (normalize case before sed to handle Feat/FEAT variants) + FEATURES=$(echo "$COMMITS" | grep -iE "^feat(\(.+\))?:" | tr '[:upper:]' '[:lower:]' | sed 's/^feat(\([^)]*\)): /- **\1**: /' | sed 's/^feat: /- /' || true) + if [ -n "$FEATURES" ]; then + ENTRY+="### Added"$'\n'"$FEATURES"$'\n\n' + fi + + # Fixes (normalize case before sed to handle Fix/FIX variants) + FIXES=$(echo "$COMMITS" | grep -iE "^fix(\(.+\))?:" | tr '[:upper:]' '[:lower:]' | sed 's/^fix(\([^)]*\)): /- **\1**: /' | sed 's/^fix: /- /' || true) + if [ -n "$FIXES" ]; then + ENTRY+="### Fixed"$'\n'"$FIXES"$'\n\n' + fi + + # Other changes (refactor, perf, style, or non-conventional commits) + OTHERS=$(echo "$COMMITS" | grep -ivE "^(feat|fix|chore|ci|docs|test)(\(.+\))?:" | head -10 | sed 's/^/- /' || true) + if [ -n "$OTHERS" ]; then + ENTRY+="### Changed"$'\n'"$OTHERS"$'\n\n' + fi + + # Save entry to file for use in PR body + echo "$ENTRY" > /tmp/changelog_entry.md + + # Prepend to CHANGELOG.md if it exists + # Preserves full header (everything before first "## [" section marker) + if [ -f "CHANGELOG.md" ]; then + # Find line number of first version section (## [x.y.z] or ## [Unreleased]) + HEADER_END=$(grep -n '^## \[' CHANGELOG.md | head -1 | cut -d: -f1) + if [ -n "$HEADER_END" ]; then + HEADER_END=$((HEADER_END - 1)) + head -n "$HEADER_END" CHANGELOG.md > /tmp/changelog_new.md + echo "" >> /tmp/changelog_new.md + cat /tmp/changelog_entry.md >> /tmp/changelog_new.md + tail -n +"$((HEADER_END + 1))" CHANGELOG.md >> /tmp/changelog_new.md + else + # No version sections found, append to end + cat CHANGELOG.md > /tmp/changelog_new.md + echo "" >> /tmp/changelog_new.md + cat /tmp/changelog_entry.md >> /tmp/changelog_new.md + fi + mv /tmp/changelog_new.md CHANGELOG.md + echo "Updated CHANGELOG.md" + fi + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Commit changes + run: | + NEW_VERSION="${{ steps.version.outputs.new_version }}" + git add package.json + [ -f CHANGELOG.md ] && git add CHANGELOG.md + git commit -m "chore(release): prepare v$NEW_VERSION" + + - name: Push branch + run: | + BRANCH_NAME="release-v${{ steps.version.outputs.new_version }}" + git push -u origin "$BRANCH_NAME" + + - name: Create Pull Request + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + NEW_VERSION="${{ steps.version.outputs.new_version }}" + CURRENT_VERSION="${{ steps.current.outputs.version }}" + + # Create PR body + cat << 'EOF' > /tmp/pr_body.md + ## Release v$NEW_VERSION + + This PR prepares the release of version **$NEW_VERSION** (from $CURRENT_VERSION). + + ### Changes included + + EOF + + # Append changelog entry if exists + if [ -f /tmp/changelog_entry.md ]; then + cat /tmp/changelog_entry.md >> /tmp/pr_body.md + fi + + cat << 'EOF' >> /tmp/pr_body.md + + --- + + ### Checklist + + - [ ] Version bump is correct + - [ ] CHANGELOG.md is accurate + - [ ] All tests pass + + ### After merge + + When this PR is merged: + 1. A git tag `v$NEW_VERSION` will be created automatically + 2. A GitHub release will be published + 3. The package will be published to npm + EOF + + # Replace version placeholders + sed -i "s/\$NEW_VERSION/$NEW_VERSION/g" /tmp/pr_body.md + sed -i "s/\$CURRENT_VERSION/$CURRENT_VERSION/g" /tmp/pr_body.md + + gh pr create \ + --title "Release v$NEW_VERSION" \ + --body-file /tmp/pr_body.md \ + --base main \ + --label "release" \ + --label "automated" diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml new file mode 100644 index 0000000..d1015bf --- /dev/null +++ b/.github/workflows/release-publish.yml @@ -0,0 +1,137 @@ +name: Publish Release + +on: + pull_request: + types: [closed] + branches: [main] + +permissions: + contents: write + id-token: write + +jobs: + publish: + # Only run if PR was merged and has release label + if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release') + runs-on: ubuntu-latest + environment: publish # Required for npm OIDC trusted publisher + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + # Pin to merge commit SHA to avoid race condition with new commits + ref: ${{ github.event.pull_request.merge_commit_sha }} + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + + - name: Setup Node.js (for npm publish) + uses: actions/setup-node@v4 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + run: bun install + + - name: Build + run: bun run build + + - name: Run tests + run: bun test + + - name: Get version from package.json + id: version + run: | + VERSION=$(jq -r '.version' package.json) + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Release version: $VERSION" + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Create and push tag + run: | + VERSION="${{ steps.version.outputs.version }}" + TAG="v$VERSION" + + # Check if tag already exists + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Tag $TAG already exists, skipping tag creation" + else + git tag -a "$TAG" -m "Release $TAG" + git push origin "$TAG" + echo "Created and pushed tag: $TAG" + fi + + - name: Generate release notes + id: release_notes + run: | + VERSION="${{ steps.version.outputs.version }}" + + # Get previous tag (exclude current version to find actual previous release) + PREV_TAG=$(git describe --tags --abbrev=0 --exclude="v${VERSION}" HEAD 2>/dev/null || echo "") + + # Build release notes + cat << EOF > /tmp/release_notes.md + ## What's Changed + + EOF + + # Get commits for this release + if [ -n "$PREV_TAG" ]; then + git log --pretty=format:"- %s (%h)" ${PREV_TAG}..HEAD >> /tmp/release_notes.md + echo "" >> /tmp/release_notes.md + echo "" >> /tmp/release_notes.md + echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/${PREV_TAG}...v${VERSION}" >> /tmp/release_notes.md + else + git log --pretty=format:"- %s (%h)" >> /tmp/release_notes.md + fi + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION="${{ steps.version.outputs.version }}" + TAG="v$VERSION" + + # Check if release already exists + if gh release view "$TAG" >/dev/null 2>&1; then + echo "Release $TAG already exists, skipping release creation" + else + gh release create "$TAG" \ + --title "$TAG" \ + --notes-file /tmp/release_notes.md + echo "Created GitHub release: $TAG" + fi + + - name: Publish to npm + # Uses OIDC trusted publisher - no NPM_TOKEN needed + run: | + VERSION="${{ steps.version.outputs.version }}" + PACKAGE_NAME="opencode-toolbox" + + # Check if this version is already published on npm (idempotency) + if npm view "$PACKAGE_NAME@$VERSION" >/dev/null 2>&1; then + echo "Version $PACKAGE_NAME@$VERSION already exists on npm, skipping publish" + else + npm publish --provenance --access public + echo "Published to npm: $PACKAGE_NAME@$VERSION" + fi + + - name: Post release summary + run: | + VERSION="${{ steps.version.outputs.version }}" + echo "## Release Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "✅ **Version**: $VERSION" >> $GITHUB_STEP_SUMMARY + echo "✅ **Git Tag**: v$VERSION" >> $GITHUB_STEP_SUMMARY + echo "✅ **GitHub Release**: https://github.com/${{ github.repository }}/releases/tag/v$VERSION" >> $GITHUB_STEP_SUMMARY + echo "✅ **npm Package**: https://www.npmjs.com/package/opencode-toolbox/v/$VERSION" >> $GITHUB_STEP_SUMMARY diff --git a/RELEASE.md b/RELEASE.md index 25a344c..fcbe16b 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,136 +1,190 @@ # Release Process -This document describes the release process for opencode-toolbox. Follow these steps in order. +This document describes the automated release process for opencode-toolbox. + +## Overview + +``` +┌──────────────────────────────────────────────────────────────────────────────┐ +│ RELEASE AUTOMATION FLOW │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────────────┐ │ +│ │ 1. TRIGGER │ │ 2. REVIEW │ │ 3. AUTO-PUBLISH │ │ +│ │ │ │ │ │ (on PR merge) │ │ +│ │ Manual dispatch │───▶│ Release PR │───▶│ │ │ +│ │ via GitHub UI │ │ created │ │ • Create git tag │ │ +│ │ │ │ │ │ • GitHub release │ │ +│ │ Inputs: │ │ Branch: │ │ • npm publish │ │ +│ │ - version type │ │ release-vX.Y.Z │ │ │ │ +│ │ - custom ver │ │ │ │ │ │ +│ └─────────────────┘ └──────────────────┘ └─────────────────────────┘ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ +``` ## Prerequisites -- Git with GPG signing configured -- npm account with publish access -- GitHub CLI (`gh`) installed and authenticated +### One-time Setup (Already Done ✅) -## Release Steps +1. **npm OIDC Trusted Publisher**: Configured via npm → Package Settings → Trusted Publishers + - Repository: `assagman/opencode-toolbox` + - Workflow: `release-publish.yml` + - Environment: `publish` + - **No secrets required** - uses OpenID Connect for secure, token-less publishing -### 1. Determine Version Number +2. **GitHub Environment**: Create environment `publish` in repo settings + - Go to Settings → Environments → New environment → `publish` -Review changes since last release and determine version bump: +3. **GitHub Labels**: Ensure these labels exist: + - `release` - Triggers the publish workflow on PR merge + - `automated` - Optional, for tracking automated PRs -```bash -git log --oneline $(git describe --tags --abbrev=0)..HEAD -``` +## Creating a Release -Follow [Semantic Versioning](https://semver.org/): -- **MAJOR** (X.0.0): Breaking changes -- **MINOR** (0.X.0): New features, backward compatible -- **PATCH** (0.0.X): Bug fixes, backward compatible +### Step 1: Trigger the Release Workflow -### 2. Update CHANGELOG.md +1. Go to **Actions** → **Create Release PR** +2. Click **Run workflow** +3. Select options: -Add a new section at the top of CHANGELOG.md (after the header): +| Option | Description | +|--------|-------------| +| `patch` | Bug fixes (0.0.X) | +| `minor` | New features, backward compatible (0.X.0) | +| `major` | Breaking changes (X.0.0) | +| `auto` | Analyze commits to determine version bump | +| Custom version | Override with specific version (e.g., `2.0.0`) | -```markdown -## [X.Y.Z] - YYYY-MM-DD +4. Click **Run workflow** -### Added -- New feature descriptions +### Step 2: Review the Release PR -### Changed -- Changes to existing functionality +The workflow automatically: +- Creates branch `release-vX.Y.Z` from latest `main` +- Updates `package.json` version +- Updates `CHANGELOG.md` with categorized commits +- Creates a PR with the `release` label -### Fixed -- Bug fix descriptions +Review the PR: +- [ ] Verify version bump is correct +- [ ] Review and edit CHANGELOG if needed +- [ ] Ensure all CI checks pass -### Removed -- Removed features -``` +### Step 3: Merge to Publish -Use the commit history to write meaningful descriptions. Group related changes. +When the PR is merged, the publish workflow automatically: +1. Creates a signed git tag `vX.Y.Z` +2. Creates a GitHub Release with auto-generated notes +3. Publishes to npm with provenance (via OIDC - no tokens needed) -### 3. Bump Version in package.json +## Version Determination (Auto Mode) -Update the version field: +When using `auto` version type, the workflow analyzes commits since the last tag: -```json -"version": "X.Y.Z" -``` +| Commit Pattern | Version Bump | +|----------------|--------------| +| `feat!:` or `BREAKING CHANGE` | **major** | +| `feat:` or `feature:` | **minor** | +| All other commits | **patch** | -### 4. Commit Release +Use [Conventional Commits](https://www.conventionalcommits.org/) for best results: +- `feat: add new feature` → minor +- `fix: resolve bug` → patch +- `feat!: breaking change` → major +- `chore: update deps` → patch -Stage and commit both files together: +## Manual Release (Fallback) -```bash -git add CHANGELOG.md package.json -git commit -m "Release vX.Y.Z" -``` +If automation fails, follow this manual process: -### 5. Push to Remote +### 1. Create Release Branch ```bash -git push +git checkout main +git pull origin main +git checkout -b release-vX.Y.Z ``` -### 6. Create Signed Tag +### 2. Update Version ```bash -git tag -s -m "Release vX.Y.Z" vX.Y.Z +npm version X.Y.Z --no-git-tag-version ``` -### 7. Push Tag +### 3. Update CHANGELOG.md -```bash -git push --follow-tags -``` +Add a new section: -### 8. Publish to npm +```markdown +## [X.Y.Z] - YYYY-MM-DD -```bash -npm publish +### Added +- New features + +### Fixed +- Bug fixes ``` -> **Note**: This requires an OTP code from your authenticator app. The human must run this command. +### 4. Commit and Push -### 9. Create GitHub Release +```bash +git add package.json CHANGELOG.md +git commit -m "chore(release): prepare vX.Y.Z" +git push -u origin release-vX.Y.Z +``` -Write release notes summarizing the changes (can be derived from CHANGELOG): +### 5. Create PR ```bash -gh release create vX.Y.Z --title "vX.Y.Z" --notes "RELEASE_NOTES_HERE" +gh pr create --title "Release vX.Y.Z" --label "release" --base main ``` -For multi-line notes, use a heredoc: +### 6. After PR Merge (if auto-publish fails) ```bash -gh release create vX.Y.Z --title "vX.Y.Z" --notes "$(cat <<'EOF' -## What's New +git checkout main +git pull +git tag -s -m "Release vX.Y.Z" vX.Y.Z +git push --tags +gh release create vX.Y.Z --title "vX.Y.Z" --generate-notes +npm publish +``` -Summary of changes... +## Verification -### Features -- Feature 1 -- Feature 2 +After release, verify: -### Bug Fixes -- Fix 1 +| Check | URL | +|-------|-----| +| npm package | https://www.npmjs.com/package/opencode-toolbox | +| GitHub Release | https://github.com/assagman/opencode-toolbox/releases | +| Git tags | `git tag -l` | -**Full Changelog**: https://github.com/assagman/opencode-toolbox/compare/vPREVIOUS...vX.Y.Z -EOF -)" -``` +## Troubleshooting -## Verification +### NPM Publish Fails -After release, verify: +- Verify `publish` environment exists in GitHub repo settings +- Check OIDC trusted publisher config matches workflow file name +- Ensure `id-token: write` permission is set in workflow +- Verify package name is available on npm -1. **npm**: https://www.npmjs.com/package/opencode-toolbox -2. **GitHub Releases**: https://github.com/assagman/opencode-toolbox/releases -3. **Git tags**: `git tag -l` +### PR Not Triggering Publish -## Quick Reference (Make Targets) +- Verify PR has the `release` label +- Check PR was actually merged (not just closed) +- Review workflow run logs in Actions tab -Helper targets for common operations: +### Version Conflicts -```bash -make release-tag VERSION=X.Y.Z # Create signed tag -make release-push # Push commits and tags -``` +- If tag already exists, the workflow skips tag creation +- **Do not delete existing tags** - create a new patch version instead (e.g., `v1.0.1` if `v1.0.0` had issues) +- For problematic releases, deprecate the npm version: `npm deprecate opencode-toolbox@X.Y.Z "Reason for deprecation"` + +## Workflow Files -> **Note**: `npm publish` requires OTP and must be run manually by the human. +| File | Purpose | +|------|---------| +| `.github/workflows/release-pr.yml` | Creates release PR with version bump | +| `.github/workflows/release-publish.yml` | Publishes on PR merge |