diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c0fcfe..4be295a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,14 +13,14 @@ jobs: check: # macOS: the app's primary platform, and no extra WebKitGTK system # packages are needed to compile the Tauri crate. - runs-on: macos-latest + runs-on: macos-26 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 # Version comes from package.json's packageManager field. - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version-file: .node-version cache: pnpm @@ -45,7 +45,7 @@ jobs: - run: pnpm build # Load unpacked from the unzipped folder (apps/extension/README.md) - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: taskmint-extension path: apps/extension/dist/ @@ -92,13 +92,13 @@ jobs: # Linux, where the images run. The server's own gates run in the compose # test profile (its tests need Postgres), then the whole stack is built # from scratch and smoke-tested through the web app. - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 timeout-minutes: 60 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 # The smoke test is plain Node, with nothing to install - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version-file: .node-version diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..09781c9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,113 @@ +name: Release + +# A vX.Y.Z tag (pushed by `pnpm release publish`) builds every file a release +# ships and creates the GitHub release with the version's CHANGELOG section. +# Manual runs, and pull requests that change how releases are built, build the +# same files as workflow artifacts and publish nothing. +on: + push: + tags: ['v*'] + pull_request: + paths: + - .github/workflows/release.yml + - scripts/release.mjs + - scripts/release/** + - apps/desktop/src-tauri/tauri.conf.json + - apps/extension/build.mjs + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + build: + name: ${{ matrix.target }} + strategy: + fail-fast: false + matrix: + include: + - target: macos-arm64 + os: macos-26 + # Cross-compiled on Apple silicon (scripts/release/lib.mjs buildPlan) + - target: macos-x64 + os: macos-26 + - target: windows-x64 + os: windows-2025 + - target: chrome-extension + os: ubuntu-26.04 + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + steps: + # CHANGELOG.md is bundled into the About page as text, so Windows keeps + # its LF line ends too. + - run: git config --global core.autocrlf false + + - uses: actions/checkout@v7 + with: + persist-credentials: false + + # Version comes from package.json's packageManager field. + - uses: pnpm/action-setup@v6 + + # No dependency caches: a release is built from a clean install. + - uses: actions/setup-node@v7 + with: + node-version-file: .node-version + package-manager-cache: false + + - name: The tag is the app's version + if: github.ref_type == 'tag' + shell: bash + run: | + version=$(node -p "require('./apps/desktop/package.json').version") + if [ "$GITHUB_REF_NAME" != "v$version" ]; then + echo "::error::The tag is $GITHUB_REF_NAME, but apps/desktop/package.json says $version" + exit 1 + fi + + - run: pnpm install --frozen-lockfile + + # The version and components in rust-toolchain.toml + - if: matrix.target != 'chrome-extension' + run: rustup toolchain install + + - run: node scripts/release.mjs --rebuild --target ${{ matrix.target }} + env: + # Error reporting in the desktop builds; with no secret they report nothing + VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} + + - uses: actions/upload-artifact@v7 + with: + name: ${{ matrix.target }} + path: releases/ + if-no-files-found: error + + github-release: + if: github.ref_type == 'tag' + needs: build + runs-on: ubuntu-26.04 + permissions: + contents: write + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: actions/setup-node@v7 + with: + node-version-file: .node-version + package-manager-cache: false + + - uses: actions/download-artifact@v8 + with: + path: releases + merge-multiple: true + + - run: node scripts/release.mjs github-release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} diff --git a/README.md b/README.md index 8996336..6b50237 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,8 @@ them, and `git blame --ignore-revs-file .git-blame-ignore-revs` does locally. CI (`.github/workflows/ci.yml`) runs them on pushes to `main` and on pull requests: everything but the server on macOS, and the server's gates and the -smoke-tested stack in a Linux docker job. +smoke-tested stack in a Linux docker job. The Release workflow +(`.github/workflows/release.yml`) builds the release files (see Releasing). ### Releasing @@ -218,27 +219,53 @@ on `main`: ```bash pnpm release --dry-run # the next version and its CHANGELOG section; changes nothing pnpm release # bump, CHANGELOG, build the DMG, commit "Release X.Y.Z", tag vX.Y.Z +pnpm release publish # push main and the tag; CI builds and publishes the release ``` `pnpm release` takes the highest `bump` among the notes. It writes that version into every manifest (the desktop, web, client and contracts `package.json`s, the three crates and `Cargo.lock`; the extension keeps its -own), adds the CHANGELOG section, and deletes the notes. It then builds and -copies the installer to `releases/TaskMint-X.Y.Z-macos-arm64.dmg`. Only after -the build succeeds does it commit and tag. If the build fails, the version -files, CHANGELOG and notes are put back. Nothing is pushed. +own), adds the CHANGELOG section, and deletes the notes. It then builds this +Mac's installer into `releases/TaskMint-X.Y.Z-macos-arm64.dmg`, to check the +release builds before it's tagged. Only after the build succeeds does it +commit and tag. If the build fails, the version files, CHANGELOG and notes +are put back. Nothing is pushed. | Option | | |---|---| | `--patch`, `--minor`, `--major` | bump this level instead (needed when there are no notes) | -| `--windows` | also cross-compile `releases/TaskMint-X.Y.Z-windows-x64-setup.exe` (needs cargo-xwin) | +| `--target ` | build this instead of this computer's installer (repeat it for more): `macos-arm64`, `macos-x64` (cross-compiled on Apple silicon), `windows-x64`, `chrome-extension` | +| `--windows` | also build `windows-x64` (on a Mac, cross-compiled with cargo-xwin) | | `--no-build` | version, CHANGELOG, commit and tag only | -| `--rebuild` | build the current version's installers again, with no bump or commit | -| `--publish` | then push `main` and the tag, and create the GitHub release with the notes and installers | +| `--rebuild` | build the current version's installers again, with no bump or commit (takes `--target` and `--windows`) | +| `--publish` | then push `main` and the tag | -`pnpm release publish` publishes the current version on its own; if the -GitHub release already exists, it adds the installers to it. The repository -is public, so a published release's installers are public too. +`pnpm release publish` pushes `main` and the current version's tag. The tag +starts the Release workflow, which builds every file a release ships on +GitHub's runners and then creates the GitHub release with the version's +CHANGELOG section (`gh run list --workflow release.yml` follows it): + +| File | Built on | +|---|---| +| `TaskMint-X.Y.Z-macos-arm64.dmg` | macOS, Apple silicon | +| `TaskMint-X.Y.Z-macos-x64.dmg` | macOS, cross-compiled for Intel | +| `TaskMint-X.Y.Z-windows-x64-setup.exe` | Windows | +| `TaskMint-X.Y.Z-chrome-extension.zip` | Linux ([apps/extension/README.md](apps/extension/README.md) says how to install it) | + +The release is created only once all four are built, and a re-run replaces +the files of a release that already exists. The repository is public, so a +published release's files are public too. Running the workflow by hand +(Actions → Release → Run workflow), or a pull request that changes how +releases are built, builds the same files as workflow artifacts and +publishes nothing. The desktop builds report errors to Sentry when the +repository has a `VITE_SENTRY_DSN` secret (Settings → Secrets and variables +→ Actions); without it they report nothing. + +The installers aren't signed (the Mac app is signed ad hoc). The first time +a downloaded build is opened, macOS says it can't verify TaskMint: open +System Settings → Privacy & Security and click **Open Anyway**. On Windows, +SmartScreen says it protected the PC: click **More info**, then **Run +anyway**. A plain `pnpm tauri build` still works for a build that isn't a release; its installer stays in `target/release/bundle/`. diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 4268d22..ebafe7b 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -58,6 +58,9 @@ "bundle": { "active": true, "targets": ["nsis", "dmg"], + "macOS": { + "signingIdentity": "-" + }, "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/apps/extension/README.md b/apps/extension/README.md index 0193465..70501b3 100644 --- a/apps/extension/README.md +++ b/apps/extension/README.md @@ -48,14 +48,22 @@ capture as it arrives, in any open tab. ## Install (load unpacked) -1. Build it: `pnpm --filter @taskmint/extension build` writes `apps/extension/dist/` - (CI also uploads it as the `taskmint-extension` artifact). +1. Get it. Either download `TaskMint-X.Y.Z-chrome-extension.zip` from the + [GitHub release](https://github.com/BeardedUnicorn/taskmint/releases/latest) + and unzip it into a folder you'll keep (Chrome loads it from there each + time it starts), or build it: `pnpm --filter @taskmint/extension build` + writes `apps/extension/dist/` (CI also uploads it as the + `taskmint-extension` artifact). 2. Open `chrome://extensions`. 3. Enable **Developer mode** (top right). -4. Click **Load unpacked** and select the `apps/extension/dist/` folder. After - a rebuild, click **Reload** on the extension's card. +4. Click **Load unpacked** and select the unzipped folder, or + `apps/extension/dist/`. For a newer version, replace the folder's + contents (or rebuild), then click **Reload** on the extension's card. 5. Visit the DataAnnotation payments page while logged in. +The manifest's `key` gives the extension the same ID from any folder, and +that ID is the one TaskMint accepts captures from. + An extension loaded from `apps/extension/` itself (before it was built) has to be removed and loaded again from `dist/`. Its ID stays the same. diff --git a/changes/release-builds.md b/changes/release-builds.md new file mode 100644 index 0000000..9063c42 --- /dev/null +++ b/changes/release-builds.md @@ -0,0 +1,6 @@ +--- +bump: minor +type: Added +--- +Each release on GitHub now has installers for Intel Macs and Windows, next to +the Apple silicon one, and the Chrome extension as a zip to load unpacked. diff --git a/packages/client/src/components/pages/payouts/__tests__/PayoutsPage.test.tsx b/packages/client/src/components/pages/payouts/__tests__/PayoutsPage.test.tsx index 4a22057..04676f2 100644 --- a/packages/client/src/components/pages/payouts/__tests__/PayoutsPage.test.tsx +++ b/packages/client/src/components/pages/payouts/__tests__/PayoutsPage.test.tsx @@ -480,20 +480,29 @@ describe('PayoutsPage', () => { }; it('counts a running timer toward today, second by second', () => { - const running = stoppedSessionOn(0, 'task-1', 1); - running.timerInstances[0] = { - ...running.timerInstances[0], - state: TimerState.running, - isRunning: true, - endTime: undefined, - duration: undefined, - }; - mockSessionContext.sessions = [running]; - mockTaskContext.tasks = [createMockTask({ id: 'task-1', name: 'Task task-1', hourlyRate: 60 })]; - render(); - - expect(screen.getByText('Daily reconciliation')).toBeInTheDocument(); - expect(screen.getAllByText('Estimate').length).toBeGreaterThan(0); + // The timer starts at 09:00 today, so the clock reads noon: before 09:00 + // it hadn't started yet. Only Date is faked, so the 1 s tick still runs. + const today = new Date(); + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(today.getFullYear(), today.getMonth(), today.getDate(), 12, 0)); + try { + const running = stoppedSessionOn(0, 'task-1', 1); + running.timerInstances[0] = { + ...running.timerInstances[0], + state: TimerState.running, + isRunning: true, + endTime: undefined, + duration: undefined, + }; + mockSessionContext.sessions = [running]; + mockTaskContext.tasks = [createMockTask({ id: 'task-1', name: 'Task task-1', hourlyRate: 60 })]; + render(); + + expect(screen.getByText('Daily reconciliation')).toBeInTheDocument(); + expect(screen.getAllByText('Estimate').length).toBeGreaterThan(0); + } finally { + vi.useRealTimers(); + } }); it('says money is available a day later, for one day', () => { diff --git a/scripts/release.mjs b/scripts/release.mjs index 85694b5..725ab80 100755 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -3,7 +3,10 @@ * Cuts a TaskMint release from the change notes in changes/ (see * changes/README.md): bumps the shared version by the highest note, writes * the CHANGELOG section, builds the installers into releases/, then commits - * "Release X.Y.Z" and tags vX.Y.Z. Nothing is pushed unless --publish. + * "Release X.Y.Z" and tags vX.Y.Z. Nothing is pushed unless --publish. The + * pushed tag starts the Release workflow (.github/workflows/release.yml), + * which builds every installer with --rebuild and creates the GitHub release + * with github-release. * * Run it in the main checkout, on main: `pnpm release --help`. */ @@ -14,20 +17,27 @@ import { tmpdir } from 'node:os'; import { join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; import { + assetName, BUMPS, + buildPlan, + buildTargets, bumpVersion, + bundlePath, cargoTomlName, cargoTomlVersion, extractSection, highestBump, insertSection, isoDate, + isTarget, + missingAssets, packageJsonVersion, parseNote, renderSection, setCargoLockVersions, setCargoTomlVersion, setPackageJsonVersion, + TARGETS, } from './release/lib.mjs'; const root = fileURLToPath(new URL('..', import.meta.url)); @@ -44,28 +54,36 @@ const CARGO_LOCK = 'Cargo.lock'; const CHANGELOG = 'CHANGELOG.md'; const CHANGES = 'changes'; const RELEASES = 'releases'; -const WINDOWS_TARGET = 'x86_64-pc-windows-msvc'; +const EXTENSION_DIST = 'apps/extension/dist'; +const RELEASE_WORKFLOW = 'release.yml'; const USAGE = `Usage: pnpm release [options] pnpm release publish + pnpm release github-release Bumps the version by the highest change note in changes/, writes the -CHANGELOG section, builds the installers into releases/, then commits -"Release X.Y.Z" and tags vX.Y.Z. Run it in the main checkout, on main. +CHANGELOG section, builds this computer's installer into releases/, then +commits "Release X.Y.Z" and tags vX.Y.Z. Run it in the main checkout, on main. Options: --dry-run Print the next version and its section; change nothing --patch, --minor, --major Bump this level instead of the notes' highest - --windows Also cross-compile the Windows installer (cargo-xwin) + --target Build this instead of this computer's installer; repeat + it for more: ${TARGETS.join(', ')} + --windows Also build windows-x64 (on a Mac, cross-compiled with cargo-xwin) --no-build Bump, write the changelog, commit and tag; no installers --rebuild Build the current version's installers into releases/ (no bump, no commit) - --publish Then push main and the tag, and create the GitHub release + --publish Then push main and the tag -h, --help Show this -publish Push main and the current version's tag, and create - (or add the installers to) its GitHub release with gh`; +publish Push main and the current version's tag. The tag starts + the Release workflow, which builds every installer and + creates the GitHub release. +github-release (The Release workflow's last step) Create the current + version's GitHub release with its CHANGELOG section and + every installer in releases/, or replace its installers`; class Stop extends Error {} const stop = message => { @@ -74,12 +92,30 @@ const stop = message => { // ── Shell ─────────────────────────────────────────────────────────────────── -/** Runs a command with its output shown; stops if it fails. */ +/** + * Runs a command with its output shown; stops if it fails. On Windows, pnpm + * is a .cmd shim, which only a shell runs: it gets the line as one string + * (Node warns about args passed with a shell), and the words it's run with + * need no quoting. + */ function run(command, args, { cwd = root } = {}) { - console.log(`\n$ ${[command, ...args].join(' ')}`); - const result = spawnSync(command, args, { cwd, stdio: 'inherit' }); + const line = [command, ...args].join(' '); + console.log(`\n$ ${line}`); + const result = + process.platform === 'win32' && command === 'pnpm' + ? spawnSync(line, { cwd, stdio: 'inherit', shell: true }) + : spawnSync(command, args, { cwd, stdio: 'inherit' }); if (result.error) stop(`${command}: ${result.error.message}`); - if (result.status !== 0) stop(`${[command, ...args].join(' ')} failed (exit ${result.status})`); + if (result.status !== 0) stop(`${line} failed (exit ${result.status})`); +} + +/** The pure parts' errors are the user's to fix, so they stop without a stack trace. */ +function checked(fn) { + try { + return fn(); + } catch (error) { + stop(error.message); + } } /** A command's output; stops if it fails. */ @@ -167,39 +203,50 @@ function restore(paths) { git('restore', '--source=HEAD', '--staged', '--worktree', '--', ...paths); } -/** Builds the installers and copies them into releases/; returns their paths. */ -function build(version, { windows }) { - if (process.platform !== 'darwin') stop('The DMG is built on macOS.'); - run('pnpm', ['--filter', '@taskmint/desktop', 'tauri', 'build']); - if (windows) { - run('rustup', ['target', 'add', WINDOWS_TARGET]); - run('pnpm', ['--filter', '@taskmint/desktop', 'build:win']); - } - return collect(version, { windows }); +/** The targets to build and how this computer builds each; stops before anything is built if it can't. */ +function planBuilds({ targets, windows }) { + const host = { platform: process.platform, arch: process.arch }; + return checked(() => + buildTargets({ targets, windows, ...host }).map(target => ({ target, ...buildPlan(target, host) })) + ); } -/** Copies the version's installers from Tauri's bundle folders into releases/. */ -function collect(version, { windows }) { - const name = productName(); - const arch = process.arch === 'arm64' ? { tauri: 'aarch64', ours: 'arm64' } : { tauri: 'x64', ours: 'x64' }; - const installers = [ - [`target/release/bundle/dmg/${name}_${version}_${arch.tauri}.dmg`, `${name}-${version}-macos-${arch.ours}.dmg`], - ]; - if (windows) { - installers.push([ - `target/${WINDOWS_TARGET}/release/bundle/nsis/${name}_${version}_x64-setup.exe`, - `${name}-${version}-windows-x64-setup.exe`, - ]); - } +/** Builds each planned target and puts its file in releases/; returns their paths. */ +function build(version, plans) { + if (plans.length === 0) return []; mkdirSync(join(root, RELEASES), { recursive: true }); - return installers.map(([from, to]) => { - if (!existsSync(join(root, from))) stop(`The build finished, but ${from} isn't there.`); - const destination = join(root, RELEASES, to); - copyFileSync(join(root, from), destination); + return plans.map(plan => { + const destination = join(root, RELEASES, assetName(productName(), version, plan.target)); + if (plan.kind === 'zip') zipExtension(destination); + else copyInstaller(buildInstaller(version, plan), destination); return destination; }); } +/** Builds a desktop installer with Tauri; returns where Tauri left it. */ +function buildInstaller(version, plan) { + if (plan.triple) run('rustup', ['target', 'add', plan.triple]); + if (plan.kind === 'xwin') run('pnpm', ['--filter', '@taskmint/desktop', 'build:win']); + else { + const cross = plan.triple ? ['--target', plan.triple] : []; + run('pnpm', ['--filter', '@taskmint/desktop', 'tauri', 'build', ...cross]); + } + return bundlePath(productName(), version, plan.target, plan); +} + +function copyInstaller(from, destination) { + if (!existsSync(join(root, from))) stop(`The build finished, but ${from} isn't there.`); + copyFileSync(join(root, from), destination); +} + +/** Builds the extension and zips dist/'s contents, manifest.json at the top, for Load unpacked. */ +function zipExtension(destination) { + run('pnpm', ['--filter', '@taskmint/extension', 'build']); + // zip adds to an archive that's already there + rmSync(destination, { force: true }); + run('zip', ['-qrX', destination, '.'], { cwd: join(root, EXTENSION_DIST) }); +} + function commitAndTag(version, section, paths) { git('add', '--all', '--', ...paths); const body = section.split('\n').slice(1).join('\n').trim(); @@ -240,6 +287,7 @@ To release without any, pass --patch, --minor or --major.`); requireMainAndClean(); if (tagExists(tagOf(version))) stop(`The tag ${tagOf(version)} already exists.`); + const plans = options.build ? planBuilds(options) : []; console.log(`Releasing ${version} (${current} → ${version}, ${level})`); const paths = releasePaths(notes); @@ -247,7 +295,7 @@ To release without any, pass --patch, --minor or --major.`); let files = []; try { writeRelease(version, section, notes); - if (options.build) files = build(version, options); + files = build(version, plans); commitAndTag(version, section, paths); } catch (error) { // Once the release is committed, only the tag is missing @@ -262,64 +310,94 @@ To release without any, pass --patch, --minor or --major.`); function rebuild(options) { const version = currentVersion(); + const plans = planBuilds(options); if (git('status', '--porcelain')) console.warn('The working tree has changes: the installers include them.'); - const files = build(version, options); + const files = build(version, plans); report(files); reveal(files); } -/** Pushes main and the tag, then creates the GitHub release with the notes, or adds the installers to it. */ +/** Pushes main and the tag. The tag starts the Release workflow, which builds the installers and creates the GitHub release. */ function publish(version = currentVersion()) { const tag = tagOf(version); if (!tagExists(tag)) stop(`There's no tag ${tag}: release ${version} first.`); if (git('branch', '--show-current') !== 'main') stop('Publish from main.'); + if (extractSection(read(CHANGELOG), version) === null) stop(`CHANGELOG.md has no ${version} section.`); + + run('git', ['push', 'origin', 'main', tag]); + console.log(` +Pushed main and ${tag}. The Release workflow builds every installer and +creates the GitHub release; follow it with: + gh run list --workflow ${RELEASE_WORKFLOW}`); +} + +/** + * The Release workflow's last step: creates the GitHub release for the + * checked-out tag with its CHANGELOG section and every target's file from + * releases/ (the build jobs' artifacts), or replaces the files of a release + * that already exists. A release is never made with a file missing. + */ +function githubRelease(version = currentVersion()) { + const tag = tagOf(version); + const ref = process.env.GITHUB_REF_NAME; + if (ref && ref !== tag) stop(`This run is for ${ref}, but the manifests say ${version}.`); + if (!tagExists(tag)) stop(`There's no tag ${tag}.`); const notes = extractSection(read(CHANGELOG), version); if (notes === null) stop(`CHANGELOG.md has no ${version} section.`); - const prefix = `${productName()}-${version}-`; + const name = productName(); const dir = join(root, RELEASES); - const files = existsSync(dir) - ? readdirSync(dir) - .filter(name => name.startsWith(prefix)) - .map(name => join(dir, name)) - : []; - if (files.length === 0) console.warn(`Nothing in ${RELEASES}/ for ${version}: the release will have no installers.`); + const missing = missingAssets(name, version, existsSync(dir) ? readdirSync(dir) : []); + if (missing.length > 0) stop(`${RELEASES}/ is missing ${missing.join(', ')}.`); + const files = TARGETS.map(target => join(dir, assetName(name, version, target))); - run('git', ['push', 'origin', 'main', tag]); if (succeeds('gh', ['release', 'view', tag])) { - if (files.length > 0) run('gh', ['release', 'upload', tag, '--clobber', ...files]); - } else { - const notesFile = join(tmpdir(), `taskmint-${version}-notes.md`); - writeFileSync(notesFile, `${notes}\n`); - try { - run('gh', [ - 'release', - 'create', - tag, - '--verify-tag', - '--title', - `${productName()} ${version}`, - '--notes-file', - notesFile, - ...files, - ]); - } finally { - rmSync(notesFile, { force: true }); - } + run('gh', ['release', 'upload', tag, '--clobber', ...files]); + return; + } + const notesFile = join(tmpdir(), `taskmint-${version}-notes.md`); + writeFileSync(notesFile, `${notes}\n`); + try { + run('gh', [ + 'release', + 'create', + tag, + '--verify-tag', + '--title', + `${name} ${version}`, + '--notes-file', + notesFile, + ...files, + ]); + } finally { + rmSync(notesFile, { force: true }); } } // ── Arguments ─────────────────────────────────────────────────────────────── function parseArgs(args) { - const options = { dryRun: false, build: true, rebuild: false, windows: false, publish: false, level: null }; + const options = { + dryRun: false, + build: true, + rebuild: false, + windows: false, + publish: false, + level: null, + targets: [], + }; let command = 'release'; - for (const arg of args) { + for (let index = 0; index < args.length; index++) { + const arg = args[index]; if (arg === '-h' || arg === '--help') { console.log(USAGE); process.exit(0); - } else if (arg === 'publish') command = 'publish'; - else if (arg === '--dry-run') options.dryRun = true; + } else if (arg === 'publish' || arg === 'github-release') command = arg; + else if (arg === '--target' || arg.startsWith('--target=')) { + const target = arg === '--target' ? args[++index] : arg.slice('--target='.length); + if (!isTarget(target)) stop(`--target takes one of ${TARGETS.join(', ')}${target ? ` (not ${target})` : ''}.`); + options.targets.push(target); + } else if (arg === '--dry-run') options.dryRun = true; else if (arg === '--no-build') options.build = false; else if (arg === '--rebuild') options.rebuild = true; else if (arg === '--windows') options.windows = true; @@ -329,17 +407,20 @@ function parseArgs(args) { options.level = arg.slice(2); } else stop(`Unknown option ${arg}\n\n${USAGE}`); } - if (command === 'publish' && args.length > 1) stop('`pnpm release publish` takes no options.'); + if (command !== 'release' && args.length > 1) stop(`\`pnpm release ${command}\` takes no options.`); if (options.rebuild && (options.level || !options.build || options.publish || options.dryRun)) { - stop('--rebuild only takes --windows.'); + stop('--rebuild only takes --target and --windows.'); + } + if (!options.build && (options.windows || options.targets.length > 0)) { + stop('--target and --windows build installers, so they need a build.'); } - if (!options.build && options.windows) stop('--windows builds an installer, so it needs a build.'); return { command, options }; } try { const { command, options } = parseArgs(process.argv.slice(2)); if (command === 'publish') publish(); + else if (command === 'github-release') githubRelease(); else if (options.rebuild) rebuild(options); else release(options); } catch (error) { diff --git a/scripts/release/lib.mjs b/scripts/release/lib.mjs index b3d1d99..609357b 100644 --- a/scripts/release/lib.mjs +++ b/scripts/release/lib.mjs @@ -1,7 +1,8 @@ /** * The release's pure parts (scripts/release.mjs does the files, git and * builds): reading change notes, the next version, the CHANGELOG section, - * and the version in each manifest. Tested by lib.test.mjs (`node --test`). + * the version in each manifest, and what a release ships and how each host + * builds it. Tested by lib.test.mjs (`node --test`). */ /** Bump levels, lowest first. */ @@ -184,3 +185,102 @@ export function setCargoLockVersions(text, names, version) { } return next; } + +// What a release ships: one file per target, each named +// `--`. The Release workflow builds every target, +// and a local release builds the computer's own installer unless told which. + +/** Every target a release ships, in the order the release lists them. */ +export const TARGETS = ['macos-arm64', 'macos-x64', 'windows-x64', 'chrome-extension']; + +const SUFFIXES = { + 'macos-arm64': 'macos-arm64.dmg', + 'macos-x64': 'macos-x64.dmg', + 'windows-x64': 'windows-x64-setup.exe', + 'chrome-extension': 'chrome-extension.zip', +}; + +/** The desktop targets: the OS and CPU each runs on, and the Tauri bundle it comes from. */ +const DESKTOP = { + 'macos-arm64': { + platform: 'darwin', + arch: 'arm64', + triple: 'aarch64-apple-darwin', + bundle: 'dmg', + file: 'aarch64.dmg', + }, + 'macos-x64': { platform: 'darwin', arch: 'x64', triple: 'x86_64-apple-darwin', bundle: 'dmg', file: 'x64.dmg' }, + 'windows-x64': { + platform: 'win32', + arch: 'x64', + triple: 'x86_64-pc-windows-msvc', + bundle: 'nsis', + file: 'x64-setup.exe', + }, +}; + +const PLATFORMS = { darwin: 'macOS', win32: 'Windows', linux: 'Linux' }; +const describeHost = ({ platform, arch }) => `${PLATFORMS[platform] ?? platform} (${arch})`; + +export const isTarget = name => TARGETS.includes(name); + +function requireTarget(target) { + if (!isTarget(target)) throw new Error(`Unknown target "${target}" (one of ${TARGETS.join(', ')})`); +} + +/** The file a target ships as. */ +export function assetName(product, version, target) { + requireTarget(target); + return `${product}-${version}-${SUFFIXES[target]}`; +} + +/** Every file a release ships for the version that isn't among `files` (file names). */ +export function missingAssets(product, version, files) { + return TARGETS.map(target => assetName(product, version, target)).filter(name => !files.includes(name)); +} + +/** + * How a host builds a target: a Tauri build, `native` or cross-compiled with + * `--target ` (the other Mac CPU); `xwin`, the Windows installer + * cross-compiled on a Mac; or `zip`, the extension zipped with the zip + * command. Throws for a target the host can't build. + */ +export function buildPlan(target, host) { + requireTarget(target); + const desktop = DESKTOP[target]; + if (!desktop) { + if (host.platform === 'darwin' || host.platform === 'linux') return { kind: 'zip' }; + } else if (desktop.platform === host.platform) { + return { kind: 'tauri', triple: desktop.arch === host.arch ? null : desktop.triple }; + } else if (target === 'windows-x64' && host.platform === 'darwin') { + return { kind: 'xwin', triple: desktop.triple }; + } + throw new Error(`${target} can't be built on ${describeHost(host)}`); +} + +/** Where Tauri leaves a desktop target's installer, relative to the repository root. */ +export function bundlePath(product, version, target, plan) { + requireTarget(target); + const desktop = DESKTOP[target]; + if (!desktop || plan.kind === 'zip') throw new Error(`${target} isn't a Tauri bundle`); + const dir = plan.triple ? `target/${plan.triple}/release` : 'target/release'; + return `${dir}/bundle/${desktop.bundle}/${product}_${version}_${desktop.file}`; +} + +/** + * The targets to build: the ones named (in order, once each), or else the + * host's own desktop installer. `windows` adds windows-x64. + */ +export function buildTargets({ targets = [], windows = false, platform, arch }) { + for (const target of targets) requireTarget(target); + let chosen = [...new Set(targets)]; + if (chosen.length === 0) { + const own = Object.keys(DESKTOP).find( + target => DESKTOP[target].platform === platform && DESKTOP[target].arch === arch + ); + if (!own) throw new Error(`There's no installer for ${describeHost({ platform, arch })}: name one with --target`); + chosen = [own]; + } + if (windows && !chosen.includes('windows-x64')) chosen.push('windows-x64'); + return chosen; +} diff --git a/scripts/release/lib.test.mjs b/scripts/release/lib.test.mjs index 9e247c4..7f11358 100644 --- a/scripts/release/lib.test.mjs +++ b/scripts/release/lib.test.mjs @@ -1,7 +1,11 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { + assetName, + buildPlan, + buildTargets, bumpVersion, + bundlePath, cargoTomlName, cargoTomlVersion, changelogVersions, @@ -9,13 +13,16 @@ import { highestBump, insertSection, isoDate, + isTarget, isVersion, + missingAssets, packageJsonVersion, parseNote, renderSection, setCargoLockVersions, setCargoTomlVersion, setPackageJsonVersion, + TARGETS, } from './lib.mjs'; const note = (front, body = 'A change.') => `---\n${front}\n---\n${body}\n`; @@ -214,3 +221,91 @@ describe('manifests', () => { assert.throws(() => setCargoLockVersions(lock, ['taskmint-server'], '2.1.0'), /lists taskmint-server 0 times/); }); }); + +describe('targets', () => { + const mac = { platform: 'darwin', arch: 'arm64' }; + const intelMac = { platform: 'darwin', arch: 'x64' }; + const windows = { platform: 'win32', arch: 'x64' }; + const linux = { platform: 'linux', arch: 'x64' }; + + it('names each file the release ships', () => { + assert.deepEqual( + TARGETS.map(target => assetName('TaskMint', '2.2.0', target)), + [ + 'TaskMint-2.2.0-macos-arm64.dmg', + 'TaskMint-2.2.0-macos-x64.dmg', + 'TaskMint-2.2.0-windows-x64-setup.exe', + 'TaskMint-2.2.0-chrome-extension.zip', + ] + ); + }); + + it('tells a target from anything else', () => { + assert.equal(isTarget('windows-x64'), true); + assert.equal(isTarget('windows'), false); + assert.throws(() => assetName('TaskMint', '2.2.0', 'linux-x64'), /Unknown target "linux-x64" \(one of macos-arm64/); + }); + + it("lists the release's files that aren't there", () => { + const all = TARGETS.map(target => assetName('TaskMint', '2.2.0', target)); + assert.deepEqual(missingAssets('TaskMint', '2.2.0', all), []); + assert.deepEqual( + missingAssets('TaskMint', '2.2.0', ['TaskMint-2.2.0-macos-arm64.dmg', 'TaskMint-2.1.0-macos-x64.dmg']), + ['TaskMint-2.2.0-macos-x64.dmg', 'TaskMint-2.2.0-windows-x64-setup.exe', 'TaskMint-2.2.0-chrome-extension.zip'] + ); + }); + + it('builds a Mac installer natively, or cross-compiled for the other CPU', () => { + assert.deepEqual(buildPlan('macos-arm64', mac), { kind: 'tauri', triple: null }); + assert.deepEqual(buildPlan('macos-x64', mac), { kind: 'tauri', triple: 'x86_64-apple-darwin' }); + assert.deepEqual(buildPlan('macos-x64', intelMac), { kind: 'tauri', triple: null }); + assert.deepEqual(buildPlan('macos-arm64', intelMac), { kind: 'tauri', triple: 'aarch64-apple-darwin' }); + }); + + it('builds the Windows installer on Windows, or with cargo-xwin on a Mac', () => { + assert.deepEqual(buildPlan('windows-x64', windows), { kind: 'tauri', triple: null }); + assert.deepEqual(buildPlan('windows-x64', mac), { kind: 'xwin', triple: 'x86_64-pc-windows-msvc' }); + }); + + it('zips the extension where there is a zip command', () => { + assert.deepEqual(buildPlan('chrome-extension', mac), { kind: 'zip' }); + assert.deepEqual(buildPlan('chrome-extension', linux), { kind: 'zip' }); + }); + + it("refuses a target the host can't build", () => { + assert.throws(() => buildPlan('macos-arm64', windows), /^Error: macos-arm64 can't be built on Windows \(x64\)$/); + assert.throws(() => buildPlan('windows-x64', linux), /windows-x64 can't be built on Linux \(x64\)/); + assert.throws(() => buildPlan('chrome-extension', windows), /chrome-extension can't be built on Windows/); + assert.throws(() => buildPlan('macos-x64', { platform: 'freebsd', arch: 'x64' }), /on freebsd \(x64\)/); + }); + + it("finds Tauri's installer in target/release, or under the triple when cross-compiled", () => { + const at = (target, host) => bundlePath('TaskMint', '2.2.0', target, buildPlan(target, host)); + assert.equal(at('macos-arm64', mac), 'target/release/bundle/dmg/TaskMint_2.2.0_aarch64.dmg'); + assert.equal(at('macos-x64', mac), 'target/x86_64-apple-darwin/release/bundle/dmg/TaskMint_2.2.0_x64.dmg'); + assert.equal(at('windows-x64', windows), 'target/release/bundle/nsis/TaskMint_2.2.0_x64-setup.exe'); + assert.equal( + at('windows-x64', mac), + 'target/x86_64-pc-windows-msvc/release/bundle/nsis/TaskMint_2.2.0_x64-setup.exe' + ); + assert.throws(() => bundlePath('TaskMint', '2.2.0', 'chrome-extension', { kind: 'zip' }), /isn't a Tauri bundle/); + }); + + it("builds the host's own installer unless targets are named", () => { + assert.deepEqual(buildTargets(mac), ['macos-arm64']); + assert.deepEqual(buildTargets(intelMac), ['macos-x64']); + assert.deepEqual(buildTargets(windows), ['windows-x64']); + assert.deepEqual(buildTargets({ ...linux, targets: ['chrome-extension'] }), ['chrome-extension']); + assert.throws(() => buildTargets(linux), /There's no installer for Linux \(x64\): name one with --target/); + }); + + it('builds the named targets once each, in order, with --windows adding windows-x64', () => { + assert.deepEqual(buildTargets({ ...mac, targets: ['macos-x64', 'macos-arm64', 'macos-x64'] }), [ + 'macos-x64', + 'macos-arm64', + ]); + assert.deepEqual(buildTargets({ ...mac, windows: true }), ['macos-arm64', 'windows-x64']); + assert.deepEqual(buildTargets({ ...mac, windows: true, targets: ['windows-x64'] }), ['windows-x64']); + assert.throws(() => buildTargets({ ...mac, targets: ['ios'] }), /Unknown target "ios"/); + }); +});