diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0e57a1c --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ded78f9 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/README.md b/README.md index 115a696..4cdb59f 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 0000000..bdcf6f3 --- /dev/null +++ b/docs/release.md @@ -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. diff --git a/justfile b/justfile new file mode 100644 index 0000000..526e0c0 --- /dev/null +++ b/justfile @@ -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 diff --git a/src-tauri/tauri.release.conf.json b/src-tauri/tauri.release.conf.json index d50663d..189ef57 100644 --- a/src-tauri/tauri.release.conf.json +++ b/src-tauri/tauri.release.conf.json @@ -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" + ] + } } }