Release #32
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Release | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| action: | |
| description: 'Action to perform' | |
| required: true | |
| type: choice | |
| options: | |
| - create | |
| - rc | |
| - finalize | |
| version: | |
| description: 'Version (e.g., 1.28.0 or 2.0.0)' | |
| required: true | |
| type: string | |
| dry_run: | |
| description: 'Dry run (skip npm publish, tagging, and push)' | |
| required: false | |
| type: boolean | |
| default: false | |
| concurrency: | |
| group: release-${{ inputs.version }} | |
| cancel-in-progress: false | |
| env: | |
| NODE_VERSION: 24 | |
| jobs: | |
| validate-version: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 2 | |
| permissions: | |
| contents: read | |
| outputs: | |
| branch: ${{ steps.validate.outputs.branch }} | |
| is_major: ${{ steps.validate.outputs.is_major }} | |
| 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.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) | |
| IS_MAJOR="false" | |
| if echo "$VERSION" | grep -qE '^(0|[1-9][0-9]*)\.0\.0$'; then | |
| IS_MAJOR="true" | |
| fi | |
| echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" | |
| echo "is_major=$IS_MAJOR" >> "$GITHUB_OUTPUT" | |
| - name: Reject already-published versions | |
| env: | |
| VERSION: ${{ inputs.version }} | |
| run: | | |
| for pkg in @opengsd/gsd-core; do | |
| if npm view "$pkg@$VERSION" version >/dev/null 2>&1; then | |
| echo "::error::$pkg@$VERSION is already published on npm — bump to a new version" | |
| exit 1 | |
| fi | |
| done | |
| 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 rc/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 release branch | |
| env: | |
| BRANCH: ${{ needs.validate-version.outputs.branch }} | |
| VERSION: ${{ inputs.version }} | |
| IS_MAJOR: ${{ needs.validate-version.outputs.is_major }} | |
| run: | | |
| git checkout -b "$BRANCH" | |
| npm version "$VERSION" --no-git-tag-version | |
| git add package.json package-lock.json | |
| git commit -m "chore: bump version to ${VERSION} for release" | |
| git push origin "$BRANCH" | |
| echo "## Release branch created" >> "$GITHUB_STEP_SUMMARY" | |
| echo "- Branch: \`$BRANCH\`" >> "$GITHUB_STEP_SUMMARY" | |
| echo "- Version: \`$VERSION\`" >> "$GITHUB_STEP_SUMMARY" | |
| if [ "$IS_MAJOR" = "true" ]; then | |
| echo "- Type: **Major** (will start with beta pre-releases)" >> "$GITHUB_STEP_SUMMARY" | |
| else | |
| echo "- Type: **Minor** (will start with RC pre-releases)" >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "Next: run this workflow with \`rc\` action to publish a pre-release to \`next\`" >> "$GITHUB_STEP_SUMMARY" | |
| install-smoke-rc: | |
| needs: validate-version | |
| if: inputs.action == 'rc' | |
| permissions: | |
| contents: read | |
| uses: ./.github/workflows/install-smoke.yml | |
| with: | |
| ref: ${{ needs.validate-version.outputs.branch }} | |
| rc: | |
| needs: [validate-version, install-smoke-rc] | |
| if: inputs.action == 'rc' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: 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: Determine pre-release version | |
| id: prerelease | |
| env: | |
| VERSION: ${{ inputs.version }} | |
| IS_MAJOR: ${{ needs.validate-version.outputs.is_major }} | |
| run: | | |
| # Determine pre-release type: major → beta, minor → rc | |
| if [ "$IS_MAJOR" = "true" ]; then | |
| PREFIX="beta" | |
| else | |
| PREFIX="rc" | |
| fi | |
| # Find next pre-release number by checking existing tags | |
| N=1 | |
| while git tag -l "v${VERSION}-${PREFIX}.${N}" | grep -q .; do | |
| N=$((N + 1)) | |
| done | |
| PRE_VERSION="${VERSION}-${PREFIX}.${N}" | |
| echo "pre_version=$PRE_VERSION" >> "$GITHUB_OUTPUT" | |
| echo "prefix=$PREFIX" >> "$GITHUB_OUTPUT" | |
| - 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: Bump to pre-release version | |
| env: | |
| PRE_VERSION: ${{ steps.prerelease.outputs.pre_version }} | |
| run: | | |
| npm version "$PRE_VERSION" --no-git-tag-version | |
| - name: Install and test | |
| run: | | |
| npm ci | |
| node scripts/check-npm-integrity.cjs | |
| npm run test:coverage:unit | |
| - name: Commit pre-release version bump | |
| env: | |
| PRE_VERSION: ${{ steps.prerelease.outputs.pre_version }} | |
| run: | | |
| git add package.json package-lock.json | |
| git commit -m "chore: bump to ${PRE_VERSION}" | |
| # npm bundled with Node 24 (pinned via setup-node) already supports trusted publishing (#318) | |
| - name: Dry-run publish validation | |
| run: npm publish --dry-run --tag next | |
| - name: Tag and push | |
| if: ${{ !inputs.dry_run }} | |
| env: | |
| PRE_VERSION: ${{ steps.prerelease.outputs.pre_version }} | |
| BRANCH: ${{ needs.validate-version.outputs.branch }} | |
| run: | | |
| if git rev-parse -q --verify "refs/tags/v${PRE_VERSION}" >/dev/null; then | |
| EXISTING_SHA=$(git rev-parse "refs/tags/v${PRE_VERSION}") | |
| HEAD_SHA=$(git rev-parse HEAD) | |
| if [ "$EXISTING_SHA" != "$HEAD_SHA" ]; then | |
| echo "::error::Tag v${PRE_VERSION} already exists pointing to different commit" | |
| exit 1 | |
| fi | |
| echo "Tag v${PRE_VERSION} already exists on current commit; skipping tag" | |
| else | |
| git tag "v${PRE_VERSION}" | |
| fi | |
| git push origin "$BRANCH" --tags | |
| - name: Publish to npm (next) | |
| if: ${{ !inputs.dry_run }} | |
| # --provenance is automatic under OIDC trusted publishing | |
| run: npm publish --provenance --access public --tag next | |
| - name: Create GitHub pre-release | |
| if: ${{ !inputs.dry_run }} | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| PRE_VERSION: ${{ steps.prerelease.outputs.pre_version }} | |
| run: | | |
| gh release create "v${PRE_VERSION}" \ | |
| --title "v${PRE_VERSION}" \ | |
| --generate-notes \ | |
| --prerelease | |
| - name: Verify publish | |
| if: ${{ !inputs.dry_run }} | |
| env: | |
| PRE_VERSION: ${{ steps.prerelease.outputs.pre_version }} | |
| run: | | |
| sleep 10 | |
| PUBLISHED=$(npm view @opengsd/gsd-core@"$PRE_VERSION" version 2>/dev/null || echo "NOT_FOUND") | |
| if [ "$PUBLISHED" != "$PRE_VERSION" ]; then | |
| echo "::error::Published version verification failed. Expected $PRE_VERSION, got $PUBLISHED" | |
| exit 1 | |
| fi | |
| echo "✓ Verified: @opengsd/gsd-core@$PRE_VERSION is live on npm" | |
| # Also verify dist-tag | |
| NEXT_TAG=$(npm dist-tag ls @opengsd/gsd-core 2>/dev/null | grep "next:" | awk '{print $2}') | |
| echo "✓ next tag points to: $NEXT_TAG" | |
| - name: Summary | |
| env: | |
| PRE_VERSION: ${{ steps.prerelease.outputs.pre_version }} | |
| DRY_RUN: ${{ inputs.dry_run }} | |
| run: | | |
| echo "## Pre-release v${PRE_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 \`next\`" >> "$GITHUB_STEP_SUMMARY" | |
| echo "- Install: \`npx @opengsd/gsd-core@next\`" >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "To publish another pre-release: run \`rc\` again" >> "$GITHUB_STEP_SUMMARY" | |
| echo "To finalize: run \`finalize\` action" >> "$GITHUB_STEP_SUMMARY" | |
| install-smoke-finalize: | |
| 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-finalize] | |
| if: inputs.action == 'finalize' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| 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: Set final version | |
| env: | |
| VERSION: ${{ inputs.version }} | |
| run: | | |
| npm version "$VERSION" --no-git-tag-version --allow-same-version | |
| git add package.json package-lock.json | |
| git diff --cached --quiet || git commit -m "chore: finalize v${VERSION}" | |
| - name: Install and test | |
| env: | |
| NODE_OPTIONS: --max-old-space-size=6144 | |
| run: | | |
| npm ci | |
| node scripts/check-npm-integrity.cjs | |
| npm run test:coverage:unit | |
| # npm bundled with Node 24 (pinned via setup-node) already supports trusted publishing (#318) | |
| - name: Dry-run publish validation | |
| run: npm publish --dry-run | |
| - name: Create PR to merge release back to main | |
| if: ${{ !inputs.dry_run }} | |
| continue-on-error: true | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| BRANCH: ${{ needs.validate-version.outputs.branch }} | |
| VERSION: ${{ inputs.version }} | |
| run: | | |
| # Non-fatal: repos that disable "Allow GitHub Actions to create and | |
| # approve pull requests" cause this step to fail with GraphQL 403. | |
| # The release itself (tag + npm publish + GitHub Release) must still | |
| # proceed. Open the merge-back PR manually afterwards with: | |
| # gh pr create --base main --head release/${VERSION} \ | |
| # --title "chore: merge release v${VERSION} to main" | |
| EXISTING_PR=$(gh pr list --base main --head "$BRANCH" --state open --json number --jq '.[0].number' 2>/dev/null || echo "") | |
| if [ -n "$EXISTING_PR" ]; then | |
| echo "PR #$EXISTING_PR already exists; updating" | |
| gh pr edit "$EXISTING_PR" \ | |
| --title "chore: merge release v${VERSION} to main" \ | |
| --body "Merge release branch back to main after v${VERSION} stable release." \ | |
| || echo "::warning::Could not update merge-back PR (likely PR-creation policy disabled). Open it manually after release." | |
| else | |
| gh pr create \ | |
| --base main \ | |
| --head "$BRANCH" \ | |
| --title "chore: merge release v${VERSION} to main" \ | |
| --body "Merge release branch back to main after v${VERSION} stable release." \ | |
| || echo "::warning::Could not create merge-back PR (likely PR-creation policy disabled). Open it manually after release." | |
| fi | |
| - name: Tag and push | |
| if: ${{ !inputs.dry_run }} | |
| env: | |
| VERSION: ${{ inputs.version }} | |
| BRANCH: ${{ needs.validate-version.outputs.branch }} | |
| 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 tag" | |
| else | |
| git tag "v${VERSION}" | |
| fi | |
| git push origin "$BRANCH" --tags | |
| - name: Publish to npm (latest) | |
| if: ${{ !inputs.dry_run }} | |
| # --provenance is automatic under OIDC trusted publishing | |
| run: npm publish --provenance --access public | |
| - name: Create GitHub Release | |
| if: ${{ !inputs.dry_run }} | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| VERSION: ${{ inputs.version }} | |
| run: | | |
| gh release create "v${VERSION}" \ | |
| --title "v${VERSION}" \ | |
| --generate-notes \ | |
| --latest | |
| - name: Clean up next dist-tag | |
| if: ${{ !inputs.dry_run }} | |
| env: | |
| VERSION: ${{ inputs.version }} | |
| 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 "@opengsd/gsd-core@${VERSION}" next 2>/dev/null || true | |
| echo "✓ next dist-tag updated to v${VERSION}" | |
| - name: Verify publish | |
| if: ${{ !inputs.dry_run }} | |
| env: | |
| VERSION: ${{ inputs.version }} | |
| run: | | |
| sleep 10 | |
| PUBLISHED=$(npm view @opengsd/gsd-core@"$VERSION" version 2>/dev/null || echo "NOT_FOUND") | |
| if [ "$PUBLISHED" != "$VERSION" ]; then | |
| echo "::error::Published version verification failed. Expected $VERSION, got $PUBLISHED" | |
| exit 1 | |
| fi | |
| echo "✓ Verified: @opengsd/gsd-core@$VERSION is live on npm" | |
| # Verify latest tag | |
| LATEST_TAG=$(npm dist-tag ls @opengsd/gsd-core 2>/dev/null | grep "latest:" | awk '{print $2}') | |
| echo "✓ latest tag points to: $LATEST_TAG" | |
| - name: Summary | |
| env: | |
| VERSION: ${{ inputs.version }} | |
| DRY_RUN: ${{ inputs.dry_run }} | |
| run: | | |
| echo "## Release 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" | |
| echo "- Install: \`npx @opengsd/gsd-core@latest\`" >> "$GITHUB_STEP_SUMMARY" | |
| fi |