diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..8dca5edb --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,25 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + labels: + - dependencies + - type: chore + commit-message: + prefix: "chore(deps):" + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + labels: + - dependencies + - type: chore + commit-message: + prefix: "chore(ci):" diff --git a/.github/workflows/auto-branch.yml b/.github/workflows/auto-branch.yml new file mode 100644 index 00000000..8b64f7ec --- /dev/null +++ b/.github/workflows/auto-branch.yml @@ -0,0 +1,85 @@ +name: Auto-Branch from Issue Label + +on: + issues: + types: [labeled] + +permissions: + contents: write + issues: write + +jobs: + create-branch: + runs-on: ubuntu-latest + timeout-minutes: 2 + if: >- + contains(fromJSON('["bug", "enhancement", "priority: critical", "type: chore", "area: docs"]'), + github.event.label.name) + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Create branch + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const label = context.payload.label.name; + const issue = context.payload.issue; + const number = issue.number; + + // Generate slug from title + const slug = issue.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .substring(0, 40); + + // Map label to branch prefix + const prefixMap = { + 'bug': 'fix', + 'enhancement': 'feat', + 'priority: critical': 'fix', + 'type: chore': 'chore', + 'area: docs': 'docs', + }; + const prefix = prefixMap[label]; + if (!prefix) return; + + // For priority: critical, use fix/critical-NNN-slug to avoid + // colliding with the hotfix workflow's hotfix/X.Y.Z naming. + const branch = label === 'priority: critical' + ? `fix/critical-${number}-${slug}` + : `${prefix}/${number}-${slug}`; + + // Check if branch already exists + try { + await github.rest.git.getRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `heads/${branch}`, + }); + core.info(`Branch ${branch} already exists`); + return; + } catch (e) { + if (e.status !== 404) throw e; + } + + // Create branch from main HEAD + const mainRef = await github.rest.git.getRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: 'heads/main', + }); + + await github.rest.git.createRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `refs/heads/${branch}`, + sha: mainRef.data.object.sha, + }); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: number, + body: `Branch \`${branch}\` created.\n\n\`\`\`bash\ngit fetch origin && git checkout ${branch}\n\`\`\``, + }); diff --git a/.github/workflows/branch-naming.yml b/.github/workflows/branch-naming.yml new file mode 100644 index 00000000..3a826c55 --- /dev/null +++ b/.github/workflows/branch-naming.yml @@ -0,0 +1,38 @@ +name: Validate Branch Name + +on: + pull_request: + types: [opened, synchronize] + +permissions: {} + +jobs: + check-branch: + runs-on: ubuntu-latest + timeout-minutes: 1 + steps: + - name: Validate branch naming convention + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const branch = context.payload.pull_request.head.ref; + + const validPrefixes = [ + 'feat/', 'fix/', 'hotfix/', 'docs/', 'chore/', + 'refactor/', 'test/', 'release/', 'ci/', 'perf/', 'revert/', + ]; + + const alwaysValid = ['main', 'develop']; + if (alwaysValid.includes(branch)) return; + if (branch.startsWith('dependabot/') || branch.startsWith('renovate/')) return; + // GSD auto-created branches + if (branch.startsWith('gsd/') || branch.startsWith('claude/')) return; + + const isValid = validPrefixes.some(prefix => branch.startsWith(prefix)); + if (!isValid) { + const prefixList = validPrefixes.map(p => `\`${p}\``).join(', '); + core.warning( + `Branch "${branch}" doesn't follow naming convention. ` + + `Expected prefixes: ${prefixList}` + ); + } diff --git a/.github/workflows/hotfix.yml b/.github/workflows/hotfix.yml new file mode 100644 index 00000000..7f9e61fb --- /dev/null +++ b/.github/workflows/hotfix.yml @@ -0,0 +1,229 @@ +name: Hotfix Release + +on: + workflow_dispatch: + inputs: + action: + description: 'Action to perform' + required: true + type: choice + options: + - create + - finalize + version: + description: 'Patch version (e.g., 1.27.1)' + required: true + type: string + dry_run: + description: 'Dry run (skip npm publish, tagging, and push)' + required: false + type: boolean + default: false + +concurrency: + group: hotfix-${{ inputs.version }} + cancel-in-progress: false + +env: + NODE_VERSION: 24 + +jobs: + validate-version: + runs-on: ubuntu-latest + timeout-minutes: 2 + permissions: + contents: read + outputs: + base_tag: ${{ steps.validate.outputs.base_tag }} + branch: ${{ steps.validate.outputs.branch }} + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + + - name: Validate version format + id: validate + env: + VERSION: ${{ inputs.version }} + run: | + # Must be X.Y.Z where Z > 0 (patch release) + if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[1-9][0-9]*$'; then + echo "::error::Version must be a patch release (e.g., 1.27.1, not 1.28.0)" + exit 1 + fi + MAJOR_MINOR=$(echo "$VERSION" | cut -d. -f1-2) + TARGET_TAG="v${VERSION}" + BRANCH="hotfix/${VERSION}" + BASE_TAG=$(git tag -l "v${MAJOR_MINOR}.*" \ + | grep -E "^v[0-9]+\.[0-9]+\.[0-9]+$" \ + | sort -V \ + | awk -v target="$TARGET_TAG" '$1 < target { last=$1 } END { if (last != "") print last }') + if [ -z "$BASE_TAG" ]; then + echo "::error::No prior stable tag found for ${MAJOR_MINOR}.x before $TARGET_TAG" + exit 1 + fi + echo "base_tag=$BASE_TAG" >> "$GITHUB_OUTPUT" + echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" + + create: + needs: validate-version + if: inputs.action == 'create' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + + - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.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 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 hotfix branch + if: inputs.dry_run != 'true' + env: + BRANCH: ${{ needs.validate-version.outputs.branch }} + BASE_TAG: ${{ needs.validate-version.outputs.base_tag }} + VERSION: ${{ inputs.version }} + run: | + git checkout -b "$BRANCH" "$BASE_TAG" + # Bump version in package.json + npm version "$VERSION" --no-git-tag-version + git add package.json package-lock.json + git commit -m "chore: bump version to $VERSION for hotfix" + git push origin "$BRANCH" + echo "## Hotfix branch created" >> "$GITHUB_STEP_SUMMARY" + echo "- Branch: \`$BRANCH\`" >> "$GITHUB_STEP_SUMMARY" + echo "- Based on: \`$BASE_TAG\`" >> "$GITHUB_STEP_SUMMARY" + echo "- Apply your fix, push, then run this workflow again with \`finalize\`" >> "$GITHUB_STEP_SUMMARY" + + finalize: + needs: validate-version + 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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ needs.validate-version.outputs.branch }} + fetch-depth: 0 + + - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.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: Install and test + run: | + npm ci + npm run test:coverage + + - name: Create PR to merge hotfix back to main + if: ${{ !inputs.dry_run }} + env: + GH_TOKEN: ${{ github.token }} + BRANCH: ${{ needs.validate-version.outputs.branch }} + VERSION: ${{ inputs.version }} + run: | + EXISTING_PR=$(gh pr list --base main --head "$BRANCH" --state open --json number --jq '.[0].number') + if [ -n "$EXISTING_PR" ]; then + echo "PR #$EXISTING_PR already exists; updating" + gh pr edit "$EXISTING_PR" \ + --title "chore: merge hotfix v${VERSION} back to main" \ + --body "Merge hotfix changes back to main after v${VERSION} release." + else + gh pr create \ + --base main \ + --head "$BRANCH" \ + --title "chore: merge hotfix v${VERSION} back to main" \ + --body "Merge hotfix changes back to main after v${VERSION} release." + fi + + - name: Tag and push + if: ${{ !inputs.dry_run }} + env: + VERSION: ${{ inputs.version }} + 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" + else + git tag "v${VERSION}" + git push origin "v${VERSION}" + fi + + - name: Publish to npm (latest) + if: ${{ !inputs.dry_run }} + run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - 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 + 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" + + - name: Summary + env: + VERSION: ${{ inputs.version }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + echo "## Hotfix 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 "- Tagged \`v${VERSION}\`" >> "$GITHUB_STEP_SUMMARY" + echo "- PR created to merge back to main" >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml new file mode 100644 index 00000000..c549cb45 --- /dev/null +++ b/.github/workflows/pr-gate.yml @@ -0,0 +1,67 @@ +name: PR Gate + +on: + pull_request: + types: [opened, synchronize] + +permissions: + pull-requests: write + issues: write + +jobs: + size-check: + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + + - name: Check PR size + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const files = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + per_page: 100, + }); + + const additions = files.reduce((sum, f) => sum + f.additions, 0); + const deletions = files.reduce((sum, f) => sum + f.deletions, 0); + const total = additions + deletions; + + let label = ''; + if (total <= 50) label = 'size/S'; + else if (total <= 200) label = 'size/M'; + else if (total <= 500) label = 'size/L'; + else label = 'size/XL'; + + // Remove existing size labels + const existingLabels = context.payload.pull_request.labels || []; + const sizeLabels = existingLabels.filter(l => l.name.startsWith('size/')); + for (const staleLabel of sizeLabels) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + name: staleLabel.name + }).catch(() => {}); // ignore if already removed + } + + // Add size label + try { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: [label], + }); + } catch (e) { + core.warning(`Could not add label: ${e.message}`); + } + + if (total > 500) { + core.warning(`Large PR: ${total} lines changed (${additions}+ / ${deletions}-). Consider splitting.`); + } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..b07adccb --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,374 @@ +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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + + - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.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 + git add package.json package-lock.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" + + rc: + needs: validate-version + if: inputs.action == 'rc' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + id-token: write + environment: npm-publish + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ needs.validate-version.outputs.branch }} + fetch-depth: 0 + + - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.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 + + - 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 + git commit -m "chore: bump to ${PRE_VERSION}" + + - name: Dry-run publish validation + run: 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: 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" + # 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 "- 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" + + finalize: + needs: validate-version + 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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ needs.validate-version.outputs.branch }} + fetch-depth: 0 + + - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.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 + git add package.json package-lock.json + git diff --cached --quiet || git commit -m "chore: finalize v${VERSION}" + + - name: Install and test + run: | + npm ci + npm run test:coverage + + - name: Dry-run publish validation + run: npm publish --dry-run + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Create PR to merge release back to main + if: ${{ !inputs.dry_run }} + env: + GH_TOKEN: ${{ github.token }} + BRANCH: ${{ needs.validate-version.outputs.branch }} + VERSION: ${{ inputs.version }} + run: | + EXISTING_PR=$(gh pr list --base main --head "$BRANCH" --state open --json number --jq '.[0].number') + 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." + 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." + 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: 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 + 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" + # 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 "- 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 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 00000000..72a7f34d --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,34 @@ +name: Stale Cleanup + +on: + schedule: + - cron: '0 9 * * 1' # Monday 9am UTC + workflow_dispatch: + +permissions: + issues: write + pull-requests: write + +jobs: + stale: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9.0.0 + with: + days-before-stale: 28 + days-before-close: 14 + stale-issue-message: > + This issue has been inactive for 28 days. It will be closed in 14 days + if there is no further activity. If this is still relevant, please comment + or update to the latest GSD version and retest. + stale-pr-message: > + This PR has been inactive for 28 days. It will be closed in 14 days + if there is no further activity. + close-issue-message: > + Closed due to inactivity. If this is still relevant, please reopen + with updated reproduction steps on the latest GSD version. + stale-issue-label: 'stale' + stale-pr-label: 'stale' + exempt-issue-labels: 'fix-pending,priority: critical,pinned,confirmed-bug,confirmed' + exempt-pr-labels: 'fix-pending,priority: critical,pinned,DO NOT MERGE' diff --git a/VERSIONING.md b/VERSIONING.md new file mode 100644 index 00000000..d4b6f37c --- /dev/null +++ b/VERSIONING.md @@ -0,0 +1,126 @@ +# Versioning & Release Strategy + +GSD follows [Semantic Versioning 2.0.0](https://semver.org/) with three release tiers mapped to npm dist-tags. + +## Release Tiers + +| Tier | What ships | Version format | npm tag | Branch | Install | +|------|-----------|---------------|---------|--------|---------| +| **Patch** | Bug fixes only | `1.27.1` | `latest` | `hotfix/1.27.1` | `npx get-shit-done-cc@latest` | +| **Minor** | Fixes + enhancements | `1.28.0` | `latest` (after RC) | `release/1.28.0` | `npx get-shit-done-cc@next` (RC) | +| **Major** | Fixes + enhancements + features | `2.0.0` | `latest` (after beta) | `release/2.0.0` | `npx get-shit-done-cc@next` (beta) | + +## npm Dist-Tags + +Only two tags, following Angular/Next.js convention: + +| Tag | Meaning | Installed by | +|-----|---------|-------------| +| `latest` | Stable production release | `npm install get-shit-done-cc` (default) | +| `next` | Pre-release (RC or beta) | `npm install get-shit-done-cc@next` (opt-in) | + +The version string (`-rc.1` vs `-beta.1`) communicates stability level. Users never get pre-releases unless they explicitly opt in. + +## Semver Rules + +| Increment | When | Examples | +|-----------|------|----------| +| **PATCH** (1.27.x) | Bug fixes, typo corrections, test additions | Hook filter fix, config corruption fix | +| **MINOR** (1.x.0) | Non-breaking enhancements, new commands, new runtime support | New workflow command, discuss-mode feature | +| **MAJOR** (x.0.0) | Breaking changes to config format, CLI flags, or runtime API; new features that alter existing behavior | Removing a command, changing config schema | + +## Pre-Release Version Progression + +Major and minor releases use different pre-release types: + +``` +Minor: 1.28.0-rc.1 → 1.28.0-rc.2 → 1.28.0 +Major: 2.0.0-beta.1 → 2.0.0-beta.2 → 2.0.0 +``` + +- **beta** (major releases only): Feature-complete but not fully tested. API mostly stable. Used for major releases to signal a longer testing cycle. +- **rc** (minor releases only): Production-ready candidate. Only critical fixes expected. +- Each version uses one pre-release type throughout its cycle. The `rc` action in the release workflow automatically selects the correct type based on the version. + +## Branch Structure + +``` +main ← stable, always deployable + │ + ├── hotfix/1.27.1 ← patch: cherry-pick fix from main, publish to latest + │ + ├── release/1.28.0 ← minor: accumulate fixes + enhancements, RC cycle + │ ├── v1.28.0-rc.1 ← tag: published to next + │ └── v1.28.0 ← tag: promoted to latest + │ + ├── release/2.0.0 ← major: features + breaking changes, beta cycle + │ ├── v2.0.0-beta.1 ← tag: published to next + │ ├── v2.0.0-beta.2 ← tag: published to next + │ └── v2.0.0 ← tag: promoted to latest + │ + ├── fix/1200-bug-description ← bug fix branch (merges to main) + ├── feat/925-feature-name ← feature branch (merges to main) + └── chore/1206-maintenance ← maintenance branch (merges to main) +``` + +## Release Workflows + +### Patch Release (Hotfix) + +For critical bugs that can't wait for the next minor release. + +1. Trigger `hotfix.yml` with version (e.g., `1.27.1`) +2. Workflow creates `hotfix/1.27.1` branch from the latest patch tag for that minor version (e.g., `v1.27.0` or `v1.27.1`) +3. Cherry-pick or apply fix on the hotfix branch +4. Push — CI runs tests automatically +5. Trigger `hotfix.yml` finalize action +6. Workflow runs full test suite, bumps version, tags, publishes to `latest` +7. Merge hotfix branch back to main + +### Minor Release (Standard Cycle) + +For accumulated fixes and enhancements. + +1. Trigger `release.yml` with action `create` and version (e.g., `1.28.0`) +2. Workflow creates `release/1.28.0` branch from main, bumps package.json +3. Trigger `release.yml` with action `rc` to publish `1.28.0-rc.1` to `next` +4. Test the RC: `npx get-shit-done-cc@next` +5. If issues found: fix on release branch, publish `rc.2`, `rc.3`, etc. +6. Trigger `release.yml` with action `finalize` — publishes `1.28.0` to `latest` +7. Merge release branch to main + +### Major Release + +Same as minor but uses `-beta.N` instead of `-rc.N`, signaling a longer testing cycle. + +1. Trigger `release.yml` with action `create` and version (e.g., `2.0.0`) +2. Trigger `release.yml` with action `rc` to publish `2.0.0-beta.1` to `next` +3. If issues found: fix on release branch, publish `beta.2`, `beta.3`, etc. +4. Trigger `release.yml` with action `finalize` -- publishes `2.0.0` to `latest` +5. Merge release branch to main + +## Conventional Commits + +Branch names map to commit types: + +| Branch prefix | Commit type | Version bump | +|--------------|-------------|-------------| +| `fix/` | `fix:` | PATCH | +| `feat/` | `feat:` | MINOR | +| `hotfix/` | `fix:` | PATCH (immediate) | +| `chore/` | `chore:` | none | +| `docs/` | `docs:` | none | +| `refactor/` | `refactor:` | none | + +## Publishing Commands (Reference) + +```bash +# Stable release (sets latest tag automatically) +npm publish + +# Pre-release (must use --tag to avoid overwriting latest) +npm publish --tag next + +# Verify what latest and next point to +npm dist-tag ls get-shit-done-cc +```