Repository navigation
Release #7
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 | |
| # Dispatch with a bump (npm's names, or an exact version), or push a v* tag. | |
| # A version with a "-" becomes a GitHub pre-release: invisible to releases/latest, so the updater | |
| # feed never offers it. | |
| # The release stays a draft until every bundle has been read back; `publish` lifts it. | |
| # Nothing is pushed until then: the bump is applied to each checkout, and the bump commit and tag | |
| # land on the branch only after every platform and the update feed have passed. A failed run | |
| # leaves the branch and tags untouched, plus a draft release that a re-run reuses. | |
| # TAURI_SIGNING_PRIVATE_KEY is required. Apple secrets are optional; without them macOS bundles are | |
| # unsigned. | |
| # The web app is deployed last, by a Cloudflare Pages deploy hook (CLOUDFLARE_DEPLOY_HOOK), and | |
| # only for a mainline release: a pre-release deploys nothing. | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| bump: | |
| description: 'Version bump' | |
| type: choice | |
| default: none | |
| options: | |
| - none | |
| - patch | |
| - minor | |
| - major | |
| - prerelease | |
| - prepatch | |
| - preminor | |
| - premajor | |
| version: | |
| description: 'Exact version instead of a bump, e.g. 0.2.0-rc.1' | |
| type: string | |
| default: '' | |
| preid: | |
| description: 'Pre-release identifier for the pre* bumps' | |
| type: string | |
| default: 'rc' | |
| push: | |
| tags: | |
| - 'v*' | |
| # Queue, never cancel: a half-cancelled run leaves a tag with no bundles. | |
| concurrency: | |
| group: release-${{ github.ref }} | |
| cancel-in-progress: false | |
| permissions: | |
| contents: write | |
| jobs: | |
| prepare: | |
| name: Version & tag | |
| runs-on: ubuntu-22.04 | |
| outputs: | |
| release: ${{ steps.version.outputs.release }} | |
| bumped: ${{ steps.version.outputs.bumped }} | |
| version: ${{ steps.version.outputs.version }} | |
| tag: ${{ steps.version.outputs.tag }} | |
| prerelease: ${{ steps.version.outputs.prerelease }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| # Resolves the bump to a version string. The files it edits are thrown away with this | |
| # checkout; `build` and `publish` re-apply the exact version. | |
| - name: Work out the version | |
| id: version | |
| env: | |
| EVENT: ${{ github.event_name }} | |
| REF_NAME: ${{ github.ref_name }} | |
| BUMP: ${{ inputs.bump }} | |
| EXACT: ${{ inputs.version }} | |
| PREID: ${{ inputs.preid }} | |
| run: | | |
| set -euo pipefail | |
| release=true | |
| bumped=false | |
| if [ "$EVENT" = "push" ]; then | |
| # Hand-pushed tag: the tree must already hold this version. | |
| version="${REF_NAME#v}" | |
| in_tree=$(node -p "require('./package.json').version") | |
| if [ "$version" != "$in_tree" ]; then | |
| echo "::error::tag $REF_NAME names version $version but the tree is at $in_tree. Cut releases with Run workflow, or bump and commit before tagging." | |
| exit 1 | |
| fi | |
| node scripts/sync-version.mjs --check | |
| elif [ "$BUMP" = "none" ] && [ -z "$EXACT" ]; then | |
| release=false | |
| version=$(node -p "require('./package.json').version") | |
| echo "No bump requested: building every platform, releasing nothing." | |
| else | |
| npm version "${EXACT:-$BUMP}" --preid "$PREID" --no-git-tag-version > /dev/null | |
| version=$(node -p "require('./package.json').version") | |
| bumped=true | |
| fi | |
| case "$version" in | |
| *-*) prerelease=true ;; | |
| *) prerelease=false ;; | |
| esac | |
| echo "version $version (release=$release, prerelease=$prerelease)" | |
| { | |
| echo "release=$release" | |
| echo "bumped=$bumped" | |
| echo "version=$version" | |
| echo "tag=v$version" | |
| echo "prerelease=$prerelease" | |
| } >> "$GITHUB_OUTPUT" | |
| # Early, so a doomed run does not spend three platforms of build minutes. | |
| - name: The tag has to be new | |
| if: steps.version.outputs.bumped == 'true' | |
| env: | |
| TAG: ${{ steps.version.outputs.tag }} | |
| run: | | |
| set -euo pipefail | |
| if git ls-remote --exit-code --tags origin "refs/tags/$TAG" > /dev/null 2>&1; then | |
| echo "::error::$TAG already exists. Pick a different bump, or delete the tag and its release first." | |
| exit 1 | |
| fi | |
| build: | |
| needs: prepare | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| # One universal macOS leg; tauri-action lists it under both darwin targets in latest.json. | |
| - platform: macos-latest # universal .app + .dmg | |
| args: '--target universal-apple-darwin' | |
| bundle: src-tauri/target/universal-apple-darwin/release/bundle | |
| - platform: ubuntu-22.04 # AppImage + .deb + .rpm | |
| args: '' | |
| bundle: src-tauri/target/release/bundle | |
| - platform: windows-latest # NSIS .exe | |
| args: '' | |
| bundle: src-tauri/target/release/bundle | |
| runs-on: ${{ matrix.platform }} | |
| env: | |
| # A step's `if:` cannot read secrets. An unset secret is "", and the bundler treats an empty | |
| # APPLE_CERTIFICATE as a key. | |
| SIGN_MACOS: ${{ secrets.APPLE_CERTIFICATE != '' }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - uses: ./.github/actions/linux-deps | |
| if: matrix.platform == 'ubuntu-22.04' | |
| with: | |
| bundling: 'true' | |
| - uses: ./.github/actions/setup-node | |
| with: | |
| install: 'false' | |
| # The bundles bake in the version, so the tree must carry it before the build. Same edit | |
| # `publish` commits, so the tag's tree is what was built. | |
| - name: Apply the version bump | |
| if: needs.prepare.outputs.bumped == 'true' | |
| shell: bash | |
| env: | |
| VERSION: ${{ needs.prepare.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| npm version "$VERSION" --no-git-tag-version > /dev/null | |
| node scripts/sync-version.mjs | |
| - name: Install Rust | |
| uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master, 2026-08 | |
| with: | |
| toolchain: stable | |
| targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} | |
| - name: Rust cache | |
| uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 | |
| with: | |
| workspaces: './src-tauri -> target' | |
| key: release${{ matrix.args }} | |
| - name: Install frontend dependencies | |
| run: pnpm install --frozen-lockfile | |
| # `tr -d`: base64 on Linux wraps at 76 columns. | |
| - name: Prepare Apple signing | |
| if: startsWith(matrix.platform, 'macos') && env.SIGN_MACOS == 'true' | |
| env: | |
| 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 }} | |
| run: | | |
| for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY \ | |
| APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID; do | |
| value=$(printf '%s' "${!name}" | tr -d '\r\n') | |
| if [ -z "$value" ]; then | |
| echo "::error::$name is empty. Signing is all-or-nothing: set every Apple secret, or none." | |
| exit 1 | |
| fi | |
| echo "$name=$value" >> "$GITHUB_ENV" | |
| done | |
| - name: Build and release | |
| uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0 | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| # The updater (minisign) key, not code signing. Password is empty when the key has none. | |
| TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} | |
| TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} | |
| # Apple variables come from the step above, and only when set. | |
| with: | |
| args: ${{ matrix.args }} | |
| # Empty tagName builds everything and releases nothing (the "none" bump). | |
| tagName: ${{ needs.prepare.outputs.release == 'true' && needs.prepare.outputs.tag || '' }} | |
| releaseName: ${{ needs.prepare.outputs.release == 'true' && 'Set v__VERSION__' || '' }} | |
| releaseBody: 'See the assets below to download and install this version.' | |
| # Draft until every check passes. A pre-release stays out of releases/latest. | |
| releaseDraft: true | |
| prerelease: ${{ needs.prepare.outputs.prerelease }} | |
| # Runs after the assets are attached, which is why the release is a draft. | |
| - name: Check the bundles | |
| if: matrix.platform != 'windows-latest' | |
| run: scripts/check-bundle.sh '${{ matrix.bundle }}' | |
| - name: Check the installers | |
| if: matrix.platform == 'windows-latest' | |
| shell: pwsh | |
| run: scripts/check-bundle.ps1 '${{ matrix.bundle }}' | |
| # !cancelled(): a rejected bundle is the one worth downloading. | |
| - name: Upload bundles as artifacts (no-release runs) | |
| if: ${{ !cancelled() && needs.prepare.outputs.release != 'true' }} | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: set-${{ matrix.platform }}${{ matrix.args != '' && format('-{0}', matrix.args) || '' }} | |
| path: | | |
| src-tauri/target/**/release/bundle/**/*.dmg | |
| src-tauri/target/**/release/bundle/**/*.app/** | |
| src-tauri/target/**/release/bundle/**/*.AppImage | |
| src-tauri/target/**/release/bundle/**/*.deb | |
| src-tauri/target/**/release/bundle/**/*.rpm | |
| src-tauri/target/**/release/bundle/**/*.exe | |
| src-tauri/target/**/release/bundle/**/*.tar.gz | |
| src-tauri/target/**/release/bundle/**/*.sig | |
| if-no-files-found: warn | |
| retention-days: 7 | |
| # Every build leg read-modify-writes latest.json with no lock, so an entry can be lost. Re-run to | |
| # fix. | |
| updater-feed: | |
| name: Check the update feed | |
| needs: [prepare, build] | |
| if: needs.prepare.outputs.release == 'true' | |
| runs-on: ubuntu-22.04 | |
| steps: | |
| - name: Every platform is in latest.json | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| TAG: ${{ needs.prepare.outputs.tag }} | |
| run: | | |
| set -euo pipefail | |
| gh release download "$TAG" \ | |
| --repo "$GITHUB_REPOSITORY" --pattern latest.json --dir . | |
| missing=0 | |
| for platform in darwin-aarch64 darwin-x86_64 linux-x86_64 windows-x86_64; do | |
| url=$(jq -r --arg p "$platform" '.platforms[$p].url // ""' latest.json) | |
| signature=$(jq -r --arg p "$platform" '.platforms[$p].signature // ""' latest.json) | |
| if [ -z "$url" ] || [ -z "$signature" ]; then | |
| echo "::error::latest.json has no signed bundle for $platform" | |
| missing=1 | |
| else | |
| echo "$platform -> $url" | |
| fi | |
| done | |
| exit "$missing" | |
| # A plain version moves the updater feed. A pre-release is published but offered to nobody. | |
| publish: | |
| name: Publish the release | |
| needs: [prepare, build, updater-feed] | |
| if: needs.prepare.outputs.release == 'true' | |
| runs-on: ubuntu-22.04 | |
| steps: | |
| # Full history: a shallow clone cannot push. | |
| - name: Checkout | |
| if: needs.prepare.outputs.bumped == 'true' | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| fetch-depth: 0 | |
| - uses: ./.github/actions/setup-node | |
| if: needs.prepare.outputs.bumped == 'true' | |
| with: | |
| install: 'false' | |
| # The tag has to exist before the draft is lifted, or GitHub would create it at github.sha, | |
| # one commit short of the bump. | |
| - name: Commit the bump and tag | |
| if: needs.prepare.outputs.bumped == 'true' | |
| env: | |
| VERSION: ${{ needs.prepare.outputs.version }} | |
| TAG: ${{ needs.prepare.outputs.tag }} | |
| BRANCH: ${{ github.ref_name }} | |
| run: | | |
| set -euo pipefail | |
| npm version "$VERSION" --no-git-tag-version > /dev/null | |
| node scripts/sync-version.mjs | |
| git config user.name 'github-actions[bot]' | |
| git config user.email '41898282+github-actions[bot]@users.noreply.github.com' | |
| git add package.json src-tauri/Cargo.toml src-tauri/Cargo.lock src-tauri/tauri.conf.json | |
| git commit -m "chore(release): $TAG" | |
| git tag -a "$TAG" -m "$TAG" | |
| # The bump sits on the dispatched commit, which is what was built. If the branch has | |
| # moved since, the push is rejected rather than tagging a tree nobody built. | |
| # --atomic: branch and tag together, so a rejected push leaves neither. | |
| if ! git push --atomic origin "HEAD:refs/heads/$BRANCH" "refs/tags/$TAG"; then | |
| echo "::error::could not push $TAG and the bump to $BRANCH. Either $BRANCH moved during the run, or $TAG appeared. The release is still a draft; re-run the workflow to rebuild from the branch tip." | |
| exit 1 | |
| fi | |
| # Version-free download links. Globs, since each bundler picks its own arch suffix; zero or | |
| # many matches fails. | |
| - name: Stable download names | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| TAG: ${{ needs.prepare.outputs.tag }} | |
| run: | | |
| set -euo pipefail | |
| alias_asset() { | |
| local pattern="$1" stable="$2" | |
| local found=(dist/$pattern) | |
| if [ ! -e "${found[0]}" ]; then | |
| echo "::error::no release asset matches $pattern (wanted as $stable)" | |
| return 1 | |
| fi | |
| if [ "${#found[@]}" -gt 1 ]; then | |
| echo "::error::$pattern matches ${#found[@]} assets, so which one $stable should be is ambiguous: ${found[*]}" | |
| return 1 | |
| fi | |
| cp "${found[0]}" "$stable" | |
| echo "$stable <- $(basename "${found[0]}")" | |
| } | |
| # Not the .tar.gz updater bundles. | |
| gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir dist \ | |
| --pattern '*universal.dmg' \ | |
| --pattern '*-setup.exe' \ | |
| --pattern '*.AppImage' \ | |
| --pattern '*.deb' \ | |
| --pattern '*.rpm' | |
| alias_asset '*universal.dmg' Set-macOS.dmg | |
| alias_asset '*-setup.exe' Set-Windows-x64-setup.exe | |
| alias_asset '*.AppImage' Set-linux-x86_64.AppImage | |
| alias_asset '*.deb' Set-linux-amd64.deb | |
| alias_asset '*.rpm' Set-linux-x86_64.rpm | |
| # --clobber so a re-run over an existing draft replaces the aliases. | |
| gh release upload "$TAG" --repo "$GITHUB_REPOSITORY" --clobber \ | |
| Set-macOS.dmg \ | |
| Set-Windows-x64-setup.exe \ | |
| Set-linux-x86_64.AppImage \ | |
| Set-linux-amd64.deb \ | |
| Set-linux-x86_64.rpm | |
| - name: Publish | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| TAG: ${{ needs.prepare.outputs.tag }} | |
| PRERELEASE: ${{ needs.prepare.outputs.prerelease }} | |
| run: | | |
| set -euo pipefail | |
| # Best-effort: the first release has nothing to compare against. | |
| notes=$(gh api "repos/$GITHUB_REPOSITORY/releases/generate-notes" \ | |
| -f tag_name="$TAG" --jq .body 2> /dev/null || true) | |
| if [ -n "$notes" ]; then | |
| gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --notes "$notes" | |
| fi | |
| if [ "$PRERELEASE" = 'true' ]; then | |
| gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" \ | |
| --draft=false --prerelease --latest=false | |
| else | |
| gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" \ | |
| --draft=false --prerelease=false --latest | |
| fi | |
| echo "published $TAG (prerelease=$PRERELEASE)" | |
| web: | |
| name: Deploy the web app | |
| needs: [prepare, publish] | |
| if: needs.prepare.outputs.release == 'true' && needs.prepare.outputs.prerelease != 'true' | |
| runs-on: ubuntu-22.04 | |
| steps: | |
| # A Cloudflare Pages deploy hook: one POST builds the project's branch, which by now holds | |
| # the release commit. The URL is the credential, so it lives in a secret. | |
| - name: Ask Cloudflare Pages to build | |
| env: | |
| HOOK: ${{ secrets.CLOUDFLARE_DEPLOY_HOOK }} | |
| TAG: ${{ needs.prepare.outputs.tag }} | |
| run: | | |
| set -euo pipefail | |
| if [ -z "$HOOK" ]; then | |
| echo "::error::CLOUDFLARE_DEPLOY_HOOK is not set; $TAG was released but the web app was not deployed." | |
| exit 1 | |
| fi | |
| curl --fail --silent --show-error --request POST "$HOOK" > /dev/null | |
| echo "deploy requested for $TAG" |