mirror of
https://github.com/glittercowboy/get-shit-done
synced 2026-05-05 23:02:20 +02:00
* feat(workflows): hotfix auto-cherry-pick + SDK-bundle parity (#2955) hotfix.yml: - create: auto-cherry-picks fix:/chore: commits from origin/main since BASE_TAG, oldest-first. Patch-equivalents skipped via git cherry. feat:/refactor: never auto-included. Conflicts halt with offending SHA. - finalize: install-smoke gate, sdk-bundle/gsd-sdk.tgz parity with release-sdk.yml, tightened next dist-tag re-point, --latest on gh release create. SDK package.json bumped in lockstep. release-sdk.yml: - New action input (publish | hotfix) and auto_cherry_pick boolean. - New prepare job branches hotfix/X.YY.Z from highest vX.YY.* tag, cherry-picks same logic as hotfix.yml, outputs effective ref. - install-smoke and release consume prepare.outputs.ref. - Hotfix mode forces tag=latest, opens merge-back PR. Idempotent if branch already exists. VERSIONING.md: documents the cumulative-tag invariant (vX.YY.Z anchors vX.YY.{Z+1}) and both workflow paths. Closes #2955 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(code-review): wire --fix dispatch and update stale command references (#2947) * fix(#2893): surface non-canonical plan filenames instead of silently returning zero plans Reporter saw `plan_count: 0` from `/gsd:execute-phase` even though five plan files existed on disk. Investigation showed the planner had written files like `01-PLAN-01-foundation.md`, while `phase-plan-index`'s strict filter (`f.endsWith('-PLAN.md') || f === 'PLAN.md'`) rejected them silently — collapsing two distinct states into the same `plans: []` return: - directory truly has no plans (legit empty) - directory has plans but the filter rejected them (user/agent error) The canonical contract is documented in three places: - `agents/gsd-planner.md` write_phase_prompt step (lines 1063-1080) - `commands/gsd/plan-phase.md` - `references/universal-anti-patterns.md` (rule 26) It mandates `{padded_phase}-{NN}-PLAN.md` and explicitly forbids `PLAN-NN.md` / `01-PLAN-01.md` / `plan-NN.md` etc. The strict filter is correct per that contract. The bug is that the executor never tells the user when the contract was violated — they just see `plan_count: 0` with no signal. Fix: add a diagnostic helper `describeNonCanonicalPlans()` that scans the phase directory for files matching `*PLAN*.md` (the diagnostic net) that the canonical filter rejected, excluding legit derivatives like `*-PLAN-OUTLINE.md` and `*-PLAN.pre-bounce.md`. When offenders exist, return a `warning` field naming each one and citing the canonical pattern so the user knows what to rename to. Wired into the three filter sites: - `phase-plan-index` (the executor's main entry point) - `phases list --type plans` - `find-phase` The strict filter itself is unchanged — existing canonical plans behave identically. This is purely a diagnostic that converts silent-empty into loud-with-actionable-error. Tests: - `phase-plan-index returns warning for reporter's exact filename pattern (`01-PLAN-01-foundation.md`)` - `truly empty dir does not emit a warning` - `canonical plans + outline + pre-bounce files do not emit a warning` Closes #2893 * test(#2893): add parity tests for find-phase and phases list --type plans warnings CodeRabbit's only finding on the prior commit: I wired the warning into three filter sites (`phase-plan-index`, `find-phase`, `phases list --type plans`) but only `phase-plan-index` had test coverage for the warning shape. The other two paths could silently diverge during future refactors — exactly the silent-drift class of bug this fix exists to prevent. Add four parity tests mirroring the existing two: - find-phase: non-canonical filenames produce a warning naming each offender + citing the canonical pattern. - find-phase: canonical plan + derivative files (PLAN-OUTLINE, pre-bounce) produce no warning. - phases list --type plans: same non-canonical case, but assert the warning is prefixed with `${dir}: ` (this path aggregates across phase directories so each offender is tagged with its dir). - phases list --type plans: canonical case, no warning. `node --test tests/phase.test.cjs`: 98/98 pass (was 94, +4 new). * docs(changelog): hotfix flow auto-cherry-pick + SDK bundle parity (#2955) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(workflows): address CodeRabbit findings on hotfix flow (#2955) 5 findings, all real: 1. BASE_TAG selection used lexicographic awk compare, breaking on multi-digit patches (v1.27.10 wrongly < v1.27.2). Fixed in both hotfix.yml and release-sdk.yml: append TARGET_TAG to candidate list, sort -V, take preceding entry. Semver-correct. 2,4. Cherry-pick conflict aborted locally with no remote branch to resolve from. Now the skeleton branch is pushed up-front (real runs); on conflict we abort, push the partial-pick state with --force-with-lease, and emit operator instructions in the run summary. 3. release-sdk.yml dry_run exited before cherry-pick, defeating the purpose. Now dry_run still applies cherry-picks locally (catches conflicts), just skips push. Downstream install-smoke runs against BASE_TAG; the cherry-pick verification itself is the dry-run signal. 5. release-sdk.yml release job missing pull-requests: write — gh pr create for the merge-back PR would have failed under restricted token defaults. Permission added. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(workflows): CR round 2 — dry-run signal + post-publish reconciliation (#2955) 3 findings, all real: 6. hotfix.yml create dry_run skipped every step (branch creation, cherry-pick, version bump) — a green dry-run gave no signal at all. Now the local checkout/cherry-pick/bump always runs; only the git push calls are gated on dry_run. Conflicts surface in dry-run too. 7,8. "Refuse if version already on npm" preflight hard-failed reruns, so a transient failure between npm publish and a later step (tag push, GH release, merge-back PR, dist-tag re-point) left the release half-shipped with no path to reconcile. Replaced with a prior_publish detect step that warns and sets skip_publish=true; the publish step is gated on that flag, but tag/release/PR/dist-tag continue. GitHub Release create is now idempotent (edit --latest if already exists). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(workflows): CR round 3 — preserve dry-run cherry-pick history in conflict guidance (#2955) Dry-run conflict path discarded successful picks with the runner, but the message told operators to rerun with auto_cherry_pick=false — which recreates the branch from BASE_TAG and silently loses every pick that had succeeded before the conflict. Updated both hotfix.yml and release-sdk.yml: dry-run conflict summary now lists the lost SHAs and recommends re-running with auto_cherry_pick=true (real, not dry-run) to materialize the partial branch on origin. Real-run guidance unchanged. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
496 lines
19 KiB
YAML
496 lines
19 KiB
YAML
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:
|
|
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
|
|
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
|
|
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.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}"
|
|
# 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 { 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"
|
|
|
|
create:
|
|
needs: validate-version
|
|
if: inputs.action == 'create'
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
permissions:
|
|
contents: write
|
|
steps:
|
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
|
with:
|
|
node-version: ${{ env.NODE_VERSION }}
|
|
|
|
- name: Check branch doesn't already exist
|
|
env:
|
|
BRANCH: ${{ needs.validate-version.outputs.branch }}
|
|
run: |
|
|
if git ls-remote --exit-code origin "refs/heads/$BRANCH" >/dev/null 2>&1; then
|
|
echo "::error::Branch $BRANCH already exists. Delete it first or use 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 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: |
|
|
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"
|
|
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"
|
|
|
|
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: 15
|
|
permissions:
|
|
contents: write
|
|
pull-requests: write
|
|
id-token: write
|
|
environment: npm-publish
|
|
steps:
|
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
with:
|
|
ref: ${{ needs.validate-version.outputs.branch }}
|
|
fetch-depth: 0
|
|
|
|
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
|
with:
|
|
node-version: ${{ env.NODE_VERSION }}
|
|
registry-url: 'https://registry.npmjs.org'
|
|
cache: 'npm'
|
|
|
|
- name: Configure git identity
|
|
run: |
|
|
git config user.name "github-actions[bot]"
|
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
|
|
- name: 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: 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:
|
|
VERSION: ${{ inputs.version }}
|
|
run: |
|
|
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 }}
|
|
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 && steps.prior_publish.outputs.skip_publish != 'true' }}
|
|
env:
|
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
run: npm publish --provenance --access public --tag latest
|
|
|
|
- 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: |
|
|
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: 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
|
|
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 }}
|
|
env:
|
|
VERSION: ${{ inputs.version }}
|
|
run: |
|
|
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::Version $VERSION did not appear on the registry within timeout"
|
|
exit 1
|
|
fi
|
|
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}"
|
|
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"
|