diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4bad5fe..4893934 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,22 +21,26 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: '20' + node-version: "20" cache: npm - name: Install dependencies run: npm ci || npm install - name: Syntax check - run: node --check bin/rewrite-release-notes.mjs + run: | + node --check bin/rewrite-release-notes.mjs + node --check bin/build-updater-manifest.mjs - name: Help works - run: node bin/rewrite-release-notes.mjs --help + run: | + node bin/rewrite-release-notes.mjs --help + node bin/build-updater-manifest.mjs --help # Smoke: dry-run renders without an LLM call. Uses the repo's own tags # if any exist; otherwise creates synthetic ones so the script has # something to diff. - - name: Dry-run smoke + - name: Dry-run smoke (release-notes) run: | if [ -z "$(git tag --list)" ]; then git -c user.email=ci@example.com -c user.name=ci tag v0.0.1 HEAD @@ -45,3 +49,37 @@ jobs: git -c user.email=ci@example.com -c user.name=ci tag v0.0.2 HEAD fi node bin/rewrite-release-notes.mjs --dry-run + + # Smoke: build-updater-manifest produces a valid latest.json from a + # synthetic dist/ tree. Verifies the platform classifier + sig pairing + # without needing a real Tauri build. + - name: Smoke (build-updater-manifest) + run: | + mkdir -p smoke-dist/macos smoke-dist/nsis smoke-dist/appimage + echo "stub-mac" > smoke-dist/macos/MyApp_0.0.1_universal.app.tar.gz + echo "sig-mac" > smoke-dist/macos/MyApp_0.0.1_universal.app.tar.gz.sig + echo "stub-win" > smoke-dist/nsis/MyApp_0.0.1_x64-setup.nsis.zip + echo "sig-win" > smoke-dist/nsis/MyApp_0.0.1_x64-setup.nsis.zip.sig + echo "stub-linux" > smoke-dist/appimage/MyApp_0.0.1_amd64.AppImage.tar.gz + echo "sig-linux" > smoke-dist/appimage/MyApp_0.0.1_amd64.AppImage.tar.gz.sig + node bin/build-updater-manifest.mjs \ + --version 0.0.1 \ + --dist smoke-dist \ + --base-url https://example.test/0.0.1 \ + --out smoke-latest.json + # Sanity: manifest must list all four platform keys (universal mac + # expands to two) and embed the sig contents. + node -e ' + const m = require("./smoke-latest.json"); + const keys = Object.keys(m.platforms).sort(); + const expected = ["darwin-aarch64","darwin-x86_64","linux-x86_64","windows-x86_64"]; + if (JSON.stringify(keys) !== JSON.stringify(expected)) { + console.error("Bad platform keys:", keys); + process.exit(1); + } + if (m.platforms["darwin-aarch64"].signature !== "sig-mac") { + console.error("Bad sig embed"); + process.exit(1); + } + console.log("manifest smoke ok"); + ' diff --git a/.github/workflows/tauri-release.yml b/.github/workflows/tauri-release.yml new file mode 100644 index 0000000..a82f8d3 --- /dev/null +++ b/.github/workflows/tauri-release.yml @@ -0,0 +1,289 @@ +name: Tauri Release + +# Reusable workflow that builds, signs, notarizes, and publishes a Tauri 2 +# desktop app across macOS (universal), Windows, and Linux. Consumers call +# it from their own thin `build-desktop.yml`: +# +# jobs: +# desktop: +# uses: protoLabsAI/release-tools/.github/workflows/tauri-release.yml@v1 +# secrets: inherit +# with: +# project-path: apps/desktop +# app-identifier: studio.protolabs.example +# r2-bucket: example-desktop-releases +# r2-public-base-url: https://dl.example.studio +# draft-github-release: true +# +# See the README's "Tauri release workflow" section for the full secret list. + +on: + workflow_call: + inputs: + project-path: + description: "Path to the Tauri project (the directory that contains src-tauri/)." + required: true + type: string + app-identifier: + description: "CFBundleIdentifier / Tauri identifier (e.g. studio.protolabs.example)." + required: true + type: string + r2-bucket: + description: "Cloudflare R2 bucket name where binaries + latest.json are uploaded." + required: true + type: string + r2-public-base-url: + description: "Public-read base URL for the bucket (e.g. https://dl.example.studio). Used in the updater manifest." + required: true + type: string + node-version: + description: "Node.js version for the build." + required: false + type: string + default: "22" + pnpm-version: + description: 'pnpm version. Set "" to use npm instead.' + required: false + type: string + default: "9.15.0" + pre-build-command: + description: "Optional shell command run after install, before tauri build (e.g. workspace builds)." + required: false + type: string + default: "" + draft-github-release: + description: "Create a draft GitHub Release with all artifacts attached." + required: false + type: boolean + default: true + release-notes-text: + description: "Notes embedded in latest.json (the GH Release body comes from action-gh-release)." + required: false + type: string + default: "See the GitHub Release for details." + +permissions: + contents: write + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - os: macos-14 + platform: macos + args: --target universal-apple-darwin + - os: windows-latest + platform: windows + args: "" + - os: ubuntu-22.04 + platform: linux + args: "" + + runs-on: ${{ matrix.os }} + + env: + # Opt cloudflare/wrangler-action and other node20-pinned actions into + # the Node 24 runner ahead of the 2026-06-02 forced upgrade. + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + + steps: + - uses: actions/checkout@v6 + + - name: Setup pnpm + if: inputs.pnpm-version != '' + uses: pnpm/action-setup@v6 + with: + version: ${{ inputs.pnpm-version }} + + - uses: actions/setup-node@v6 + with: + node-version: ${{ inputs.node-version }} + cache: ${{ inputs.pnpm-version != '' && 'pnpm' || 'npm' }} + + - uses: dtolnay/rust-toolchain@stable + + - name: Add macOS Rust targets (universal) + if: matrix.platform == 'macos' + run: | + rustup target add aarch64-apple-darwin + rustup target add x86_64-apple-darwin + + - name: Install Linux deps + if: matrix.platform == 'linux' + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libappindicator3-dev \ + librsvg2-dev \ + patchelf + + - name: Install dependencies (pnpm) + if: inputs.pnpm-version != '' + run: pnpm install --frozen-lockfile + + - name: Install dependencies (npm) + if: inputs.pnpm-version == '' + run: npm ci + + - name: Pre-build command + if: inputs.pre-build-command != '' + run: ${{ inputs.pre-build-command }} + shell: bash + + # ─── macOS: write the App Store Connect API key to disk ────────────── + # Tauri's notarization step expects APPLE_API_KEY_PATH to be a + # filesystem path to the .p8 (not the base64 content). GitHub + # secrets are strings, so consumers store the file as + # APPLE_API_KEY_BASE64 and we decode it here. + - name: Prepare App Store Connect API key (.p8) + if: matrix.platform == 'macos' && env.APPLE_API_KEY_BASE64 != '' + env: + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }} + run: | + mkdir -p "$RUNNER_TEMP/appstoreconnect/private_keys" + KEY_PATH="$RUNNER_TEMP/appstoreconnect/private_keys/AuthKey_${APPLE_API_KEY}.p8" + echo "$APPLE_API_KEY_BASE64" | base64 --decode > "$KEY_PATH" + chmod 600 "$KEY_PATH" + echo "APPLE_API_KEY_PATH=$KEY_PATH" >> "$GITHUB_ENV" + + # ─── macOS: sign + notarize + updater-sign ──────────────────────────── + - name: Build (macOS, signed + notarized) + if: matrix.platform == 'macos' + uses: tauri-apps/tauri-action@v0 + env: + # Code signing + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + # Notarization — App Store Connect API key path (preferred). + # APPLE_API_KEY_PATH is set above by the prepare step from the + # base64-encoded secret. + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + # Notarization — app-specific password fallback + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + # Updater signing + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + with: + projectPath: ${{ inputs.project-path }} + args: ${{ matrix.args }} + + # ─── Windows: SSL.com eSigner + updater-sign ────────────────────────── + - name: Build (Windows, signed via eSigner) + if: matrix.platform == 'windows' + uses: tauri-apps/tauri-action@v0 + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + ESIGNER_USERNAME: ${{ secrets.ESIGNER_USERNAME }} + ESIGNER_PASSWORD: ${{ secrets.ESIGNER_PASSWORD }} + ESIGNER_CREDENTIAL_ID: ${{ secrets.ESIGNER_CREDENTIAL_ID }} + ESIGNER_TOTP_SECRET: ${{ secrets.ESIGNER_TOTP_SECRET }} + with: + projectPath: ${{ inputs.project-path }} + + # ─── Linux: AppImage, no signing ────────────────────────────────────── + - name: Build (Linux, AppImage) + if: matrix.platform == 'linux' + uses: tauri-apps/tauri-action@v0 + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + with: + projectPath: ${{ inputs.project-path }} + + - name: Upload build artifacts + uses: actions/upload-artifact@v7 + with: + name: tauri-${{ matrix.platform }} + path: | + ${{ inputs.project-path }}/src-tauri/target/release/bundle/dmg/*.dmg + ${{ inputs.project-path }}/src-tauri/target/release/bundle/macos/*.app.tar.gz + ${{ inputs.project-path }}/src-tauri/target/release/bundle/macos/*.app.tar.gz.sig + ${{ inputs.project-path }}/src-tauri/target/universal-apple-darwin/release/bundle/dmg/*.dmg + ${{ inputs.project-path }}/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz* + ${{ inputs.project-path }}/src-tauri/target/release/bundle/nsis/*.exe + ${{ inputs.project-path }}/src-tauri/target/release/bundle/nsis/*-setup.nsis.zip + ${{ inputs.project-path }}/src-tauri/target/release/bundle/nsis/*-setup.nsis.zip.sig + ${{ inputs.project-path }}/src-tauri/target/release/bundle/appimage/*.AppImage + ${{ inputs.project-path }}/src-tauri/target/release/bundle/appimage/*.AppImage.tar.gz + ${{ inputs.project-path }}/src-tauri/target/release/bundle/appimage/*.AppImage.tar.gz.sig + if-no-files-found: error + + publish: + needs: build + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + + steps: + - uses: actions/checkout@v6 + + - uses: actions/download-artifact@v8 + with: + path: dist + merge-multiple: true + + - uses: actions/setup-node@v6 + with: + node-version: ${{ inputs.node-version }} + + - name: Compute version + id: version + run: echo "version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT" + + - name: Generate updater manifest (latest.json) + run: | + npx --yes -p '@protolabsai/release-tools@latest' build-updater-manifest \ + --version "${{ steps.version.outputs.version }}" \ + --dist dist \ + --base-url "${{ inputs.r2-public-base-url }}/${{ steps.version.outputs.version }}" \ + --notes "${{ inputs.release-notes-text }}" \ + --out latest.json + + - name: Upload binaries to R2 (versioned prefix) + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.R2_ACCOUNT_ID }} + command: | + for file in dist/*; do + name=$(basename "$file") + wrangler r2 object put \ + "${{ inputs.r2-bucket }}/${{ steps.version.outputs.version }}/$name" \ + --file "$file" \ + --remote + done + + - name: Upload updater manifest to R2 (latest.json, no-cache) + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.R2_ACCOUNT_ID }} + command: | + wrangler r2 object put \ + "${{ inputs.r2-bucket }}/latest.json" \ + --file latest.json \ + --content-type 'application/json' \ + --cache-control 'no-cache, no-store, must-revalidate' \ + --remote + + - name: Create draft GitHub Release + if: inputs.draft-github-release + uses: softprops/action-gh-release@v3 + with: + files: dist/**/* + draft: true + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 97223e5..596d8de 100644 --- a/README.md +++ b/README.md @@ -30,16 +30,16 @@ without forking the logic. ### Inputs -| Input | Required | Default | Description | -| ------------------ | -------- | ----------------------------------------- | ---------------------------------------------------------------------------- | -| `version` | yes | — | Tag being released (e.g. `v0.34.0`) | -| `previous-version` | yes | — | Previous tag for the diff range | -| `post-discord` | no | `'true'` | Post the notes to `DISCORD_RELEASE_WEBHOOK` | -| `dry-run` | no | `'false'` | Print the prompt and exit; no LLM call, no Discord post | -| `model` | no | `protolabs/fast` | LLM model alias | -| `base-url` | no | `https://api.proto-labs.ai/v1` | Gateway base URL | -| `repo` | no | `${{ github.repository }}` | `owner/name` for the release URL + footer | -| `footer` | no | `protoLabs · ` | Override Discord embed footer | +| Input | Required | Default | Description | +| ------------------ | -------- | ------------------------------ | ------------------------------------------------------- | +| `version` | yes | — | Tag being released (e.g. `v0.34.0`) | +| `previous-version` | yes | — | Previous tag for the diff range | +| `post-discord` | no | `'true'` | Post the notes to `DISCORD_RELEASE_WEBHOOK` | +| `dry-run` | no | `'false'` | Print the prompt and exit; no LLM call, no Discord post | +| `model` | no | `protolabs/fast` | LLM model alias | +| `base-url` | no | `https://api.proto-labs.ai/v1` | Gateway base URL | +| `repo` | no | `${{ github.repository }}` | `owner/name` for the release URL + footer | +| `footer` | no | `protoLabs · ` | Override Discord embed footer | ### Required secrets @@ -93,12 +93,104 @@ RELEASE_NOTES_FOOTER Override the Discord embed footer. If all commits are filtered out, the script exits without calling the LLM or posting to Discord — maintenance releases ("CI-only") don't blast the channel. +## Tauri release workflow + +A reusable workflow for the **pre-release** half of the desktop pipeline: +build, sign, notarize, and publish a Tauri 2 app across macOS (universal), +Windows, and Linux. Same lifecycle as `rewrite-release-notes`, just upstream +of it. + +### Use it + +```yaml +# .github/workflows/build-desktop.yml in your repo +name: Build Desktop App + +on: + push: + tags: ["v*"] + workflow_dispatch: + +jobs: + desktop: + uses: protoLabsAI/release-tools/.github/workflows/tauri-release.yml@v1 + secrets: inherit + with: + project-path: apps/desktop + app-identifier: studio.protolabs.example + r2-bucket: example-desktop-releases + r2-public-base-url: https://dl.example.studio + pre-build-command: pnpm build # optional: workspace builds before tauri-action +``` + +### Inputs + +| Input | Required | Default | Description | +| ---------------------- | -------- | --------------------------------------- | --------------------------------------------------------------------- | +| `project-path` | yes | — | Directory containing `src-tauri/` (e.g. `apps/desktop`). | +| `app-identifier` | yes | — | CFBundleIdentifier (e.g. `studio.protolabs.example`). | +| `r2-bucket` | yes | — | Cloudflare R2 bucket for binaries + `latest.json`. | +| `r2-public-base-url` | yes | — | Public-read base URL of the bucket. Embedded in the updater manifest. | +| `node-version` | no | `'22'` | Node.js version for the build. | +| `pnpm-version` | no | `'9.15.0'` | pnpm version. Set `''` to use npm. | +| `pre-build-command` | no | `''` | Shell command run after install, before `tauri-action`. | +| `draft-github-release` | no | `true` | Create a draft Release with all artifacts attached. | +| `release-notes-text` | no | `'See the GitHub Release for details.'` | Embedded in `latest.json` (Tauri updater shows this in the prompt). | + +### Required secrets (pass via `secrets: inherit`) + +**macOS code signing:** + +- `APPLE_CERTIFICATE` — base64 of your Developer ID Application `.p12` +- `APPLE_CERTIFICATE_PASSWORD` — `.p12` export password +- `APPLE_SIGNING_IDENTITY` — `Developer ID Application: Your Name (TEAMID)` +- `APPLE_TEAM_ID` — 10-char Team ID +- `KEYCHAIN_PASSWORD` — any random string; used for the temporary CI keychain + +**macOS notarization (pick one path):** + +- App Store Connect API key (preferred): `APPLE_API_ISSUER`, `APPLE_API_KEY`, `APPLE_API_KEY_BASE64` — store the `.p8` file as base64 (`base64 -i AuthKey_XYZ.p8 | pbcopy`); the workflow decodes it to disk and points `APPLE_API_KEY_PATH` at the file. +- App-specific password (fallback): `APPLE_ID`, `APPLE_PASSWORD` + +**Windows code signing (SSL.com eSigner):** + +- `ESIGNER_USERNAME`, `ESIGNER_PASSWORD`, `ESIGNER_CREDENTIAL_ID`, `ESIGNER_TOTP_SECRET` + +**Tauri updater signing:** + +- `TAURI_SIGNING_PRIVATE_KEY` — generated via `tauri signer generate` +- `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` + +**Cloudflare R2 publish:** + +- `CLOUDFLARE_API_TOKEN` — R2 Object Read & Write on your bucket +- `R2_ACCOUNT_ID` + +### Updater manifest CLI + +The `publish` job calls `build-updater-manifest` (the second binary this +package exports) to emit a `latest.json` for the in-app Tauri updater. +You can also run it directly: + +```bash +npx -p @protolabsai/release-tools build-updater-manifest \ + --version 0.2.1 \ + --dist ./artifacts \ + --base-url https://dl.example.studio/0.2.1 \ + --out ./latest.json +``` + +Walks the `--dist` directory, finds platform binaries + their `.sig` files, +and writes a manifest in the exact shape Tauri's updater expects. Exits +non-zero if any binary is missing its signature, so the `publish` job fails +loudly when signing didn't run. + ## Development ```bash npm install node bin/rewrite-release-notes.mjs --help -node bin/rewrite-release-notes.mjs --dry-run +node bin/build-updater-manifest.mjs --help ``` CI runs `node --check`, `--help`, and `--dry-run` smoke tests on every push. diff --git a/bin/build-updater-manifest.mjs b/bin/build-updater-manifest.mjs new file mode 100755 index 0000000..82ce3a5 --- /dev/null +++ b/bin/build-updater-manifest.mjs @@ -0,0 +1,211 @@ +#!/usr/bin/env node +/** + * @license + * Copyright 2026 protoLabs + * SPDX-License-Identifier: Apache-2.0 + * + * Builds the JSON manifest the Tauri auto-updater pulls from to learn about + * a new release. Walks a directory of release artifacts (the output of a + * tauri-action build), finds each platform's bundle + matching .sig file, + * and emits a `latest.json` shaped exactly the way Tauri's updater expects. + * + * Designed to run in CI between artifact assembly and uploading the manifest + * to a public bucket (R2, S3, etc.) that the in-app updater fetches. + * + * Usage: + * build-updater-manifest --version --dist --base-url [flags] + * + * Required flags: + * --version Semver being released (e.g. 0.2.1). Strip any leading "v". + * --dist Directory containing the downloaded artifacts. + * Walked recursively for *.dmg, *.app.tar.gz, *.nsis.zip, + * *.exe, *.AppImage, plus matching .sig files. + * --base-url Public-read base URL where the binaries land + * (e.g. https://dl.example.com/0.2.1). + * + * Optional flags: + * --out Where to write the manifest. Default: ./latest.json. + * --notes Release-notes text embedded in the manifest. + * Default: "See the GitHub Release for details." + * --pub-date Override the pub_date timestamp. Default: now (UTC ISO). + * --help Show this help and exit. + * + * Platform detection (filename-based, conservative): + * darwin-aarch64 *.app.tar.gz built for aarch64-apple-darwin + * darwin-x86_64 *.app.tar.gz built for x86_64-apple-darwin + * darwin-universal *.app.tar.gz built for universal-apple-darwin + * (emitted as both darwin-aarch64 and darwin-x86_64) + * windows-x86_64 *-setup.nsis.zip OR *.msi.zip + * linux-x86_64 *.AppImage.tar.gz + * + * The script ignores .dmg / .exe / .AppImage on their own — the updater + * downloads the .tar.gz / .zip equivalents because they're seekable + + * signature-verifiable. Make sure tauri-action emitted those (it does by + * default when the updater plugin is enabled). + * + * Exits non-zero if any platform that has a binary is missing its signature. + */ + +import { readFileSync, writeFileSync, statSync, readdirSync } from 'node:fs'; +import { join, basename, relative } from 'node:path'; + +// ─── Help ──────────────────────────────────────────────────────────────────── + +if (process.argv.includes('--help') || process.argv.includes('-h')) { + const url = await import('node:url'); + const self = url.fileURLToPath(import.meta.url); + const src = readFileSync(self, 'utf8').split('\n'); + const start = src.findIndex((l) => l.startsWith(' * Builds')); + const end = src.findIndex((l, i) => i > start && l.startsWith(' */')); + const help = src + .slice(start, end) + .map((l) => l.replace(/^ \* ?/, '')) + .join('\n'); + console.log(help); + process.exit(0); +} + +// ─── Arg parsing (no deps) ─────────────────────────────────────────────────── + +function parseArgs(argv) { + const out = {}; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (!a.startsWith('--')) continue; + const key = a.slice(2); + const next = argv[i + 1]; + if (next === undefined || next.startsWith('--')) { + out[key] = true; + } else { + out[key] = next; + i++; + } + } + return out; +} + +const args = parseArgs(process.argv.slice(2)); + +const version = String(args.version || '').replace(/^v/, ''); +const distDir = args.dist; +const baseUrl = String(args['base-url'] || '').replace(/\/+$/, ''); +const outPath = args.out || './latest.json'; +const notes = args.notes || 'See the GitHub Release for details.'; +const pubDate = args['pub-date'] || new Date().toISOString(); + +if (!version || !distDir || !baseUrl) { + console.error( + 'Error: --version, --dist, and --base-url are required. Run with --help for usage.', + ); + process.exit(2); +} + +// ─── Walk the dist directory ───────────────────────────────────────────────── + +function walk(dir) { + const entries = readdirSync(dir); + const files = []; + for (const name of entries) { + const full = join(dir, name); + const s = statSync(full); + if (s.isDirectory()) files.push(...walk(full)); + else files.push(full); + } + return files; +} + +const files = walk(distDir); + +// ─── Classify each binary by platform ──────────────────────────────────────── + +/** + * Returns the list of (platform-key, binary-path) tuples for a given file. + * A universal mac bundle expands to two platform keys. + */ +function platformsFor(filePath) { + const name = basename(filePath); + // macOS .app bundle + if (name.endsWith('.app.tar.gz')) { + if (filePath.includes('universal-apple-darwin') || /universal/i.test(name)) { + return ['darwin-aarch64', 'darwin-x86_64']; + } + if (filePath.includes('aarch64-apple-darwin') || /aarch64|arm64/i.test(name)) { + return ['darwin-aarch64']; + } + if (filePath.includes('x86_64-apple-darwin') || /x86_64|x64/i.test(name)) { + return ['darwin-x86_64']; + } + return ['darwin-aarch64']; // safe default for native runner builds + } + // Windows NSIS installer (zipped for the updater) + if (name.endsWith('-setup.nsis.zip') || name.endsWith('.msi.zip')) { + return ['windows-x86_64']; + } + // Linux AppImage + if (name.endsWith('.AppImage.tar.gz')) { + return ['linux-x86_64']; + } + return []; +} + +const platforms = {}; +const missingSigs = []; + +for (const f of files) { + const keys = platformsFor(f); + if (keys.length === 0) continue; + const sigPath = `${f}.sig`; + let signature; + try { + signature = readFileSync(sigPath, 'utf8').trim(); + } catch { + missingSigs.push(relative(distDir, f)); + continue; + } + const url = `${baseUrl}/${basename(f)}`; + for (const key of keys) { + if (platforms[key]) { + console.warn( + `Warning: duplicate binary for ${key}: ${relative(distDir, f)} (keeping first)`, + ); + continue; + } + platforms[key] = { signature, url }; + } +} + +if (missingSigs.length > 0) { + console.error( + `Error: ${missingSigs.length} binary file(s) have no matching .sig:\n ${missingSigs.join('\n ')}\n` + + 'tauri-action emits .sig files when TAURI_SIGNING_PRIVATE_KEY is set on the build job. ' + + 'Confirm the secret is wired and the bundle.targets list includes the updater format ' + + '(.app.tar.gz on macOS, *-setup.nsis.zip on Windows, *.AppImage.tar.gz on Linux).', + ); + process.exit(3); +} + +if (Object.keys(platforms).length === 0) { + console.error( + `Error: no recognized platform binaries found under ${distDir}. ` + + 'The script looks for *.app.tar.gz, *-setup.nsis.zip, *.msi.zip, *.AppImage.tar.gz.', + ); + process.exit(3); +} + +// ─── Emit the manifest ─────────────────────────────────────────────────────── + +const manifest = { + version, + notes, + pub_date: pubDate, + platforms, +}; + +writeFileSync(outPath, JSON.stringify(manifest, null, 2) + '\n'); +console.log( + `Wrote ${outPath} for v${version} (${Object.keys(platforms).length} platform(s): ${Object.keys( + platforms, + ) + .sort() + .join(', ')})`, +); diff --git a/package.json b/package.json index 56e1dbe..d53c6a5 100644 --- a/package.json +++ b/package.json @@ -16,14 +16,16 @@ "node": ">=20" }, "bin": { - "rewrite-release-notes": "./bin/rewrite-release-notes.mjs" + "rewrite-release-notes": "./bin/rewrite-release-notes.mjs", + "build-updater-manifest": "./bin/build-updater-manifest.mjs" }, "files": [ "bin/", "lib/", "action.yml", "README.md", - "LICENSE" + "LICENSE", + ".github/workflows/tauri-release.yml" ], "publishConfig": { "access": "public", @@ -31,7 +33,7 @@ }, "scripts": { "lint": "eslint .", - "smoke": "node bin/rewrite-release-notes.mjs --help || true", + "smoke": "node bin/rewrite-release-notes.mjs --help && node bin/build-updater-manifest.mjs --help", "test": "node --test test/" }, "devDependencies": {