| author | |
| committer | |
| log | 001f06d543beb92cc66bb7e8d54d233a20d802b4 |
| tree | d6535fc11dda341b2169a71432cb729a2cab7f5b |
| parent | 7ac93e300ee17a4ee5e92192264fcfd438805b6f |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>4 files changed, 147 insertions(+), 50 deletions(-)
.github/scripts/check-nightly-release.cjs+33-10| ... | @@ -1,19 +1,21 @@ | ... | @@ -1,19 +1,21 @@ |
| 1 | const MINIMUM_RELEASE_GAP_MS = 6 * 60 * 60 * 1000; | 1 | const MINIMUM_RELEASE_GAP_MS = 6 * 60 * 60 * 1000; |
| 2 | 2 | ||
| 3 | // Runs after the workflow acquires the nightly concurrency lock. | 3 | const isNightlyTag = (tag) => /^v.*-nightly\./.test(tag) || tag.startsWith("nightly-v"); |
| 4 | async function shouldReleaseNightly({ github, context, core, now = Date.now() }) { | 4 | |
| 5 | // Newest published nightly by publication time, or undefined when none exists. | ||
| 6 | async function findLatestNightly({ github, context }) { | ||
| 5 | const releases = await github.paginate(github.rest.repos.listReleases, { | 7 | const releases = await github.paginate(github.rest.repos.listReleases, { |
| 6 | ...context.repo, | 8 | ...context.repo, |
| 7 | per_page: 100, | 9 | per_page: 100, |
| 8 | }); | 10 | }); |
| 9 | const lastNightly = releases | 11 | return releases |
| 10 | .filter( | 12 | .filter((release) => !release.draft && release.published_at && isNightlyTag(release.tag_name)) |
| 11 | (release) => | ||
| 12 | !release.draft && | ||
| 13 | release.published_at && | ||
| 14 | (/^v.*-nightly\./.test(release.tag_name) || release.tag_name.startsWith("nightly-v")), | ||
| 15 | ) | ||
| 16 | .sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at))[0]; | 13 | .sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at))[0]; |
| 14 | } | ||
| 15 | |||
| 16 | // Runs after the workflow acquires the nightly concurrency lock. | ||
| 17 | async function shouldReleaseNightly({ github, context, core, now = Date.now() }) { | ||
| 18 | const lastNightly = await findLatestNightly({ github, context }); | ||
| 17 | 19 | ||
| 18 | if (!lastNightly) { | 20 | if (!lastNightly) { |
| 19 | core.info("No published nightly found. Proceeding with release."); | 21 | core.info("No published nightly found. Proceeding with release."); |
| ... | @@ -41,4 +43,25 @@ async function shouldReleaseNightly({ github, context, core, now = Date.now() }) | ... | @@ -41,4 +43,25 @@ async function shouldReleaseNightly({ github, context, core, now = Date.now() }) |
| 41 | return true; | 43 | return true; |
| 42 | } | 44 | } |
| 43 | 45 | ||
| 44 | module.exports = { shouldReleaseNightly }; | 46 | // Stable releases build the commit the latest nightly shipped, so the stable |
| 47 | // build is one nightly users already ran. Returns the nightly tag, its commit, | ||
| 48 | // and the stable version that nightly was a preview of. | ||
| 49 | async function resolveLatestNightlyCommit({ github, context, core }) { | ||
| 50 | const lastNightly = await findLatestNightly({ github, context }); | ||
| 51 | if (!lastNightly) { | ||
| 52 | throw new Error("No published nightly found. Stable releases build the latest nightly commit."); | ||
| 53 | } | ||
| 54 | |||
| 55 | const tag = lastNightly.tag_name; | ||
| 56 | // repos.getCommit dereferences annotated tags, so this is the commit either way. | ||
| 57 | const { data: commit } = await github.rest.repos.getCommit({ ...context.repo, ref: tag }); | ||
| 58 | const version = /^(?:nightly-)?v(\d+\.\d+\.\d+)-nightly\./.exec(tag)?.[1]; | ||
| 59 | if (!version) { | ||
| 60 | throw new Error(`Cannot derive a stable version from nightly tag ${tag}.`); | ||
| 61 | } | ||
| 62 | |||
| 63 | core.info(`Latest nightly ${tag} shipped ${commit.sha} as a preview of ${version}.`); | ||
| 64 | return { tag, sha: commit.sha, version }; | ||
| 65 | } | ||
| 66 | |||
| 67 | module.exports = { shouldReleaseNightly, resolveLatestNightlyCommit }; |
.github/scripts/check-nightly-release.test.cjs+42| ... | @@ -99,3 +99,45 @@ for (const status of ["behind", "diverged"]) { | ... | @@ -99,3 +99,45 @@ for (const status of ["behind", "diverged"]) { |
| 99 | assert.equal(await shouldReleaseNightly(options), false); | 99 | assert.equal(await shouldReleaseNightly(options), false); |
| 100 | }); | 100 | }); |
| 101 | } | 101 | } |
| 102 | |||
| 103 | const { resolveLatestNightlyCommit } = require("./check-nightly-release.cjs"); | ||
| 104 | |||
| 105 | function nightlyCommitFixture({ releases, commitSha = "abc123" }) { | ||
| 106 | const refs = []; | ||
| 107 | const { options } = fixture({ releases }); | ||
| 108 | options.github.rest.repos.getCommit = async ({ ref }) => { | ||
| 109 | refs.push(ref); | ||
| 110 | return { data: { sha: commitSha } }; | ||
| 111 | }; | ||
| 112 | return { options, refs }; | ||
| 113 | } | ||
| 114 | |||
| 115 | test("stable releases resolve the commit of the newest published nightly", async () => { | ||
| 116 | const { options, refs } = nightlyCommitFixture({ | ||
| 117 | releases: [ | ||
| 118 | nightly(10, { tag_name: "v1.0.1-nightly.20260905.100" }), | ||
| 119 | nightly(1, { tag_name: "v1.0.1-nightly.20260905.123" }), | ||
| 120 | nightly(0, { tag_name: "v1.0.0" }), | ||
| 121 | nightly(0, { draft: true, tag_name: "v1.0.1-nightly.20260905.999" }), | ||
| 122 | ], | ||
| 123 | commitSha: "deadbeef", | ||
| 124 | }); | ||
| 125 | assert.deepEqual(await resolveLatestNightlyCommit(options), { | ||
| 126 | tag: "v1.0.1-nightly.20260905.123", | ||
| 127 | sha: "deadbeef", | ||
| 128 | version: "1.0.1", | ||
| 129 | }); | ||
| 130 | assert.deepEqual(refs, ["v1.0.1-nightly.20260905.123"]); | ||
| 131 | }); | ||
| 132 | |||
| 133 | test("stable releases derive the version from legacy nightly tags", async () => { | ||
| 134 | const { options } = nightlyCommitFixture({ | ||
| 135 | releases: [nightly(1, { tag_name: "nightly-v0.9.0-nightly.20260905.5" })], | ||
| 136 | }); | ||
| 137 | assert.equal((await resolveLatestNightlyCommit(options)).version, "0.9.0"); | ||
| 138 | }); | ||
| 139 | |||
| 140 | test("stable releases fail without a published nightly", async () => { | ||
| 141 | const { options } = nightlyCommitFixture({ releases: [nightly(0, { tag_name: "v1.0.0" })] }); | ||
| 142 | await assert.rejects(resolveLatestNightlyCommit(options), /No published nightly/); | ||
| 143 | }); |
.github/workflows/release.yml+51-30| ... | @@ -19,7 +19,7 @@ on: | ... | @@ -19,7 +19,7 @@ on: |
| 19 | - stable | 19 | - stable |
| 20 | - nightly | 20 | - nightly |
| 21 | version: | 21 | version: |
| 22 | description: "Release version (for example 1.2.3 or v1.2.3)" | 22 | description: "Stable version override (for example 1.2.3). Defaults to the version the latest nightly previewed." |
| 23 | required: false | 23 | required: false |
| 24 | type: string | 24 | type: string |
| 25 | 25 | ||
| ... | @@ -39,33 +39,54 @@ permissions: | ... | @@ -39,33 +39,54 @@ permissions: |
| 39 | id-token: none | 39 | id-token: none |
| 40 | 40 | ||
| 41 | jobs: | 41 | jobs: |
| 42 | check_changes: | 42 | # Picks the commit every later job builds. Nightlies and tag pushes build the |
| 43 | name: Check automatic nightly release | 43 | # triggering commit. Manual stable releases build the commit of the latest |
| 44 | if: github.event_name == 'schedule' | 44 | # published nightly, so stable only ever ships a build that nightly users |
| 45 | # have already run. Scheduled runs also decide here whether a nightly is due. | ||
| 46 | resolve_commit: | ||
| 47 | name: Resolve release commit | ||
| 45 | runs-on: blacksmith-8vcpu-ubuntu-2404 | 48 | runs-on: blacksmith-8vcpu-ubuntu-2404 |
| 46 | timeout-minutes: 5 | 49 | timeout-minutes: 5 |
| 47 | outputs: | 50 | outputs: |
| 48 | has_changes: ${{ steps.check.outputs.result }} | 51 | ref: ${{ steps.resolve.outputs.ref }} |
| 52 | nightly_version: ${{ steps.resolve.outputs.nightly_version }} | ||
| 53 | has_changes: ${{ steps.resolve.outputs.has_changes }} | ||
| 49 | steps: | 54 | steps: |
| 50 | - name: Checkout | 55 | - name: Checkout |
| 51 | uses: actions/checkout@v6 | 56 | uses: actions/checkout@v6 |
| 52 | with: | 57 | with: |
| 53 | sparse-checkout: .github/scripts | 58 | sparse-checkout: .github/scripts |
| 54 | 59 | ||
| 55 | - id: check | 60 | - id: resolve |
| 56 | name: Check release gap and new commits | 61 | name: Resolve release commit |
| 57 | uses: actions/github-script@v8 | 62 | uses: actions/github-script@v8 |
| 63 | env: | ||
| 64 | DISPATCH_CHANNEL: ${{ inputs.channel }} | ||
| 58 | with: | 65 | with: |
| 59 | script: | | 66 | script: | |
| 60 | const { shouldReleaseNightly } = require('./.github/scripts/check-nightly-release.cjs'); | 67 | const { |
| 61 | return await shouldReleaseNightly({ github, context, core }); | 68 | shouldReleaseNightly, |
| 69 | resolveLatestNightlyCommit, | ||
| 70 | } = require('./.github/scripts/check-nightly-release.cjs'); | ||
| 71 | |||
| 72 | if (context.eventName === 'schedule') { | ||
| 73 | core.setOutput('has_changes', await shouldReleaseNightly({ github, context, core })); | ||
| 74 | core.setOutput('ref', context.sha); | ||
| 75 | } else if (context.eventName === 'workflow_dispatch' && process.env.DISPATCH_CHANNEL !== 'nightly') { | ||
| 76 | const { tag, sha, version } = await resolveLatestNightlyCommit({ github, context, core }); | ||
| 77 | core.notice(`Stable release builds ${sha}, the commit shipped by ${tag}.`); | ||
| 78 | core.setOutput('ref', sha); | ||
| 79 | core.setOutput('nightly_version', version); | ||
| 80 | } else { | ||
| 81 | core.setOutput('ref', context.sha); | ||
| 82 | } | ||
| 62 | 83 | ||
| 63 | preflight: | 84 | preflight: |
| 64 | name: Preflight | 85 | name: Preflight |
| 65 | needs: [check_changes] | 86 | needs: [resolve_commit] |
| 66 | if: | | 87 | if: | |
| 67 | !failure() && !cancelled() && | 88 | needs.resolve_commit.result == 'success' && |
| 68 | (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true') | 89 | (github.event_name != 'schedule' || needs.resolve_commit.outputs.has_changes == 'true') |
| 69 | runs-on: blacksmith-8vcpu-ubuntu-2404 | 90 | runs-on: blacksmith-8vcpu-ubuntu-2404 |
| 70 | timeout-minutes: 10 | 91 | timeout-minutes: 10 |
| 71 | outputs: | 92 | outputs: |
| ... | @@ -78,11 +99,12 @@ jobs: | ... | @@ -78,11 +99,12 @@ jobs: |
| 78 | cli_dist_tag: ${{ steps.release_meta.outputs.cli_dist_tag }} | 99 | cli_dist_tag: ${{ steps.release_meta.outputs.cli_dist_tag }} |
| 79 | is_prerelease: ${{ steps.release_meta.outputs.is_prerelease }} | 100 | is_prerelease: ${{ steps.release_meta.outputs.is_prerelease }} |
| 80 | make_latest: ${{ steps.release_meta.outputs.make_latest }} | 101 | make_latest: ${{ steps.release_meta.outputs.make_latest }} |
| 81 | ref: ${{ github.sha }} | 102 | ref: ${{ needs.resolve_commit.outputs.ref }} |
| 82 | steps: | 103 | steps: |
| 83 | - name: Checkout | 104 | - name: Checkout |
| 84 | uses: actions/checkout@v6 | 105 | uses: actions/checkout@v6 |
| 85 | with: | 106 | with: |
| 107 | ref: ${{ needs.resolve_commit.outputs.ref }} | ||
| 86 | fetch-depth: 0 | 108 | fetch-depth: 0 |
| 87 | sparse-checkout: | | 109 | sparse-checkout: | |
| 88 | /* | 110 | /* |
| ... | @@ -104,8 +126,9 @@ jobs: | ... | @@ -104,8 +126,9 @@ jobs: |
| 104 | env: | 126 | env: |
| 105 | DISPATCH_CHANNEL: ${{ github.event.inputs.channel }} | 127 | DISPATCH_CHANNEL: ${{ github.event.inputs.channel }} |
| 106 | DISPATCH_VERSION: ${{ github.event.inputs.version }} | 128 | DISPATCH_VERSION: ${{ github.event.inputs.version }} |
| 129 | NIGHTLY_VERSION: ${{ needs.resolve_commit.outputs.nightly_version }} | ||
| 107 | NIGHTLY_DATE: ${{ github.run_started_at }} | 130 | NIGHTLY_DATE: ${{ github.run_started_at }} |
| 108 | NIGHTLY_SHA: ${{ github.sha }} | 131 | NIGHTLY_SHA: ${{ needs.resolve_commit.outputs.ref }} |
| 109 | NIGHTLY_RUN_NUMBER: ${{ github.run_number }} | 132 | NIGHTLY_RUN_NUMBER: ${{ github.run_number }} |
| 110 | run: | | 133 | run: | |
| 111 | if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "nightly" ) ]]; then | 134 | if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "nightly" ) ]]; then |
| ... | @@ -123,9 +146,9 @@ jobs: | ... | @@ -123,9 +146,9 @@ jobs: |
| 123 | echo "make_latest=false" >> "$GITHUB_OUTPUT" | 146 | echo "make_latest=false" >> "$GITHUB_OUTPUT" |
| 124 | else | 147 | else |
| 125 | if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then | 148 | if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then |
| 126 | raw="${DISPATCH_VERSION}" | 149 | raw="${DISPATCH_VERSION:-$NIGHTLY_VERSION}" |
| 127 | if [[ -z "$raw" ]]; then | 150 | if [[ -z "$raw" ]]; then |
| 128 | echo "workflow_dispatch stable releases require the version input." >&2 | 151 | echo "workflow_dispatch stable releases need a version input or a published nightly." >&2 |
| 129 | exit 1 | 152 | exit 1 |
| 130 | fi | 153 | fi |
| 131 | else | 154 | else |
| ... | @@ -210,14 +233,12 @@ jobs: | ... | @@ -210,14 +233,12 @@ jobs: |
| 210 | 233 | ||
| 211 | relay_public_config: | 234 | relay_public_config: |
| 212 | name: Resolve T3 Connect public config | 235 | name: Resolve T3 Connect public config |
| 213 | # Consumes only the commit SHA, not preflight's resolved version, so it runs | 236 | # Consumes only the release commit, not preflight's resolved version, so it |
| 214 | # alongside preflight instead of after it. The condition mirrors preflight's: | 237 | # runs alongside preflight instead of after it. The condition mirrors preflight's. |
| 215 | # check_changes is skipped on manual and tag releases (skipped is neither failure | 238 | needs: [resolve_commit] |
| 216 | # nor success, so success() would be wrong here). | ||
| 217 | needs: [check_changes] | ||
| 218 | if: | | 239 | if: | |
| 219 | !failure() && !cancelled() && | 240 | needs.resolve_commit.result == 'success' && |
| 220 | (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true') | 241 | (github.event_name != 'schedule' || needs.resolve_commit.outputs.has_changes == 'true') |
| 221 | runs-on: blacksmith-8vcpu-ubuntu-2404 | 242 | runs-on: blacksmith-8vcpu-ubuntu-2404 |
| 222 | timeout-minutes: 5 | 243 | timeout-minutes: 5 |
| 223 | environment: | 244 | environment: |
| ... | @@ -239,7 +260,7 @@ jobs: | ... | @@ -239,7 +260,7 @@ jobs: |
| 239 | - name: Checkout | 260 | - name: Checkout |
| 240 | uses: actions/checkout@v6 | 261 | uses: actions/checkout@v6 |
| 241 | with: | 262 | with: |
| 242 | ref: ${{ github.sha }} | 263 | ref: ${{ needs.resolve_commit.outputs.ref }} |
| 243 | sparse-checkout: | | 264 | sparse-checkout: | |
| 244 | /* | 265 | /* |
| 245 | !/.repos/ | 266 | !/.repos/ |
| ... | @@ -312,19 +333,19 @@ jobs: | ... | @@ -312,19 +333,19 @@ jobs: |
| 312 | # machine. node-pty is N-API, so one binary works across all WSL Node versions. | 333 | # machine. node-pty is N-API, so one binary works across all WSL Node versions. |
| 313 | build_wsl_node_pty: | 334 | build_wsl_node_pty: |
| 314 | name: Build WSL node-pty (linux-x64) | 335 | name: Build WSL node-pty (linux-x64) |
| 315 | # Same gating as relay_public_config: only the commit SHA is needed, so this | 336 | # Same gating as relay_public_config: only the release commit is needed, so |
| 316 | # runs alongside preflight. See the condition comment there. | 337 | # this runs alongside preflight. See the condition comment there. |
| 317 | needs: [check_changes] | 338 | needs: [resolve_commit] |
| 318 | if: | | 339 | if: | |
| 319 | !failure() && !cancelled() && | 340 | needs.resolve_commit.result == 'success' && |
| 320 | (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true') | 341 | (github.event_name != 'schedule' || needs.resolve_commit.outputs.has_changes == 'true') |
| 321 | runs-on: blacksmith-8vcpu-ubuntu-2404 | 342 | runs-on: blacksmith-8vcpu-ubuntu-2404 |
| 322 | timeout-minutes: 15 | 343 | timeout-minutes: 15 |
| 323 | steps: | 344 | steps: |
| 324 | - name: Checkout | 345 | - name: Checkout |
| 325 | uses: actions/checkout@v6 | 346 | uses: actions/checkout@v6 |
| 326 | with: | 347 | with: |
| 327 | ref: ${{ github.sha }} | 348 | ref: ${{ needs.resolve_commit.outputs.ref }} |
| 328 | sparse-checkout: | | 349 | sparse-checkout: | |
| 329 | /* | 350 | /* |
| 330 | !/.repos/ | 351 | !/.repos/ |
docs/operations/release.md+21-10| ... | @@ -8,9 +8,18 @@ This document covers the unified release workflow for stable and nightly desktop | ... | @@ -8,9 +8,18 @@ This document covers the unified release workflow for stable and nightly desktop |
| 8 | 8 | ||
| 9 | - Workflow: `.github/workflows/release.yml` | 9 | - Workflow: `.github/workflows/release.yml` |
| 10 | - Triggers: | 10 | - Triggers: |
| 11 | - push tag matching `v*.*.*` for stable releases | 11 | - manual `workflow_dispatch` with `channel=stable`, the normal way to ship stable |
| 12 | - push tag matching `v*.*.*` for a stable release of an explicit commit | ||
| 12 | - scheduled nightly check every 30 minutes | 13 | - scheduled nightly check every 30 minutes |
| 13 | - manual `workflow_dispatch` for either channel | 14 | - manual `workflow_dispatch` with `channel=nightly` |
| 15 | - A manual stable release builds the commit of the latest published nightly, not `main` HEAD. | ||
| 16 | Nightly is the release candidate: verify the nightly, then promote it. Merges to `main` keep | ||
| 17 | landing while you verify and never leak into the stable build. | ||
| 18 | - The version defaults to the one the nightly previewed (`0.0.39-nightly.*` ships as `0.0.39`). | ||
| 19 | Pass the `version` input to override it, for example for a minor bump. | ||
| 20 | - The stable tag is created on the nightly's commit when the GitHub Release is published. | ||
| 21 | - Pushing a `vX.Y.Z` tag by hand still works and builds exactly the tagged commit. Use it when | ||
| 22 | the commit to ship is not the latest nightly, such as a cherry-picked fix on a release branch. | ||
| 14 | - Runs lint, typecheck, and tests alongside artifact builds. Publishing waits for every check. | 23 | - Runs lint, typecheck, and tests alongside artifact builds. Publishing waits for every check. |
| 15 | - Reads the shared production T3 Connect relay URL and Clerk client configuration before packaging clients. | 24 | - Reads the shared production T3 Connect relay URL and Clerk client configuration before packaging clients. |
| 16 | - Builds four artifacts in parallel for both channels: | 25 | - Builds four artifacts in parallel for both channels: |
| ... | @@ -291,8 +300,8 @@ risk, manually dispatch `channel=nightly`; this still publishes a real nightly n | ... | @@ -291,8 +300,8 @@ risk, manually dispatch `channel=nightly`; this still publishes a real nightly n |
| 291 | prerelease, desktop updater release, and hosted nightly alias, but it does not update stable aliases or | 300 | prerelease, desktop updater release, and hosted nightly alias, but it does not update stable aliases or |
| 292 | commit a version bump to `main`. Only run it when a real nightly release is acceptable. | 301 | commit a version bump to `main`. Only run it when a real nightly release is acceptable. |
| 293 | 302 | ||
| 294 | Manual `channel=stable` with a version input is also a real stable-channel release. Omitting signing | 303 | Manual `channel=stable` is also a real stable-channel release. Omitting signing secrets only makes |
| 295 | secrets only makes platform artifacts unsigned; it does not prevent publication. | 304 | platform artifacts unsigned; it does not prevent publication. |
| 296 | 305 | ||
| 297 | ## 2) Apple signing + notarization setup (macOS) | 306 | ## 2) Apple signing + notarization setup (macOS) |
| 298 | 307 | ||
| ... | @@ -370,17 +379,19 @@ Checklist: | ... | @@ -370,17 +379,19 @@ Checklist: |
| 370 | 379 | ||
| 371 | ## 4) Ongoing release checklist | 380 | ## 4) Ongoing release checklist |
| 372 | 381 | ||
| 373 | 1. Ensure `main` is green in CI. | 382 | 1. Pick the latest nightly and verify it: run the smoke test above against its artifacts and |
| 374 | 2. Bump app version as needed. | 383 | check the nightly channel for regressions. |
| 375 | 3. Create release tag: `vX.Y.Z`. | 384 | 2. Dispatch the Release workflow with `channel=stable`. Leave `version` empty unless the version |
| 376 | 4. Push tag. | 385 | should differ from the one the nightly previewed. |
| 377 | 5. Verify workflow steps: | 386 | 3. Confirm the `Resolve release commit` notice names the nightly tag and commit you verified. If a |
| 387 | newer nightly published in between, the run builds that one instead. | ||
| 388 | 4. Verify workflow steps: | ||
| 378 | - preflight passes | 389 | - preflight passes |
| 379 | - release quality checks pass | 390 | - release quality checks pass |
| 380 | - all matrix builds pass | 391 | - all matrix builds pass |
| 381 | - `publish_cli` publishes the exact release version before the release job | 392 | - `publish_cli` publishes the exact release version before the release job |
| 382 | - release job uploads expected files | 393 | - release job uploads expected files |
| 383 | 6. Smoke test downloaded artifacts. | 394 | 5. Smoke test downloaded artifacts. |
| 384 | 395 | ||
| 385 | ## 5) Troubleshooting | 396 | ## 5) Troubleshooting |
| 386 | 397 |