diff --git a/.github/workflows/hotfix.yml b/.github/workflows/hotfix.yml index fdc2700e3..6284cca30 100644 --- a/.github/workflows/hotfix.yml +++ b/.github/workflows/hotfix.yml @@ -1,5 +1,27 @@ name: Hotfix Release +# Hotfix flow for X.YY.Z patch releases (Z > 0). +# +# create: +# - Branches hotfix/X.YY.Z from the highest existing vX.YY.* tag (1.27.2 from +# v1.27.1, 1.27.1 from v1.27.0). The base IS the cumulative-fix anchor for +# the previous patch. +# - Auto-cherry-picks every fix:/chore: commit on origin/main that isn't +# already in the base, oldest-first. Patch-equivalents (already applied) +# are skipped via `git cherry`. feat:/refactor: are NEVER auto-included. +# - Conflicts fail the workflow with the offending SHA so the operator can +# resolve manually on the branch and re-run finalize with auto_cherry_pick=false. +# - Step summary lists every included SHA so the eventual vX.YY.Z tag +# self-documents what shipped. +# +# finalize: +# - install-smoke gate (cross-platform, parity with release.yml/release-sdk.yml) +# - Bundles SDK as both loose tree (sdk/dist/cli.js) and recoverable tarball +# (sdk-bundle/gsd-sdk.tgz) — parity with release-sdk.yml so a hotfix shipped +# during the @gsd-build-token outage carries the same payload shape. +# - Publishes to @latest, tags vX.YY.Z, re-points @next → vX.YY.Z, opens +# merge-back PR. + on: workflow_dispatch: inputs: @@ -14,6 +36,11 @@ on: description: 'Patch version (e.g., 1.27.1)' required: true type: string + auto_cherry_pick: + description: 'Auto-cherry-pick fix:/chore: commits from origin/main since base tag (create only)' + required: false + type: boolean + default: true dry_run: description: 'Dry run (skip npm publish, tagging, and push)' required: false @@ -54,10 +81,13 @@ jobs: 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]+$" \ + # Append TARGET_TAG to the candidate list, then sort -V, then walk the + # sorted list and print whatever immediately precedes TARGET_TAG. This + # is semver-correct for multi-digit patches (v1.27.10 > v1.27.9) where + # a plain `awk '$1 < target'` lexicographic compare would mis-order. + BASE_TAG=$( ( git tag -l "v${MAJOR_MINOR}.*" | grep -E "^v[0-9]+\.[0-9]+\.[0-9]+$"; echo "$TARGET_TAG" ) \ | sort -V \ - | awk -v target="$TARGET_TAG" '$1 < target { last=$1 } END { if (last != "") print last }') + | awk -v target="$TARGET_TAG" '$1 == target { print prev; exit } { prev = $1 }') if [ -z "$BASE_TAG" ]; then echo "::error::No prior stable tag found for ${MAJOR_MINOR}.x before $TARGET_TAG" exit 1 @@ -95,29 +125,160 @@ jobs: 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' + - name: Create hotfix branch from base tag and push (skeleton) + env: + BRANCH: ${{ needs.validate-version.outputs.branch }} + BASE_TAG: ${{ needs.validate-version.outputs.base_tag }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + git checkout -b "$BRANCH" "$BASE_TAG" + # Push the skeleton branch up-front so any subsequent cherry-pick + # conflict leaves a remote artefact the operator can fetch, resolve, + # and re-push. Skipped on dry-run — local checkout still exercises + # the same cherry-pick + bump flow so conflicts are caught. + if [ "$DRY_RUN" != "true" ]; then + git push -u origin "$BRANCH" + fi + + - name: Cherry-pick fix/chore commits from origin/main since base tag + if: ${{ inputs.auto_cherry_pick }} + env: + BRANCH: ${{ needs.validate-version.outputs.branch }} + BASE_TAG: ${{ needs.validate-version.outputs.base_tag }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + git fetch origin main:refs/remotes/origin/main + + # `git cherry $BASE_TAG origin/main` lists every commit on main not + # patch-equivalent in BASE_TAG. + means needs picking, - means + # already applied (skipped silently). + CANDIDATES=$(git cherry "$BASE_TAG" origin/main | awk '/^\+ / {print $2}') + + if [ -z "$CANDIDATES" ]; then + echo "No commits on origin/main beyond $BASE_TAG." + echo "## Cherry-pick summary" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Base: \`$BASE_TAG\` — no commits to consider." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + # Re-order chronologically (oldest first) for predictable application. + ORDERED=$(git log --reverse --format='%H' "$BASE_TAG..origin/main" \ + | grep -F -f <(echo "$CANDIDATES") || true) + + INCLUDED="" + SKIPPED="" + while IFS= read -r SHA; do + [ -z "$SHA" ] && continue + SUBJECT=$(git log -1 --format='%s' "$SHA") + # fix: or chore:, optional scope, optional ! breaking marker + if echo "$SUBJECT" | grep -qE '^(fix|chore)(\([^)]+\))?!?: '; then + echo "→ cherry-picking $SHA $SUBJECT" + if ! git cherry-pick -x "$SHA"; then + # Abort restores HEAD to the last successful pick. On real + # runs, push that state so the operator can fetch, resolve + # $SHA manually, and finalize with auto_cherry_pick=false. + git cherry-pick --abort || true + if [ "$DRY_RUN" != "true" ]; then + git push --force-with-lease origin "$BRANCH" || git push origin "$BRANCH" || true + fi + { + echo "## Cherry-pick conflict" + echo "" + echo "Failed at: \`${SHA}\` — \`${SUBJECT}\`" + echo "" + if [ "$DRY_RUN" = "true" ]; then + echo "**Dry run:** branch was not pushed, so the picks below were discarded with the runner." + if [ -n "$INCLUDED" ]; then + echo "" + echo "Already-applied picks (lost — must be re-applied before resolving \`${SHA}\`):" + echo "" + echo "$INCLUDED" + fi + echo "" + echo "**To resolve:** re-run \`create\` with \`auto_cherry_pick=true\` (real, not dry-run) to materialize the partial branch on origin, then resolve \`${SHA}\` manually. Re-running with \`auto_cherry_pick=false\` would recreate the branch from \`${BASE_TAG}\` and lose every pick listed above." + else + echo "Branch \`${BRANCH}\` was pushed with picks applied up to (but not including) the conflicting commit." + echo "" + echo "**To resolve:** \`git fetch origin && git checkout ${BRANCH} && git cherry-pick -x ${SHA}\`, fix the conflict, push, then re-run \`finalize\` with \`auto_cherry_pick=false\`." + fi + } >> "$GITHUB_STEP_SUMMARY" + echo "::error::Cherry-pick of $SHA failed. See summary." + exit 1 + fi + INCLUDED="${INCLUDED}- \`${SHA}\` ${SUBJECT}"$'\n' + else + echo " skip $SHA $SUBJECT (not fix/chore)" + SKIPPED="${SKIPPED}- \`${SHA}\` ${SUBJECT}"$'\n' + fi + done <<< "$ORDERED" + + { + echo "## Cherry-pick summary" + echo "" + echo "Base: \`$BASE_TAG\`" + echo "" + if [ -n "$INCLUDED" ]; then + echo "### Included (fix/chore)" + echo "" + echo "$INCLUDED" + else + echo "_No fix/chore commits to include._" + echo "" + fi + if [ -n "$SKIPPED" ]; then + echo "### Skipped (feat/refactor/etc — not auto-included)" + echo "" + echo "$SKIPPED" + fi + } >> "$GITHUB_STEP_SUMMARY" + + - name: Bump version and push env: BRANCH: ${{ needs.validate-version.outputs.branch }} BASE_TAG: ${{ needs.validate-version.outputs.base_tag }} VERSION: ${{ inputs.version }} + DRY_RUN: ${{ inputs.dry_run }} run: | - git checkout -b "$BRANCH" "$BASE_TAG" - # Bump version in package.json + set -euo pipefail npm version "$VERSION" --no-git-tag-version git add package.json package-lock.json + # Keep sdk/package.json in lockstep (parity with release-sdk.yml). + if [ -f sdk/package.json ]; then + (cd sdk && npm version "$VERSION" --no-git-tag-version) + git add sdk/package.json + [ -f sdk/package-lock.json ] && git add sdk/package-lock.json + fi 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" + if [ "$DRY_RUN" != "true" ]; then + git push origin "$BRANCH" + else + echo "DRY RUN — branch not pushed. Local checkout exercised the cherry-pick and bump flow." + fi + { + echo "## Hotfix branch created" + echo "" + echo "- Branch: \`$BRANCH\`" + echo "- Based on: \`$BASE_TAG\`" + echo "- Apply additional manual fixes if needed, then run \`finalize\`." + } >> "$GITHUB_STEP_SUMMARY" - finalize: + install-smoke: 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] + if: inputs.action == 'finalize' runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 permissions: contents: write pull-requests: write @@ -140,31 +301,83 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + - name: Detect prior publish (reconciliation mode) + id: prior_publish + env: + VERSION: ${{ inputs.version }} + run: | + EXISTING=$(npm view get-shit-done-cc@"$VERSION" version 2>/dev/null || true) + if [ -n "$EXISTING" ]; then + echo "::warning::get-shit-done-cc@${VERSION} is already on the registry — entering reconciliation mode (skip publish, continue with tag/release/PR/dist-tag)." + echo "skip_publish=true" >> "$GITHUB_OUTPUT" + else + echo "skip_publish=false" >> "$GITHUB_OUTPUT" + fi + - name: Install and test run: | npm ci npm run test:coverage - - name: Create PR to merge hotfix back to main - if: ${{ !inputs.dry_run }} + - name: Build SDK dist for tarball + run: npm run build:sdk + + - name: Verify CC tarball ships sdk/dist/cli.js (bug #2647 guard) + run: bash scripts/verify-tarball-sdk-dist.sh + + - name: Pack SDK as tarball and bundle into CC source tree 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." + set -e + cd sdk + npm pack + TARBALL="gsd-build-sdk-${VERSION}.tgz" + if [ ! -f "$TARBALL" ]; then + echo "::error::Expected $TARBALL but npm pack did not produce it." + ls -la + exit 1 fi + mkdir -p ../sdk-bundle + mv "$TARBALL" ../sdk-bundle/gsd-sdk.tgz + cd .. + ls -la sdk-bundle/ + + - name: Add sdk-bundle to CC files whitelist (in-tree, not committed) + run: | + node <<'NODE' + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); + if (!Array.isArray(pkg.files)) { + console.error('::error::package.json files is not an array'); + process.exit(1); + } + if (!pkg.files.includes('sdk-bundle')) { + pkg.files.push('sdk-bundle'); + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); + console.log('Added sdk-bundle/ to package.json files whitelist'); + } + NODE + + - name: Verify CC tarball will contain sdk-bundle/gsd-sdk.tgz + run: | + set -e + TARBALL=$(npm pack --ignore-scripts 2>/dev/null | tail -1) + if [ -z "$TARBALL" ] || [ ! -f "$TARBALL" ]; then + echo "::error::npm pack produced no tarball" + exit 1 + fi + if ! tar -tzf "$TARBALL" | grep -q "package/sdk-bundle/gsd-sdk.tgz"; then + echo "::error::CC tarball is missing package/sdk-bundle/gsd-sdk.tgz" + exit 1 + fi + echo "✅ CC tarball contains sdk-bundle/gsd-sdk.tgz" + rm -f "$TARBALL" + + - name: Dry-run publish validation + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm publish --dry-run --tag latest - name: Tag and push if: ${{ !inputs.dry_run }} @@ -185,55 +398,98 @@ jobs: fi - name: Publish to npm (latest) - if: ${{ !inputs.dry_run }} - run: npm publish --provenance --access public + if: ${{ !inputs.dry_run && steps.prior_publish.outputs.skip_publish != 'true' }} env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm publish --provenance --access public --tag latest - - name: Create GitHub Release + - name: Re-point next dist-tag at this hotfix + if: ${{ !inputs.dry_run }} + env: + VERSION: ${{ inputs.version }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + npm dist-tag add "get-shit-done-cc@${VERSION}" next + echo "✅ next dist-tag re-pointed to v${VERSION} (matches latest)" + + - name: Create GitHub Release (idempotent) if: ${{ !inputs.dry_run }} env: GH_TOKEN: ${{ github.token }} VERSION: ${{ inputs.version }} run: | - gh release create "v${VERSION}" \ - --title "v${VERSION} (hotfix)" \ - --generate-notes + if gh release view "v${VERSION}" >/dev/null 2>&1; then + echo "GitHub Release v${VERSION} already exists; ensuring --latest flag is set" + gh release edit "v${VERSION}" --latest || true + else + gh release create "v${VERSION}" \ + --title "v${VERSION} (hotfix)" \ + --generate-notes \ + --latest + fi - - name: Clean up next dist-tag + - 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 }} - 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}" + EXISTING_PR=$(gh pr list --base main --head "$BRANCH" --state open --json number --jq '.[0].number') + if [ -n "$EXISTING_PR" ]; then + 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: Verify publish + - name: Verify publish landed on registry 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") + PUBLISHED="NOT_FOUND" + for delay in 5 10 20 30 45; do + PUBLISHED=$(npm view get-shit-done-cc@"$VERSION" version 2>/dev/null || echo "NOT_FOUND") + if [ "$PUBLISHED" = "$VERSION" ]; then + break + fi + echo "Waiting ${delay}s for registry to catch up (saw: $PUBLISHED)..." + sleep "$delay" + done if [ "$PUBLISHED" != "$VERSION" ]; then - echo "::error::Published version verification failed. Expected $VERSION, got $PUBLISHED" + echo "::error::Version $VERSION did not appear on the registry within timeout" exit 1 fi - echo "✓ Verified: get-shit-done-cc@$VERSION is live on npm" + LATEST_VER=$(npm view get-shit-done-cc dist-tags.latest 2>/dev/null || echo "NOT_FOUND") + if [ "$LATEST_VER" != "$VERSION" ]; then + echo "::error::dist-tag 'latest' resolves to '$LATEST_VER', expected '$VERSION'" + exit 1 + fi + echo "✓ Verified: get-shit-done-cc@$VERSION is live on @latest" - name: Summary env: VERSION: ${{ inputs.version }} + BASE_TAG: ${{ needs.validate-version.outputs.base_tag }} 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 + { + echo "## Hotfix v${VERSION}" + echo "" + echo "- Base (cumulative-fix anchor): \`${BASE_TAG}\`" + if [ "$DRY_RUN" = "true" ]; then + echo "- **DRY RUN** — npm publish, tagging, and push skipped" + else + echo "- Published to npm as \`latest\`" + echo "- \`next\` dist-tag re-pointed to v${VERSION}" + echo "- Tagged \`v${VERSION}\` (anchor for the next hotfix's cherry-pick base)" + echo "- SDK bundled at \`sdk-bundle/gsd-sdk.tgz\` inside CC tarball" + echo "- Merge-back PR opened against main" + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release-sdk.yml b/.github/workflows/release-sdk.yml index d5c708ecc..803ae433c 100644 --- a/.github/workflows/release-sdk.yml +++ b/.github/workflows/release-sdk.yml @@ -25,61 +25,267 @@ name: Release SDK Bundle on: workflow_dispatch: inputs: - tag: - description: 'npm dist-tag to publish under' + action: + description: 'publish = normal dev/next/latest publish; hotfix = create hotfix/X.YY.Z branch from latest vX.YY.* tag, cherry-pick fix:/chore: from main, publish to @latest' required: true type: choice + default: publish + options: + - publish + - hotfix + tag: + description: 'npm dist-tag (publish action only; hotfix forces latest)' + required: false + type: choice + default: latest options: - dev - next - latest version: - description: 'Explicit version (e.g. 1.50.0-dev.3, 1.50.0-rc.2, 1.50.0). Empty = derive from package.json base + tag-appropriate suffix.' + description: 'Version. publish: explicit (e.g. 1.50.0-dev.3) or empty to derive. hotfix: REQUIRED patch (e.g. 1.27.1, Z>0).' required: false type: string ref: - description: 'Branch or ref to build from (default: the workflow-dispatch ref, typically dev)' + description: 'Branch or ref to build from. Ignored for hotfix (workflow uses hotfix/X.YY.Z).' required: false type: string + auto_cherry_pick: + description: 'Hotfix only: auto-cherry-pick fix:/chore: commits from origin/main since base tag.' + required: false + type: boolean + default: true dry_run: - description: 'Dry run (skip npm publish, git tag, and push)' + description: 'Dry run (skip npm publish, git tag, and push). Hotfix branch creation/push also skipped.' required: false type: boolean default: false -# Per dist-tag, no concurrent publishes for the same stream. Different streams -# can publish in parallel because they target different dist-tags. +# Per stream (dist-tag for publish, version for hotfix) — no concurrent publishes for the same stream. concurrency: - group: release-sdk-${{ inputs.tag }} + group: release-sdk-${{ inputs.action == 'hotfix' && format('hotfix-{0}', inputs.version) || inputs.tag }} cancel-in-progress: false env: NODE_VERSION: 24 jobs: + # Resolves the effective git ref for this run. + # + # action=publish → outputs inputs.ref verbatim (may be empty = workflow ref) + # action=hotfix → branches hotfix/X.YY.Z from highest existing vX.YY.* tag, + # auto-cherry-picks fix:/chore: from origin/main, pushes, + # and outputs the new branch as ref. Idempotent: if branch + # already exists (operator pre-prepared it via hotfix.yml), + # we just check it out and re-run the cherry-pick step + # no-ops since `git cherry` will report nothing new. + prepare: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + outputs: + ref: ${{ steps.out.outputs.ref }} + base_tag: ${{ steps.hotfix.outputs.base_tag }} + steps: + - name: Validate hotfix inputs + if: inputs.action == 'hotfix' + env: + VERSION: ${{ inputs.version }} + run: | + if [ -z "$VERSION" ]; then + echo "::error::action=hotfix requires the 'version' input (e.g. 1.27.1)" + exit 1 + fi + if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[1-9][0-9]*$'; then + echo "::error::Hotfix version must match X.YY.Z with Z>0 (got: $VERSION)" + exit 1 + fi + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + if: inputs.action == 'hotfix' + with: + fetch-depth: 0 + + - name: Configure git identity + if: inputs.action == 'hotfix' + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Prepare hotfix branch + id: hotfix + if: inputs.action == 'hotfix' + env: + VERSION: ${{ inputs.version }} + AUTO_CHERRY_PICK: ${{ inputs.auto_cherry_pick }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + MAJOR_MINOR=$(echo "$VERSION" | cut -d. -f1-2) + TARGET_TAG="v${VERSION}" + BRANCH="hotfix/${VERSION}" + # Semver-correct selection: append TARGET_TAG, sort -V, take preceding entry. + # Plain lexicographic compare mis-orders multi-digit patches (v1.27.10 vs v1.27.9). + BASE_TAG=$( ( git tag -l "v${MAJOR_MINOR}.*" | grep -E "^v[0-9]+\.[0-9]+\.[0-9]+$"; echo "$TARGET_TAG" ) \ + | sort -V \ + | awk -v target="$TARGET_TAG" '$1 == target { print prev; exit } { prev = $1 }') + 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" + + # Idempotent branch creation — operator may have pre-prepared via hotfix.yml. + git fetch origin main:refs/remotes/origin/main + if git ls-remote --exit-code origin "refs/heads/$BRANCH" >/dev/null 2>&1; then + echo "Branch $BRANCH already exists on origin; checking out" + git fetch origin "$BRANCH" + git checkout "$BRANCH" + BRANCH_PRE_EXISTED=1 + else + git checkout -b "$BRANCH" "$BASE_TAG" + BRANCH_PRE_EXISTED=0 + # Push the skeleton up-front (real runs only) so cherry-pick conflicts + # leave a remote artefact the operator can resolve. Dry-run keeps + # everything local — no orphan branch created on origin. + if [ "$DRY_RUN" != "true" ]; then + git push -u origin "$BRANCH" + fi + fi + + if [ "$AUTO_CHERRY_PICK" = "true" ]; then + CANDIDATES=$(git cherry HEAD origin/main | awk '/^\+ / {print $2}') + if [ -n "$CANDIDATES" ]; then + ORDERED=$(git log --reverse --format='%H' "${BASE_TAG}..origin/main" \ + | grep -F -f <(echo "$CANDIDATES") || true) + INCLUDED="" + SKIPPED="" + while IFS= read -r SHA; do + [ -z "$SHA" ] && continue + SUBJECT=$(git log -1 --format='%s' "$SHA") + if echo "$SUBJECT" | grep -qE '^(fix|chore)(\([^)]+\))?!?: '; then + echo "→ cherry-picking $SHA $SUBJECT" + if ! git cherry-pick -x "$SHA"; then + git cherry-pick --abort || true + # On real runs: push the partial-pick state so the operator + # can fetch + resolve $SHA + push + re-run with auto_cherry_pick=false. + if [ "$DRY_RUN" != "true" ]; then + git push --force-with-lease origin "$BRANCH" || git push origin "$BRANCH" || true + fi + { + echo "## Cherry-pick conflict" + echo "" + echo "Failed at: \`${SHA}\` — \`${SUBJECT}\`" + echo "" + if [ "$DRY_RUN" = "true" ]; then + echo "**Dry run:** branch was not pushed, so the picks below were discarded with the runner." + if [ -n "$INCLUDED" ]; then + echo "" + echo "Already-applied picks (lost — must be re-applied before resolving \`${SHA}\`):" + echo "" + echo "$INCLUDED" + fi + echo "" + echo "**To resolve:** re-run a real hotfix with \`auto_cherry_pick=true\` to materialize the partial branch on origin, then resolve \`${SHA}\` manually. Re-running with \`auto_cherry_pick=false\` would recreate the branch from \`${BASE_TAG}\` and lose every pick listed above." + else + echo "Branch \`${BRANCH}\` was pushed with picks applied up to (but not including) the conflicting commit." + echo "" + echo "**To resolve:** \`git fetch origin && git checkout ${BRANCH} && git cherry-pick -x ${SHA}\`, fix the conflict, push, then re-run with \`auto_cherry_pick=false\`." + fi + } >> "$GITHUB_STEP_SUMMARY" + echo "::error::Cherry-pick of $SHA failed. See run summary." + exit 1 + fi + INCLUDED="${INCLUDED}- \`${SHA}\` ${SUBJECT}"$'\n' + else + SKIPPED="${SKIPPED}- \`${SHA}\` ${SUBJECT}"$'\n' + fi + done <<< "$ORDERED" + { + echo "## Cherry-pick summary" + echo "" + echo "Base: \`$BASE_TAG\` → Branch: \`$BRANCH\`$([ "$DRY_RUN" = "true" ] && echo " (DRY RUN — local only)")" + echo "" + if [ -n "$INCLUDED" ]; then + echo "### Included (fix/chore)" + echo "" + echo "$INCLUDED" + else + echo "_No fix/chore commits to include._" + fi + if [ -n "$SKIPPED" ]; then + echo "### Skipped (feat/refactor/etc — not auto-included)" + echo "" + echo "$SKIPPED" + fi + } >> "$GITHUB_STEP_SUMMARY" + fi + fi + + # Bump version on the branch (committed) so downstream install-smoke + + # release jobs build the correct version. The release job's own in-tree + # bump becomes a no-op when the file already has the right version. + CURRENT=$(node -p "require('./package.json').version") + if [ "$CURRENT" != "$VERSION" ]; then + npm version "$VERSION" --no-git-tag-version + git add package.json package-lock.json + if [ -f sdk/package.json ]; then + (cd sdk && npm version "$VERSION" --no-git-tag-version) + git add sdk/package.json + [ -f sdk/package-lock.json ] && git add sdk/package-lock.json + fi + git commit -m "chore: bump version to $VERSION for hotfix" + fi + if [ "$DRY_RUN" != "true" ]; then + git push origin "$BRANCH" + else + echo "DRY RUN — cherry-picks applied locally; branch not pushed. Downstream install-smoke will run against \`$BASE_TAG\` (the cherry-pick verification above is the dry-run signal)." + fi + + - name: Determine effective ref + id: out + env: + ACTION: ${{ inputs.action }} + INPUT_REF: ${{ inputs.ref }} + DRY_RUN: ${{ inputs.dry_run }} + BASE_TAG: ${{ steps.hotfix.outputs.base_tag }} + BRANCH: ${{ steps.hotfix.outputs.branch }} + run: | + if [ "$ACTION" = "hotfix" ]; then + if [ "$DRY_RUN" = "true" ]; then + echo "ref=$BASE_TAG" >> "$GITHUB_OUTPUT" + else + echo "ref=$BRANCH" >> "$GITHUB_OUTPUT" + fi + else + echo "ref=$INPUT_REF" >> "$GITHUB_OUTPUT" + fi + # Cross-platform install validation gate (parity with release.yml). - # Publish job depends on this — won't proceed if the package fails to - # install cleanly across the supported matrix. install-smoke: + needs: prepare permissions: contents: read uses: ./.github/workflows/install-smoke.yml with: - ref: ${{ inputs.ref }} + ref: ${{ needs.prepare.outputs.ref }} release: - needs: install-smoke + needs: [prepare, install-smoke] runs-on: ubuntu-latest timeout-minutes: 15 permissions: contents: write # tag + push + GitHub Release id-token: write # provenance + pull-requests: write # hotfix mode opens merge-back PR via gh pr create environment: npm-publish steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - ref: ${{ inputs.ref }} + ref: ${{ needs.prepare.outputs.ref }} - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: @@ -90,10 +296,24 @@ jobs: - name: Determine version id: ver env: + ACTION: ${{ inputs.action }} INPUT_TAG: ${{ inputs.tag }} INPUT_OVERRIDE: ${{ inputs.version }} run: | set -e + # Hotfix forces version=inputs.version and dist-tag=latest. + if [ "$ACTION" = "hotfix" ]; then + if [ -z "$INPUT_OVERRIDE" ]; then + echo "::error::action=hotfix requires the 'version' input" + exit 1 + fi + VERSION="$INPUT_OVERRIDE" + EFFECTIVE_TAG="latest" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=$EFFECTIVE_TAG" >> "$GITHUB_OUTPUT" + echo "→ Hotfix: will publish v${VERSION} to dist-tag '${EFFECTIVE_TAG}'" + exit 0 + fi RAW=$(node -p "require('./package.json').version") BASE=$(echo "$RAW" | sed 's/-.*//') if [ -n "$INPUT_OVERRIDE" ]; then @@ -127,14 +347,21 @@ jobs: echo "tag=$INPUT_TAG" >> "$GITHUB_OUTPUT" echo "→ Will publish v${VERSION} to dist-tag '${INPUT_TAG}'" - - name: Refuse if version already exists on npm + # Reconciliation mode: if version is already on npm (a prior run + # published successfully but a downstream step failed), don't hard-fail. + # Set a flag and skip the publish step below; tag/release/PR/dist-tag + # steps still execute so the rerun can finish reconciling state. + - name: Detect prior publish (reconciliation mode) + id: prior_publish env: VERSION: ${{ steps.ver.outputs.version }} run: | EXISTING=$(npm view get-shit-done-cc@"$VERSION" version 2>/dev/null || true) if [ -n "$EXISTING" ]; then - echo "::error::get-shit-done-cc@${VERSION} is already published. Bump version or pass an explicit override input." - exit 1 + echo "::warning::get-shit-done-cc@${VERSION} is already on the registry — entering reconciliation mode (skip publish, continue with tag/release/PR/dist-tag)." + echo "skip_publish=true" >> "$GITHUB_OUTPUT" + else + echo "skip_publish=false" >> "$GITHUB_OUTPUT" fi # Tolerant tag-existence check (matches release.yml pattern). An @@ -252,7 +479,7 @@ jobs: git push origin "v${VERSION}" - name: Publish to npm (CC bundle, SDK included as both loose tree and .tgz) - if: ${{ !inputs.dry_run }} + if: ${{ !inputs.dry_run && steps.prior_publish.outputs.skip_publish != 'true' }} env: TAG: ${{ steps.ver.outputs.tag }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -271,7 +498,7 @@ jobs: npm dist-tag add "get-shit-done-cc@${VERSION}" next echo "✅ next dist-tag re-pointed to v${VERSION} (matches latest)" - - name: Create GitHub Release + - name: Create GitHub Release (idempotent) if: ${{ !inputs.dry_run }} env: GH_TOKEN: ${{ github.token }} @@ -281,7 +508,14 @@ jobs: # Per-tag release flags: # dev, next → --prerelease (won't be highlighted as the latest release on the repo page) # latest → --latest (becomes the highlighted release) - if [ "$TAG" = "latest" ]; then + # Idempotent: if release already exists (rerun after a transient + # downstream failure), edit the latest flag instead of failing. + if gh release view "v${VERSION}" >/dev/null 2>&1; then + echo "GitHub Release v${VERSION} already exists; reconciling --latest flag" + if [ "$TAG" = "latest" ]; then + gh release edit "v${VERSION}" --latest || true + fi + elif [ "$TAG" = "latest" ]; then gh release create "v${VERSION}" \ --title "v${VERSION}" \ --generate-notes \ @@ -292,7 +526,27 @@ jobs: --generate-notes \ --prerelease fi - echo "✅ GitHub Release v${VERSION} created" + echo "✅ GitHub Release v${VERSION} ready" + + - name: Open merge-back PR (hotfix only) + if: ${{ !inputs.dry_run && inputs.action == 'hotfix' }} + env: + GH_TOKEN: ${{ github.token }} + BRANCH: ${{ needs.prepare.outputs.ref }} + VERSION: ${{ steps.ver.outputs.version }} + run: | + EXISTING_PR=$(gh pr list --base main --head "$BRANCH" --state open --json number --jq '.[0].number') + if [ -n "$EXISTING_PR" ]; then + 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: Verify publish landed on registry if: ${{ !inputs.dry_run }} @@ -322,23 +576,38 @@ jobs: - name: Summary env: + ACTION: ${{ inputs.action }} VERSION: ${{ steps.ver.outputs.version }} TAG: ${{ steps.ver.outputs.tag }} + BASE_TAG: ${{ needs.prepare.outputs.base_tag }} + BRANCH: ${{ needs.prepare.outputs.ref }} DRY_RUN: ${{ inputs.dry_run }} run: | - echo "## Release SDK Bundle: v${VERSION} → @${TAG}" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - if [ "$DRY_RUN" = "true" ]; then - echo "**DRY RUN** — npm publish, git tag, push, and GitHub Release were skipped." >> "$GITHUB_STEP_SUMMARY" - else - echo "- Published \`get-shit-done-cc@${VERSION}\` to dist-tag \`${TAG}\`" >> "$GITHUB_STEP_SUMMARY" - echo "- SDK bundled inside the CC tarball at:" >> "$GITHUB_STEP_SUMMARY" - echo " - \`sdk/dist/cli.js\` (loose tree, consumed by \`bin/gsd-sdk.js\` shim)" >> "$GITHUB_STEP_SUMMARY" - echo " - \`sdk-bundle/gsd-sdk.tgz\` (npm-installable artifact)" >> "$GITHUB_STEP_SUMMARY" - echo "- Git tag \`v${VERSION}\` pushed" >> "$GITHUB_STEP_SUMMARY" - echo "- GitHub Release \`v${VERSION}\` created" >> "$GITHUB_STEP_SUMMARY" - if [ "$TAG" = "latest" ]; then - echo "- \`next\` dist-tag re-pointed at \`v${VERSION}\` (kept current with \`latest\`)" >> "$GITHUB_STEP_SUMMARY" + { + if [ "$ACTION" = "hotfix" ]; then + echo "## Release SDK Bundle (hotfix): v${VERSION} → @${TAG}" + echo "" + echo "- Base (cumulative-fix anchor): \`${BASE_TAG}\`" + echo "- Branch: \`${BRANCH}\`" + else + echo "## Release SDK Bundle: v${VERSION} → @${TAG}" fi - echo "- Install: \`npm install -g get-shit-done-cc@${TAG}\`" >> "$GITHUB_STEP_SUMMARY" - fi + echo "" + if [ "$DRY_RUN" = "true" ]; then + echo "**DRY RUN** — npm publish, git tag, push, and GitHub Release were skipped." + else + echo "- Published \`get-shit-done-cc@${VERSION}\` to dist-tag \`${TAG}\`" + echo "- SDK bundled inside the CC tarball at:" + echo " - \`sdk/dist/cli.js\` (loose tree, consumed by \`bin/gsd-sdk.js\` shim)" + echo " - \`sdk-bundle/gsd-sdk.tgz\` (npm-installable artifact)" + echo "- Git tag \`v${VERSION}\` pushed" + echo "- GitHub Release \`v${VERSION}\` created" + if [ "$TAG" = "latest" ]; then + echo "- \`next\` dist-tag re-pointed at \`v${VERSION}\` (kept current with \`latest\`)" + fi + if [ "$ACTION" = "hotfix" ]; then + echo "- Merge-back PR opened against main" + fi + echo "- Install: \`npm install -g get-shit-done-cc@${TAG}\`" + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f1c56c15..7bb5677e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). RC. (#2833) ### Changed — 1.40.0-rc.1 +- **Hotfix release flow now auto-incorporates fixes from `main` and bundles the SDK** — `hotfix.yml create` auto-cherry-picks every `fix:`/`chore:` commit on `origin/main` not yet shipped (oldest-first; patch-equivalents skipped via `git cherry`; `feat:`/`refactor:` excluded; conflicts halt with the offending SHA; run summary lists every included SHA). `hotfix.yml finalize` adds the `install-smoke` cross-platform gate, bundles `sdk-bundle/gsd-sdk.tgz` inside the CC tarball (parity with `release-sdk.yml`), tightens the `next` dist-tag re-point, and marks the GitHub Release `--latest`. `release-sdk.yml` gains `action: publish | hotfix` plus an `auto_cherry_pick` toggle, with a new `prepare` job that branches `hotfix/X.YY.Z` from the highest existing `vX.YY.*` tag and runs the same cherry-pick logic — idempotent if the branch was pre-prepared via `hotfix.yml`. Hotfix `vX.YY.Z` is now defined as everything in `vX.YY.{Z-1}` plus every `fix:`/`chore:` since that base, so each tag is the cumulative-fix anchor for the next. (#2955) - **Planning workspace seam extracted from `core.cjs` into `planning-workspace.cjs`** — path/workstream/lock behavior now lives in a dedicated module (`planningDir`, `planningPaths`, `planningRoot`, active-workstream routing, `withPlanningLock`). `core.cjs` keeps compatibility re-exports while call-sites migrate to direct imports, improving locality and reducing coupling. (#2900) - **Skill surface consolidated 86 → 59 `commands/gsd/*.md` entries** — four new grouped skills (`capture`, `phase`, `config`, `workspace`) replace clusters of diff --git a/VERSIONING.md b/VERSIONING.md index d4b6f37ca..c680b9fc3 100644 --- a/VERSIONING.md +++ b/VERSIONING.md @@ -67,15 +67,38 @@ main ← stable, always deployable ### Patch Release (Hotfix) -For critical bugs that can't wait for the next minor release. +For fixes that need to ship without waiting for the next minor. -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 +A hotfix `vX.YY.Z` cumulatively includes everything in `vX.YY.{Z-1}` plus every `fix:`/`chore:` commit landed on `main` since that base. The base tag is the anchor — `git cherry $BASE_TAG main` reveals exactly which commits are still unshipped, and the new `vX.YY.Z` tag becomes the next hotfix's base, so the cycle is self-documenting. + +#### Two paths + +**Path A — `hotfix.yml` (canonical, two-step):** + +1. Trigger `hotfix.yml` with `action=create`, `version=1.27.1`, `auto_cherry_pick=true` (default). + - Workflow detects `BASE_TAG` = highest `v1.27.*` < `v1.27.1` (so `1.27.1` branches from `v1.27.0`; `1.27.2` would branch from `v1.27.1`). + - Branches `hotfix/1.27.1` from `BASE_TAG`. + - Auto-cherry-picks every `fix:`/`chore:` commit on `origin/main` not already in the base, oldest-first. Patch-equivalents are skipped via `git cherry`. `feat:`/`refactor:` are **never** auto-included. + - On conflict the workflow halts with the offending SHA. Resolve manually on the branch, then re-run finalize with `auto_cherry_pick=false`. + - Bumps `package.json` (and `sdk/package.json`), pushes the branch, and lists every included SHA in the run summary. +2. (Optional) push additional manual commits to `hotfix/1.27.1`. +3. Trigger `hotfix.yml` with `action=finalize`. The workflow: + - Runs `install-smoke` cross-platform gate. + - Runs full test suite + coverage. + - Builds SDK, bundles `sdk-bundle/gsd-sdk.tgz` inside the CC tarball (parity with `release-sdk.yml`). + - Tags `v1.27.1`, publishes to `@latest`, re-points `@next → v1.27.1`. + - Opens merge-back PR against `main`. + +**Path B — `release-sdk.yml` (stopgap, one-shot):** + +Active while the `@gsd-build/sdk` npm token is unavailable; bundles the SDK inside the CC tarball. + +1. Trigger `release-sdk.yml` with `action=hotfix`, `version=1.27.1`, `auto_cherry_pick=true`. + - The `prepare` job creates the branch and cherry-picks (same logic as Path A). + - `install-smoke` runs against the new branch. + - The `release` job tags, publishes to `@latest`, re-points `@next`, opens merge-back PR. + - Idempotent: if `hotfix/1.27.1` already exists (e.g. you ran `hotfix.yml create` first), the prepare job checks it out and re-runs cherry-pick as a no-op. +2. `dry_run=true` exercises the full pipeline without pushing the branch or publishing. ### Minor Release (Standard Cycle)