name: Release on: workflow_dispatch: inputs: action: description: 'Action to perform' required: true type: choice options: - create - rc - finalize version: description: 'Version (e.g., 1.28.0 or 2.0.0)' required: true type: string dry_run: description: 'Dry run (skip npm publish, tagging, and push)' required: false type: boolean default: false concurrency: group: release-${{ inputs.version }} cancel-in-progress: false env: NODE_VERSION: 24 jobs: validate-version: runs-on: ubuntu-latest timeout-minutes: 2 permissions: contents: read outputs: branch: ${{ steps.validate.outputs.branch }} is_major: ${{ steps.validate.outputs.is_major }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - name: Validate version format id: validate env: VERSION: ${{ inputs.version }} run: | # Must be X.Y.0 (minor or major release, not patch) if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.0$'; then echo "::error::Version must end in .0 (e.g., 1.28.0 or 2.0.0). Use hotfix workflow for patch releases." exit 1 fi BRANCH="release/${VERSION}" # Detect major (X.0.0) IS_MAJOR="false" if echo "$VERSION" | grep -qE '^[0-9]+\.0\.0$'; then IS_MAJOR="true" fi echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" echo "is_major=$IS_MAJOR" >> "$GITHUB_OUTPUT" create: needs: validate-version if: inputs.action == 'create' runs-on: ubuntu-latest timeout-minutes: 5 permissions: contents: write steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: ${{ env.NODE_VERSION }} - name: Check branch doesn't already exist env: BRANCH: ${{ needs.validate-version.outputs.branch }} run: | if git ls-remote --exit-code origin "refs/heads/$BRANCH" >/dev/null 2>&1; then echo "::error::Branch $BRANCH already exists. Delete it first or use rc/finalize." exit 1 fi - name: Configure git identity run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - name: Create release branch env: BRANCH: ${{ needs.validate-version.outputs.branch }} VERSION: ${{ inputs.version }} IS_MAJOR: ${{ needs.validate-version.outputs.is_major }} run: | git checkout -b "$BRANCH" npm version "$VERSION" --no-git-tag-version cd sdk && npm version "$VERSION" --no-git-tag-version && cd .. git add package.json package-lock.json sdk/package.json git commit -m "chore: bump version to ${VERSION} for release" git push origin "$BRANCH" echo "## Release branch created" >> "$GITHUB_STEP_SUMMARY" echo "- Branch: \`$BRANCH\`" >> "$GITHUB_STEP_SUMMARY" echo "- Version: \`$VERSION\`" >> "$GITHUB_STEP_SUMMARY" if [ "$IS_MAJOR" = "true" ]; then echo "- Type: **Major** (will start with beta pre-releases)" >> "$GITHUB_STEP_SUMMARY" else echo "- Type: **Minor** (will start with RC pre-releases)" >> "$GITHUB_STEP_SUMMARY" fi echo "" >> "$GITHUB_STEP_SUMMARY" echo "Next: run this workflow with \`rc\` action to publish a pre-release to \`next\`" >> "$GITHUB_STEP_SUMMARY" install-smoke-rc: needs: validate-version if: inputs.action == 'rc' permissions: contents: read uses: ./.github/workflows/install-smoke.yml with: ref: ${{ needs.validate-version.outputs.branch }} rc: needs: [validate-version, install-smoke-rc] if: inputs.action == 'rc' runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: write id-token: write environment: npm-publish steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ needs.validate-version.outputs.branch }} fetch-depth: 0 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: ${{ env.NODE_VERSION }} registry-url: 'https://registry.npmjs.org' cache: 'npm' - name: Determine pre-release version id: prerelease env: VERSION: ${{ inputs.version }} IS_MAJOR: ${{ needs.validate-version.outputs.is_major }} run: | # Determine pre-release type: major → beta, minor → rc if [ "$IS_MAJOR" = "true" ]; then PREFIX="beta" else PREFIX="rc" fi # Find next pre-release number by checking existing tags N=1 while git tag -l "v${VERSION}-${PREFIX}.${N}" | grep -q .; do N=$((N + 1)) done PRE_VERSION="${VERSION}-${PREFIX}.${N}" echo "pre_version=$PRE_VERSION" >> "$GITHUB_OUTPUT" echo "prefix=$PREFIX" >> "$GITHUB_OUTPUT" - name: Configure git identity run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - name: Bump to pre-release version env: PRE_VERSION: ${{ steps.prerelease.outputs.pre_version }} run: | npm version "$PRE_VERSION" --no-git-tag-version cd sdk && npm version "$PRE_VERSION" --no-git-tag-version && cd .. - name: Install and test run: | npm ci npm run test:coverage - name: Commit pre-release version bump env: PRE_VERSION: ${{ steps.prerelease.outputs.pre_version }} run: | git add package.json package-lock.json sdk/package.json git commit -m "chore: bump to ${PRE_VERSION}" - name: Build SDK dist for tarball run: npm run build:sdk - name: Verify tarball ships sdk/dist/cli.js (bug #2647) run: bash scripts/verify-tarball-sdk-dist.sh - name: Dry-run publish validation run: | npm publish --dry-run --tag next cd sdk && npm publish --dry-run --tag next env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Tag and push if: ${{ !inputs.dry_run }} env: PRE_VERSION: ${{ steps.prerelease.outputs.pre_version }} BRANCH: ${{ needs.validate-version.outputs.branch }} run: | if git rev-parse -q --verify "refs/tags/v${PRE_VERSION}" >/dev/null; then EXISTING_SHA=$(git rev-parse "refs/tags/v${PRE_VERSION}") HEAD_SHA=$(git rev-parse HEAD) if [ "$EXISTING_SHA" != "$HEAD_SHA" ]; then echo "::error::Tag v${PRE_VERSION} already exists pointing to different commit" exit 1 fi echo "Tag v${PRE_VERSION} already exists on current commit; skipping tag" else git tag "v${PRE_VERSION}" fi git push origin "$BRANCH" --tags - name: Publish to npm (next) if: ${{ !inputs.dry_run }} run: npm publish --provenance --access public --tag next env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Publish SDK to npm (next) if: ${{ !inputs.dry_run }} run: cd sdk && npm publish --provenance --access public --tag next env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Create GitHub pre-release if: ${{ !inputs.dry_run }} env: GH_TOKEN: ${{ github.token }} PRE_VERSION: ${{ steps.prerelease.outputs.pre_version }} run: | gh release create "v${PRE_VERSION}" \ --title "v${PRE_VERSION}" \ --generate-notes \ --prerelease - name: Verify publish if: ${{ !inputs.dry_run }} env: PRE_VERSION: ${{ steps.prerelease.outputs.pre_version }} run: | sleep 10 PUBLISHED=$(npm view get-shit-done-cc@"$PRE_VERSION" version 2>/dev/null || echo "NOT_FOUND") if [ "$PUBLISHED" != "$PRE_VERSION" ]; then echo "::error::Published version verification failed. Expected $PRE_VERSION, got $PUBLISHED" exit 1 fi echo "✓ Verified: get-shit-done-cc@$PRE_VERSION is live on npm" SDK_PUBLISHED=$(npm view @gsd-build/sdk@"$PRE_VERSION" version 2>/dev/null || echo "NOT_FOUND") if [ "$SDK_PUBLISHED" != "$PRE_VERSION" ]; then echo "::error::SDK version verification failed. Expected $PRE_VERSION, got $SDK_PUBLISHED" exit 1 fi echo "✓ Verified: @gsd-build/sdk@$PRE_VERSION is live on npm" # Also verify dist-tag NEXT_TAG=$(npm dist-tag ls get-shit-done-cc 2>/dev/null | grep "next:" | awk '{print $2}') echo "✓ next tag points to: $NEXT_TAG" - name: Summary env: PRE_VERSION: ${{ steps.prerelease.outputs.pre_version }} DRY_RUN: ${{ inputs.dry_run }} run: | echo "## Pre-release v${PRE_VERSION}" >> "$GITHUB_STEP_SUMMARY" if [ "$DRY_RUN" = "true" ]; then echo "**DRY RUN** — npm publish, tagging, and push skipped" >> "$GITHUB_STEP_SUMMARY" else echo "- Published to npm as \`next\`" >> "$GITHUB_STEP_SUMMARY" echo "- SDK also published: \`@gsd-build/sdk@${PRE_VERSION}\` on \`next\`" >> "$GITHUB_STEP_SUMMARY" echo "- Install: \`npx get-shit-done-cc@next\`" >> "$GITHUB_STEP_SUMMARY" fi echo "" >> "$GITHUB_STEP_SUMMARY" echo "To publish another pre-release: run \`rc\` again" >> "$GITHUB_STEP_SUMMARY" echo "To finalize: run \`finalize\` action" >> "$GITHUB_STEP_SUMMARY" install-smoke-finalize: needs: validate-version if: inputs.action == 'finalize' permissions: contents: read uses: ./.github/workflows/install-smoke.yml with: ref: ${{ needs.validate-version.outputs.branch }} finalize: needs: [validate-version, install-smoke-finalize] if: inputs.action == 'finalize' runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: write pull-requests: write id-token: write environment: npm-publish steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ needs.validate-version.outputs.branch }} fetch-depth: 0 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: ${{ env.NODE_VERSION }} registry-url: 'https://registry.npmjs.org' cache: 'npm' - name: Configure git identity run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - name: Set final version env: VERSION: ${{ inputs.version }} run: | npm version "$VERSION" --no-git-tag-version --allow-same-version cd sdk && npm version "$VERSION" --no-git-tag-version --allow-same-version && cd .. git add package.json package-lock.json sdk/package.json git diff --cached --quiet || git commit -m "chore: finalize v${VERSION}" - name: Install and test run: | npm ci npm run test:coverage - name: Build SDK dist for tarball run: npm run build:sdk - name: Verify tarball ships sdk/dist/cli.js (bug #2647) run: bash scripts/verify-tarball-sdk-dist.sh - name: Dry-run publish validation run: | npm publish --dry-run cd sdk && npm publish --dry-run env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Create PR to merge release back to main if: ${{ !inputs.dry_run }} continue-on-error: true env: GH_TOKEN: ${{ github.token }} BRANCH: ${{ needs.validate-version.outputs.branch }} VERSION: ${{ inputs.version }} run: | # Non-fatal: repos that disable "Allow GitHub Actions to create and # approve pull requests" cause this step to fail with GraphQL 403. # The release itself (tag + npm publish + GitHub Release) must still # proceed. Open the merge-back PR manually afterwards with: # gh pr create --base main --head release/${VERSION} \ # --title "chore: merge release v${VERSION} to main" EXISTING_PR=$(gh pr list --base main --head "$BRANCH" --state open --json number --jq '.[0].number' 2>/dev/null || echo "") if [ -n "$EXISTING_PR" ]; then echo "PR #$EXISTING_PR already exists; updating" gh pr edit "$EXISTING_PR" \ --title "chore: merge release v${VERSION} to main" \ --body "Merge release branch back to main after v${VERSION} stable release." \ || echo "::warning::Could not update merge-back PR (likely PR-creation policy disabled). Open it manually after release." else gh pr create \ --base main \ --head "$BRANCH" \ --title "chore: merge release v${VERSION} to main" \ --body "Merge release branch back to main after v${VERSION} stable release." \ || echo "::warning::Could not create merge-back PR (likely PR-creation policy disabled). Open it manually after release." fi - name: Tag and push if: ${{ !inputs.dry_run }} env: VERSION: ${{ inputs.version }} BRANCH: ${{ needs.validate-version.outputs.branch }} run: | if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then EXISTING_SHA=$(git rev-parse "refs/tags/v${VERSION}") HEAD_SHA=$(git rev-parse HEAD) if [ "$EXISTING_SHA" != "$HEAD_SHA" ]; then echo "::error::Tag v${VERSION} already exists pointing to different commit" exit 1 fi echo "Tag v${VERSION} already exists on current commit; skipping tag" else git tag "v${VERSION}" fi git push origin "$BRANCH" --tags - name: Publish to npm (latest) if: ${{ !inputs.dry_run }} run: npm publish --provenance --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Publish SDK to npm (latest) if: ${{ !inputs.dry_run }} run: cd sdk && npm publish --provenance --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Create GitHub Release if: ${{ !inputs.dry_run }} env: GH_TOKEN: ${{ github.token }} VERSION: ${{ inputs.version }} run: | gh release create "v${VERSION}" \ --title "v${VERSION}" \ --generate-notes \ --latest - name: Clean up next dist-tag if: ${{ !inputs.dry_run }} env: VERSION: ${{ inputs.version }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | # Point next to the stable release so @next never returns something # older than @latest. This prevents stale pre-release installs. npm dist-tag add "get-shit-done-cc@${VERSION}" next 2>/dev/null || true npm dist-tag add "@gsd-build/sdk@${VERSION}" next 2>/dev/null || true echo "✓ next dist-tag updated to v${VERSION}" - name: Verify publish if: ${{ !inputs.dry_run }} env: VERSION: ${{ inputs.version }} run: | sleep 10 PUBLISHED=$(npm view get-shit-done-cc@"$VERSION" version 2>/dev/null || echo "NOT_FOUND") if [ "$PUBLISHED" != "$VERSION" ]; then echo "::error::Published version verification failed. Expected $VERSION, got $PUBLISHED" exit 1 fi echo "✓ Verified: get-shit-done-cc@$VERSION is live on npm" SDK_PUBLISHED=$(npm view @gsd-build/sdk@"$VERSION" version 2>/dev/null || echo "NOT_FOUND") if [ "$SDK_PUBLISHED" != "$VERSION" ]; then echo "::error::SDK version verification failed. Expected $VERSION, got $SDK_PUBLISHED" exit 1 fi echo "✓ Verified: @gsd-build/sdk@$VERSION is live on npm" # Verify latest tag LATEST_TAG=$(npm dist-tag ls get-shit-done-cc 2>/dev/null | grep "latest:" | awk '{print $2}') echo "✓ latest tag points to: $LATEST_TAG" - name: Summary env: VERSION: ${{ inputs.version }} DRY_RUN: ${{ inputs.dry_run }} run: | echo "## Release v${VERSION}" >> "$GITHUB_STEP_SUMMARY" if [ "$DRY_RUN" = "true" ]; then echo "**DRY RUN** — npm publish, tagging, and push skipped" >> "$GITHUB_STEP_SUMMARY" else echo "- Published to npm as \`latest\`" >> "$GITHUB_STEP_SUMMARY" echo "- SDK also published: \`@gsd-build/sdk@${VERSION}\` as \`latest\`" >> "$GITHUB_STEP_SUMMARY" echo "- Tagged \`v${VERSION}\`" >> "$GITHUB_STEP_SUMMARY" echo "- PR created to merge back to main" >> "$GITHUB_STEP_SUMMARY" echo "- Install: \`npx get-shit-done-cc@latest\`" >> "$GITHUB_STEP_SUMMARY" fi