Release Desktop App #53
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 Desktop App | |
| permissions: | |
| contents: write | |
| on: | |
| push: | |
| tags: | |
| - 'v[0-9]*' | |
| - '!v*-rc.*' | |
| - '!v*-beta.*' | |
| workflow_dispatch: | |
| inputs: | |
| tag: | |
| description: 'Tag to release (e.g. v0.2.0)' | |
| required: true | |
| jobs: | |
| # Create the GitHub Release first so build jobs don't race | |
| create-release: | |
| runs-on: ubuntu-latest | |
| outputs: | |
| release_id: ${{ steps.create.outputs.result }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Extract release notes from tag | |
| id: tag_notes | |
| run: | | |
| TAG="${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}" | |
| VERSION="${TAG#v}" | |
| REPO="${{ github.repository }}" | |
| # User-authored tag annotation (if any). When absent, the Download | |
| # section below stands on its own so the release page never falls | |
| # back to a generic "see the assets" stub. | |
| NOTES=$(git tag -l --format='%(contents)' "$TAG" | sed '/^$/d') | |
| # The "## Download" block (per-platform install links + npm line) is | |
| # generated by scripts/release/release-assets.mjs — the single source | |
| # of truth shared with the asset-rename step below, so every link | |
| # always resolves to a shipped asset name. Run with the runner's | |
| # preinstalled node (no npm install / tsx needed for this .mjs). | |
| { | |
| if [ -n "$NOTES" ]; then | |
| echo "$NOTES" | |
| echo "" | |
| fi | |
| node scripts/release/release-assets.mjs download-section "$VERSION" "$REPO" "$TAG" | |
| } > /tmp/release-notes.md | |
| echo "body<<ENDOFNOTES" >> "$GITHUB_OUTPUT" | |
| cat /tmp/release-notes.md >> "$GITHUB_OUTPUT" | |
| # /tmp/release-notes.md ends with `release-assets.mjs download-section`, | |
| # whose stdout has no trailing newline (it returns lines.join('\n')). | |
| # Without this explicit newline the closing delimiter glues onto the | |
| # last notes line, so GitHub never sees a standalone `ENDOFNOTES` and | |
| # rejects the step output with "Matching delimiter not found". | |
| echo "" >> "$GITHUB_OUTPUT" | |
| echo "ENDOFNOTES" >> "$GITHUB_OUTPUT" | |
| # GB-779 — always create as a draft. A non-draft release is immediately | |
| # visible to the public AND immediately flips GitHub's `releases/latest` | |
| # pointer, but the platform `build` matrix below takes 10–30 minutes to | |
| # upload bundles + rename DMGs to their friendly names. The Tauri | |
| # updater (`src-tauri/tauri.conf.json` endpoint | |
| # `/releases/latest/download/latest.json`) resolves via that flipped | |
| # pointer and starts serving the half-published release to real users | |
| # mid-build. GitHub skips drafts for both the public Releases page AND | |
| # the `latest` pointer, so keeping the release as a draft until the | |
| # very end holds the entire publication atomic — the prior stable | |
| # release remains the user-visible "latest" right up until the final | |
| # `publish-release` job flips `draft:false`. Maintainers can still see | |
| # the in-progress draft at /releases while logged in. | |
| - name: Create release (as draft — flipped by publish-release at end) | |
| id: create | |
| uses: actions/github-script@v7 | |
| env: | |
| RELEASE_BODY: ${{ steps.tag_notes.outputs.body }} | |
| with: | |
| script: | | |
| const tag = '${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}'; | |
| // Pass the body via env, not a template literal: the notes contain | |
| // backticks (the npm `install` snippet) that would otherwise break | |
| // the JS string or have to be stripped out of the rendered page. | |
| const { data } = await github.rest.repos.createRelease({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| tag_name: tag, | |
| name: `Glassbox ${tag}`, | |
| body: process.env.RELEASE_BODY, | |
| draft: true, | |
| prerelease: false, | |
| }); | |
| return data.id; | |
| build: | |
| needs: [create-release] | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - platform: macos-latest | |
| target: aarch64-apple-darwin | |
| - platform: macos-latest | |
| target: x86_64-apple-darwin | |
| - platform: ubuntu-22.04 | |
| target: x86_64-unknown-linux-gnu | |
| - platform: windows-latest | |
| target: x86_64-pc-windows-msvc | |
| runs-on: ${{ matrix.platform }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: 20 | |
| cache: npm | |
| - name: Install Rust stable | |
| uses: dtolnay/rust-toolchain@stable | |
| with: | |
| targets: ${{ matrix.target }} | |
| - name: Install Linux dependencies | |
| if: matrix.platform == 'ubuntu-22.04' | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y libwebkit2gtk-4.1-dev librsvg2-dev patchelf | |
| - name: Install npm dependencies | |
| run: npm ci | |
| # The on-device Apple FM helper now ships inside the `apple-fm` dependency, | |
| # already Developer-ID signed + notarized. build-sidecar.sh copies the | |
| # apple-fm package (helper included) into the sidecar; the helper's embedded | |
| # signature survives, so tauri-action's notarization of the whole bundle | |
| # below covers it — no dedicated macOS-26 compile job is needed. See docs/22. | |
| - name: Build sidecar | |
| run: bash scripts/build-sidecar.sh ${{ matrix.target }} | |
| shell: bash | |
| - name: Build Tauri app and upload to release | |
| uses: tauri-apps/tauri-action@v0 | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} | |
| TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} | |
| APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} | |
| APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} | |
| APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} | |
| APPLE_ID: ${{ secrets.APPLE_ID }} | |
| APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} | |
| APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} | |
| with: | |
| tagName: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} | |
| releaseId: ${{ needs.create-release.outputs.release_id }} | |
| args: --target ${{ matrix.target }} | |
| # Rename the user-facing installer assets to human-friendly names. Runs once, | |
| # after every platform shard has uploaded its bundles, so the rename is | |
| # race-free (a single job touching each asset exactly once). The friendly | |
| # names come from scripts/release/release-assets.mjs — the same module that | |
| # generates the download links in the release body — so the links and the | |
| # asset names can never drift apart. Only .dmg files are renamed; the | |
| # updater's latest.json references the other installers by their original | |
| # filenames. | |
| rename-assets: | |
| needs: [create-release, build] | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Rename user-facing download assets to friendly names | |
| uses: actions/github-script@v7 | |
| env: | |
| RELEASE_ID: ${{ needs.create-release.outputs.release_id }} | |
| with: | |
| script: | | |
| const { pathToFileURL } = require('node:url'); | |
| const path = require('node:path'); | |
| const modPath = path.join(process.env.GITHUB_WORKSPACE, 'scripts/release/release-assets.mjs'); | |
| const { friendlyName } = await import(pathToFileURL(modPath).href); | |
| const releaseId = parseInt(process.env.RELEASE_ID); | |
| const { data: assets } = await github.rest.repos.listReleaseAssets({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| release_id: releaseId, | |
| per_page: 100, | |
| }); | |
| for (const asset of assets) { | |
| const newName = friendlyName(asset.name); | |
| if (newName !== asset.name) { | |
| console.log(`Renaming: ${asset.name} -> ${newName}`); | |
| await github.rest.repos.updateReleaseAsset({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| asset_id: asset.id, | |
| name: newName, | |
| }); | |
| } | |
| } | |
| # GB-779 — atomic flip of the release from draft → published. | |
| # | |
| # Final job in the pipeline. Runs only after every platform build has | |
| # uploaded its bundles AND the `rename-assets` job has renamed the .dmg | |
| # files to the friendly names the release notes link to. At this point the | |
| # draft release has all of its assets in place, so flipping `draft:false` | |
| # is the single atomic moment when: | |
| # - The release page becomes publicly visible at /releases/tag/<tag> | |
| # (every embedded download link resolves immediately — no broken-link | |
| # window). | |
| # - GitHub's `releases/latest` pointer moves to this release. | |
| # - The Tauri updater starts seeing the new `latest.json` | |
| # (`src-tauri/tauri.conf.json` endpoint). | |
| # | |
| # `make_latest: 'true'` is the API equivalent of GitHub's "Set as the | |
| # latest release" checkbox — explicit so the flip can't be silently | |
| # downgraded by repo-level settings. | |
| publish-release: | |
| needs: [create-release, build, rename-assets] | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Flip release from draft → published | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const releaseId = ${{ needs.create-release.outputs.release_id }}; | |
| await github.rest.repos.updateRelease({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| release_id: releaseId, | |
| draft: false, | |
| make_latest: 'true', | |
| }); | |
| console.log(`Release ${releaseId} flipped from draft → published`); |