feat: 3-tier release strategy with hotfix, release, and CI workflows (#1289)

* feat: 3-tier release strategy with hotfix, release, and CI workflows

Supersedes PRs #1208 and #1210 with a consolidated approach:

- VERSIONING.md: Strategy document with 3 release tiers (patch/minor/major)
- hotfix.yml: Emergency patch releases to latest
- release.yml: Standard release cycle with RC/beta pre-releases to next
- auto-branch.yml: Create branches from issue labels
- branch-naming.yml: Convention validation (advisory)
- pr-gate.yml: PR size analysis and labeling
- stale.yml: Weekly cleanup of inactive issues/PRs
- dependabot.yml: Automated dependency updates

npm dist-tags: latest (stable) and next (pre-release) only,
following Angular/Next.js convention.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR review findings for release workflow security and correctness

- Move all ${{ }} expression interpolation from run: blocks into env: mappings
  in both hotfix.yml (~12 instances) and release.yml (~16 instances) to prevent
  potential command injection via GitHub Actions expression evaluation
- Reorder rc job in release.yml to run npm ci and test:coverage before pushing
  the git tag, preventing broken tagged commits when tests fail
- Update VERSIONING.md to accurately describe the implementation: major releases
  use beta pre-releases only, minor releases use rc pre-releases only (no
  beta-then-rc progression)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* security: harden release workflows — SHA pinning, provenance, dry-run guards

Addresses deep adversarial review + best practices research:

HIGH:
- Fix release.yml rc/finalize: dry_run now gates tag+push (not just npm publish)
- Fix hotfix.yml finalize: reorder tag-before-publish (was publish-before-tag)

MEDIUM — Security hardening:
- Pin ALL actions to SHA hashes (actions/checkout@11bd7190,
  actions/setup-node@39370e39, actions/github-script@60a0d830)
- Add --provenance --access public to all npm publish commands
- Add id-token: write permission for npm provenance OIDC
- Add concurrency groups (cancel-in-progress: false) on both workflows
- Add branch-naming.yml permissions: {} (deny-all default)
- Scope permissions per-job instead of workflow-level where possible

MEDIUM — Reliability:
- Add post-publish verification (npm view + dist-tag check) after every publish
- Add npm publish --dry-run validation step before actual publish
- Add branch existence pre-flight check in create jobs

LOW:
- Fix VERSIONING.md Semver Rules: MINOR = "enhancements" not "new features"
  (aligns with Release Tiers table)

Tests: 1166/1166 pass

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* security: pin actions/stale to SHA hash

Last remaining action using a mutable version tag. Now all actions
across all workflow files are pinned to immutable SHA hashes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address all Copilot review findings on release strategy workflows

- Configure git identity in all committing jobs (hotfix + release)
- Base hotfix on latest patch tag instead of vX.Y.0
- Add issues: write permission for PR size labeling
- Remove stale size labels before adding new one
- Make tagging and PR creation idempotent for reruns
- Run dry-run publish validation unconditionally
- Paginate listFiles for large PRs
- Fix VERSIONING.md table formatting and docs accuracy

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: clean up next dist-tag after finalize in release and hotfix workflows

After finalizing a release, the next dist-tag was left pointing at the
last RC pre-release. Anyone running npm install @next would get a stale
version older than @latest. Now both workflows point next to the stable
release after finalize, matching Angular/Next.js convention.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(ci): address blocking issues in 3-tier release workflows

- Move back-merge PR creation before npm publish in hotfix/release finalize
- Move version bump commit after test step in rc workflow
- Gate hotfix create branch push behind dry_run check
- Add confirmed-bug and confirmed to stale.yml exempt labels
- Fix auto-branch priority: critical prefix collision with hotfix/ naming

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-04-05 23:08:31 -04:00
committed by GitHub
parent 00c6a5ea68
commit 85316d62d5
8 changed files with 978 additions and 0 deletions

25
.github/dependabot.yml vendored Normal file
View File

@@ -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):"

85
.github/workflows/auto-branch.yml vendored Normal file
View File

@@ -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\`\`\``,
});

38
.github/workflows/branch-naming.yml vendored Normal file
View File

@@ -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}`
);
}

229
.github/workflows/hotfix.yml vendored Normal file
View File

@@ -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

67
.github/workflows/pr-gate.yml vendored Normal file
View File

@@ -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.`);
}

374
.github/workflows/release.yml vendored Normal file
View File

@@ -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

34
.github/workflows/stale.yml vendored Normal file
View File

@@ -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'

126
VERSIONING.md Normal file
View File

@@ -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
```