fix: harden publish workflow for prerelease and retry #84
Workflow file for this run
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: Publish to JSR/NPM/GitHub | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| version: | |
| description: "Version to publish (e.g., 1.2.3)" | |
| required: true | |
| type: string | |
| release: | |
| types: [published] | |
| push: | |
| tags: | |
| - "v*" | |
| # Job execution order: | |
| # 1. prepare-and-build: Build the package (tests should have passed before tagging) | |
| # 2. publish-jsr: Publish to JSR first (fail-fast if OIDC not working) | |
| # 3. publish-npm: Publish to NPM (only if JSR succeeds) | |
| # 4. publish-github: Publish to GitHub Packages (only if JSR and NPM succeed) | |
| # 5. finalize: Create tags/issues based on results | |
| # | |
| # Note: Tests are not run here as they should have already passed in the | |
| # Tests workflow before a release tag is created. | |
| jobs: | |
| prepare-and-build: | |
| name: Prepare and Build | |
| runs-on: ubuntu-latest | |
| outputs: | |
| version: ${{ steps.version.outputs.version }} | |
| should-publish: ${{ steps.check.outputs.should-publish }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Init submodules with retry | |
| run: | | |
| for i in 1 2 3; do | |
| if git submodule update --init --recursive; then | |
| echo "✅ Submodules initialized" | |
| exit 0 | |
| fi | |
| echo "⚠️ Attempt $i failed, retrying in 10s..." | |
| sleep 10 | |
| done | |
| echo "❌ Failed to init submodules after 3 attempts" | |
| exit 1 | |
| - name: Verify Tests workflow passed | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| echo "Checking if Tests workflow passed for this commit..." | |
| COMMIT_SHA="${{ github.sha }}" | |
| # Get the status of the Tests workflow for this commit | |
| RESULT=$(gh run list --workflow=test.yml --commit=$COMMIT_SHA --json conclusion --limit 1 -q '.[0].conclusion' || echo "not_found") | |
| if [ "$RESULT" = "success" ]; then | |
| echo "✅ Tests workflow passed for commit $COMMIT_SHA" | |
| elif [ "$RESULT" = "not_found" ] || [ -z "$RESULT" ]; then | |
| echo "⚠️ No Tests workflow run found for commit $COMMIT_SHA" | |
| echo "This may be okay if tests were run on a parent commit." | |
| echo "Proceeding with caution..." | |
| else | |
| echo "❌ Tests workflow did not pass (status: $RESULT)" | |
| echo "Please ensure tests pass before releasing." | |
| exit 1 | |
| fi | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: "22" | |
| cache: "npm" | |
| - name: Setup Deno | |
| uses: denoland/setup-deno@v2 | |
| with: | |
| deno-version: v2.x | |
| - name: Install dependencies | |
| run: npm install | |
| - name: Install Emscripten | |
| uses: mymindstorm/setup-emsdk@v14 | |
| with: | |
| version: latest | |
| - name: Determine version | |
| id: version | |
| run: | | |
| if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then | |
| VERSION="${{ github.event.inputs.version }}" | |
| elif [[ "${{ github.event_name }}" == "release" ]]; then | |
| VERSION="${{ github.event.release.tag_name }}" | |
| elif [[ "$GITHUB_REF" == refs/tags/* ]]; then | |
| VERSION="${GITHUB_REF#refs/tags/}" | |
| else | |
| echo "No version found" | |
| exit 1 | |
| fi | |
| # Normalize version (remove 'v' prefix if present) | |
| VERSION="${VERSION#v}" | |
| # Validate version format | |
| if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then | |
| echo "Invalid version format: $VERSION" | |
| exit 1 | |
| fi | |
| echo "version=$VERSION" >> $GITHUB_OUTPUT | |
| echo "Version: $VERSION" | |
| - name: Check if should publish | |
| id: check | |
| run: | | |
| VERSION="${{ steps.version.outputs.version }}" | |
| SHOULD_PUBLISH="true" | |
| # Check if version already exists on NPM | |
| if npm view taglib-wasm@$VERSION version 2>/dev/null; then | |
| echo "Version $VERSION already exists on NPM" | |
| SHOULD_PUBLISH="false" | |
| fi | |
| echo "should-publish=$SHOULD_PUBLISH" >> $GITHUB_OUTPUT | |
| - name: Sync versions across files | |
| if: steps.check.outputs.should-publish == 'true' | |
| run: | | |
| VERSION="${{ steps.version.outputs.version }}" | |
| # Use the sync-version script to update all version references | |
| node -e " | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| // Update package.json | |
| const pkgPath = path.join(__dirname, 'package.json'); | |
| const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); | |
| pkg.version = '$VERSION'; | |
| fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); | |
| // Update deno.json | |
| const denoPath = path.join(__dirname, 'deno.json'); | |
| const deno = JSON.parse(fs.readFileSync(denoPath, 'utf8')); | |
| deno.version = '$VERSION'; | |
| fs.writeFileSync(denoPath, JSON.stringify(deno, null, 2) + '\n'); | |
| console.log('Updated versions to $VERSION'); | |
| " | |
| - name: Build project | |
| if: steps.check.outputs.should-publish == 'true' | |
| run: npm run build | |
| - name: Verify build artifacts | |
| if: steps.check.outputs.should-publish == 'true' | |
| run: | | |
| # Check that all required files exist | |
| test -f dist/index.js || (echo "Missing dist/index.js" && exit 1) | |
| test -f dist/index.d.ts || (echo "Missing dist/index.d.ts" && exit 1) | |
| test -f dist/taglib-web.wasm || (echo "Missing dist/taglib-web.wasm" && exit 1) | |
| test -f dist/taglib-wrapper.js || (echo "Missing dist/taglib-wrapper.js" && exit 1) | |
| # Check file sizes are reasonable | |
| WASM_SIZE=$(stat -c%s dist/taglib-web.wasm 2>/dev/null || stat -f%z dist/taglib-web.wasm) | |
| if [ "$WASM_SIZE" -lt 1000 ]; then | |
| echo "WASM file too small: $WASM_SIZE bytes" | |
| exit 1 | |
| fi | |
| - name: Upload build artifacts | |
| if: steps.check.outputs.should-publish == 'true' | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: build-artifacts | |
| path: | | |
| dist/ | |
| package.json | |
| deno.json | |
| README.md | |
| LICENSE | |
| retention-days: 1 | |
| publish-jsr: | |
| name: Publish to JSR | |
| needs: prepare-and-build | |
| if: needs.prepare-and-build.outputs.should-publish == 'true' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| id-token: write # Required for OIDC authentication to JSR | |
| outputs: | |
| published: ${{ steps.publish.outputs.published }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: denoland/setup-deno@v2 | |
| with: | |
| deno-version: v2.x | |
| - name: Download build artifacts | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: build-artifacts | |
| path: . | |
| - name: Publish to JSR with retry | |
| id: publish | |
| uses: nick-invision/retry@v3 | |
| with: | |
| timeout_minutes: 5 | |
| max_attempts: 3 | |
| retry_wait_seconds: 30 | |
| command: | | |
| # Use OIDC for authentication (no token needed) | |
| OUTPUT=$(deno publish --allow-slow-types --allow-dirty 2>&1) && { | |
| echo "JSR publish successful" | |
| echo "published=true" >> $GITHUB_OUTPUT | |
| exit 0 | |
| } | |
| # Treat "already published" as success | |
| if echo "$OUTPUT" | grep -qi "already published\|already exists"; then | |
| echo "JSR version already published, continuing" | |
| echo "published=true" >> $GITHUB_OUTPUT | |
| exit 0 | |
| fi | |
| echo "JSR publish failed: $OUTPUT" | |
| echo "published=false" >> $GITHUB_OUTPUT | |
| exit 1 | |
| on_retry_command: | | |
| echo "Retrying JSR publish..." | |
| publish-npm: | |
| name: Publish to NPM | |
| needs: [prepare-and-build, publish-jsr] # Now depends on JSR success | |
| if: needs.prepare-and-build.outputs.should-publish == 'true' && needs.publish-jsr.outputs.published == 'true' | |
| runs-on: ubuntu-latest | |
| outputs: | |
| published: ${{ steps.publish.outputs.published }} | |
| steps: | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: "22" | |
| registry-url: "https://registry.npmjs.org" | |
| - name: Download build artifacts | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: build-artifacts | |
| path: . | |
| - name: Prepare publish directory | |
| run: | | |
| # Create a clean directory with only files to publish | |
| mkdir -p publish-dir | |
| cp -r dist publish-dir/ | |
| cp package.json README.md LICENSE publish-dir/ | |
| cd publish-dir | |
| echo "Contents of publish directory:" | |
| ls -la | |
| echo "Contents of dist:" | |
| ls -la dist/ | |
| - name: Determine NPM tag | |
| id: npm-tag | |
| run: | | |
| VERSION="${{ needs.prepare-and-build.outputs.version }}" | |
| if [[ "$VERSION" == *-* ]]; then | |
| echo "tag=beta" >> $GITHUB_OUTPUT | |
| else | |
| echo "tag=latest" >> $GITHUB_OUTPUT | |
| fi | |
| - name: Publish to NPM with retry | |
| id: publish | |
| uses: nick-invision/retry@v3 | |
| env: | |
| NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} | |
| with: | |
| timeout_minutes: 5 | |
| max_attempts: 3 | |
| retry_wait_seconds: 30 | |
| command: | | |
| cd publish-dir && npm publish --access public --tag ${{ steps.npm-tag.outputs.tag }} | |
| echo "published=true" >> $GITHUB_OUTPUT | |
| on_retry_command: | | |
| echo "Retrying NPM publish..." | |
| - name: Verify NPM publication | |
| run: | | |
| VERSION="${{ needs.prepare-and-build.outputs.version }}" | |
| sleep 10 # Give NPM time to propagate | |
| for i in {1..5}; do | |
| if npm view taglib-wasm@$VERSION version 2>/dev/null; then | |
| echo "Successfully verified version $VERSION on NPM" | |
| exit 0 | |
| fi | |
| echo "Attempt $i: Version not found yet, waiting..." | |
| sleep 10 | |
| done | |
| echo "Failed to verify NPM publication" | |
| exit 1 | |
| publish-github: | |
| name: Publish to GitHub Packages | |
| needs: [ | |
| prepare-and-build, | |
| publish-jsr, | |
| publish-npm, | |
| ] # Depends on both JSR and NPM | |
| if: needs.prepare-and-build.outputs.should-publish == 'true' && needs.publish-jsr.outputs.published == 'true' && needs.publish-npm.outputs.published == 'true' | |
| runs-on: ubuntu-latest | |
| outputs: | |
| published: ${{ steps.publish.outputs.published }} | |
| steps: | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: "22" | |
| registry-url: "https://npm.pkg.github.com" | |
| - name: Download build artifacts | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: build-artifacts | |
| path: . | |
| - name: Prepare publish directory | |
| run: | | |
| # Create a clean directory with only files to publish | |
| mkdir -p publish-dir | |
| cp -r dist publish-dir/ | |
| cp package.json README.md LICENSE publish-dir/ | |
| - name: Update package.json for GitHub | |
| run: | | |
| cd publish-dir | |
| node -e " | |
| const fs = require('fs'); | |
| const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8')); | |
| // Update package name for GitHub | |
| pkg.name = '@charleswiltgen/taglib-wasm'; | |
| // Remove NPM-specific config | |
| delete pkg.publishConfig; | |
| // Add GitHub registry config | |
| pkg.publishConfig = { | |
| registry: 'https://npm.pkg.github.com' | |
| }; | |
| fs.writeFileSync('./package.json', JSON.stringify(pkg, null, 2) + '\n'); | |
| console.log('Updated package.json for GitHub Packages'); | |
| " | |
| - name: Determine NPM tag | |
| id: npm-tag | |
| run: | | |
| VERSION="${{ needs.prepare-and-build.outputs.version }}" | |
| if [[ "$VERSION" == *-* ]]; then | |
| echo "tag=beta" >> $GITHUB_OUTPUT | |
| else | |
| echo "tag=latest" >> $GITHUB_OUTPUT | |
| fi | |
| - name: Publish to GitHub with retry | |
| id: publish | |
| uses: nick-invision/retry@v3 | |
| env: | |
| NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| with: | |
| timeout_minutes: 5 | |
| max_attempts: 3 | |
| retry_wait_seconds: 30 | |
| command: | | |
| cd publish-dir && npm publish --tag ${{ steps.npm-tag.outputs.tag }} | |
| echo "published=true" >> $GITHUB_OUTPUT | |
| on_retry_command: | | |
| echo "Retrying GitHub publish..." | |
| finalize: | |
| name: Finalize Release | |
| needs: [prepare-and-build, publish-jsr, publish-npm, publish-github] | |
| if: always() && needs.prepare-and-build.outputs.should-publish == 'true' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write # Required to create tags | |
| issues: write # Required to create issues | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Check publication status | |
| id: status | |
| run: | | |
| VERSION="${{ needs.prepare-and-build.outputs.version }}" | |
| SUCCESS=true | |
| FAILURES="" | |
| if [ "${{ needs.publish-npm.outputs.published }}" != "true" ]; then | |
| FAILURES="${FAILURES}NPM " | |
| SUCCESS=false | |
| fi | |
| if [ "${{ needs.publish-github.outputs.published }}" != "true" ]; then | |
| FAILURES="${FAILURES}GitHub " | |
| SUCCESS=false | |
| fi | |
| if [ "${{ needs.publish-jsr.outputs.published }}" != "true" ]; then | |
| FAILURES="${FAILURES}JSR " | |
| SUCCESS=false | |
| fi | |
| echo "success=$SUCCESS" >> $GITHUB_OUTPUT | |
| echo "failures=$FAILURES" >> $GITHUB_OUTPUT | |
| if [ "$SUCCESS" = "true" ]; then | |
| echo "✅ All publications successful for version $VERSION" | |
| else | |
| echo "❌ Failed to publish to: $FAILURES" | |
| fi | |
| - name: Rollback NPM | |
| if: steps.status.outputs.success == 'false' && needs.publish-npm.outputs.published == 'true' | |
| run: | | |
| VERSION="${{ needs.prepare-and-build.outputs.version }}" | |
| echo "⚠️ Rollback required for NPM version $VERSION" | |
| echo "To rollback NPM:" | |
| echo "1. npm unpublish taglib-wasm@$VERSION" | |
| echo "2. Wait 24 hours before republishing the same version" | |
| echo "" | |
| echo "Note: NPM has restrictions on unpublishing. You may need to:" | |
| echo "- Deprecate the version instead: npm deprecate taglib-wasm@$VERSION 'Published in error'" | |
| echo "- Publish a patch version with fixes" | |
| - name: Write failure summary | |
| if: steps.status.outputs.success == 'false' | |
| run: | | |
| VERSION="${{ needs.prepare-and-build.outputs.version }}" | |
| FAILURES="${{ steps.status.outputs.failures }}" | |
| NPM_PUBLISHED="${{ needs.publish-npm.outputs.published }}" | |
| GITHUB_PUBLISHED="${{ needs.publish-github.outputs.published }}" | |
| JSR_PUBLISHED="${{ needs.publish-jsr.outputs.published }}" | |
| { | |
| echo "## ❌ Publication Failed for v${VERSION}" | |
| echo "" | |
| echo "Failed to publish to: ${FAILURES}" | |
| echo "" | |
| echo "### Publication Status" | |
| echo "| Registry | Status |" | |
| echo "|----------|--------|" | |
| echo "| JSR | $([ "$JSR_PUBLISHED" = "true" ] && echo "✅ Published" || echo "❌ Failed") |" | |
| echo "| NPM | $([ "$NPM_PUBLISHED" = "true" ] && echo "✅ Published" || echo "❌ Failed") |" | |
| echo "| GitHub Packages | $([ "$GITHUB_PUBLISHED" = "true" ] && echo "✅ Published" || echo "❌ Failed") |" | |
| echo "" | |
| if [ "$NPM_PUBLISHED" = "true" ] || [ "$GITHUB_PUBLISHED" = "true" ] || [ "$JSR_PUBLISHED" = "true" ]; then | |
| echo "### ⚠️ Rollback Required" | |
| echo "" | |
| if [ "$NPM_PUBLISHED" = "true" ]; then | |
| echo "#### NPM Rollback" | |
| echo '```bash' | |
| echo "# Option 1: Deprecate (recommended)" | |
| echo "npm deprecate taglib-wasm@${VERSION} 'Published in error'" | |
| echo "" | |
| echo "# Option 2: Unpublish (requires waiting 24h to republish)" | |
| echo "npm unpublish taglib-wasm@${VERSION}" | |
| echo '```' | |
| echo "" | |
| fi | |
| if [ "$GITHUB_PUBLISHED" = "true" ]; then | |
| echo "#### GitHub Packages Rollback" | |
| echo "Delete the package version from GitHub Packages UI or use:" | |
| echo '```bash' | |
| echo "gh api -X DELETE /user/packages/npm/@charleswiltgen%2Ftaglib-wasm/versions/VERSION_ID" | |
| echo '```' | |
| echo "" | |
| fi | |
| if [ "$JSR_PUBLISHED" = "true" ]; then | |
| echo "#### JSR Rollback" | |
| echo "JSR does not support unpublishing. Consider publishing a patch version." | |
| echo "" | |
| fi | |
| fi | |
| echo "### Next Steps" | |
| echo "1. Review the workflow logs above for specific error details" | |
| echo "2. Fix the underlying issue" | |
| echo "3. If any registries were published, follow rollback procedures above" | |
| echo "4. Re-run the workflow or publish a new patch version" | |
| } >> $GITHUB_STEP_SUMMARY |