Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 133 additions & 50 deletions .github/workflows/release-kit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -114,92 +123,154 @@ 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 [email protected] $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 [email protected] 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 [email protected] -i "pre${BUMP_LEVEL}" --preid rc "$HIGHEST_STABLE")
BUMP_TYPE="pre${BUMP_LEVEL}"
if [[ "$HIGHEST" == *-rc.* ]]; then
CONTINUED=$(npx --yes [email protected] -i prerelease --preid rc "$HIGHEST")
if [ "$(npx --yes [email protected] "$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 [email protected] -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 }}
run: |
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/<name>/ 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 "=========================================================="
echo " DRY RUN SUMMARY "
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"
Expand All @@ -211,21 +282,15 @@ jobs:
echo "$NOTES"
echo "=========================================================="

- name: Push Version Commit & Tags to Branch
if: ${{ !inputs.dry_run }}
env:
REF_NAME: ${{ github.ref_name }}
run: git push origin "$REF_NAME" --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
Expand All @@ -242,13 +307,24 @@ jobs:
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 }}
REF_NAME: ${{ github.ref_name }}
Expand All @@ -258,6 +334,13 @@ jobs:
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" \
Expand Down
Loading