|
1 | 1 | name: Release |
2 | 2 |
|
3 | 3 | on: |
4 | | - release: |
5 | | - types: [published] |
| 4 | + workflow_dispatch: |
| 5 | + inputs: |
| 6 | + version: |
| 7 | + description: "Version to publish" |
| 8 | + required: true |
| 9 | + dry_run: |
| 10 | + description: "Perform a dry run without publishing" |
| 11 | + type: boolean |
| 12 | + required: false |
| 13 | + default: true |
| 14 | + |
| 15 | +concurrency: |
| 16 | + group: npm-publish-${{ github.repository }} |
| 17 | + cancel-in-progress: false |
6 | 18 |
|
7 | 19 | jobs: |
8 | 20 | release: |
9 | 21 | name: Release workflow |
10 | | - |
11 | 22 | runs-on: ubuntu-latest |
12 | 23 |
|
| 24 | + permissions: |
| 25 | + contents: read |
| 26 | + id-token: write # Required for OIDC trusted publishing |
| 27 | + |
13 | 28 | steps: |
14 | | - - uses: actions/checkout@v4 |
| 29 | + - name: Validate GitHub release and tag exists |
| 30 | + env: |
| 31 | + GH_TOKEN: ${{ github.token }} |
| 32 | + run: | |
| 33 | + TAG="v${{ inputs.version }}" |
| 34 | + echo "Looking for release with tag $TAG..." |
| 35 | +
|
| 36 | + RELEASE=$(gh release view "$TAG" --repo ${{ github.repository }} --json tagName,name 2>/dev/null) |
| 37 | + if [ $? -ne 0 ]; then |
| 38 | + echo "❌ No GitHub release found with tag $TAG" |
| 39 | + exit 1 |
| 40 | + fi |
| 41 | +
|
| 42 | + RELEASE_NAME=$(echo "$RELEASE" | jq -r '.name') |
| 43 | + if [ "$RELEASE_NAME" != "$TAG" ]; then |
| 44 | + echo "❌ Release name '$RELEASE_NAME' does not match expected '$TAG'" |
| 45 | + exit 1 |
| 46 | + fi |
| 47 | +
|
| 48 | + echo "✅ GitHub release '$RELEASE_NAME' confirmed" |
| 49 | +
|
| 50 | + - name: Checkout tag |
| 51 | + uses: actions/checkout@v4 |
| 52 | + with: |
| 53 | + ref: "v${{ inputs.version }}" |
| 54 | + fetch-depth: 0 |
| 55 | + |
| 56 | + - name: Ensure tag commit is on master |
| 57 | + run: | |
| 58 | + if ! git branch -r --contains "$(git rev-parse HEAD)" | grep -q "origin/master"; then |
| 59 | + echo "❌ Tag is not based on master branch" |
| 60 | + exit 1 |
| 61 | + fi |
| 62 | + echo "✅ Tag commit is on master" |
15 | 63 |
|
16 | 64 | - name: Setup Node.js |
17 | 65 | uses: actions/setup-node@v4 |
18 | 66 | with: |
19 | | - node-version: '20.x' |
20 | | - registry-url: 'https://registry.npmjs.org/' |
| 67 | + node-version: "lts/*" |
| 68 | + registry-url: "https://registry.npmjs.org/" |
21 | 69 |
|
22 | | - - name: Install |
23 | | - run: yarn --frozen-lockfile --non-interactive |
| 70 | + - name: Enable Corepack |
| 71 | + run: corepack enable |
| 72 | + |
| 73 | + - name: Detect Yarn version |
| 74 | + id: yarn-version |
| 75 | + run: | |
| 76 | + # Resolve the Yarn major version from the packageManager field in |
| 77 | + # package.json if present, otherwise fall back to the installed version. |
| 78 | + YARN_VERSION=$(node -p " |
| 79 | + try { |
| 80 | + const pm = require('./package.json').packageManager ?? ''; |
| 81 | + const match = pm.match(/^yarn@(\d+)/); |
| 82 | + match ? match[1] : ''; |
| 83 | + } catch { '' } |
| 84 | + ") |
| 85 | + if [ -z "$YARN_VERSION" ]; then |
| 86 | + YARN_VERSION=$(yarn --version | cut -d. -f1) |
| 87 | + fi |
| 88 | + echo "major=$YARN_VERSION" >> "$GITHUB_OUTPUT" |
| 89 | + echo "Detected Yarn major version: $YARN_VERSION" |
| 90 | +
|
| 91 | + - name: Validate version matches package.json |
| 92 | + run: | |
| 93 | + PKG_VERSION=$(node -p "require('./package.json').version") |
| 94 | + INPUT_VERSION="${{ inputs.version }}" |
| 95 | + if [ "$PKG_VERSION" != "$INPUT_VERSION" ]; then |
| 96 | + echo "❌ Version mismatch: package.json has $PKG_VERSION but input was $INPUT_VERSION" |
| 97 | + exit 1 |
| 98 | + fi |
| 99 | + echo "✅ Version $PKG_VERSION confirmed" |
| 100 | +
|
| 101 | + - name: Install dependencies |
| 102 | + run: | |
| 103 | + if [ "${{ steps.yarn-version.outputs.major }}" = "1" ]; then |
| 104 | + yarn install --frozen-lockfile --non-interactive |
| 105 | + else |
| 106 | + yarn install --immutable |
| 107 | + fi |
| 108 | +
|
| 109 | + - name: Build package (if build script exists) |
| 110 | + run: | |
| 111 | + if node -e "process.exit(require('./package.json').scripts?.build ? 0 : 1)" 2>/dev/null; then |
| 112 | + yarn build |
| 113 | + else |
| 114 | + echo "No build script found — skipping build step" |
| 115 | + fi |
| 116 | +
|
| 117 | + - name: Publish (dry run) |
| 118 | + if: ${{ inputs.dry_run }} |
| 119 | + env: |
| 120 | + # The npm CLI automatically detects the OIDC environment via |
| 121 | + # ACTIONS_ID_TOKEN_REQUEST_URL / ACTIONS_ID_TOKEN_REQUEST_TOKEN and |
| 122 | + # handles the token exchange itself. NODE_AUTH_TOKEN must still be set |
| 123 | + # (even if empty) to satisfy the .npmrc written by actions/setup-node, |
| 124 | + # otherwise npm errors before it reaches OIDC auth. |
| 125 | + NODE_AUTH_TOKEN: "" |
| 126 | + run: npm publish --provenance --access public --dry-run |
24 | 127 |
|
25 | 128 | - name: Publish |
26 | | - run: yarn publish |
| 129 | + if: ${{ !inputs.dry_run }} |
27 | 130 | env: |
28 | | - NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} |
| 131 | + NODE_AUTH_TOKEN: "" |
| 132 | + run: npm publish --provenance --access public |
| 133 | + |
| 134 | + - name: Verify published version |
| 135 | + if: ${{ !inputs.dry_run }} |
| 136 | + run: | |
| 137 | + PACKAGE_NAME=$(node -p "require('./package.json').name") |
| 138 | + EXPECTED_VERSION="${{ inputs.version }}" |
| 139 | +
|
| 140 | + echo "Waiting for npm propagation..." |
| 141 | +
|
| 142 | + for i in {1..10}; do |
| 143 | + PUBLISHED_VERSION=$(npm view "$PACKAGE_NAME" version 2>/dev/null) |
| 144 | + if [ "$PUBLISHED_VERSION" = "$EXPECTED_VERSION" ]; then |
| 145 | + echo "✅ Version $PUBLISHED_VERSION confirmed on npm" |
| 146 | + exit 0 |
| 147 | + fi |
| 148 | + echo "Not visible yet (attempt $i)..." |
| 149 | + sleep 15 |
| 150 | + done |
| 151 | +
|
| 152 | + echo "❌ Published version not visible after waiting" |
| 153 | + exit 1 |
0 commit comments