Build and sign releases in CI, and install locally with one command

Ported from margin's pipeline, with the platform list this app actually
claims. Release is manual: it bumps tauri.conf.json, package.json and
Cargo.toml together, tags, and then builds the tag rather than whatever main
has drifted to by the time the runners pick it up.

Nothing publishes until every platform lands. The last job downloads
latest.json and refuses to take the release out of draft unless
darwin-aarch64, darwin-x86_64 and linux-x86_64 are all present, because a
half-populated manifest is worse than no release at all: the updater would
offer an update to the platforms that made it and error on the ones that did
not.

Linux builds on Ubuntu 22.04 rather than latest. The bundle will not run on
anything older than the glibc it was linked against, and 22.04 is the
baseline docs/setup.md commits to. Windows is not built, matching the bundle
targets and the README; adding it is a matrix entry, msi and nsis in the
targets, and windows-x86_64 in the publish gate.

The updater had a plugin, a capability and a menu item but no keypair and no
endpoint, so releases would have produced artifacts nothing could verify.
The public half is now in tauri.release.conf.json and the private half is a
repository secret, alongside the Google OAuth client that build.rs embeds.
Without that secret the build falls back to the example credentials and warns
rather than failing, which yields an app that runs and then says Google
Calendar is not set up.

CI enforces the gate setup.md already names, the two test suites, and nothing
more. cargo fmt --check and cargo clippy -D warnings both fail on the tree as
it stands, and adopting either is a cleanup pass to decide on rather than
something to bolt onto a new pipeline.

justfile is the local equivalent of all this. `just install` builds for the
machine it is run on and installs it, and is the same command whether or not
the app is already there, so it doubles as the update. On macOS it asks a
running copy to quit first, because replacing a bundle under a live process
leaves it half old and half new.
This commit is contained in:
pj committed 2026-08-12 20:16:29 +05:30
1 parent d4c3a304b5
commit 590506eb92
6 files changed
+458 -5

No files matched your search

+70
View File
@@ -0,0 +1,70 @@
name: CI
on:
push:
branches: [main]
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: 26
- uses: pnpm/action-setup@v6
with:
version: 10
- run: pnpm install --frozen-lockfile
# `pnpm build` is tsc then vite, so this is the typecheck and the bundle in one step.
- run: pnpm build
- run: pnpm test
rust:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v7
- name: Install Linux dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
patchelf \
libxdo-dev \
libssl-dev \
build-essential
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- uses: swatinem/rust-cache@v2
with:
workspaces: src-tauri -> target
# tauri_build::build() wants a frontendDist that exists, and build.rs wants credentials to
# embed. The example file is what a fresh clone compiles against, so that is what CI uses.
- name: Stub the build inputs
run: |
mkdir -p dist && touch dist/index.html
cp google-credentials.example.json google-credentials.json
# The gate docs/setup.md names is the test suites, and that is all this enforces. `cargo fmt
# --check` and `cargo clippy -D warnings` both fail on the tree as it stands; adopting either
# is a cleanup pass to decide on separately, not something to bolt onto CI first.
- name: Test
working-directory: src-tauri
run: cargo test
+181
View File
@@ -0,0 +1,181 @@
name: Release
on:
workflow_dispatch:
inputs:
version:
description: "Release version, e.g. 0.2.0. Leave empty to bump the patch number."
required: false
type: string
permissions:
contents: write
jobs:
prepare:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
release_id: ${{ steps.release.outputs.release_id }}
steps:
- uses: actions/checkout@v7
- name: Determine version
id: version
run: |
if [ -n "${{ inputs.version }}" ]; then
VERSION="${{ inputs.version }}"
VERSION="${VERSION#v}"
else
CURRENT=$(jq -r .version src-tauri/tauri.conf.json)
IFS=. read -r MAJOR MINOR PATCH <<< "$CURRENT"
VERSION="$MAJOR.$MINOR.$((PATCH + 1))"
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
echo "Releasing v$VERSION"
- name: Bump version in manifests
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
tmp=$(mktemp)
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > "$tmp" && mv "$tmp" src-tauri/tauri.conf.json
jq --arg v "$VERSION" '.version = $v' package.json > "$tmp" && mv "$tmp" package.json
sed -i "0,/^version = \".*\"/s//version = \"$VERSION\"/" src-tauri/Cargo.toml
- name: Commit and tag
env:
TAG: ${{ steps.version.outputs.tag }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add src-tauri/tauri.conf.json package.json src-tauri/Cargo.toml
git commit -m "chore(release): $TAG"
for attempt in 1 2 3 4 5; do
git fetch origin main
git rebase origin/main
if git push origin HEAD; then
break
fi
if [ "$attempt" = "5" ]; then
echo "::error::main kept advancing; could not push release bump after 5 attempts."
exit 1
fi
echo "main advanced during release; rebasing and retrying ($attempt)…"
sleep 3
done
git tag "$TAG"
git push origin "$TAG"
- name: Create draft release
id: release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.version.outputs.tag }}
run: |
gh release create "$TAG" --draft --title "Margin Calendar $TAG" --notes "Release $TAG"
ID=$(gh release view "$TAG" --json databaseId --jq .databaseId)
echo "release_id=$ID" >> "$GITHUB_OUTPUT"
build:
needs: prepare
strategy:
fail-fast: false
matrix:
include:
- os: macos-26
args: "--target universal-apple-darwin --config src-tauri/tauri.release.conf.json"
rust-targets: "aarch64-apple-darwin,x86_64-apple-darwin"
# Ubuntu 22.04 is the glibc baseline docs/setup.md commits to: the bundle will not run on
# anything older than the glibc it was linked against, so build on the oldest supported.
- os: ubuntu-22.04
args: "--config src-tauri/tauri.release.conf.json"
rust-targets: ""
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.prepare.outputs.tag }}
- name: Install Linux dependencies
if: startsWith(matrix.os, 'ubuntu')
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
patchelf \
libxdo-dev \
libssl-dev \
build-essential \
curl \
wget \
file
- uses: actions/setup-node@v6
with:
node-version: 26
- uses: pnpm/action-setup@v6
with:
version: 10
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.rust-targets }}
- uses: swatinem/rust-cache@v2
with:
workspaces: src-tauri -> target
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Provision Google credentials
shell: bash
env:
GOOGLE_CREDENTIALS: ${{ secrets.GOOGLE_CREDENTIALS }}
run: |
if [ -n "$GOOGLE_CREDENTIALS" ]; then
printf '%s' "$GOOGLE_CREDENTIALS" > google-credentials.json
echo "Wrote google-credentials.json from GOOGLE_CREDENTIALS secret."
else
cp google-credentials.example.json google-credentials.json
echo "::warning::GOOGLE_CREDENTIALS secret not set — embedding placeholder credentials; this build cannot connect to Google Calendar."
fi
- name: Build and upload
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
releaseId: ${{ needs.prepare.outputs.release_id }}
args: ${{ matrix.args }}
publish:
needs: [prepare, build]
runs-on: ubuntu-latest
steps:
- name: Verify manifest is complete, then publish
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
TAG: ${{ needs.prepare.outputs.tag }}
run: |
gh release download "$TAG" --repo "$REPO" --pattern latest.json --output latest.json --clobber
echo "Platforms in latest.json:"
jq '.platforms | keys' latest.json
for key in darwin-aarch64 darwin-x86_64 linux-x86_64; do
if ! jq -e ".platforms[\"$key\"].url" latest.json > /dev/null; then
echo "::error::latest.json is missing platform '$key' — refusing to publish a partial update manifest. Re-run the release."
exit 1
fi
done
gh release edit "$TAG" --repo "$REPO" --draft=false --latest
+6 -5
View File
@@ -6,8 +6,9 @@ fits without scrolling, and on a desktop the whole thing is drivable from the ke
It is a sibling to [margin](https://github.com/priyanshujain/margin) and shares its stack and its
visual language.
Setup and the Google OAuth client it needs are in [docs/setup.md](docs/setup.md), and the extra
clients a phone build needs are in [docs/mobile.md](docs/mobile.md). The product decisions are in
[docs/design.md](docs/design.md), how it is built is in
[docs/architecture.md](docs/architecture.md), and the conventions it follows are in
[docs/conventions.md](docs/conventions.md).
`just install` builds it and installs it on the machine you are on. Setup and the Google OAuth
client it needs are in [docs/setup.md](docs/setup.md), and the extra clients a phone build needs
are in [docs/mobile.md](docs/mobile.md). How releases are cut is in
[docs/release.md](docs/release.md). The product decisions are in [docs/design.md](docs/design.md),
how it is built is in [docs/architecture.md](docs/architecture.md), and the conventions it follows
are in [docs/conventions.md](docs/conventions.md).
+61
View File
@@ -0,0 +1,61 @@
# Releasing
## Installing locally
`just install` builds the app for whatever machine you are sitting at and puts it where that
machine expects to find applications: `/Applications` on macOS, or the package manager on Linux.
It is the same command whether or not the app is already installed, so it doubles as the update.
On macOS it asks a running copy to quit first, because replacing a bundle under a live process
leaves it half old and half new. `just uninstall` reverses it and leaves the data directory alone.
The local build skips the dmg and builds only the `.app`, since nothing about copying a bundle
into place needs a disk image and building one is the slowest part of a mac bundle. That makes a
locally installed app slightly different from a released one, in that it is ad-hoc signed and has
no updater artifacts. It will not update itself. Rerun `just install`.
## Cutting a release
Releases are manual: run the **Release** workflow from the Actions tab. Leave the version empty to
bump the patch number, or give one to set it. The workflow bumps `tauri.conf.json`, `package.json`
and `Cargo.toml` together, commits that to main, tags it, and builds the tag rather than whatever
main happens to be by then.
It builds a universal macOS bundle and an x86_64 Linux one, and it publishes nothing until both
have landed. The last job downloads `latest.json` and refuses to take the release out of draft
unless `darwin-aarch64`, `darwin-x86_64` and `linux-x86_64` are all in it. A half-populated
manifest is worse than no release: the updater would offer an update to the platforms that made it
and error on the ones that did not.
Linux builds on Ubuntu 22.04 on purpose. The bundle will not run on anything older than the glibc
it was linked against, and 22.04 is the baseline [setup.md](setup.md) commits to.
Windows is not built. The bundle targets in `tauri.conf.json` are the mac and Linux ones, and the
app has never claimed Windows. Adding it is a runner in the build matrix and `msi`/`nsis` in the
targets, at which point the publish gate wants `windows-x86_64` too.
Phones do not come from this pipeline at all. The store is their update channel, and what building
for one takes is in [mobile.md](mobile.md).
## What the build needs
Three repository secrets, all already set:
- `GOOGLE_CREDENTIALS`, the contents of the real `google-credentials.json`. The build writes it to
the repo root and `build.rs` embeds it. Without it the build quietly falls back to the example
file and warns, which produces an app that runs and then says Google Calendar is not set up.
- `TAURI_SIGNING_PRIVATE_KEY` and `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`, which sign the updater
artifacts. The public half is in `tauri.release.conf.json` and is baked into every build, so the
private half can never be rotated without stranding everyone who has not updated yet. It lives
in `~/.tauri/margin_calendar_updater.key` next to margin's; that copy and the password beside it
are the only ones, so back them up somewhere that is not this machine.
The OAuth client secret ends up inside the shipped binary. That is how installed apps work and
Google does not treat it as confidential: an installed client cannot keep a secret, which is why
the flow uses PKCE and why the token exchange is safe without one.
## Updates
Installed copies check
`https://github.com/priyanshujain/margin-calendar/releases/latest/download/latest.json` and update
themselves from it. `--latest` on the publish step is what moves that pointer, so a release that
fails the manifest check stays a draft and no one is offered a broken update.
+132
View File
@@ -0,0 +1,132 @@
# Building and installing Margin Calendar on the machine you are sitting at. The release pipeline
# in .github/workflows/release.yml is what builds for everyone else; this is the local equivalent,
# and `just install` is deliberately the same command whether or not the app is already installed.
set shell := ["bash", "-euo", "pipefail", "-c"]
app := "Margin Calendar"
bundle := "src-tauri/target/release/bundle"
# List the recipes.
default:
@just --list
# Run the app against the Vite dev server.
dev:
pnpm tauri dev
# The gate: the frontend suites and the Rust suites, both of which must be green.
test:
pnpm test
cd src-tauri && cargo test
# The browser suite that drives the real UI.
test-ui:
pnpm test:ui
# Build the release bundle for this machine.
build:
#!/usr/bin/env bash
set -euo pipefail
# The .app on macOS, the .deb and AppImage on Linux. No dmg: nothing here needs a disk image
# to copy a bundle into place, and building one is the slowest part of a mac bundle.
pnpm install
case "$(uname -s)" in
Darwin) pnpm tauri build --bundles app ;;
Linux) pnpm tauri build --bundles deb,appimage ;;
*) echo "just: no local build for $(uname -s); macOS and Linux are the desktop targets." >&2; exit 1 ;;
esac
# Build and install, replacing whatever version is already installed.
install: build
#!/usr/bin/env bash
set -euo pipefail
case "$(uname -s)" in
Darwin) just _install-macos ;;
Linux) just _install-linux ;;
*) echo "just: no installer for $(uname -s); macOS and Linux are the desktop targets." >&2; exit 1 ;;
esac
_install-macos:
#!/usr/bin/env bash
set -euo pipefail
src="{{bundle}}/macos/{{app}}.app"
[ -d "$src" ] || { echo "just: nothing to install, $src does not exist." >&2; exit 1; }
# /Applications is writable by admin users, so the common case needs no sudo. When it is not,
# ~/Applications is a real Launchpad location and beats prompting for a password mid-build.
if [ -w /Applications ]; then dir=/Applications; else dir="$HOME/Applications"; mkdir -p "$dir"; fi
dest="$dir/{{app}}.app"
# Which of the two names the executable carries depends on how the bundle was configured, so
# ask about both rather than assuming, and replacing a bundle out from under a live process.
running() { pgrep -x "{{app}}" > /dev/null || pgrep -x margin-calendar > /dev/null; }
# Replacing the bundle under a running app leaves it half old and half new, and the running
# copy holds the deleted files open. Ask it to quit and wait, rather than killing it.
if running; then
echo "Quitting the running {{app}}…"
osascript -e 'quit app "{{app}}"' 2> /dev/null || true
for _ in $(seq 40); do running || break; sleep 0.25; done
if running; then echo "just: {{app}} is still running; quit it and try again." >&2; exit 1; fi
fi
rm -rf "$dest"
cp -R "$src" "$dest"
version=$(defaults read "$dest/Contents/Info.plist" CFBundleShortVersionString 2> /dev/null || echo "?")
echo "Installed $version to $dest"
_install-linux:
#!/usr/bin/env bash
set -euo pipefail
deb=$(ls -t {{bundle}}/deb/*.deb 2> /dev/null | head -1 || true)
appimage=$(ls -t {{bundle}}/appimage/*.AppImage 2> /dev/null | head -1 || true)
# apt over dpkg where it exists: it pulls in the webkit and gtk runtime the package depends on,
# and --reinstall makes installing over the same version an update rather than a no-op.
if [ -n "$deb" ] && command -v apt-get > /dev/null; then
sudo apt-get install -y --reinstall "$PWD/$deb"
echo "Installed $deb"
elif [ -n "$deb" ] && command -v dpkg > /dev/null; then
sudo dpkg -i "$deb"
echo "Installed $deb"
elif [ -n "$appimage" ]; then
install -Dm755 "$appimage" "$HOME/.local/bin/margin-calendar"
install -Dm644 src-tauri/icons/128x128.png "$HOME/.local/share/icons/margin-calendar.png"
mkdir -p "$HOME/.local/share/applications"
printf '%s\n' \
'[Desktop Entry]' \
'Type=Application' \
'Name=Margin Calendar' \
'Comment=A calendar for Google Calendar' \
'Exec=margin-calendar' \
'Icon=margin-calendar' \
'Categories=Office;Calendar;' \
> "$HOME/.local/share/applications/margin-calendar.desktop"
echo "Installed $appimage to ~/.local/bin/margin-calendar"
echo "Make sure ~/.local/bin is on your PATH."
else
echo "just: nothing to install, no .deb or AppImage under {{bundle}}." >&2
exit 1
fi
# Remove the installed app, leaving its data directory alone.
uninstall:
#!/usr/bin/env bash
set -euo pipefail
# What the data directory is and what deleting it costs you is in docs/setup.md.
case "$(uname -s)" in
Darwin)
for dest in "/Applications/{{app}}.app" "$HOME/Applications/{{app}}.app"; do
if [ -d "$dest" ]; then rm -rf "$dest"; echo "Removed $dest"; fi
done
;;
Linux)
if dpkg -s margin-calendar > /dev/null 2>&1; then sudo apt-get remove -y margin-calendar; fi
rm -f "$HOME/.local/bin/margin-calendar" \
"$HOME/.local/share/icons/margin-calendar.png" \
"$HOME/.local/share/applications/margin-calendar.desktop"
echo "Removed the AppImage install, if there was one."
;;
*) echo "just: nothing to uninstall on $(uname -s)." >&2; exit 1 ;;
esac
+8
View File
@@ -2,5 +2,13 @@
"$schema": "https://schema.tauri.app/config/2",
"bundle": {
"createUpdaterArtifacts": true
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEY2Q0UwNEZFN0ExMTRFOTkKUldTWlRoRjYvZ1RPOW44bVRqaElibXpkazgrMXZ0bVRCbWRVdC9LRUVGYWtCc1d0cFlVY2I3UnMK",
"endpoints": [
"https://github.com/priyanshujain/margin-calendar/releases/latest/download/latest.json"
]
}
}
}