From 7739942ca91007b2434e3dbf9a03ee741daa5e30 Mon Sep 17 00:00:00 2001 From: Eric Rasputin Date: Mon, 14 Sep 2026 21:08:31 +0530 Subject: [PATCH] Publish signed fork updates after successful main CI --- .github/workflows/ci.yml | 16 ++ .github/workflows/fork-release.yml | 84 ++++++++++ .github/workflows/release.yml | 3 + .gitignore | 1 + README.md | 27 +++- docs/fork-updates.md | 75 +++++++++ package.json | 3 +- scripts/fork-release.mjs | 252 +++++++++++++++++++++++++++++ scripts/fork-release.test.mjs | 115 +++++++++++++ src-tauri/tauri.fork.conf.json | 9 +- src/lib/updater.test.ts | 84 +++++++++- src/lib/updater.ts | 34 ++-- src/lib/updaterConfig.test.ts | 49 ++++-- 13 files changed, 715 insertions(+), 37 deletions(-) create mode 100644 .github/workflows/fork-release.yml create mode 100644 docs/fork-updates.md create mode 100644 scripts/fork-release.mjs create mode 100644 scripts/fork-release.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0204cb2..0c834796 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,8 @@ name: CI +permissions: + contents: read + on: push: branches: [main] @@ -35,8 +38,21 @@ jobs: - run: npm ci - run: npm test + - run: npm run test:release - run: npx tsc --noEmit - run: cargo fmt --check - run: cargo clippy --workspace --all-targets -- -D warnings - run: cargo check - run: cargo test + + fork-release: + needs: check + if: >- + github.repository == 'EricRasputin/monocode-eric' && + github.ref == 'refs/heads/main' && + (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + permissions: + contents: write + uses: ./.github/workflows/fork-release.yml + secrets: + updater-private-key: ${{ secrets.FORK_UPDATER_PRIVATE_KEY }} diff --git a/.github/workflows/fork-release.yml b/.github/workflows/fork-release.yml new file mode 100644 index 00000000..df4e2ca4 --- /dev/null +++ b/.github/workflows/fork-release.yml @@ -0,0 +1,84 @@ +name: Fork release + +# CI calls this only after all three platform checks pass on main. +on: + workflow_call: + secrets: + updater-private-key: + required: true + +concurrency: + group: fork-release + cancel-in-progress: false + +permissions: + contents: read + +env: + FORK_VERSION: 0.2.${{ github.run_number }} + +jobs: + build: + strategy: + fail-fast: false + matrix: + target: [aarch64-apple-darwin, x86_64-apple-darwin] + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - uses: Swatinem/rust-cache@v2 + with: + key: fork-${{ matrix.target }} + - run: npm ci + - name: Build and sign fork update + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.updater-private-key }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: "" + run: | + test -n "$TAURI_SIGNING_PRIVATE_KEY" || { echo 'FORK_UPDATER_PRIVATE_KEY is missing'; exit 1; } + npm run build:fork -- --ci --target '${{ matrix.target }}' --config "{\"version\":\"$FORK_VERSION\"}" + - name: Verify app and stage update + env: + BUILD_TARGET: ${{ matrix.target }} + run: | + APP="target/$BUILD_TARGET/release/bundle/macos/MonoCode Fork.app" + test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP/Contents/Info.plist")" = 'com.monocode.fork.worktrees' + test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP/Contents/Info.plist")" = "$FORK_VERSION" + EXPECTED_ARCH=x86_64 + if [[ "$BUILD_TARGET" == aarch64-* ]]; then EXPECTED_ARCH=arm64; fi + test "$(lipo -archs "$APP/Contents/MacOS/monocode")" = "$EXPECTED_ARCH" + codesign --verify --deep --strict "$APP" + node scripts/fork-release.mjs stage "$FORK_VERSION" "$BUILD_TARGET" + - uses: actions/upload-artifact@v4 + with: + name: fork-${{ matrix.target }} + path: release-artifacts/ + if-no-files-found: error + retention-days: 7 + + publish: + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - uses: actions/download-artifact@v4 + with: + pattern: fork-* + path: release-artifacts + merge-multiple: true + - name: Publish complete update + env: + GH_TOKEN: ${{ github.token }} + run: node scripts/fork-release.mjs publish "$FORK_VERSION" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 11557bce..fa34ef2b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,7 @@ permissions: jobs: release: + if: github.repository == 'hardbeat920/monocode' needs: [linux, windows] runs-on: macos-latest steps: @@ -305,6 +306,7 @@ jobs: "$DEB" "$APPIMAGE" "$NSIS" "$NSIS_SIG" linux: + if: github.repository == 'hardbeat920/monocode' name: Linux packages runs-on: ubuntu-22.04 steps: @@ -346,6 +348,7 @@ jobs: if-no-files-found: error windows: + if: github.repository == 'hardbeat920/monocode' name: Windows packages runs-on: windows-latest steps: diff --git a/.gitignore b/.gitignore index 5cde0cc3..2f5c623c 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ dist/ dist-ssr/ build/ target/ +release-artifacts/ gen/schemas/ coverage/ .vite/ diff --git a/README.md b/README.md index 8d3aed86..f405a0ec 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,15 @@ Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, OpenCod ## Install +**This fork (macOS):** download **MonoCode Fork** for Apple Silicon (`darwin-aarch64`) +or Intel (`darwin-x86_64`) from [fork releases](https://github.com/EricRasputin/monocode-eric/releases/latest) +and install it in `/Applications`. Then use **Check for Updates…** in the app menu +or Settings. The app asks before downloading, installing, and restarting. +Builds from before `0.2.0` need this one-time installation to enable updates. +See [fork updates](docs/fork-updates.md) for release and signing details. + +The download links below are for upstream MonoCode, which is a separate app. + > Install and log in to at least one provider first: > > - [Claude Code](https://claude.com/product/claude-code) - `claude auth login` @@ -48,13 +57,21 @@ Small, focused pull requests are welcome. Anything large is worth an issue first ## Build from source -For the macOS fork, run `npm ci` followed by `npm run build:fork`. The app and -DMG are written under `target/release/bundle/`. This uses the existing -**MonoCode Fork** identity (`com.monocode.fork.worktrees`) and its session data, -with upstream automatic updates disabled. +Fork releases are built and published automatically after changes to `main` +pass CI on macOS, Linux, and Windows. Users update from inside the app; no local +build is needed. Fork versions use `0.2.` independently of upstream. + +For a local macOS build, run `npm ci` followed by `npm run build:fork` with +`TAURI_SIGNING_PRIVATE_KEY` set to the fork's signing key path and +`TAURI_SIGNING_PRIVATE_KEY_PASSWORD` set to an empty string. The app, DMG, and +signed update archive are written under `target/release/bundle/`. This preserves +the **MonoCode Fork** identity (`com.monocode.fork.worktrees`) and its session data, +and checks only this fork's update feed. Without the signing key, use +`npm run build:fork -- --config '{"bundle":{"createUpdaterArtifacts":false}}'` +to create a local app and DMG without a publishable update archive. When building from a session inside MonoCode Fork, leave the running app in -place. Quit it before replacing `~/Applications/MonoCode Fork.app` with the +place. Quit it before replacing `/Applications/MonoCode Fork.app` with the new bundle, then reopen it. Build in this checkout's own `target` directory so other running development builds are unaffected. diff --git a/docs/fork-updates.md b/docs/fork-updates.md new file mode 100644 index 00000000..63368397 --- /dev/null +++ b/docs/fork-updates.md @@ -0,0 +1,75 @@ +# MonoCode Fork updates + +Install the appropriate macOS DMG from +[fork releases](https://github.com/EricRasputin/monocode-eric/releases/latest) in +`/Applications`. The app keeps the existing `com.monocode.fork.worktrees` identity +and saved conversations. Do not install upstream MonoCode as a fork update. + +Choose **Check for Updates…** from the app menu or Settings. If a newer release +exists, the app shows its version and release notes and asks whether to install +and restart. Declining leaves the app running. Accepting downloads the package, +verifies its signature, installs it, and restarts. Startup checks also show an +available update in the sidebar. Failed checks or downloads do not count as a +successful update. + +The old `0.1.x` fork builds have no update endpoint or public key, so they need +one initial replacement with a `0.2.x` build. Quit the old app first. If both +`~/Applications/MonoCode Fork.app` and `/Applications/MonoCode Fork.app` exist, +launch the new copy from `/Applications` to avoid opening the old build. + +## Releases + +The `CI` workflow calls `fork-release.yml` after **all** macOS, Linux, and Windows +checks pass for a push to `main`. PR checks never publish. A manual run of `CI` +on `main` can also publish a release. No version edit, tag push, local build, +or agent request is needed after merging a change. + +- The app's fork version is `0.2.`. Upstream's version stays in + `package.json`, Cargo, and the base Tauri config; the fork config overrides it. +- Release tags use `fork-v0.2.`, so upstream `v*` tags stay separate. +- Both `darwin-aarch64` and `darwin-x86_64` packages must finish successfully. +- The workflow checks bundle identity, version, architecture, and code signature. + It stages a DMG, signed `.app.tar.gz`, and signature for each architecture. +- A single publisher validates artifact hashes and commit/version consistency, + then uploads all packages, `SHA256SUMS`, and `latest.json` to a draft release. + Only a complete upload becomes public and the latest release. +- The app reads `releases/latest/download/latest.json`; package URLs point to + immutable version tags. The embedded public key verifies update signatures. +- Releases are serialized. Reruns and delayed older builds never replace a + published newer release. A failed draft can be retried with the same CI run; + a fresh manual CI run gets a new version. + +The inherited upstream release workflow is restricted to `hardbeat920/monocode`. +It cannot publish an upstream-branded release over this fork's update feed. +Fork distribution currently targets macOS; Linux and Windows remain CI targets. + +## Signing + +`src-tauri/tauri.fork.conf.json` contains the public updater key. The matching +private key is the GitHub Actions repository secret `FORK_UPDATER_PRIVATE_KEY`, +passed only to the packaging step. The key has no password, so the workflow sets +`TAURI_SIGNING_PRIVATE_KEY_PASSWORD` to an empty string. Never commit the private +key or print it in logs. The initial local backup is stored outside the repo at +`~/.config/monocode-eric/updater/private.key` with owner-only permissions; keep a +durable backup because losing it prevents updates to already-installed apps. + +Tauri update signing authenticates downloaded update packages. Apple signing and +notarization are separate: these builds currently use the existing ad-hoc macOS +signature. A downloaded installer may require approval in macOS Privacy & +Security on first installation. Apple Developer ID distribution and notarization +can be configured later without replacing the updater key or application identity. + +References: [Tauri updater](https://v2.tauri.app/plugin/updater/) and +[macOS signing](https://v2.tauri.app/distribute/sign/macos/). + +## Validation and recovery + +`npm run check:web` includes release metadata tests and the updater interaction +tests. CI additionally compiles and tests Rust on all three platforms. To recover +from a bad release, revert the source change and let CI publish a higher fork +version. Do not move an existing release tag or replace a published archive. + +Local builds default to `0.2.0`; release builds get their version through the +Tauri config override in CI. Local builds are useful for development but are not +published releases. A private-key-free local build can disable generation of +updater artifacts using the command in the README. diff --git a/package.json b/package.json index 79a00607..f56daa2b 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,10 @@ "build:fork": "tauri build --bundles app,dmg --config src-tauri/tauri.fork.conf.json", "preview": "vite preview", "test": "vitest run", + "test:release": "node --test scripts/fork-release.test.mjs", "test:watch": "vitest", "check": "npm run check:web && npm run check:rust", - "check:web": "vitest run && tsc --noEmit", + "check:web": "vitest run && npm run test:release && tsc --noEmit", "check:rust": "cargo fmt --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test", "set-version": "node scripts/bump-version.mjs", "tauri": "tauri", diff --git a/scripts/fork-release.mjs b/scripts/fork-release.mjs new file mode 100644 index 00000000..a5c769fa --- /dev/null +++ b/scripts/fork-release.mjs @@ -0,0 +1,252 @@ +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { + copyFileSync, + mkdirSync, + readFileSync, + readdirSync, + statSync, + writeFileSync, +} from "node:fs"; +import { basename, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const repository = "EricRasputin/monocode-eric"; +const platforms = { + "aarch64-apple-darwin": "darwin-aarch64", + "x86_64-apple-darwin": "darwin-x86_64", +}; + +function releaseTag(version) { + if (!/^0\.2\.[1-9]\d*$/.test(version)) { + throw new Error(`Invalid fork release version: ${version}`); + } + return `fork-v${version}`; +} + +function digest(path) { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +function json(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +function writeJson(path, value) { + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); +} + +function gitSha() { + return execFileSync("git", ["rev-parse", "HEAD"], { + encoding: "utf8", + }).trim(); +} + +export function stage(version, target, root = process.cwd()) { + releaseTag(version); + const platform = platforms[target]; + if (!platform) throw new Error(`Unsupported target: ${target}`); + const bundle = join(root, "target", target, "release/bundle"); + const output = join(root, "release-artifacts"); + mkdirSync(output, { recursive: true }); + const archiveSource = join(bundle, "macos/MonoCode Fork.app.tar.gz"); + const signature = readFileSync(`${archiveSource}.sig`, "utf8").trim(); + if (!signature) throw new Error("Missing updater signature"); + const dmgs = readdirSync(join(bundle, "dmg")).filter((name) => + name.endsWith(".dmg"), + ); + if (dmgs.length !== 1) throw new Error("Expected exactly one DMG"); + const stem = `MonoCode-Fork_${version}_${platform}`; + const archive = `${stem}.app.tar.gz`; + const dmg = `${stem}.dmg`; + copyFileSync(archiveSource, join(output, archive)); + copyFileSync(`${archiveSource}.sig`, join(output, `${archive}.sig`)); + copyFileSync(join(bundle, "dmg", dmgs[0]), join(output, dmg)); + writeJson(join(output, `${platform}.json`), { + version, + sha: gitSha(), + upstreamVersion: json(join(root, "package.json")).version, + platform, + archive, + dmg, + signature, + sha256: { + [archive]: digest(join(output, archive)), + [dmg]: digest(join(output, dmg)), + }, + }); +} + +export function manifest(version, sha, notes, directory = "release-artifacts") { + const tag = releaseTag(version); + const update = { + version, + notes, + pub_date: new Date().toISOString(), + platforms: {}, + }; + const assets = []; + const sums = []; + for (const platform of Object.values(platforms)) { + const metadata = json(join(directory, `${platform}.json`)); + if ( + metadata.version !== version || + metadata.sha !== sha || + metadata.platform !== platform + ) { + throw new Error(`Mismatched release metadata for ${platform}`); + } + const stem = `MonoCode-Fork_${version}_${platform}`; + if ( + metadata.archive !== `${stem}.app.tar.gz` || + metadata.dmg !== `${stem}.dmg` + ) { + throw new Error(`Unexpected asset names for ${platform}`); + } + const signature = readFileSync( + join(directory, `${metadata.archive}.sig`), + "utf8", + ).trim(); + if (!signature || signature !== metadata.signature) { + throw new Error(`Mismatched updater signature for ${platform}`); + } + for (const name of [metadata.archive, metadata.dmg]) { + const hash = digest(join(directory, name)); + if ( + !statSync(join(directory, name)).size || + hash !== metadata.sha256[name] + ) { + throw new Error(`Damaged release asset: ${name}`); + } + assets.push(join(directory, name)); + sums.push(`${hash} ${name}`); + } + assets.push(join(directory, `${metadata.archive}.sig`)); + update.platforms[platform] = { + url: `https://github.com/${repository}/releases/download/${tag}/${metadata.archive}`, + signature, + }; + } + writeJson(join(directory, "latest.json"), update); + writeFileSync(join(directory, "SHA256SUMS"), `${sums.join("\n")}\n`); + return [ + ...assets, + join(directory, "latest.json"), + join(directory, "SHA256SUMS"), + ]; +} + +function gh(...args) { + return execFileSync("gh", args, { + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024, + }); +} + +export function shouldPublish(version, releases) { + releaseTag(version); + const build = Number(version.split(".")[2]); + return !releases.some((release) => { + const match = /^fork-v0\.2\.([1-9]\d*)$/.exec(release.tag_name); + return ( + !release.draft && + !release.prerelease && + match && + Number(match[1]) >= build + ); + }); +} + +function publish(version) { + const tag = releaseTag(version); + if ( + process.env.GITHUB_REPOSITORY !== repository || + process.env.GITHUB_REF !== "refs/heads/main" + ) { + throw new Error( + "Fork releases must be published from this repository's main branch", + ); + } + const sha = gitSha(); + if (sha !== process.env.GITHUB_SHA) + throw new Error("Checkout differs from the CI commit"); + const releases = JSON.parse( + gh("api", `repos/${repository}/releases`, "--paginate", "--slurp"), + ).flat(); + if (!shouldPublish(version, releases)) { + console.log( + "This version or a newer fork update is already published; leaving it unchanged.", + ); + return; + } + const previous = releases.find( + (release) => !release.draft && /^fork-v0\.2\./.test(release.tag_name), + ); + const notesArgs = [ + "api", + `repos/${repository}/releases/generate-notes`, + "-f", + `tag_name=${tag}`, + "-f", + `target_commitish=${sha}`, + ]; + if (previous) notesArgs.push("-f", `previous_tag_name=${previous.tag_name}`); + const generated = JSON.parse(gh(...notesArgs)).body; + const upstream = json("package.json").version; + const notes = `MonoCode Fork ${version} (upstream ${upstream}).\n\n${generated}`; + const assets = manifest(version, sha, notes); + const notesFile = "release-artifacts/release-notes.md"; + writeFileSync(notesFile, `${notes}\n`); + const draft = releases.find((release) => release.tag_name === tag); + if (draft) { + if (!draft.draft || draft.target_commitish !== sha) { + throw new Error("Existing release does not match this draft and commit"); + } + gh("release", "edit", tag, "--repo", repository, "--notes-file", notesFile); + gh("release", "upload", tag, ...assets, "--repo", repository, "--clobber"); + } else { + gh( + "release", + "create", + tag, + ...assets, + "--repo", + repository, + "--target", + sha, + "--draft", + "--title", + `MonoCode Fork ${version}`, + "--notes-file", + notesFile, + ); + } + const uploaded = JSON.parse( + gh("release", "view", tag, "--repo", repository, "--json", "assets"), + ).assets; + for (const path of assets) { + if ( + !uploaded.some( + (asset) => + asset.name === basename(path) && asset.size === statSync(path).size, + ) + ) { + throw new Error(`Release upload incomplete: ${basename(path)}`); + } + } + gh("release", "edit", tag, "--repo", repository, "--draft=false", "--latest"); + console.log(`Published https://github.com/${repository}/releases/tag/${tag}`); +} + +if ( + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + const [command, version, target] = process.argv.slice(2); + if (command === "stage") stage(version, target); + else if (command === "publish") publish(version); + else + throw new Error( + "Usage: node scripts/fork-release.mjs [target]", + ); +} diff --git a/scripts/fork-release.test.mjs b/scripts/fork-release.test.mjs new file mode 100644 index 00000000..e9a5e4bc --- /dev/null +++ b/scripts/fork-release.test.mjs @@ -0,0 +1,115 @@ +import { createHash } from "node:crypto"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { manifest, shouldPublish } from "./fork-release.mjs"; + +function fixture(t) { + const dir = mkdtempSync(join(tmpdir(), "fork-release-test-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const metadata = {}; + for (const platform of ["darwin-aarch64", "darwin-x86_64"]) { + const stem = `MonoCode-Fork_0.2.7_${platform}`; + const archive = `${stem}.app.tar.gz`; + const dmg = `${stem}.dmg`; + const contents = `build 7 for ${platform}`; + const hash = createHash("sha256").update(contents).digest("hex"); + const signature = `signature-for-${platform}`; + writeFileSync(join(dir, archive), contents); + writeFileSync(join(dir, dmg), contents); + writeFileSync(join(dir, `${archive}.sig`), signature); + metadata[platform] = { + version: "0.2.7", + sha: "commit-7", + platform, + archive, + dmg, + signature, + sha256: { [archive]: hash, [dmg]: hash }, + }; + writeFileSync( + join(dir, `${platform}.json`), + JSON.stringify(metadata[platform]), + ); + } + return { dir, metadata }; +} + +test("publishes a complete feed with immutable URLs and architecture-specific signatures", (t) => { + const { dir } = fixture(t); + const assets = manifest( + "0.2.7", + "commit-7", + "New worktree improvements", + dir, + ); + const feed = JSON.parse(readFileSync(join(dir, "latest.json"))); + assert.equal(assets.length, 8); + assert.equal(feed.version, "0.2.7"); + assert.equal(feed.notes, "New worktree improvements"); + assert.deepEqual(Object.keys(feed.platforms), [ + "darwin-aarch64", + "darwin-x86_64", + ]); + for (const [platform, item] of Object.entries(feed.platforms)) { + assert.equal(item.signature, `signature-for-${platform}`); + assert.equal( + item.url, + `https://github.com/EricRasputin/monocode-eric/releases/download/fork-v0.2.7/MonoCode-Fork_0.2.7_${platform}.app.tar.gz`, + ); + } +}); + +test("does not produce an update feed if either architecture is missing", (t) => { + const { dir } = fixture(t); + rmSync(join(dir, "darwin-x86_64.json")); + assert.throws(() => manifest("0.2.7", "commit-7", "", dir), /ENOENT/); + assert.throws(() => readFileSync(join(dir, "latest.json")), /ENOENT/); +}); + +test("rejects mixed commits and versions", (t) => { + const { dir } = fixture(t); + assert.throws( + () => manifest("0.2.7", "commit-8", "", dir), + /Mismatched release metadata/, + ); + assert.throws( + () => manifest("0.2.8", "commit-7", "", dir), + /Mismatched release metadata/, + ); +}); + +test("rejects a changed bundle or signature after staging", (t) => { + const { dir, metadata } = fixture(t); + const arm = metadata["darwin-aarch64"]; + writeFileSync(join(dir, arm.archive), "corrupted download"); + assert.throws( + () => manifest("0.2.7", "commit-7", "", dir), + /Damaged release asset/, + ); + writeFileSync(join(dir, `${arm.archive}.sig`), "different signature"); + assert.throws( + () => manifest("0.2.7", "commit-7", "", dir), + /Mismatched updater signature/, + ); +}); + +test("reruns and late older builds cannot replace a published newer update", () => { + const release = (build, draft = false) => ({ + tag_name: `fork-v0.2.${build}`, + draft, + prerelease: false, + }); + assert.equal(shouldPublish("0.2.7", []), true); + assert.equal(shouldPublish("0.2.7", [release(7, true)]), true); + assert.equal(shouldPublish("0.2.7", [release(6)]), true); + assert.equal(shouldPublish("0.2.7", [release(7)]), false); + assert.equal(shouldPublish("0.2.7", [release(10)]), false); + assert.equal(shouldPublish("0.2.10", [release(9)]), true); + assert.throws( + () => shouldPublish("0.1.46", []), + /Invalid fork release version/, + ); +}); diff --git a/src-tauri/tauri.fork.conf.json b/src-tauri/tauri.fork.conf.json index a5ba0911..d1755f1b 100644 --- a/src-tauri/tauri.fork.conf.json +++ b/src-tauri/tauri.fork.conf.json @@ -1,14 +1,17 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "MonoCode Fork", + "version": "0.2.0", "identifier": "com.monocode.fork.worktrees", "bundle": { - "createUpdaterArtifacts": false + "createUpdaterArtifacts": true }, "plugins": { "updater": { - "pubkey": "", - "endpoints": [] + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQ3OUNCMzAzQjNGNzVDQ0YKUldUUFhQZXpBN09jMTgzTVJqZ1R2VGU0MnlhOCtyOTA1UjFBUEVXcXJVdkQxd1EweXBsZjFqU1MK", + "endpoints": [ + "https://github.com/EricRasputin/monocode-eric/releases/latest/download/latest.json" + ] } } } diff --git a/src/lib/updater.test.ts b/src/lib/updater.test.ts index 848cddb8..9f200a16 100644 --- a/src/lib/updater.test.ts +++ b/src/lib/updater.test.ts @@ -2,17 +2,22 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ announce: vi.fn(), + ask: vi.fn(), check: vi.fn(), downloadAndInstall: vi.fn(), + getName: vi.fn().mockResolvedValue("MonoCode"), getVersion: vi.fn(), message: vi.fn(), relaunch: vi.fn(), remember: vi.fn(), })); -vi.mock("@tauri-apps/api/app", () => ({ getVersion: mocks.getVersion })); +vi.mock("@tauri-apps/api/app", () => ({ + getName: mocks.getName, + getVersion: mocks.getVersion, +})); vi.mock("@tauri-apps/plugin-dialog", () => ({ - ask: vi.fn(), + ask: mocks.ask, message: mocks.message, })); vi.mock("@tauri-apps/plugin-process", () => ({ relaunch: mocks.relaunch })); @@ -24,10 +29,85 @@ beforeEach(() => { vi.clearAllMocks(); vi.resetModules(); mocks.getVersion.mockResolvedValue("0.1.22"); + mocks.getName.mockResolvedValue("MonoCode Fork"); mocks.relaunch.mockResolvedValue(undefined); mocks.message.mockResolvedValue(undefined); }); +describe("Check for Updates", () => { + it("asks before downloading and leaves the app running when declined", async () => { + mocks.ask.mockResolvedValue(false); + const updater = await updaterWithPendingUpdate(); + + const result = await updater.runUpdateFlow(true); + + expect(result.phase).toBe("available"); + expect(mocks.ask).toHaveBeenCalledWith( + expect.stringContaining( + "MonoCode Fork 0.1.23 is available (you have 0.1.22)", + ), + { title: "Update available", kind: "info" }, + ); + expect(mocks.downloadAndInstall).not.toHaveBeenCalled(); + expect(mocks.relaunch).not.toHaveBeenCalled(); + }); + + it("downloads after confirmation, reports progress, then restarts", async () => { + mocks.ask.mockResolvedValue(true); + mocks.downloadAndInstall.mockImplementation(async (progress) => { + progress({ event: "Started", data: { contentLength: 100 } }); + progress({ event: "Progress", data: { chunkLength: 50 } }); + progress({ event: "Progress", data: { chunkLength: 50 } }); + progress({ event: "Finished" }); + }); + const updater = await updaterWithPendingUpdate(); + const progress = vi.fn(); + + await expect(updater.runUpdateFlow(true, progress)).resolves.toEqual({ + phase: "current", + currentVersion: "0.1.23", + }); + expect(mocks.ask.mock.invocationCallOrder[0]).toBeLessThan( + mocks.downloadAndInstall.mock.invocationCallOrder[0], + ); + expect(progress).toHaveBeenCalledWith( + expect.objectContaining({ phase: "downloading", progress: 50 }), + ); + expect(progress).toHaveBeenCalledWith( + expect.objectContaining({ phase: "downloading", progress: 100 }), + ); + expect(mocks.relaunch).toHaveBeenCalledOnce(); + }); + + it("reports that the current release is up to date", async () => { + mocks.check.mockResolvedValue(null); + const updater = await import("./updater"); + await expect(updater.runUpdateFlow(true)).resolves.toEqual({ + phase: "current", + currentVersion: "0.1.22", + }); + expect(mocks.message).toHaveBeenCalledWith( + "You're on the latest version.", + { title: "MonoCode Fork" }, + ); + expect(mocks.ask).not.toHaveBeenCalled(); + }); + + it("does not restart or report success when signature verification fails", async () => { + mocks.ask.mockResolvedValue(true); + mocks.downloadAndInstall.mockRejectedValue( + new Error("signature verification failed"), + ); + const updater = await updaterWithPendingUpdate(); + await expect(updater.runUpdateFlow(true)).resolves.toMatchObject({ + phase: "error", + error: "signature verification failed", + }); + expect(mocks.relaunch).not.toHaveBeenCalled(); + expect(mocks.remember).not.toHaveBeenCalled(); + }); +}); + async function updaterWithPendingUpdate() { const update = { version: "0.1.23", diff --git a/src/lib/updater.ts b/src/lib/updater.ts index bc1df616..52d3934b 100644 --- a/src/lib/updater.ts +++ b/src/lib/updater.ts @@ -1,17 +1,16 @@ -import { getVersion } from "@tauri-apps/api/app"; +import { getName, getVersion } from "@tauri-apps/api/app"; import { ask, message } from "@tauri-apps/plugin-dialog"; import { relaunch } from "@tauri-apps/plugin-process"; -import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater"; +import { + check, + type DownloadEvent, + type Update, +} from "@tauri-apps/plugin-updater"; import { announceUpdateAvailable } from "./sounds"; import { rememberInstalledUpdate } from "./updateNotice"; export type UpdaterPhase = - | "idle" - | "checking" - | "current" - | "available" - | "downloading" - | "error"; + "idle" | "checking" | "current" | "available" | "downloading" | "error"; export type UpdaterSnapshot = { phase: UpdaterPhase; @@ -48,6 +47,7 @@ export async function runUpdateFlow( onProgress?: (snapshot: UpdaterSnapshot) => void, ): Promise { const currentVersion = await readAppVersion(); + const appName = await getName().catch(() => "MonoCode"); const base: UpdaterSnapshot = { phase: "checking", currentVersion }; onProgress?.(base); @@ -58,7 +58,7 @@ export async function runUpdateFlow( const current: UpdaterSnapshot = { phase: "current", currentVersion }; onProgress?.(current); if (manual) { - await message("You're on the latest version.", { title: "MonoCode" }); + await message("You're on the latest version.", { title: appName }); } return current; } @@ -77,7 +77,7 @@ export async function runUpdateFlow( const notes = update.body?.trim(); const detail = notes ? `\n\n${notes}` : ""; const yes = await ask( - `MonoCode ${update.version} is available (you have ${currentVersion}).${detail}\n\nInstall now?`, + `${appName} ${update.version} is available (you have ${currentVersion}).${detail}\n\nInstall and restart now?`, { title: "Update available", kind: "info" }, ); if (!yes) return available; @@ -89,9 +89,13 @@ export async function runUpdateFlow( const idle: UpdaterSnapshot = { phase: "idle", currentVersion }; onProgress?.(idle); if (manual) { + const releases = + appName === "MonoCode Fork" + ? "https://github.com/EricRasputin/monocode-eric/releases/latest" + : "https://github.com/hardbeat920/monocode/releases/latest"; await message( - "Automatic updates aren't configured for this build.\n\nDownload releases at https://github.com/hardbeat920/monocode/releases/latest", - { title: "MonoCode" }, + `Automatic updates aren't configured for this build.\n\nDownload the latest app at ${releases}`, + { title: appName }, ); } return idle; @@ -102,7 +106,7 @@ export async function runUpdateFlow( onProgress?.(failed); if (manual) { await message(`Couldn't check for updates.\n\n${error}`, { - title: "MonoCode", + title: appName, }); } return failed; @@ -169,7 +173,9 @@ export async function installPendingUpdate( error, }; onProgress?.(failed); - await message(`Couldn't install the update.\n\n${error}`, { title: "MonoCode" }); + await message(`Couldn't install the update.\n\n${error}`, { + title: "MonoCode", + }); return failed; } } diff --git a/src/lib/updaterConfig.test.ts b/src/lib/updaterConfig.test.ts index cbc57b28..1d190134 100644 --- a/src/lib/updaterConfig.test.ts +++ b/src/lib/updaterConfig.test.ts @@ -1,14 +1,17 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const { getVersion, check, message, ask, relaunch } = vi.hoisted(() => ({ - getVersion: vi.fn(), - check: vi.fn(), - message: vi.fn(), - ask: vi.fn(), - relaunch: vi.fn(), -})); +const { getName, getVersion, check, message, ask, relaunch } = vi.hoisted( + () => ({ + getName: vi.fn().mockResolvedValue("MonoCode"), + getVersion: vi.fn(), + check: vi.fn(), + message: vi.fn(), + ask: vi.fn(), + relaunch: vi.fn(), + }), +); -vi.mock("@tauri-apps/api/app", () => ({ getVersion })); +vi.mock("@tauri-apps/api/app", () => ({ getName, getVersion })); vi.mock("@tauri-apps/plugin-updater", () => ({ check })); vi.mock("@tauri-apps/plugin-dialog", () => ({ ask, message })); vi.mock("@tauri-apps/plugin-process", () => ({ relaunch })); @@ -17,13 +20,16 @@ vi.mock("./sounds", () => ({ announceUpdateAvailable: vi.fn() })); import { runUpdateFlow } from "./updater"; describe("updater", () => { + beforeEach(() => getName.mockResolvedValue("MonoCode")); afterEach(() => { vi.resetAllMocks(); }); it("keeps automatic checks quiet when updater endpoints are missing", async () => { getVersion.mockResolvedValue("0.1.23"); - check.mockRejectedValue(new Error("Updater does not have any endpoints set")); + check.mockRejectedValue( + new Error("Updater does not have any endpoints set"), + ); await expect(runUpdateFlow(false)).resolves.toEqual({ phase: "idle", @@ -34,14 +40,18 @@ describe("updater", () => { it("points manual checks without updater endpoints to GitHub releases", async () => { getVersion.mockResolvedValue("0.1.23"); - check.mockRejectedValue(new Error("Updater does not have any endpoints set")); + check.mockRejectedValue( + new Error("Updater does not have any endpoints set"), + ); await expect(runUpdateFlow(true)).resolves.toEqual({ phase: "idle", currentVersion: "0.1.23", }); expect(message).toHaveBeenCalledWith( - expect.stringContaining("https://github.com/hardbeat920/monocode/releases/latest"), + expect.stringContaining( + "https://github.com/hardbeat920/monocode/releases/latest", + ), { title: "MonoCode" }, ); }); @@ -56,4 +66,19 @@ describe("updater", () => { }); expect(message).toHaveBeenCalledOnce(); }); + + it("points fork builds to fork releases when updates are not configured", async () => { + getName.mockResolvedValue("MonoCode Fork"); + getVersion.mockResolvedValue("0.2.0"); + check.mockRejectedValue( + new Error("Updater does not have any endpoints set"), + ); + await runUpdateFlow(true); + expect(message).toHaveBeenCalledWith( + expect.stringContaining( + "https://github.com/EricRasputin/monocode-eric/releases/latest", + ), + { title: "MonoCode Fork" }, + ); + }); });