From e14218a4245d9e7e834cecb5786a4568333a9b6e Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 4 Jun 2026 14:33:06 -0400 Subject: [PATCH] fix(#676): consolidate hotfix into release.yml (delete standalone hotfix workflow) (#678) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(#676): consolidate hotfix into release.yml; delete standalone hotfix workflow npm allows only one trusted publisher per package and it is release.yml, so the standalone hotfix.yml (token-auth) could never publish via OIDC (ENEEDAUTH on the v1.3.1 finalize). Fold the patch/hotfix path into release.yml — the sole OIDC trusted publisher — and delete hotfix.yml. - validate-version accepts X.Y.Z (Z>0) → hotfix/X.Y.Z + base_tag; rejects rc for hotfixes; X.Y.0 still → release/X.Y.0. - create branches hotfix/X.Y.Z from the base tag with optional auto-cherry-pick (default on) of fix:/chore: from next; release path unchanged. - finalize is branch-agnostic already and publishes @latest via the existing OIDC trusted publisher (no NODE_AUTH_TOKEN). - CONTRIBUTING branching table updated; hotfix.yml removed. Fixes #676 Supersedes #677. Co-Authored-By: Claude Opus 4.8 * fix(#676): add hotfix/patch path to release.yml (OIDC trusted publisher) The companion to the hotfix.yml deletion: release.yml now handles patch versions (X.Y.Z) via hotfix/X.Y.Z branches and publishes @latest through the existing OIDC trusted publisher. CONTRIBUTING branching table updated. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .github/workflows/hotfix.yml | 447 ---------------------------------- .github/workflows/release.yml | 198 ++++++++++++++- CONTRIBUTING.md | 2 +- 3 files changed, 189 insertions(+), 458 deletions(-) delete mode 100644 .github/workflows/hotfix.yml diff --git a/.github/workflows/hotfix.yml b/.github/workflows/hotfix.yml deleted file mode 100644 index f40090446..000000000 --- a/.github/workflows/hotfix.yml +++ /dev/null @@ -1,447 +0,0 @@ -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) -# - 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/next (fallback 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/next 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 - - # Under the next-branch model, day-to-day fixes land on `next` first - # and only reach `main` via release back-merge. So `next` is the - # canonical cherry-pick source. Fall back to `main` if `next` doesn't - # exist yet (legacy single-branch repos) or as a transition guard. - if git ls-remote --exit-code origin next >/dev/null 2>&1; then - git fetch origin next:refs/remotes/origin/next - SOURCE="origin/next" - else - git fetch origin main:refs/remotes/origin/main - SOURCE="origin/main" - fi - echo "Cherry-pick source: $SOURCE" - - # `git cherry $BASE_TAG $SOURCE` lists every commit on the source not - # patch-equivalent in BASE_TAG. + means needs picking, - means - # already applied (skipped silently). - CANDIDATES=$(git cherry "$BASE_TAG" "$SOURCE" | awk '/^\+ / {print $2}') - - if [ -z "$CANDIDATES" ]; then - echo "No commits on $SOURCE beyond $BASE_TAG." - echo "## Cherry-pick summary" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "Base: \`$BASE_TAG\` (source: \`$SOURCE\`) — 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..$SOURCE" \ - | 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 - 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 @opengsd/gsd-core@"$VERSION" version 2>/dev/null || true) - if [ -n "$EXISTING" ]; then - echo "::warning::@opengsd/gsd-core@${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:unit - - - - name: Dry-run publish validation - env: - NODE_AUTH_TOKEN: ${{ secrets.GETSHITDONEREDUXNPMTOKEN }} - 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.GETSHITDONEREDUXNPMTOKEN }} - 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.GETSHITDONEREDUXNPMTOKEN }} - run: | - npm dist-tag add "@opengsd/gsd-core@${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 - # Reformat the auto-generated notes into the curated - # Install + Feature/Enhancement/Fix format. - node scripts/release-notes/format-github-release-notes.cjs \ - --tag "v${VERSION}" --latest --apply - - - 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 @opengsd/gsd-core@"$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 @opengsd/gsd-core 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: @opengsd/gsd-core@$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 "- Merge-back PR opened against main" - fi - } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 806c694fa..1ac2ca9a6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,9 +12,14 @@ on: - rc - finalize version: - description: 'Version (e.g., 1.28.0 or 2.0.0)' + description: 'Version: X.Y.0 (minor/major) or X.Y.Z with Z>0 (hotfix/patch)' required: true type: string + auto_cherry_pick: + description: 'Hotfix create only: auto-cherry-pick fix:/chore: commits from origin/next (fallback origin/main) since base tag' + required: false + type: boolean + default: true dry_run: description: 'Dry run (skip npm publish, tagging, and push)' required: false @@ -37,6 +42,8 @@ jobs: outputs: branch: ${{ steps.validate.outputs.branch }} is_major: ${{ steps.validate.outputs.is_major }} + is_hotfix: ${{ steps.validate.outputs.is_hotfix }} + base_tag: ${{ steps.validate.outputs.base_tag }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -46,20 +53,44 @@ jobs: id: validate env: VERSION: ${{ inputs.version }} + ACTION: ${{ inputs.action }} run: | - # Must be X.Y.0 (minor or major release, not patch), no leading zeros in any segment - if ! echo "$VERSION" | grep -qE '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.0$'; then - echo "::error::Version '$VERSION' is invalid. Must be X.Y.0 with no leading zeros (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) + set -euo pipefail + IS_HOTFIX="false" IS_MAJOR="false" - if echo "$VERSION" | grep -qE '^(0|[1-9][0-9]*)\.0\.0$'; then - IS_MAJOR="true" + BASE_TAG="" + if echo "$VERSION" | grep -qE '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.0$'; then + # Minor or major release (X.Y.0) + BRANCH="release/${VERSION}" + if echo "$VERSION" | grep -qE '^(0|[1-9][0-9]*)\.0\.0$'; then + IS_MAJOR="true" + fi + elif echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[1-9][0-9]*$'; then + # Patch / hotfix release (X.Y.Z, Z>0) + IS_HOTFIX="true" + if [ "$ACTION" = "rc" ]; then + echo "::error::Hotfix (patch) releases skip the rc action — run create, then finalize." + exit 1 + fi + BRANCH="hotfix/${VERSION}" + MAJOR_MINOR=$(echo "$VERSION" | cut -d. -f1-2) + TARGET_TAG="v${VERSION}" + # semver-correct base tag: highest vMAJOR_MINOR.* strictly below TARGET_TAG + 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 + else + echo "::error::Version '$VERSION' is invalid. Use X.Y.0 (minor/major) or X.Y.Z with Z>0 (hotfix), no leading zeros." + exit 1 fi echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" echo "is_major=$IS_MAJOR" >> "$GITHUB_OUTPUT" + echo "is_hotfix=$IS_HOTFIX" >> "$GITHUB_OUTPUT" + echo "base_tag=$BASE_TAG" >> "$GITHUB_OUTPUT" - name: Reject already-published versions env: @@ -103,6 +134,7 @@ jobs: git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - name: Create release branch + if: needs.validate-version.outputs.is_hotfix != 'true' env: BRANCH: ${{ needs.validate-version.outputs.branch }} VERSION: ${{ inputs.version }} @@ -124,6 +156,152 @@ jobs: echo "" >> "$GITHUB_STEP_SUMMARY" echo "Next: run this workflow with \`rc\` action to publish a pre-release to \`next\`" >> "$GITHUB_STEP_SUMMARY" + - name: Create hotfix branch from base tag (skeleton) + if: needs.validate-version.outputs.is_hotfix == 'true' + 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 up-front so a later cherry-pick conflict leaves a + # remote artefact the operator can fetch, resolve, and re-push. + if [ "$DRY_RUN" != "true" ]; then + git push -u origin "$BRANCH" + fi + + - name: Cherry-pick fix/chore commits from origin/next since base tag + if: ${{ needs.validate-version.outputs.is_hotfix == 'true' && 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 + + # Under the next-branch model, day-to-day fixes land on `next` first + # and only reach `main` via release back-merge. So `next` is the + # canonical cherry-pick source. Fall back to `main` if `next` doesn't + # exist yet (legacy single-branch repos) or as a transition guard. + if git ls-remote --exit-code origin next >/dev/null 2>&1; then + git fetch origin next:refs/remotes/origin/next + SOURCE="origin/next" + else + git fetch origin main:refs/remotes/origin/main + SOURCE="origin/main" + fi + echo "Cherry-pick source: $SOURCE" + + # `git cherry $BASE_TAG $SOURCE` lists every commit on the source not + # patch-equivalent in BASE_TAG. + means needs picking, - means + # already applied (skipped silently). + CANDIDATES=$(git cherry "$BASE_TAG" "$SOURCE" | awk '/^\+ / {print $2}') + + if [ -z "$CANDIDATES" ]; then + echo "No commits on $SOURCE beyond $BASE_TAG." + echo "## Cherry-pick summary" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Base: \`$BASE_TAG\` (source: \`$SOURCE\`) — 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..$SOURCE" \ + | 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 hotfix version and push + if: needs.validate-version.outputs.is_hotfix == 'true' + 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 + 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." + 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-rc: needs: validate-version if: inputs.action == 'rc' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ef458d327..2b0caebf8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -133,7 +133,7 @@ and `next` (integration for the upcoming release). **Almost every PR targets | `chore/`, `docs/`, `refactor/`, `test/`, `perf/`, `ci/`, `revert/` | `next` | All routine work | | `fix/critical-NNN-slug` | `main` | Production-down emergencies only; auto-back-merges to `next` | | `release/X.Y.0` | `main` | Created by `release.yml` — don't make these by hand | -| `hotfix/X.Y.Z` | `main` | Created by `hotfix.yml` — don't make these by hand | +| `hotfix/X.Y.Z` | `main` | Created by `release.yml` (dispatch with a patch version X.Y.Z) — don't make these by hand | | Stabilization PR for an in-flight release | `release/X.Y.0` | Fix a regression found during the RC cycle | **Day-to-day commands:**