From 15d03f932a789af7043c9902d63de20531568e5f Mon Sep 17 00:00:00 2001 From: Mike Herold Date: Sat, 7 Nov 2026 18:38:32 -0700 Subject: [PATCH 1/2] Build every release file in CI and publish from the tag A pushed vX.Y.Z tag now runs the Release workflow: the Apple silicon and Intel DMGs (Intel cross-compiled on macos-26), the Windows installer (built natively on windows-2025) and the Chrome extension zip, then the GitHub release with the version's CHANGELOG section once all four exist. Pull requests that touch the release machinery, and manual runs, build the same files as artifacts and publish nothing. scripts/release.mjs gains --target (one per file a release ships), native Windows builds, the extension zip and a github-release command for the workflow; `publish` now only pushes main and the tag. The Mac app is signed ad hoc so a downloaded build gets "Open Anyway", not "damaged". CI: actions move to their Node 24 majors (checkout v7, setup-node v7, upload-artifact v7, pnpm/action-setup v6) and the runners are pinned (macos-26, ubuntu-26.04), clearing run 42424925366's Node 20 and ubuntu-latest migration annotations. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 16 +- .github/workflows/release.yml | 113 ++++++++++++ README.md | 49 ++++-- apps/desktop/src-tauri/tauri.conf.json | 3 + apps/extension/README.md | 16 +- changes/release-builds.md | 6 + scripts/release.mjs | 231 +++++++++++++++++-------- scripts/release/lib.mjs | 102 ++++++++++- scripts/release/lib.test.mjs | 95 ++++++++++ 9 files changed, 532 insertions(+), 99 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 changes/release-builds.md 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/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"/); + }); +}); From a6fb09931abcd18943ee068742d622926f884597 Mon Sep 17 00:00:00 2001 From: Mike Herold Date: Sat, 7 Nov 2026 19:53:15 -0700 Subject: [PATCH 2/2] Pin the clock in the running-timer Payouts test The test's running timer starts at 09:00 today, so before 09:00 local time it hadn't started and the reconciliation table never rendered. CI runs in UTC, so the check job failed whenever it ran before 09:00 UTC. The test now reads the time as noon (only Date is faked, so the 1 s tick still runs). Co-Authored-By: Claude Opus 5.5 --- .../payouts/__tests__/PayoutsPage.test.tsx | 37 ++++++++++++------- 1 file changed, 23 insertions(+), 14 deletions(-) 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', () => {