From 3b4366d15f45669b9927130b7ae46f3259b07faa Mon Sep 17 00:00:00 2001 From: Eric Rasputin Date: Mon, 14 Sep 2026 21:59:14 +0530 Subject: [PATCH 1/2] Publish fork updates only when explicitly requested --- .github/workflows/ci.yml | 9 ++++++++- .github/workflows/fork-release.yml | 2 +- README.md | 9 ++++++--- docs/fork-updates.md | 28 +++++++++++++++++++++------- 4 files changed, 36 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c834796..763d12bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,12 @@ on: branches: [main] pull_request: workflow_dispatch: + inputs: + publish_release: + description: Publish fork update after checks pass (main only) + type: boolean + required: false + default: false concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} @@ -50,7 +56,8 @@ jobs: if: >- github.repository == 'EricRasputin/monocode-eric' && github.ref == 'refs/heads/main' && - (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + github.event_name == 'workflow_dispatch' && + inputs.publish_release permissions: contents: write uses: ./.github/workflows/fork-release.yml diff --git a/.github/workflows/fork-release.yml b/.github/workflows/fork-release.yml index 2349593e..acba816f 100644 --- a/.github/workflows/fork-release.yml +++ b/.github/workflows/fork-release.yml @@ -1,6 +1,6 @@ name: Fork release -# CI calls this only after all three platform checks pass on main. +# CI calls this only for an explicitly requested release after all checks pass on main. on: workflow_call: secrets: diff --git a/README.md b/README.md index f405a0ec..5d9ab63d 100644 --- a/README.md +++ b/README.md @@ -57,9 +57,12 @@ Small, focused pull requests are welcome. Anything large is worth an issue first ## Build from source -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. +Changes to `main` run CI without publishing a release. When a batch is ready, +open **Actions → CI → Run workflow**, select **main**, and enable **Publish fork +update after checks pass (main only)**. After macOS, Linux, and Windows checks +pass, packaging and publishing run automatically. Users update from inside the +app; no local build is needed. Fork versions use `0.2.` +independently of upstream. See [fork updates](docs/fork-updates.md) for details. 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 diff --git a/docs/fork-updates.md b/docs/fork-updates.md index 79fbbe1a..d1cc71af 100644 --- a/docs/fork-updates.md +++ b/docs/fork-updates.md @@ -19,13 +19,26 @@ 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. +Merges accumulate on `main`. The `CI` workflow runs checks for every push and PR, +but neither event packages or publishes an app update. You decide when the +accumulated changes are ready to ship: + +1. Open [Actions → CI](https://github.com/EricRasputin/monocode-eric/actions/workflows/ci.yml). +2. Choose **Run workflow** and select the **main** branch. +3. Enable **Publish fork update after checks pass (main only)**, then run it. + +That run checks the selected main commit on macOS, Linux, and Windows. Only after +**all** checks pass does it call `fork-release.yml` to package, sign, and publish +both macOS builds. Commits merged after the run starts wait for a later release. +The app offers the new version through **Check for Updates…** after publication. +No local build, version edit, tag push, or agent request is needed. + +The publish option is off by default, so a manual run can also be used just to +check CI. Selecting another branch never publishes, even with the option enabled. - 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. + Version numbers can skip because CI-only runs do not publish releases. - Release tags use `fork-v0.2.`, so upstream `v*` tags stay separate. - Both `darwin-aarch64` and `darwin-x86_64` packages must finish successfully. - Packaging adds a changelog section for the fork version from changes on main @@ -40,7 +53,7 @@ or agent request is needed after merging a change. 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. + a fresh manual CI run with publishing enabled 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. @@ -69,8 +82,9 @@ References: [Tauri updater](https://v2.tauri.app/plugin/updater/) and `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. +from a bad release, revert the source change and manually run CI with publishing +enabled to ship 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 From 79b8662ae0945cd5131c1c18e9574f431647506d Mon Sep 17 00:00:00 2001 From: Eric Rasputin Date: Mon, 14 Sep 2026 22:16:14 +0530 Subject: [PATCH 2/2] Give the fork an independent release version sequence --- .github/workflows/ci.yml | 7 + .github/workflows/fork-release.yml | 35 ++++- README.md | 6 +- docs/fork-updates.md | 37 +++-- scripts/fork-release.mjs | 150 +++++++++++++++--- scripts/fork-release.test.mjs | 243 ++++++++++++++++++++++++++--- src-tauri/tauri.fork.conf.json | 2 +- 7 files changed, 420 insertions(+), 60 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 763d12bb..28523ad9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,11 @@ on: type: boolean required: false default: false + release_version: + description: Fork version (blank for next patch; for example 1.1.0) + type: string + required: false + default: "" concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} @@ -61,5 +66,7 @@ jobs: permissions: contents: write uses: ./.github/workflows/fork-release.yml + with: + version: ${{ inputs.release_version }} secrets: updater-private-key: ${{ secrets.FORK_UPDATER_PRIVATE_KEY }} diff --git a/.github/workflows/fork-release.yml b/.github/workflows/fork-release.yml index acba816f..28ab8896 100644 --- a/.github/workflows/fork-release.yml +++ b/.github/workflows/fork-release.yml @@ -3,6 +3,11 @@ name: Fork release # CI calls this only for an explicitly requested release after all checks pass on main. on: workflow_call: + inputs: + version: + type: string + required: false + default: "" secrets: updater-private-key: required: true @@ -14,11 +19,31 @@ concurrency: permissions: contents: read -env: - FORK_VERSION: 0.2.${{ github.run_number }} - jobs: + version: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + publish: ${{ steps.version.outputs.publish }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Select fork release version + id: version + env: + GH_TOKEN: ${{ github.token }} + FORK_REQUESTED_VERSION: ${{ inputs.version }} + run: node scripts/fork-release.mjs version "$FORK_REQUESTED_VERSION" + build: + needs: version + if: needs.version.outputs.publish == 'true' + env: + FORK_VERSION: ${{ needs.version.outputs.version }} strategy: fail-fast: false matrix: @@ -68,7 +93,9 @@ jobs: retention-days: 7 publish: - needs: build + needs: [version, build] + env: + FORK_VERSION: ${{ needs.version.outputs.version }} runs-on: ubuntu-latest permissions: contents: write diff --git a/README.md b/README.md index 5d9ab63d..42472a6a 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,10 @@ Changes to `main` run CI without publishing a release. When a batch is ready, open **Actions → CI → Run workflow**, select **main**, and enable **Publish fork update after checks pass (main only)**. After macOS, Linux, and Windows checks pass, packaging and publishing run automatically. Users update from inside the -app; no local build is needed. Fork versions use `0.2.` -independently of upstream. See [fork updates](docs/fork-updates.md) for details. +app; no local build is needed. The fork has its own versions (`1.0.0`, `1.0.1`, +and so on) and `fork-v*` tags, independent of upstream and CI run numbers. Leave +**Fork version** blank for the next patch or supply a newer minor/major version. +See [fork updates](docs/fork-updates.md) for details. 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 diff --git a/docs/fork-updates.md b/docs/fork-updates.md index d1cc71af..c09cbcd4 100644 --- a/docs/fork-updates.md +++ b/docs/fork-updates.md @@ -13,7 +13,7 @@ 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 +one initial replacement with a `0.2.x` or newer 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. @@ -25,7 +25,10 @@ accumulated changes are ready to ship: 1. Open [Actions → CI](https://github.com/EricRasputin/monocode-eric/actions/workflows/ci.yml). 2. Choose **Run workflow** and select the **main** branch. -3. Enable **Publish fork update after checks pass (main only)**, then run it. +3. Enable **Publish fork update after checks pass (main only)**. +4. Leave **Fork version** blank for the next patch release, or enter a version + such as `1.1.0` or `2.0.0` when deliberately starting a minor or major release. +5. Run the workflow. That run checks the selected main commit on macOS, Linux, and Windows. Only after **all** checks pass does it call `fork-release.yml` to package, sign, and publish @@ -36,10 +39,20 @@ No local build, version edit, tag push, or agent request is needed. The publish option is off by default, so a manual run can also be used just to check CI. Selecting another branch never publishes, even with the option enabled. -- 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. - Version numbers can skip because CI-only runs do not publish releases. -- Release tags use `fork-v0.2.`, so upstream `v*` tags stay separate. +- The fork has its own version sequence: `1.0.0`, `1.0.1`, `1.0.2`, and so on. + CI run numbers and upstream releases do not advance it. The first release under + this scheme is `1.0.0`, which upgrades from the earlier `0.2.x` fork builds. + Only stable versions in `major.minor.patch` form are accepted. +- Version selection runs inside the serialized release workflow, before either + architecture builds. It finds the highest published stable `fork-v*` version + and increments its patch number unless you supply a newer version explicitly. + Drafts, prereleases, and upstream tags do not determine the next version. +- Release tags use `fork-v1.0.0`, independently of upstream's `v*` tags. The fork + also has its own application identity, updater key, and release feed, so an + upstream release with the same numeric version cannot be installed as an update. +- Upstream's version stays in `package.json`, Cargo, and the base Tauri config; + the fork config overrides the app version. Release notes retain the upstream + version as provenance rather than using it as the fork's version. - Both `darwin-aarch64` and `darwin-x86_64` packages must finish successfully. - Packaging adds a changelog section for the fork version from changes on main since the previous fork release, so the post-update **What's new** view works. @@ -51,9 +64,13 @@ check CI. Selecting another branch never publishes, even with the option enabled 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 with publishing enabled gets a new version. +- An automatic version selection for an already-released commit skips packaging. + A delayed run for a commit that does not include the latest fork release fails + before packaging. Explicit versions must be newer than the latest release. + Neither reruns nor delayed builds replace a published newer release. +- A failed draft can be retried for its original commit and version. If that + version has a draft for another commit, choose a different explicit fork version; + the workflow refuses to overwrite a draft belonging to another commit. The inherited upstream release workflow is restricted to `hardbeat920/monocode`. It cannot publish an upstream-branded release over this fork's update feed. @@ -86,7 +103,7 @@ from a bad release, revert the source change and manually run CI with publishing enabled to ship 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 +Local builds default to `1.0.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/scripts/fork-release.mjs b/scripts/fork-release.mjs index 9c35e44f..ff0a41a1 100644 --- a/scripts/fork-release.mjs +++ b/scripts/fork-release.mjs @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import { execFileSync } from "node:child_process"; import { + appendFileSync, copyFileSync, mkdirSync, readFileSync, @@ -17,13 +18,75 @@ const platforms = { "x86_64-apple-darwin": "darwin-x86_64", }; -function releaseTag(version) { - if (!/^0\.2\.[1-9]\d*$/.test(version)) { +function versionParts(version) { + if ( + typeof version !== "string" || + version !== version.trim() || + !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version) + ) { throw new Error(`Invalid fork release version: ${version}`); } + const parts = version.split(".").map(Number); + if (!parts.every(Number.isSafeInteger)) { + throw new Error(`Invalid fork release version: ${version}`); + } + return parts; +} + +function compareVersions(left, right) { + const a = versionParts(left); + const b = versionParts(right); + for (let index = 0; index < a.length; index++) { + if (a[index] !== b[index]) return a[index] - b[index]; + } + return 0; +} + +function releaseTag(version) { + versionParts(version); return `fork-v${version}`; } +function publishedForkReleases(releases) { + return releases + .filter((release) => { + if ( + release.draft || + release.prerelease || + !release.tag_name.startsWith("fork-v") + ) { + return false; + } + try { + versionParts(release.tag_name.slice(6)); + return true; + } catch { + return false; + } + }) + .sort((a, b) => compareVersions(b.tag_name.slice(6), a.tag_name.slice(6))); +} + +export function nextForkVersion(requested, releases) { + const latest = publishedForkReleases(releases)[0]?.tag_name.slice(6); + if (requested) { + versionParts(requested); + if (compareVersions(requested, "1.0.0") < 0) { + throw new Error("New fork releases start at 1.0.0"); + } + if (latest && compareVersions(requested, latest) <= 0) { + throw new Error(`Fork version must be newer than ${latest}`); + } + return requested; + } + if (!latest || compareVersions(latest, "1.0.0") < 0) return "1.0.0"; + const parts = versionParts(latest); + parts[2]++; + const next = parts.join("."); + versionParts(next); + return next; +} + function digest(path) { return createHash("sha256").update(readFileSync(path)).digest("hex"); } @@ -66,7 +129,7 @@ function prepare(version) { "--tags", "--first-parent", "--match", - "fork-v0.2.*", + "fork-v[0-9]*", "--abbrev=0", "HEAD^", ], @@ -203,20 +266,12 @@ function gh(...args) { 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 - ); - }); + return !publishedForkReleases(releases).some( + (release) => compareVersions(release.tag_name.slice(6), version) >= 0, + ); } -function publish(version) { - const tag = releaseTag(version); +function assertReleaseSource() { if ( process.env.GITHUB_REPOSITORY !== repository || process.env.GITHUB_REF !== "refs/heads/main" @@ -228,18 +283,70 @@ function publish(version) { const sha = gitSha(); if (sha !== process.env.GITHUB_SHA) throw new Error("Checkout differs from the CI commit"); - const releases = JSON.parse( + return sha; +} + +function listReleases() { + return JSON.parse( gh("api", `repos/${repository}/releases`, "--paginate", "--slurp"), ).flat(); +} + +function resolveVersion(requested) { + const sha = assertReleaseSource(); + const releases = listReleases(); + const previous = publishedForkReleases(releases)[0]; + let alreadyReleased = false; + if (previous) { + const previousSha = execFileSync( + "git", + ["rev-parse", `${previous.tag_name}^{commit}`], + { encoding: "utf8" }, + ).trim(); + // A delayed run must not ship an older snapshot with a newer version. + try { + execFileSync("git", ["merge-base", "--is-ancestor", previousSha, sha]); + } catch { + throw new Error( + `Release commit must include the latest fork release (${previous.tag_name})`, + ); + } + alreadyReleased = previousSha === sha && !requested; + } + const version = alreadyReleased + ? previous.tag_name.slice(6) + : nextForkVersion(requested, releases); + const draft = releases.find( + (release) => release.tag_name === releaseTag(version) && release.draft, + ); + if (draft && draft.target_commitish !== sha) { + throw new Error( + `Draft fork-v${version} belongs to another commit; choose a different fork version`, + ); + } + if (!process.env.GITHUB_OUTPUT) throw new Error("GITHUB_OUTPUT is required"); + appendFileSync( + process.env.GITHUB_OUTPUT, + `version=${version}\npublish=${!alreadyReleased}\n`, + ); + console.log( + alreadyReleased + ? `This commit is already released as ${version}; nothing to publish.` + : `Selected MonoCode Fork ${version} for ${sha}`, + ); +} + +function publish(version) { + const tag = releaseTag(version); + const sha = assertReleaseSource(); + const releases = listReleases(); 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 previous = publishedForkReleases(releases)[0]; const notesArgs = [ "api", `repos/${repository}/releases/generate-notes`, @@ -301,11 +408,12 @@ if ( resolve(process.argv[1]) === fileURLToPath(import.meta.url) ) { const [command, version, target] = process.argv.slice(2); - if (command === "prepare") prepare(version); + if (command === "version") resolveVersion(version); + else if (command === "prepare") prepare(version); else if (command === "stage") stage(version, target); else if (command === "publish") publish(version); else throw new Error( - "Usage: node scripts/fork-release.mjs [target]", + "Usage: node scripts/fork-release.mjs [version] [target]", ); } diff --git a/scripts/fork-release.test.mjs b/scripts/fork-release.test.mjs index a2e312c4..42d9bdc9 100644 --- a/scripts/fork-release.test.mjs +++ b/scripts/fork-release.test.mjs @@ -1,18 +1,27 @@ import { createHash } from "node:crypto"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { test } from "node:test"; import assert from "node:assert/strict"; import { forkChangelogSection, manifest, + nextForkVersion, shouldPublish, } from "./fork-release.mjs"; test("bundled notes use the fork version and human-readable merge titles", () => { const section = forkChangelogSection( - "0.2.7", + "1.0.7", [ "Merge pull request #8 from owner/updates\n\nEnable in-app updates", "Fix session recovery\n\nPreserve archived conversations.", @@ -20,11 +29,11 @@ test("bundled notes use the fork version and human-readable merge titles", () => "0.1.46", "2026-09-14", ); - assert.match(section, /^## \[0\.2\.7\] - 2026-09-14\n/m); + assert.match(section, /^## \[1\.0\.7\] - 2026-09-14\n/m); assert.match(section, /- Enable in-app updates\n- Fix session recovery/); assert.match(section, /upstream MonoCode 0\.1\.46/); assert.ok(!section.includes("Merge pull request")); - assert.match(section, /releases\/tag\/fork-v0\.2\.7/); + assert.match(section, /releases\/tag\/fork-v1\.0\.7/); }); function fixture(t) { @@ -32,7 +41,7 @@ function fixture(t) { 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 stem = `MonoCode-Fork_1.0.7_${platform}`; const archive = `${stem}.app.tar.gz`; const dmg = `${stem}.dmg`; const contents = `build 7 for ${platform}`; @@ -42,7 +51,7 @@ function fixture(t) { writeFileSync(join(dir, dmg), contents); writeFileSync(join(dir, `${archive}.sig`), signature); metadata[platform] = { - version: "0.2.7", + version: "1.0.7", sha: "commit-7", platform, archive, @@ -61,14 +70,14 @@ function fixture(t) { test("publishes a complete feed with immutable URLs and architecture-specific signatures", (t) => { const { dir } = fixture(t); const assets = manifest( - "0.2.7", + "1.0.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.version, "1.0.7"); assert.equal(feed.notes, "New worktree improvements"); assert.deepEqual(Object.keys(feed.platforms), [ "darwin-aarch64", @@ -78,7 +87,7 @@ test("publishes a complete feed with immutable URLs and architecture-specific si 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`, + `https://github.com/EricRasputin/monocode-eric/releases/download/fork-v1.0.7/MonoCode-Fork_1.0.7_${platform}.app.tar.gz`, ); } }); @@ -86,18 +95,18 @@ test("publishes a complete feed with immutable URLs and architecture-specific si 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(() => manifest("1.0.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), + () => manifest("1.0.7", "commit-8", "", dir), /Mismatched release metadata/, ); assert.throws( - () => manifest("0.2.8", "commit-7", "", dir), + () => manifest("1.0.8", "commit-7", "", dir), /Mismatched release metadata/, ); }); @@ -107,30 +116,220 @@ test("rejects a changed bundle or signature after staging", (t) => { const arm = metadata["darwin-aarch64"]; writeFileSync(join(dir, arm.archive), "corrupted download"); assert.throws( - () => manifest("0.2.7", "commit-7", "", dir), + () => manifest("1.0.7", "commit-7", "", dir), /Damaged release asset/, ); writeFileSync(join(dir, `${arm.archive}.sig`), "different signature"); assert.throws( - () => manifest("0.2.7", "commit-7", "", dir), + () => manifest("1.0.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}`, + tag_name: `fork-v1.0.${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.equal(shouldPublish("1.0.7", []), true); + assert.equal(shouldPublish("1.0.7", [release(7, true)]), true); + assert.equal(shouldPublish("1.0.7", [release(6)]), true); + assert.equal(shouldPublish("1.0.7", [release(7)]), false); + assert.equal(shouldPublish("1.0.7", [release(10)]), false); + assert.equal(shouldPublish("1.0.10", [release(9)]), true); assert.throws( - () => shouldPublish("0.1.46", []), + () => shouldPublish("v1.0.0", []), /Invalid fork release version/, ); }); + +const release = (version, overrides = {}) => ({ + tag_name: `fork-v${version}`, + draft: false, + prerelease: false, + ...overrides, +}); + +test("fork numbering starts at 1.0.0 and ignores upstream, drafts, and prereleases", () => { + assert.equal(nextForkVersion("", []), "1.0.0"); + assert.equal(nextForkVersion("", [release("0.2.12")]), "1.0.0"); + assert.equal( + nextForkVersion("", [ + release("1.0.0"), + release("99.0.0", { tag_name: "v99.0.0" }), + release("2.0.0", { draft: true }), + release("3.0.0", { prerelease: true }), + release("4.0.0-beta.1"), + release("not-a-version"), + ]), + "1.0.1", + ); +}); + +test("fork versions advance independently and support explicit minor and major releases", () => { + const published = [release("1.9.12"), release("1.10.2"), release("0.2.99")]; + assert.equal(nextForkVersion("", published), "1.10.3"); + assert.equal(nextForkVersion("1.11.0", published), "1.11.0"); + assert.equal(nextForkVersion("2.0.0", published), "2.0.0"); + assert.equal(shouldPublish("1.0.0", [release("0.2.99")]), true); + assert.equal(shouldPublish("1.9.99", published), false); + assert.equal(shouldPublish("2.0.0", published), true); +}); + +test("rejects duplicate, older, malformed, and unsafe version numbers", () => { + for (const version of ["1.0.0", "0.2.13"]) { + assert.throws(() => nextForkVersion(version, [release("1.0.0")])); + } + for (const version of [ + "v1.0.0", + "1.01.0", + "1.0", + "1.0.0-beta.1", + "1.0.0+fork", + "1.0.0\n", + "1.0.9007199254740992", + ]) { + assert.throws( + () => nextForkVersion(version, []), + /Invalid fork release version/, + ); + } +}); + +function gitFixture(t) { + const dir = mkdtempSync(join(tmpdir(), "fork-version-test-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const git = (...args) => + execFileSync("git", args, { + cwd: dir, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + git("init"); + git("config", "user.name", "Release Test"); + git("config", "user.email", "release-test@example.invalid"); + git("config", "commit.gpgsign", "false"); + git("config", "tag.gpgsign", "false"); + const commit = (message) => { + git("commit", "--allow-empty", "-m", message); + return git("rev-parse", "HEAD"); + }; + const first = commit("First fork release"); + git("tag", "fork-v1.0.0"); + const releasesFile = join(dir, "releases.json"); + writeFileSync( + releasesFile, + JSON.stringify([[release("1.0.0", { target_commitish: first })]]), + ); + // Replace only gh's network boundary; exercise the CLI against a real git repository. + const preload = join(dir, "offline-gh.mjs"); + writeFileSync( + preload, + ` + import childProcess from "node:child_process"; + import { readFileSync } from "node:fs"; + import { syncBuiltinESMExports } from "node:module"; + const original = childProcess.execFileSync; + childProcess.execFileSync = (command, ...args) => command === "gh" + ? readFileSync(process.env.FORK_TEST_RELEASES, "utf8") + : original(command, ...args); + syncBuiltinESMExports(); + `, + ); + const output = join(dir, "output.txt"); + const script = fileURLToPath(new URL("./fork-release.mjs", import.meta.url)); + const run = (command, version = "") => + execFileSync( + process.execPath, + ["--import", pathToFileURL(preload).href, script, command, version], + { + cwd: dir, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + GITHUB_REPOSITORY: "EricRasputin/monocode-eric", + GITHUB_REF: "refs/heads/main", + GITHUB_SHA: git("rev-parse", "HEAD"), + GITHUB_OUTPUT: output, + FORK_TEST_RELEASES: releasesFile, + }, + }, + ); + return { dir, git, commit, first, releasesFile, output, run }; +} + +test("version preflight skips an already-released commit and selects the next batch", (t) => { + const fixture = gitFixture(t); + fixture.run("version"); + assert.match( + readFileSync(fixture.output, "utf8"), + /version=1.0.0\npublish=false/, + ); + fixture.commit("Batch of improvements"); + fixture.run("version"); + assert.match( + readFileSync(fixture.output, "utf8"), + /version=1.0.1\npublish=true/, + ); + fixture.run("version", "1.1.0"); + assert.match( + readFileSync(fixture.output, "utf8"), + /version=1.1.0\npublish=true/, + ); +}); + +test("a failed draft can be retried only for its original commit", (t) => { + const fixture = gitFixture(t); + const candidate = fixture.commit("Release candidate"); + writeFileSync( + fixture.releasesFile, + JSON.stringify([ + [ + release("1.0.0", { target_commitish: fixture.first }), + release("1.0.1", { draft: true, target_commitish: candidate }), + ], + ]), + ); + fixture.run("version"); + const selected = readFileSync(fixture.output, "utf8"); + assert.match(selected, /version=1.0.1\npublish=true/); + fixture.commit("Different release candidate"); + assert.throws(() => fixture.run("version"), /belongs to another commit/); + assert.equal(readFileSync(fixture.output, "utf8"), selected); +}); + +test("version preflight rejects an old commit after a newer release has shipped", (t) => { + const fixture = gitFixture(t); + const newer = fixture.commit("Newer release"); + fixture.git("tag", "fork-v1.0.1"); + writeFileSync( + fixture.releasesFile, + JSON.stringify([[release("1.0.1", { target_commitish: newer })]]), + ); + fixture.git("checkout", "--detach", fixture.first); + assert.throws(() => fixture.run("version")); + assert.equal(existsSync(fixture.output), false); +}); + +test("bundled notes include the batch since the last independently numbered fork release", (t) => { + const fixture = gitFixture(t); + fixture.commit("First fix in the batch"); + fixture.commit("Second fix in the batch"); + writeFileSync( + join(fixture.dir, "package.json"), + JSON.stringify({ version: "9.8.7" }), + ); + writeFileSync( + join(fixture.dir, "CHANGELOG.md"), + "# Changelog\n\nUpstream history\n", + ); + fixture.run("prepare", "1.0.1"); + const notes = readFileSync(join(fixture.dir, "CHANGELOG.md"), "utf8"); + assert.match(notes, /## \[1\.0\.1\]/); + assert.match(notes, /upstream MonoCode 9\.8\.7/); + assert.match(notes, /First fix in the batch/); + assert.match(notes, /Second fix in the batch/); + assert.ok(!notes.includes("First fork release")); +}); diff --git a/src-tauri/tauri.fork.conf.json b/src-tauri/tauri.fork.conf.json index d1755f1b..39c49170 100644 --- a/src-tauri/tauri.fork.conf.json +++ b/src-tauri/tauri.fork.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "MonoCode Fork", - "version": "0.2.0", + "version": "1.0.0", "identifier": "com.monocode.fork.worktrees", "bundle": { "createUpdaterArtifacts": true