From a2133305ce57569953ed44c1efd388636314a059 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Thu, 27 Aug 2026 16:41:19 +0100 Subject: [PATCH] chore(ci): sync release-kit workflow from kits branch Release Kit is always dispatched from the kits branch, so the copy on next never executes; it only lists the workflow in the Actions UI. Sync it with the version that landed on kits in #2990 so the default branch does not show the old versioning logic. --- .github/workflows/release-kit.yaml | 205 ++++++++++++++++++++--------- 1 file changed, 140 insertions(+), 65 deletions(-) diff --git a/.github/workflows/release-kit.yaml b/.github/workflows/release-kit.yaml index 5a2384475..e9f6b13ad 100644 --- a/.github/workflows/release-kit.yaml +++ b/.github/workflows/release-kit.yaml @@ -41,6 +41,15 @@ on: default: true type: boolean +# All releases push to the same branch; a concurrent run would fail its push +# after already publishing to npm, leaving a version with no commit or tag. +# GitHub keeps at most ONE pending run per group: with a run in progress and +# one queued, a third dispatch cancels the queued one. Dispatch kits one at a +# time and check for a "Canceled" run before assuming a release happened. +concurrency: + group: release-kit + cancel-in-progress: false + jobs: # ========================================================= # 1. TEST JOB (Low Permissions: Read-Only) @@ -51,15 +60,7 @@ jobs: permissions: contents: read steps: - - name: Print inputs - env: - INPUTS: ${{ toJSON(inputs) }} - run: | - echo "$INPUTS" - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # zizmor: ignore[artipacked] - with: - ref: "kits" - name: Setup Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 @@ -81,17 +82,16 @@ jobs: runs-on: ubuntu-latest permissions: contents: write # Needed to push version commit, git tag & create GitHub Release - id-token: write # Needed to publish to npm steps: - name: Checkout repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # zizmor: ignore[artipacked] - with: - ref: "kits" - name: Setup Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "24" + registry-url: "https://wombat-dressing-room.appspot.com" + always-auth: false - name: Configure Git User if: ${{ !inputs.dry_run }} @@ -123,31 +123,91 @@ jobs: echo "$NOTES" >> $GITHUB_OUTPUT echo "$EOF" >> $GITHUB_OUTPUT - - name: Determine Version Bump Type + - name: Determine Next Version id: config working-directory: ${{ inputs.target_kit }} env: BUMP_LEVEL: ${{ inputs.bump_level }} IS_PRERELEASE: ${{ inputs.is_prerelease }} + # The registry is the source of truth for versions: npm reserves every + # version ever published (even after unpublish), and package.json on the + # branch can lag behind what was actually released. + NPM_CONFIG_REGISTRY: https://registry.npmjs.org run: | - CURRENT_VER=$(node -p "require('./package.json').version") - BUMP="$BUMP_LEVEL" + set -o pipefail + + # The push at the end of the job would create a stray branch named + # after the ref if this were dispatched from a tag. + if [ "$GITHUB_REF_TYPE" != "branch" ]; then + echo "::error::Release Kit must be dispatched from a branch, got $GITHUB_REF_TYPE '$GITHUB_REF_NAME'." + exit 1 + fi + + PKG_NAME=$(node -p "require('./package.json').name") + LOCAL_VER=$(node -p "require('./package.json').version") + # The packument is fetched directly rather than via `npm view`: npm + # hides a fully unpublished package behind E404 (dropping its reserved + # versions in time.unpublished) and its --json output shape changes + # across npm majors. The registry itself 404s only for never-published + # names. time keys include per-version-unpublished versions. + PACKUMENT="$RUNNER_TEMP/packument.json" + HTTP_STATUS=$(curl -sS --retry 3 -o "$PACKUMENT" -w '%{http_code}' "$NPM_CONFIG_REGISTRY/$PKG_NAME") + if [ "$HTTP_STATUS" = "404" ]; then + TAKEN="" + elif [ "$HTTP_STATUS" != "200" ]; then + echo "::error::Registry returned HTTP $HTTP_STATUS for $PKG_NAME; cannot trust the version computation." + exit 1 + else + TAKEN=$(node -e ' + const data = JSON.parse(require("fs").readFileSync(0, "utf8")); + const time = data.time || {}; + const meta = ["created", "modified", "unpublished"]; + const keys = Object.keys(time).filter(v => !meta.includes(v)); + const unpub = (time.unpublished && time.unpublished.versions) || []; + const unpubList = Array.isArray(unpub) ? unpub : Object.keys(unpub); + console.log(keys.concat(unpubList).join(" ")); + ' < "$PACKUMENT") + fi + + HIGHEST=$(npx --yes semver@7.8.5 $LOCAL_VER $TAKEN | tail -n 1) + # 0.0.0 seed keeps grep from emptying the pipe when no stable exists yet. + HIGHEST_STABLE=$(npx --yes semver@7.8.5 0.0.0 $LOCAL_VER $TAKEN | grep -v -- - | tail -n 1) + + # The bump level is applied to the highest stable version; an existing + # rc line continues unless the requested level opens a higher line. if [ "$IS_PRERELEASE" = "true" ]; then - if [[ "$CURRENT_VER" =~ -rc\.[0-9]+$ ]]; then - BUMP_TYPE="prerelease" - else - BUMP_TYPE="pre${BUMP}" + NEW_VER=$(npx --yes semver@7.8.5 -i "pre${BUMP_LEVEL}" --preid rc "$HIGHEST_STABLE") + BUMP_TYPE="pre${BUMP_LEVEL}" + if [[ "$HIGHEST" == *-rc.* ]]; then + CONTINUED=$(npx --yes semver@7.8.5 -i prerelease --preid rc "$HIGHEST") + if [ "$(npx --yes semver@7.8.5 "$NEW_VER" "$CONTINUED" | tail -n 1)" = "$CONTINUED" ]; then + NEW_VER="$CONTINUED" + BUMP_TYPE="prerelease" + fi fi TARGET_TAG="next" else - BUMP_TYPE="$BUMP" + NEW_VER=$(npx --yes semver@7.8.5 -i "$BUMP_LEVEL" "$HIGHEST_STABLE") + BUMP_TYPE="$BUMP_LEVEL" TARGET_TAG="latest (and next)" fi - echo "current_ver=$CURRENT_VER" >> $GITHUB_OUTPUT + for v in $TAKEN; do + if [ "$v" = "$NEW_VER" ]; then + echo "::error::Computed version $NEW_VER already exists on the registry." + exit 1 + fi + done + + echo "current_ver=$LOCAL_VER" >> $GITHUB_OUTPUT + echo "highest_ver=$HIGHEST" >> $GITHUB_OUTPUT + echo "highest_stable=$HIGHEST_STABLE" >> $GITHUB_OUTPUT echo "bump_type=$BUMP_TYPE" >> $GITHUB_OUTPUT echo "target_tag=$TARGET_TAG" >> $GITHUB_OUTPUT + echo "version=$NEW_VER" >> $GITHUB_OUTPUT + echo "pkg_name=$PKG_NAME" >> $GITHUB_OUTPUT + echo "tag_name=${PKG_NAME}@${NEW_VER}" >> $GITHUB_OUTPUT - name: Install Dependencies & Build working-directory: ${{ inputs.target_kit }} @@ -155,52 +215,52 @@ jobs: npm ci --registry=https://registry.npmjs.org npm run build - - name: Bump Version (Clear CHANGELOG on Stable Release Only) - id: versioning + - name: Apply Version Bump (Clear CHANGELOG on Stable Release Only) working-directory: ${{ inputs.target_kit }} env: - BUMP_TYPE: ${{ steps.config.outputs.bump_type }} + NEW_VER: ${{ steps.config.outputs.version }} IS_PRERELEASE: ${{ inputs.is_prerelease }} DRY_RUN: ${{ inputs.dry_run }} run: | - if [ "$DRY_RUN" = "true" ]; then - npm version $BUMP_TYPE --preid=rc --no-git-tag-version - else - # Only truncate CHANGELOG.md if this is a STABLE release (not a prerelease) - if [ "$IS_PRERELEASE" != "true" ]; then - echo "Stable release detected: clearing CHANGELOG.md..." - > CHANGELOG.md - git add CHANGELOG.md - else - echo "Prerelease detected: preserving CHANGELOG.md content." - fi + # npm version only performs git operations when .git sits next to + # package.json; kits live in kits// with .git at the repo root, + # so git commit/tag are done explicitly in the next step. + npm version "$NEW_VER" --no-git-tag-version - # Create version bump commit and git tag - if [[ "$BUMP_TYPE" == pre* ]] || [[ "$BUMP_TYPE" == "prerelease" ]]; then - npm version $BUMP_TYPE --preid=rc -m "chore(release): %s [skip ci]" - else - npm version $BUMP_TYPE -m "chore(release): %s [skip ci]" - fi + if [ "$DRY_RUN" != "true" ] && [ "$IS_PRERELEASE" != "true" ]; then + echo "Stable release detected: clearing CHANGELOG.md..." + > CHANGELOG.md fi - NEW_VER=$(node -p "require('./package.json').version") - PKG_NAME=$(node -p "require('./package.json').name") - - echo "version=$NEW_VER" >> $GITHUB_OUTPUT - echo "pkg_name=$PKG_NAME" >> $GITHUB_OUTPUT - echo "tag_name=${PKG_NAME}@${NEW_VER}" >> $GITHUB_OUTPUT + - name: Commit & Tag Release + if: ${{ !inputs.dry_run }} + env: + TARGET_KIT: ${{ inputs.target_kit }} + TAG_NAME: ${{ steps.config.outputs.tag_name }} + run: | + git add "$TARGET_KIT/package.json" + # npm version also bumps the lockfile; kits currently ship npm-shrinkwrap.json. + [ -f "$TARGET_KIT/npm-shrinkwrap.json" ] && git add "$TARGET_KIT/npm-shrinkwrap.json" + [ -f "$TARGET_KIT/package-lock.json" ] && git add "$TARGET_KIT/package-lock.json" + [ -f "$TARGET_KIT/CHANGELOG.md" ] && git add "$TARGET_KIT/CHANGELOG.md" + # git commit exits non-zero if the bump staged no changes, so a broken + # bump fails the release instead of publishing without a release commit. + git commit -m "chore(release): $TAG_NAME [skip ci]" + git tag -a "$TAG_NAME" -m "$TAG_NAME" - name: Dry Run Summary if: ${{ inputs.dry_run }} env: - PKG_NAME: ${{ steps.versioning.outputs.pkg_name }} + PKG_NAME: ${{ steps.config.outputs.pkg_name }} TARGET_KIT: ${{ inputs.target_kit }} CURRENT_VER: ${{ steps.config.outputs.current_ver }} - VERSION: ${{ steps.versioning.outputs.version }} + HIGHEST_VER: ${{ steps.config.outputs.highest_ver }} + HIGHEST_STABLE: ${{ steps.config.outputs.highest_stable }} + VERSION: ${{ steps.config.outputs.version }} BUMP_TYPE: ${{ steps.config.outputs.bump_type }} TARGET_TAG: ${{ steps.config.outputs.target_tag }} IS_PRERELEASE: ${{ inputs.is_prerelease }} - TAG_NAME: ${{ steps.versioning.outputs.tag_name }} + TAG_NAME: ${{ steps.config.outputs.tag_name }} NOTES: ${{ steps.changelog.outputs.notes }} run: | echo "==========================================================" @@ -208,7 +268,9 @@ jobs: echo "==========================================================" echo " Package Name: $PKG_NAME" echo " Target Directory: $TARGET_KIT" - echo " Current Version: $CURRENT_VER" + echo " package.json Ver: $CURRENT_VER" + echo " Highest Known: $HIGHEST_VER" + echo " Highest Stable: $HIGHEST_STABLE" echo " Target Version: $VERSION" echo " Version Bump: $BUMP_TYPE" echo " NPM Dist-Tag: $TARGET_TAG" @@ -220,20 +282,15 @@ jobs: echo "$NOTES" echo "==========================================================" - - name: Push Version Commit & Tags to Branch - if: ${{ !inputs.dry_run }} - env: - TARGET_BRANCH: "kits" - run: git push origin "$TARGET_BRANCH" --follow-tags - - name: Publish to NPM # zizmor: ignore[use-trusted-publishing] working-directory: ${{ inputs.target_kit }} env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} IS_PRERELEASE: ${{ inputs.is_prerelease }} DRY_RUN: ${{ inputs.dry_run }} - PKG_NAME: ${{ steps.versioning.outputs.pkg_name }} - VERSION: ${{ steps.versioning.outputs.version }} + PKG_NAME: ${{ steps.config.outputs.pkg_name }} + VERSION: ${{ steps.config.outputs.version }} run: | DRY_RUN_FLAG="" if [ "$DRY_RUN" = "true" ]; then @@ -242,32 +299,50 @@ jobs: fi if [ "$IS_PRERELEASE" = "true" ]; then - npm publish --tag next --provenance --access public $DRY_RUN_FLAG + npm publish --tag next --access public $DRY_RUN_FLAG else - npm publish --tag latest --provenance --access public $DRY_RUN_FLAG + npm publish --tag latest --access public $DRY_RUN_FLAG if [ "$DRY_RUN" != "true" ]; then npm dist-tag add ${PKG_NAME}@${VERSION} next fi fi + # Pushed only after a successful publish: a failed publish leaves the + # branch untouched, and the next run recomputes the version from the + # registry either way. + - name: Push Version Commit & Tag to Branch + if: ${{ !inputs.dry_run }} + env: + REF_NAME: ${{ github.ref_name }} + TAG_NAME: ${{ steps.config.outputs.tag_name }} + run: | + git push --atomic origin "HEAD:refs/heads/$REF_NAME" "refs/tags/$TAG_NAME" + - name: Create GitHub Release via GitHub CLI if: ${{ !inputs.dry_run }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG_NAME: ${{ steps.versioning.outputs.tag_name }} - PKG_NAME: ${{ steps.versioning.outputs.pkg_name }} - VERSION: ${{ steps.versioning.outputs.version }} + TAG_NAME: ${{ steps.config.outputs.tag_name }} + PKG_NAME: ${{ steps.config.outputs.pkg_name }} + VERSION: ${{ steps.config.outputs.version }} IS_PRERELEASE: ${{ inputs.is_prerelease }} NOTES: ${{ steps.changelog.outputs.notes }} - TARGET_BRANCH: "kits" + REF_NAME: ${{ github.ref_name }} run: | PRERELEASE_FLAG="" if [ "$IS_PRERELEASE" = "true" ]; then PRERELEASE_FLAG="--prerelease" fi + # A re-run after a transient gh failure must not mint a new version + # just to get a release page, so creation is skipped if it exists. + if gh release view "$TAG_NAME" > /dev/null 2>&1; then + echo "Release $TAG_NAME already exists; skipping creation." + exit 0 + fi + gh release create "$TAG_NAME" \ --title "$PKG_NAME v$VERSION" \ --notes "$NOTES" \ - --target "$TARGET_BRANCH" \ + --target "$REF_NAME" \ $PRERELEASE_FLAG