|
| 1 | +name: (Runtime) Release From Source |
| 2 | + |
| 3 | +on: |
| 4 | + workflow_dispatch: |
| 5 | + inputs: |
| 6 | + commit_sha: |
| 7 | + required: true |
| 8 | + type: |
| 9 | + required: true |
| 10 | + description: Type of release to publish |
| 11 | + type: choice |
| 12 | + options: |
| 13 | + - nightly |
| 14 | + - stable-latest |
| 15 | + - stable-untagged |
| 16 | + - experimental_only |
| 17 | + only_packages: |
| 18 | + description: Packages to publish (space separated) |
| 19 | + type: string |
| 20 | + skip_packages: |
| 21 | + description: Packages to NOT publish (space separated) |
| 22 | + type: string |
| 23 | + dry: |
| 24 | + required: true |
| 25 | + description: Dry run instead of publish? |
| 26 | + type: boolean |
| 27 | + default: true |
| 28 | + force_notify: |
| 29 | + description: Force a Discord notification? |
| 30 | + type: boolean |
| 31 | + default: false |
| 32 | + schedule: |
| 33 | + # At 10 minutes past 16:00 on Mon, Tue, Wed, Thu, and Fri. |
| 34 | + # Scheduled runs always publish a nightly (see `Resolve release inputs`). |
| 35 | + - cron: 10 16 * * 1,2,3,4,5 |
| 36 | + |
| 37 | +permissions: {} |
| 38 | + |
| 39 | +env: |
| 40 | + TZ: /usr/share/zoneinfo/America/Los_Angeles |
| 41 | + # https://github.com/actions/cache/blob/main/tips-and-workarounds.md#cache-segment-restore-timeout |
| 42 | + SEGMENT_DOWNLOAD_TIMEOUT_MINS: 1 |
| 43 | + |
| 44 | +jobs: |
| 45 | + prepare: |
| 46 | + name: Prepare release |
| 47 | + runs-on: ubuntu-latest |
| 48 | + outputs: |
| 49 | + release_type: ${{ steps.resolve.outputs.release_type }} |
| 50 | + commit_sha: ${{ steps.resolve.outputs.commit_sha }} |
| 51 | + steps: |
| 52 | + # Manual dispatches always notify before the release starts so the team |
| 53 | + # has a heads-up that a release is incoming. Scheduled (nightly) runs |
| 54 | + # don't notify up front; we only notify on failure (see `notify` job). |
| 55 | + - name: Notify Discord (release starting) |
| 56 | + if: ${{ github.event_name == 'workflow_dispatch' }} |
| 57 | + uses: tsickert/discord-webhook@86dc739f3f165f16dadc5666051c367efa1692f4 |
| 58 | + with: |
| 59 | + webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }} |
| 60 | + embed-author-name: ${{ github.event.sender.login }} |
| 61 | + embed-author-url: ${{ github.event.sender.html_url }} |
| 62 | + embed-author-icon-url: ${{ github.event.sender.avatar_url }} |
| 63 | + embed-title: "⚠️ Publishing ${{ inputs.type }} release from source${{ (inputs.dry && ' (dry run)') || '' }}" |
| 64 | + embed-description: | |
| 65 | + ```json |
| 66 | + ${{ toJson(inputs) }} |
| 67 | + ``` |
| 68 | + embed-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} |
| 69 | + |
| 70 | + - name: Resolve release inputs |
| 71 | + id: resolve |
| 72 | + run: | |
| 73 | + # Scheduled runs always publish a nightly. Manual dispatches always |
| 74 | + # supply `inputs.type`. Anything else is unsupported and fails fast. |
| 75 | + if [ "${{ github.event_name }}" = "schedule" ]; then |
| 76 | + release_type=nightly |
| 77 | + commit_sha="${{ github.sha }}" |
| 78 | + elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then |
| 79 | + release_type="${{ inputs.type }}" |
| 80 | + commit_sha="${{ inputs.commit_sha }}" |
| 81 | + else |
| 82 | + echo "Unsupported event: ${{ github.event_name }}" >&2 |
| 83 | + exit 1 |
| 84 | + fi |
| 85 | + echo "release_type=$release_type" >> "$GITHUB_OUTPUT" |
| 86 | + echo "commit_sha=$commit_sha" >> "$GITHUB_OUTPUT" |
| 87 | +
|
| 88 | + - uses: actions/checkout@v4 |
| 89 | + with: |
| 90 | + ref: ${{ steps.resolve.outputs.commit_sha }} |
| 91 | + - uses: actions/setup-node@v4 |
| 92 | + with: |
| 93 | + node-version-file: '.nvmrc' |
| 94 | + cache: yarn |
| 95 | + cache-dependency-path: yarn.lock |
| 96 | + - name: Restore cached node_modules |
| 97 | + uses: actions/cache/restore@v4 |
| 98 | + id: node_modules |
| 99 | + with: |
| 100 | + path: | |
| 101 | + **/node_modules |
| 102 | + key: runtime-release-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock', 'scripts/release/yarn.lock') }} |
| 103 | + fail-on-cache-miss: true |
| 104 | + - name: Ensure clean build directory |
| 105 | + run: rm -rf build |
| 106 | + |
| 107 | + # Build only the channels we'll actually publish. `yarn build -r stable` |
| 108 | + # produces build/oss-stable (canary-tagged) and build/oss-stable-semver |
| 109 | + # (semver/@latest-tagged). `yarn build -r experimental` produces |
| 110 | + # build/oss-experimental. |
| 111 | + # |
| 112 | + # - stable-latest → semver stable published with @latest. Build stable channel. |
| 113 | + # - stable-untagged → semver stable published without a dist-tag. Build stable channel. |
| 114 | + # - experimental_only → only experimental is published. Build experimental. |
| 115 | + # - nightly → publishes canary + experimental. Build both channels. |
| 116 | + - name: Build stable channel |
| 117 | + if: ${{ steps.resolve.outputs.release_type == 'stable-latest' || steps.resolve.outputs.release_type == 'stable-untagged' || steps.resolve.outputs.release_type == 'nightly' }} |
| 118 | + run: yarn build -r stable |
| 119 | + - name: Build experimental channel |
| 120 | + if: ${{ steps.resolve.outputs.release_type == 'experimental_only' || steps.resolve.outputs.release_type == 'nightly' }} |
| 121 | + run: yarn build -r experimental |
| 122 | + |
| 123 | + - name: Inspect prepared build folders |
| 124 | + run: ls -1 ./build |
| 125 | + |
| 126 | + # Upload only the channel folders the publish job needs. Each is uploaded |
| 127 | + # under its own artifact so the publish job can pick the right one without |
| 128 | + # an extra rename step. |
| 129 | + - name: Archive semver stable artifacts |
| 130 | + if: ${{ steps.resolve.outputs.release_type == 'stable-latest' || steps.resolve.outputs.release_type == 'stable-untagged' }} |
| 131 | + uses: actions/upload-artifact@v4 |
| 132 | + with: |
| 133 | + name: release-build-stable-semver |
| 134 | + path: ./build/oss-stable-semver |
| 135 | + retention-days: 7 |
| 136 | + if-no-files-found: error |
| 137 | + - name: Archive canary artifacts |
| 138 | + if: ${{ steps.resolve.outputs.release_type == 'nightly' }} |
| 139 | + uses: actions/upload-artifact@v4 |
| 140 | + with: |
| 141 | + name: release-build-canary |
| 142 | + path: ./build/oss-stable |
| 143 | + retention-days: 7 |
| 144 | + if-no-files-found: error |
| 145 | + - name: Archive experimental artifacts |
| 146 | + if: ${{ steps.resolve.outputs.release_type == 'nightly' || steps.resolve.outputs.release_type == 'experimental_only' }} |
| 147 | + uses: actions/upload-artifact@v4 |
| 148 | + with: |
| 149 | + name: release-build-experimental |
| 150 | + path: ./build/oss-experimental |
| 151 | + retention-days: 7 |
| 152 | + if-no-files-found: error |
| 153 | + |
| 154 | + publish: |
| 155 | + name: Publish release |
| 156 | + needs: prepare |
| 157 | + runs-on: ubuntu-latest |
| 158 | + # Protected environment — requires reviewer approval before the publish |
| 159 | + # job starts running, and gates access to NPM_TOKEN. Both stable variants |
| 160 | + # (stable-latest, stable-untagged) go through the stricter `npm-stable` |
| 161 | + # environment (different reviewers / protection rules); nightly and |
| 162 | + # experimental_only share `npm-nightly`. |
| 163 | + environment: ${{ startsWith(needs.prepare.outputs.release_type, 'stable-') && 'npm-stable' || 'npm-nightly' }} |
| 164 | + env: |
| 165 | + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} |
| 166 | + steps: |
| 167 | + - uses: actions/checkout@v4 |
| 168 | + with: |
| 169 | + ref: ${{ needs.prepare.outputs.commit_sha }} |
| 170 | + - uses: actions/setup-node@v4 |
| 171 | + with: |
| 172 | + node-version-file: '.nvmrc' |
| 173 | + cache: yarn |
| 174 | + cache-dependency-path: yarn.lock |
| 175 | + - name: Restore cached node_modules |
| 176 | + uses: actions/cache/restore@v4 |
| 177 | + id: node_modules |
| 178 | + with: |
| 179 | + path: | |
| 180 | + **/node_modules |
| 181 | + key: runtime-release-node_modules-v6-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('yarn.lock', 'scripts/release/yarn.lock') }} |
| 182 | + fail-on-cache-miss: true |
| 183 | + - run: cp ./scripts/release/ci-npmrc ~/.npmrc |
| 184 | + - name: Ensure clean build directory |
| 185 | + run: rm -rf ./build && mkdir ./build |
| 186 | + |
| 187 | + # publish.js always reads from build/node_modules. For each channel we |
| 188 | + # download the matching artifact into build/node_modules, publish, then |
| 189 | + # clean up before the next channel. |
| 190 | + |
| 191 | + # ----- stable (semver) — either @latest or untagged ----- |
| 192 | + - name: Download semver stable artifacts |
| 193 | + if: ${{ startsWith(needs.prepare.outputs.release_type, 'stable-') }} |
| 194 | + uses: actions/download-artifact@v4 |
| 195 | + with: |
| 196 | + name: release-build-stable-semver |
| 197 | + path: ./build/node_modules |
| 198 | + - name: Publish semver stable to @latest |
| 199 | + if: ${{ needs.prepare.outputs.release_type == 'stable-latest' }} |
| 200 | + run: | |
| 201 | + ls -1 build/node_modules |
| 202 | + scripts/release/publish.js \ |
| 203 | + --ci \ |
| 204 | + --tags=latest \ |
| 205 | + ${{ inputs.only_packages && format('--onlyPackages={0}', inputs.only_packages) || '' }} \ |
| 206 | + ${{ inputs.skip_packages && format('--skipPackages={0}', inputs.skip_packages) || '' }} \ |
| 207 | + ${{ inputs.dry && '--dry' || '' }} |
| 208 | + # `--tags=untagged` makes publish.js attach the temporary `untagged` tag |
| 209 | + # at publish time and then `npm dist-tag rm <pkg> untagged` to leave the |
| 210 | + # version published but pointed-to by no dist-tag. |
| 211 | + - name: Publish semver stable without a dist-tag |
| 212 | + if: ${{ needs.prepare.outputs.release_type == 'stable-untagged' }} |
| 213 | + run: | |
| 214 | + ls -1 build/node_modules |
| 215 | + scripts/release/publish.js \ |
| 216 | + --ci \ |
| 217 | + --tags=untagged \ |
| 218 | + ${{ inputs.only_packages && format('--onlyPackages={0}', inputs.only_packages) || '' }} \ |
| 219 | + ${{ inputs.skip_packages && format('--skipPackages={0}', inputs.skip_packages) || '' }} \ |
| 220 | + ${{ inputs.dry && '--dry' || '' }} |
| 221 | +
|
| 222 | + # ----- nightly: canary first, then experimental ----- |
| 223 | + # NOTE: Intentionally running sequentially because npm will sometimes |
| 224 | + # fail if you try to concurrently publish two different versions of the |
| 225 | + # same package, even if they use different dist tags. |
| 226 | + - name: Download canary artifacts |
| 227 | + if: ${{ needs.prepare.outputs.release_type == 'nightly' }} |
| 228 | + uses: actions/download-artifact@v4 |
| 229 | + with: |
| 230 | + name: release-build-canary |
| 231 | + path: ./build/node_modules |
| 232 | + - name: Publish canary to @canary,@next |
| 233 | + if: ${{ needs.prepare.outputs.release_type == 'nightly' }} |
| 234 | + run: | |
| 235 | + ls -1 build/node_modules |
| 236 | + scripts/release/publish.js \ |
| 237 | + --ci \ |
| 238 | + --tags=canary,next \ |
| 239 | + ${{ inputs.only_packages && format('--onlyPackages={0}', inputs.only_packages) || '' }} \ |
| 240 | + ${{ inputs.skip_packages && format('--skipPackages={0}', inputs.skip_packages) || '' }} \ |
| 241 | + ${{ inputs.dry && '--dry' || '' }} |
| 242 | + - name: Swap canary out of build/node_modules |
| 243 | + if: ${{ needs.prepare.outputs.release_type == 'nightly' }} |
| 244 | + run: rm -rf ./build/node_modules |
| 245 | + |
| 246 | + # ----- experimental (nightly + experimental_only) ----- |
| 247 | + - name: Download experimental artifacts |
| 248 | + if: ${{ needs.prepare.outputs.release_type == 'nightly' || needs.prepare.outputs.release_type == 'experimental_only' }} |
| 249 | + uses: actions/download-artifact@v4 |
| 250 | + with: |
| 251 | + name: release-build-experimental |
| 252 | + path: ./build/node_modules |
| 253 | + - name: Publish experimental to @experimental |
| 254 | + if: ${{ needs.prepare.outputs.release_type == 'nightly' || needs.prepare.outputs.release_type == 'experimental_only' }} |
| 255 | + run: | |
| 256 | + ls -1 build/node_modules |
| 257 | + scripts/release/publish.js \ |
| 258 | + --ci \ |
| 259 | + --tags=experimental \ |
| 260 | + ${{ inputs.only_packages && format('--onlyPackages={0}', inputs.only_packages) || '' }} \ |
| 261 | + ${{ inputs.skip_packages && format('--skipPackages={0}', inputs.skip_packages) || '' }} \ |
| 262 | + ${{ inputs.dry && '--dry' || '' }} |
| 263 | +
|
| 264 | + notify: |
| 265 | + name: Notify Discord on failure |
| 266 | + needs: [prepare, publish] |
| 267 | + # Runs for every workflow run (manual + scheduled) and only fires when |
| 268 | + # something didn't complete successfully — i.e. an actual failure or a |
| 269 | + # cancellation. Successful runs stay silent. |
| 270 | + if: ${{ always() && (needs.prepare.result == 'failure' || needs.prepare.result == 'cancelled' || needs.publish.result == 'failure' || needs.publish.result == 'cancelled') }} |
| 271 | + runs-on: ubuntu-latest |
| 272 | + steps: |
| 273 | + - name: Discord Webhook Action |
| 274 | + uses: tsickert/discord-webhook@86dc739f3f165f16dadc5666051c367efa1692f4 |
| 275 | + with: |
| 276 | + webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }} |
| 277 | + embed-author-name: 'GitHub Actions' |
| 278 | + embed-title: "❌ [Runtime] Release from source failed (${{ needs.prepare.outputs.release_type || inputs.type || 'nightly' }})" |
| 279 | + embed-description: | |
| 280 | + prepare: `${{ needs.prepare.result }}` |
| 281 | + publish: `${{ needs.publish.result }}` |
| 282 | + event: `${{ github.event_name }}` |
| 283 | + embed-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }} |
0 commit comments