Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/
Expand Down Expand Up @@ -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

Expand Down
113 changes: 113 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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 }}
49 changes: 38 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 <name>` | 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/`.
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@
"bundle": {
"active": true,
"targets": ["nsis", "dmg"],
"macOS": {
"signingIdentity": "-"
},
"icon": [
"icons/32x32.png",
"icons/128x128.png",
Expand Down
16 changes: 12 additions & 4 deletions apps/extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
6 changes: 6 additions & 0 deletions changes/release-builds.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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(<PayoutsPage />);

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(<PayoutsPage />);

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', () => {
Expand Down
Loading
Loading