From 52fb07ad9787b091e74bc6a7bd37626b030b47ed Mon Sep 17 00:00:00 2001 From: David <202521901+EaeDave@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:42:36 -0300 Subject: [PATCH 1/7] feat: automate cross-platform releases --- .github/workflows/release-windows-qd.yml | 55 --------- .github/workflows/release.yml | 116 +++++++++++++++++++ README.md | 12 ++ package.json | 2 + scripts/release.test.ts | 21 ++++ scripts/release.ts | 136 +++++++++++++++++++++++ 6 files changed, 287 insertions(+), 55 deletions(-) delete mode 100644 .github/workflows/release-windows-qd.yml create mode 100644 .github/workflows/release.yml create mode 100644 scripts/release.test.ts create mode 100644 scripts/release.ts diff --git a/.github/workflows/release-windows-qd.yml b/.github/workflows/release-windows-qd.yml deleted file mode 100644 index 87a4386..0000000 --- a/.github/workflows/release-windows-qd.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Release Windows qd - -on: - pull_request: - paths: - - .github/workflows/release-windows-qd.yml - - cli/** - - scripts/package-windows-qd-release.ts - - package.json - - src-tauri/tauri.conf.json - workflow_dispatch: - inputs: - tag: - description: Existing GitHub release tag - required: true - default: v0.1.1 - release: - types: [published] - -permissions: - contents: write - -jobs: - build-and-upload: - runs-on: windows-latest - env: - RELEASE_TAG: ${{ github.event.inputs.tag || github.event.release.tag_name }} - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || env.RELEASE_TAG }} - - - uses: dtolnay/rust-toolchain@stable - with: - targets: x86_64-pc-windows-msvc - - - uses: oven-sh/setup-bun@v2 - - - name: Build Windows CLI and TUI - run: cargo build --release --target x86_64-pc-windows-msvc --manifest-path cli/Cargo.toml - - - name: Package release assets - run: bun run qd:package:windows - - - name: Upload assets to release - if: github.event_name != 'pull_request' - shell: pwsh - env: - GH_TOKEN: ${{ github.token }} - run: | - $version = (Get-Content src-tauri/tauri.conf.json | ConvertFrom-Json).version - gh release upload $env:RELEASE_TAG ` - "cli/target/release/qd_${version}_x86_64-windows.exe" ` - "cli/target/release/qd_${version}_x86_64-windows.exe.sha256" ` - --clobber diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7871837 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,116 @@ +name: Build and release + +on: + pull_request: + paths: + - .github/workflows/release.yml + - cli/** + - src-tauri/** + - src/desktop/** + - scripts/package-linux-release.ts + - scripts/package-windows-qd-release.ts + - scripts/release.ts + - package.json + - bun.lock + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: write + +jobs: + linux: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-unknown-linux-gnu + + - uses: oven-sh/setup-bun@v2 + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + cli + src-tauri + + - name: Install Linux build dependencies + run: | + sudo apt-get update + sudo apt-get install -y libayatana-appindicator3-dev libgtk-3-dev libwebkit2gtk-4.1-dev + + - name: Install JavaScript dependencies + run: bun install --frozen-lockfile + + - name: Build Linux desktop and qd + run: | + bun tauri build --ci --no-bundle + cargo build --release --target x86_64-unknown-linux-gnu --manifest-path cli/Cargo.toml + + - name: Package Linux assets + run: bun run scripts/package-linux-release.ts + + - uses: actions/upload-artifact@v4 + with: + name: linux-release + path: | + src-tauri/target/release/quickdrop_*_x86_64-linux + cli/target/release/qd_*_x86_64-linux + cli/target/release/qd_*_x86_64-linux.sha256 + if-no-files-found: error + + windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-pc-windows-msvc + + - uses: oven-sh/setup-bun@v2 + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + cli + src-tauri + + - name: Install JavaScript dependencies + run: bun install --frozen-lockfile + + - name: Build Windows desktop installer and qd + run: | + bun tauri build --ci --target x86_64-pc-windows-msvc --bundles nsis + cargo build --release --target x86_64-pc-windows-msvc --manifest-path cli/Cargo.toml + + - name: Package Windows qd assets + run: bun run scripts/package-windows-qd-release.ts + + - uses: actions/upload-artifact@v4 + with: + name: windows-release + path: | + src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/QuickDrop_*_x64-setup.exe + cli/target/release/qd_*_x86_64-windows.exe + cli/target/release/qd_*_x86_64-windows.exe.sha256 + if-no-files-found: error + + publish: + if: startsWith(github.ref, 'refs/tags/v') + needs: [linux, windows] + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + path: release-assets + merge-multiple: true + + - name: Publish GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: gh release create "${GITHUB_REF_NAME}" release-assets/* --repo "${GITHUB_REPOSITORY}" --verify-tag --generate-notes --title "${GITHUB_REF_NAME}" diff --git a/README.md b/README.md index b568626..c6cbbb8 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,18 @@ Package the Linux release: bun run desktop:package:linux ``` +### Publishing a release + +From a clean, up-to-date `main` branch, publish with one command: + +```bash +bun run release patch +# or: bun run release minor +# or: bun run release 1.0.0 +``` + +The command keeps the desktop and CLI manifests aligned, runs both Cargo checks, commits the version, and atomically pushes `main` with the annotated tag. GitHub Actions then builds Linux and Windows in parallel with Rust caches and publishes the desktop binaries, Windows installer, CLI/TUI binaries, and checksums. Use `bun run release:check` for a non-publishing manifest check. + ### End-user installation Windows PowerShell: diff --git a/package.json b/package.json index c74414c..d85ef3d 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "db:check": "bunx drizzle-kit check", "cleanup:run": "bun run src/server/cleanup-cli.ts", "metrics:text": "bun run scripts/text-metrics-report.ts", + "release": "bun run scripts/release.ts", + "release:check": "bun run scripts/release.ts --check", "qd": "mise exec rust@stable -- cargo run --quiet --manifest-path cli/Cargo.toml --", "qd:build": "mise exec rust@stable -- cargo build --release --manifest-path cli/Cargo.toml", "qd:build:linux": "mise exec rust@stable -- cargo build --release --target x86_64-unknown-linux-gnu --manifest-path cli/Cargo.toml", diff --git a/scripts/release.test.ts b/scripts/release.test.ts new file mode 100644 index 0000000..e5fa980 --- /dev/null +++ b/scripts/release.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test"; +import { nextVersion, parseVersion } from "./release"; + +describe("release version selection", () => { + test("increments semantic versions", () => { + expect(nextVersion("1.2.3", "patch")).toBe("1.2.4"); + expect(nextVersion("1.2.3", "minor")).toBe("1.3.0"); + expect(nextVersion("1.2.3", "major")).toBe("2.0.0"); + }); + + test("accepts an explicit version with or without v", () => { + expect(nextVersion("1.2.3", "2.0.1")).toBe("2.0.1"); + expect(nextVersion("1.2.3", "v2.0.1")).toBe("2.0.1"); + }); + + test("rejects malformed or ambiguous versions", () => { + for (const version of ["1.2", "1.2.3-beta", "01.2.3", "latest"]) { + expect(() => parseVersion(version)).toThrow("Invalid semantic version"); + } + }); +}); diff --git a/scripts/release.ts b/scripts/release.ts new file mode 100644 index 0000000..2104e54 --- /dev/null +++ b/scripts/release.ts @@ -0,0 +1,136 @@ +import { readFile, writeFile } from "node:fs/promises"; + +const VERSION_FILES = [ + "src-tauri/tauri.conf.json", + "src-tauri/Cargo.toml", + "src-tauri/Cargo.lock", + "cli/Cargo.toml", + "cli/Cargo.lock", +] as const; + +type ReleaseKind = "patch" | "minor" | "major"; + +export function parseVersion(value: string): [number, number, number] { + const match = /^(?:v)?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(value); + if (!match) throw new Error(`Invalid semantic version: ${value}`); + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +export function nextVersion(current: string, requested: string): string { + if (!["patch", "minor", "major"].includes(requested)) { + return parseVersion(requested).join("."); + } + const [major, minor, patch] = parseVersion(current); + switch (requested as ReleaseKind) { + case "major": + return `${major + 1}.0.0`; + case "minor": + return `${major}.${minor + 1}.0`; + case "patch": + return `${major}.${minor}.${patch + 1}`; + } +} + +async function manifestVersions(): Promise> { + const versions = new Map(); + const tauriConfig = (await Bun.file(VERSION_FILES[0]).json()) as { version?: string }; + if (!tauriConfig.version) throw new Error(`${VERSION_FILES[0]} has no version`); + versions.set(VERSION_FILES[0], tauriConfig.version); + + for (const path of VERSION_FILES.slice(1)) { + const text = await readFile(path, "utf8"); + const packageName = path.startsWith("cli/") ? "quickdrop-cli" : "quickdrop"; + const packageBlock = new RegExp(`(?:^|\\n)(?:\\[\\[package\\]\\]|\\[package\\])\\nname = "${packageName}"\\nversion = "([^"]+)"`); + const match = packageBlock.exec(text); + if (!match) throw new Error(`Could not find ${packageName} version in ${path}`); + versions.set(path, match[1]!); + } + return versions; +} + +export async function currentVersion(): Promise { + const versions = await manifestVersions(); + const unique = [...new Set(versions.values())]; + if (unique.length !== 1) { + throw new Error( + `Release versions disagree:\n${[...versions].map(([path, version]) => ` ${path}: ${version}`).join("\n")}`, + ); + } + return unique[0]!; +} + +async function replaceVersion(path: string, current: string, next: string): Promise { + if (path.endsWith("tauri.conf.json")) { + const config = (await Bun.file(path).json()) as Record; + config.version = next; + await writeFile(path, `${JSON.stringify(config, null, 2)}\n`); + return; + } + + const packageName = path.startsWith("cli/") ? "quickdrop-cli" : "quickdrop"; + const text = await readFile(path, "utf8"); + const packageBlock = new RegExp(`((?:^|\\n)(?:\\[\\[package\\]\\]|\\[package\\])\\nname = "${packageName}"\\nversion = ")${current}(" )?`); + const match = packageBlock.exec(text); + if (!match) throw new Error(`Could not update ${packageName} version in ${path}`); + const updated = text.slice(0, match.index) + match[0].replace(`version = "${current}"`, `version = "${next}"`) + text.slice(match.index + match[0].length); + await writeFile(path, updated); +} + +async function output(command: string[]): Promise { + const result = Bun.spawnSync(command, { stdout: "pipe", stderr: "inherit" }); + if (result.exitCode !== 0) throw new Error(`Command failed: ${command.join(" ")}`); + return result.stdout.toString().trim(); +} + +async function run(command: string[]): Promise { + const process = Bun.spawn(command, { stdout: "inherit", stderr: "inherit", stdin: "inherit" }); + if ((await process.exited) !== 0) throw new Error(`Command failed: ${command.join(" ")}`); +} + +async function preflight(tag: string): Promise { + if ((await output(["git", "branch", "--show-current"])) !== "main") { + throw new Error("Releases must be created from main"); + } + if (await output(["git", "status", "--porcelain"])) { + throw new Error("Working tree must be clean before releasing"); + } + await run(["git", "fetch", "origin", "main", "--tags"]); + if ((await output(["git", "rev-parse", "HEAD"])) !== (await output(["git", "rev-parse", "origin/main"]))) { + throw new Error("Local main must match origin/main before releasing"); + } + if (Bun.spawnSync(["git", "rev-parse", "--verify", "--quiet", `refs/tags/${tag}`]).exitCode === 0) { + throw new Error(`Tag ${tag} already exists`); + } +} + +async function main(): Promise { + const requested = process.argv[2]; + const checkOnly = requested === "--check"; + const current = await currentVersion(); + if (checkOnly) { + console.log(`Release manifests agree on v${current}`); + return; + } + if (!requested) throw new Error("Usage: bun run release "); + + const next = nextVersion(current, requested); + if (next === current) throw new Error(`Version is already ${current}`); + const tag = `v${next}`; + await preflight(tag); + + for (const path of VERSION_FILES) await replaceVersion(path, current, next); + await run(["cargo", "check", "--manifest-path", "cli/Cargo.toml"]); + await run(["cargo", "check", "--manifest-path", "src-tauri/Cargo.toml"]); + await run(["git", "add", ...VERSION_FILES]); + await run(["git", "commit", "-m", `chore: release ${tag}`]); + await run(["git", "tag", "-a", tag, "-m", `QuickDrop ${tag}`]); + await run(["git", "push", "--atomic", "origin", "main", tag]); + console.log(`Published ${tag}; GitHub Actions is building the release assets.`); +} + +if (import.meta.main) { + main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + }); +} From 102486011dcdc010f7107666c53a6fdd882e6f04 Mon Sep 17 00:00:00 2001 From: David <202521901+EaeDave@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:55:33 -0300 Subject: [PATCH 2/7] fix: build Linux releases locally --- .github/workflows/release.yml | 78 ++++++++++------------------------- AGENTS.md | 10 +++++ README.md | 2 +- scripts/release.ts | 33 ++++++++++++++- 4 files changed, 64 insertions(+), 59 deletions(-) create mode 100644 AGENTS.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7871837..15eab56 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: Build and release +name: Build Windows release on: pull_request: @@ -7,7 +7,6 @@ on: - cli/** - src-tauri/** - src/desktop/** - - scripts/package-linux-release.ts - scripts/package-windows-qd-release.ts - scripts/release.ts - package.json @@ -16,57 +15,23 @@ on: tags: - "v*" workflow_dispatch: + inputs: + tag: + description: Existing GitHub release tag to rebuild + required: true permissions: contents: write jobs: - linux: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: dtolnay/rust-toolchain@stable - with: - targets: x86_64-unknown-linux-gnu - - - uses: oven-sh/setup-bun@v2 - - - uses: Swatinem/rust-cache@v2 - with: - workspaces: | - cli - src-tauri - - - name: Install Linux build dependencies - run: | - sudo apt-get update - sudo apt-get install -y libayatana-appindicator3-dev libgtk-3-dev libwebkit2gtk-4.1-dev - - - name: Install JavaScript dependencies - run: bun install --frozen-lockfile - - - name: Build Linux desktop and qd - run: | - bun tauri build --ci --no-bundle - cargo build --release --target x86_64-unknown-linux-gnu --manifest-path cli/Cargo.toml - - - name: Package Linux assets - run: bun run scripts/package-linux-release.ts - - - uses: actions/upload-artifact@v4 - with: - name: linux-release - path: | - src-tauri/target/release/quickdrop_*_x86_64-linux - cli/target/release/qd_*_x86_64-linux - cli/target/release/qd_*_x86_64-linux.sha256 - if-no-files-found: error - windows: runs-on: windows-latest + env: + RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || env.RELEASE_TAG }} - uses: dtolnay/rust-toolchain@stable with: @@ -100,17 +65,16 @@ jobs: cli/target/release/qd_*_x86_64-windows.exe.sha256 if-no-files-found: error - publish: - if: startsWith(github.ref, 'refs/tags/v') - needs: [linux, windows] - runs-on: ubuntu-latest - steps: - - uses: actions/download-artifact@v4 - with: - path: release-assets - merge-multiple: true - - - name: Publish GitHub release + - name: Upload Windows assets to release + if: github.event_name != 'pull_request' + shell: pwsh env: GH_TOKEN: ${{ github.token }} - run: gh release create "${GITHUB_REF_NAME}" release-assets/* --repo "${GITHUB_REPOSITORY}" --verify-tag --generate-notes --title "${GITHUB_REF_NAME}" + run: | + $version = (Get-Content src-tauri/tauri.conf.json | ConvertFrom-Json).version + $installer = Get-ChildItem "src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/QuickDrop_*_x64-setup.exe" | Select-Object -First 1 -ExpandProperty FullName + gh release upload $env:RELEASE_TAG ` + $installer ` + "cli/target/release/qd_${version}_x86_64-windows.exe" ` + "cli/target/release/qd_${version}_x86_64-windows.exe.sha256" ` + --clobber diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d1bb87a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,10 @@ +# Repository instructions + +## Releases + +- Releases MUST be started from a clean, up-to-date local `main` branch on Linux with `bun run release `. +- Agents MUST use the release command instead of manually editing versions, creating tags, creating GitHub releases, or uploading individual assets. +- Linux desktop and `qd` binaries MUST be built locally and uploaded by `scripts/release.ts`; do not add a Linux GitHub Actions build. +- Windows desktop installer and `qd.exe` MUST be built by `.github/workflows/release.yml` on `windows-latest`; do not attempt to cross-compile or publish them locally. +- A release is complete only after the Linux assets are public, the Windows workflow succeeds, and every public download endpoint returns the matching release assets. +- `bun run release:check` is the non-publishing validation command. diff --git a/README.md b/README.md index c6cbbb8..87a380d 100644 --- a/README.md +++ b/README.md @@ -229,7 +229,7 @@ bun run release patch # or: bun run release 1.0.0 ``` -The command keeps the desktop and CLI manifests aligned, runs both Cargo checks, commits the version, and atomically pushes `main` with the annotated tag. GitHub Actions then builds Linux and Windows in parallel with Rust caches and publishes the desktop binaries, Windows installer, CLI/TUI binaries, and checksums. Use `bun run release:check` for a non-publishing manifest check. +The command keeps the desktop and CLI manifests aligned, validates them, builds and packages all Linux assets locally, commits the version, and atomically pushes `main` with its annotated tag. It immediately creates the GitHub release with the Linux desktop and CLI/TUI binaries plus checksum. The tag starts the cached Windows Actions job, which adds the Windows installer and CLI/TUI assets when ready. Linux is intentionally never built in Actions; use `bun run release:check` for a non-publishing manifest check. ### End-user installation diff --git a/scripts/release.ts b/scripts/release.ts index 2104e54..3bb54c5 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -112,6 +112,9 @@ async function main(): Promise { return; } if (!requested) throw new Error("Usage: bun run release "); + if (process.platform !== "linux") { + throw new Error("QuickDrop releases must be started from Linux; Windows assets are built in GitHub Actions"); + } const next = nextVersion(current, requested); if (next === current) throw new Error(`Version is already ${current}`); @@ -121,11 +124,39 @@ async function main(): Promise { for (const path of VERSION_FILES) await replaceVersion(path, current, next); await run(["cargo", "check", "--manifest-path", "cli/Cargo.toml"]); await run(["cargo", "check", "--manifest-path", "src-tauri/Cargo.toml"]); + await run(["bun", "tauri", "build", "--ci", "--no-bundle"]); + await run([ + "cargo", + "build", + "--release", + "--target", + "x86_64-unknown-linux-gnu", + "--manifest-path", + "cli/Cargo.toml", + ]); + await run(["bun", "run", "scripts/package-linux-release.ts"]); + + const linuxAssets = [ + `src-tauri/target/release/quickdrop_${next}_x86_64-linux`, + `cli/target/release/qd_${next}_x86_64-linux`, + `cli/target/release/qd_${next}_x86_64-linux.sha256`, + ]; await run(["git", "add", ...VERSION_FILES]); await run(["git", "commit", "-m", `chore: release ${tag}`]); await run(["git", "tag", "-a", tag, "-m", `QuickDrop ${tag}`]); await run(["git", "push", "--atomic", "origin", "main", tag]); - console.log(`Published ${tag}; GitHub Actions is building the release assets.`); + await run([ + "gh", + "release", + "create", + tag, + ...linuxAssets, + "--verify-tag", + "--generate-notes", + "--title", + tag, + ]); + console.log(`Published ${tag} with Linux assets; GitHub Actions is building the Windows assets.`); } if (import.meta.main) { From e1f67517f1b16b8a7762c1ad6191a80d3ad947af Mon Sep 17 00:00:00 2001 From: David <202521901+EaeDave@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:16:49 -0300 Subject: [PATCH 3/7] fix: require x86_64 Linux release host --- scripts/release.test.ts | 10 +++++++++- scripts/release.ts | 12 +++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/scripts/release.test.ts b/scripts/release.test.ts index e5fa980..9c41348 100644 --- a/scripts/release.test.ts +++ b/scripts/release.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { nextVersion, parseVersion } from "./release"; +import { assertReleasePlatform, nextVersion, parseVersion } from "./release"; describe("release version selection", () => { test("increments semantic versions", () => { @@ -19,3 +19,11 @@ describe("release version selection", () => { } }); }); + +describe("release host validation", () => { + test("only accepts x86_64 Linux", () => { + expect(() => assertReleasePlatform("linux", "x64")).not.toThrow(); + expect(() => assertReleasePlatform("linux", "arm64")).toThrow("x86_64 Linux"); + expect(() => assertReleasePlatform("win32", "x64")).toThrow("x86_64 Linux"); + }); +}); diff --git a/scripts/release.ts b/scripts/release.ts index 3bb54c5..bfb2b89 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -31,6 +31,14 @@ export function nextVersion(current: string, requested: string): string { } } +export function assertReleasePlatform(platform: NodeJS.Platform, arch: string): void { + if (platform !== "linux" || arch !== "x64") { + throw new Error( + "QuickDrop releases must be started from x86_64 Linux; Windows assets are built in GitHub Actions", + ); + } +} + async function manifestVersions(): Promise> { const versions = new Map(); const tauriConfig = (await Bun.file(VERSION_FILES[0]).json()) as { version?: string }; @@ -112,9 +120,7 @@ async function main(): Promise { return; } if (!requested) throw new Error("Usage: bun run release "); - if (process.platform !== "linux") { - throw new Error("QuickDrop releases must be started from Linux; Windows assets are built in GitHub Actions"); - } + assertReleasePlatform(process.platform, process.arch); const next = nextVersion(current, requested); if (next === current) throw new Error(`Version is already ${current}`); From b14cedfc70208e46314ce2823fcd0d1be14fcee8 Mon Sep 17 00:00:00 2001 From: David <202521901+EaeDave@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:25:34 -0300 Subject: [PATCH 4/7] fix: verify complete cross-platform releases --- AGENTS.md | 2 +- README.md | 4 +- scripts/release.test.ts | 11 +++- scripts/release.ts | 112 +++++++++++++++++++++++++++++++++++++--- 4 files changed, 118 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d1bb87a..59ae5f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Releases -- Releases MUST be started from a clean, up-to-date local `main` branch on Linux with `bun run release `. +- Releases MUST be started from a clean, up-to-date local `main` branch on an x86_64 Linux host with `bun run release `. - Agents MUST use the release command instead of manually editing versions, creating tags, creating GitHub releases, or uploading individual assets. - Linux desktop and `qd` binaries MUST be built locally and uploaded by `scripts/release.ts`; do not add a Linux GitHub Actions build. - Windows desktop installer and `qd.exe` MUST be built by `.github/workflows/release.yml` on `windows-latest`; do not attempt to cross-compile or publish them locally. diff --git a/README.md b/README.md index 87a380d..75f5ba3 100644 --- a/README.md +++ b/README.md @@ -221,7 +221,7 @@ bun run desktop:package:linux ### Publishing a release -From a clean, up-to-date `main` branch, publish with one command: +From a clean, up-to-date `main` branch on an x86_64 Linux host, publish with one command: ```bash bun run release patch @@ -229,7 +229,7 @@ bun run release patch # or: bun run release 1.0.0 ``` -The command keeps the desktop and CLI manifests aligned, validates them, builds and packages all Linux assets locally, commits the version, and atomically pushes `main` with its annotated tag. It immediately creates the GitHub release with the Linux desktop and CLI/TUI binaries plus checksum. The tag starts the cached Windows Actions job, which adds the Windows installer and CLI/TUI assets when ready. Linux is intentionally never built in Actions; use `bun run release:check` for a non-publishing manifest check. +The command keeps the desktop and CLI manifests aligned, validates them, builds and packages all Linux assets locally, commits the version, and atomically pushes `main` with its annotated tag. It immediately creates the GitHub release with the Linux desktop and CLI/TUI binaries plus checksum. The tag starts the cached Windows Actions job, which adds the Windows installer and CLI/TUI assets. The command waits for that job and verifies every public download before succeeding. Linux is intentionally never built in Actions; use `bun run release:check` for a non-publishing manifest check. ### End-user installation diff --git a/scripts/release.test.ts b/scripts/release.test.ts index 9c41348..d9513f7 100644 --- a/scripts/release.test.ts +++ b/scripts/release.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { assertReleasePlatform, nextVersion, parseVersion } from "./release"; +import { assertReleasePlatform, nextVersion, parseVersion, replacePackageVersion } from "./release"; describe("release version selection", () => { test("increments semantic versions", () => { @@ -20,6 +20,15 @@ describe("release version selection", () => { }); }); +describe("release manifest updates", () => { + test("replaces standard Cargo package versions only", () => { + const manifest = `[package]\nname = "quickdrop-cli"\nversion = "0.1.2"\n\n[dependencies]\nexample = "0.1.2"\n`; + expect(replacePackageVersion(manifest, "quickdrop-cli", "0.1.2", "0.1.3")).toBe( + `[package]\nname = "quickdrop-cli"\nversion = "0.1.3"\n\n[dependencies]\nexample = "0.1.2"\n`, + ); + }); +}); + describe("release host validation", () => { test("only accepts x86_64 Linux", () => { expect(() => assertReleasePlatform("linux", "x64")).not.toThrow(); diff --git a/scripts/release.ts b/scripts/release.ts index bfb2b89..979bdfa 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { readFile, writeFile } from "node:fs/promises"; const VERSION_FILES = [ @@ -64,7 +65,28 @@ export async function currentVersion(): Promise { `Release versions disagree:\n${[...versions].map(([path, version]) => ` ${path}: ${version}`).join("\n")}`, ); } - return unique[0]!; + const version = unique[0]!; + parseVersion(version); + return version; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function replacePackageVersion( + text: string, + packageName: string, + current: string, + next: string, +): string { + const packageBlock = new RegExp( + `((?:^|\\n)(?:\\[\\[package\\]\\]|\\[package\\])\\nname = "${escapeRegExp(packageName)}"\\nversion = ")${escapeRegExp(current)}(")`, + ); + if (!packageBlock.test(text)) { + throw new Error(`Could not update ${packageName} version`); + } + return text.replace(packageBlock, (_match, prefix: string, suffix: string) => `${prefix}${next}${suffix}`); } async function replaceVersion(path: string, current: string, next: string): Promise { @@ -77,11 +99,7 @@ async function replaceVersion(path: string, current: string, next: string): Prom const packageName = path.startsWith("cli/") ? "quickdrop-cli" : "quickdrop"; const text = await readFile(path, "utf8"); - const packageBlock = new RegExp(`((?:^|\\n)(?:\\[\\[package\\]\\]|\\[package\\])\\nname = "${packageName}"\\nversion = ")${current}(" )?`); - const match = packageBlock.exec(text); - if (!match) throw new Error(`Could not update ${packageName} version in ${path}`); - const updated = text.slice(0, match.index) + match[0].replace(`version = "${current}"`, `version = "${next}"`) + text.slice(match.index + match[0].length); - await writeFile(path, updated); + await writeFile(path, replacePackageVersion(text, packageName, current, next)); } async function output(command: string[]): Promise { @@ -111,6 +129,84 @@ async function preflight(tag: string): Promise { } } +type ReleaseAsset = { name: string; digest: string }; + +async function waitForWindowsWorkflow(tag: string): Promise { + for (let attempt = 0; attempt < 30; attempt += 1) { + const raw = await output([ + "gh", + "run", + "list", + "--workflow", + "release.yml", + "--event", + "push", + "--branch", + tag, + "--limit", + "1", + "--json", + "databaseId", + ]); + const runs = JSON.parse(raw) as Array<{ databaseId: number }>; + if (runs[0]) { + await run(["gh", "run", "watch", String(runs[0].databaseId), "--exit-status"]); + return; + } + await Bun.sleep(2_000); + } + throw new Error(`Windows release workflow did not start for ${tag}`); +} + +async function responseDigest(response: Response): Promise { + if (!response.ok || !response.body) { + throw new Error(`HTTP ${response.status}`); + } + const hash = createHash("sha256"); + const reader = response.body.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + hash.update(value); + } + return `sha256:${hash.digest("hex")}`; +} + +async function verifyPublicAssets(tag: string, version: string): Promise { + const raw = await output(["gh", "release", "view", tag, "--json", "assets"]); + const release = JSON.parse(raw) as { assets: ReleaseAsset[] }; + const assetsByName = new Map(release.assets.map((asset) => [asset.name, asset])); + const endpoints = new Map([ + ["/linux/latest", `quickdrop_${version}_x86_64-linux`], + ["/linux/qd/latest", `qd_${version}_x86_64-linux`], + ["/linux/qd/latest.sha256", `qd_${version}_x86_64-linux.sha256`], + ["/windows/latest.exe", `QuickDrop_${version}_x64-setup.exe`], + ["/windows/qd/latest.exe", `qd_${version}_x86_64-windows.exe`], + ["/windows/qd/latest.sha256", `qd_${version}_x86_64-windows.exe.sha256`], + ]); + for (const assetName of endpoints.values()) { + if (!assetsByName.has(assetName)) throw new Error(`Release is missing ${assetName}`); + } + + const pending = new Map(endpoints); + for (let attempt = 0; attempt < 36 && pending.size > 0; attempt += 1) { + for (const [endpoint, assetName] of pending) { + try { + const response = await fetch(`https://quickdrop.eaedave.xyz${endpoint}`, { cache: "no-store" }); + if ((await responseDigest(response)) === assetsByName.get(assetName)!.digest) { + pending.delete(endpoint); + } + } catch { + // The public proxy caches release metadata briefly; retry below. + } + } + if (pending.size > 0) await Bun.sleep(10_000); + } + if (pending.size > 0) { + throw new Error(`Public downloads did not update: ${[...pending.keys()].join(", ")}`); + } +} + async function main(): Promise { const requested = process.argv[2]; const checkOnly = requested === "--check"; @@ -162,7 +258,9 @@ async function main(): Promise { "--title", tag, ]); - console.log(`Published ${tag} with Linux assets; GitHub Actions is building the Windows assets.`); + await waitForWindowsWorkflow(tag); + await verifyPublicAssets(tag, next); + console.log(`Published and verified ${tag} for Linux and Windows.`); } if (import.meta.main) { From 3e6faea0d59f703e90561535e28a8502704a429a Mon Sep 17 00:00:00 2001 From: David <202521901+EaeDave@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:35:36 -0300 Subject: [PATCH 5/7] fix: bound public release verification --- scripts/release.ts | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/scripts/release.ts b/scripts/release.ts index 979bdfa..9b62bbe 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -190,16 +190,21 @@ async function verifyPublicAssets(tag: string, version: string): Promise { const pending = new Map(endpoints); for (let attempt = 0; attempt < 36 && pending.size > 0; attempt += 1) { - for (const [endpoint, assetName] of pending) { - try { - const response = await fetch(`https://quickdrop.eaedave.xyz${endpoint}`, { cache: "no-store" }); - if ((await responseDigest(response)) === assetsByName.get(assetName)!.digest) { - pending.delete(endpoint); + await Promise.all( + [...pending].map(async ([endpoint, assetName]) => { + try { + const response = await fetch(`https://quickdrop.eaedave.xyz${endpoint}`, { + cache: "no-store", + signal: AbortSignal.timeout(15_000), + }); + if ((await responseDigest(response)) === assetsByName.get(assetName)!.digest) { + pending.delete(endpoint); + } + } catch { + // The public proxy caches release metadata briefly; retry below. } - } catch { - // The public proxy caches release metadata briefly; retry below. - } - } + }), + ); if (pending.size > 0) await Bun.sleep(10_000); } if (pending.size > 0) { From c0ea5764078635be8b0cf849ca2298c757290c5a Mon Sep 17 00:00:00 2001 From: David <202521901+EaeDave@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:36:10 -0300 Subject: [PATCH 6/7] chore: run Windows Actions only for releases --- .github/workflows/release.yml | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 15eab56..76641ac 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,16 +1,6 @@ name: Build Windows release on: - pull_request: - paths: - - .github/workflows/release.yml - - cli/** - - src-tauri/** - - src/desktop/** - - scripts/package-windows-qd-release.ts - - scripts/release.ts - - package.json - - bun.lock push: tags: - "v*" @@ -31,7 +21,7 @@ jobs: steps: - uses: actions/checkout@v5 with: - ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || env.RELEASE_TAG }} + ref: ${{ env.RELEASE_TAG }} - uses: dtolnay/rust-toolchain@stable with: From 14098f8c71994a5d3e65144bf8ef19d96e14b5ec Mon Sep 17 00:00:00 2001 From: David <202521901+EaeDave@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:44:32 -0300 Subject: [PATCH 7/7] docs: require review before release actions --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 59ae5f0..c507cc3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,7 @@ - Releases MUST be started from a clean, up-to-date local `main` branch on an x86_64 Linux host with `bun run release `. - Agents MUST use the release command instead of manually editing versions, creating tags, creating GitHub releases, or uploading individual assets. +- Pull-request commits MUST NOT trigger release builds. Wait for review approval and merge first; only the release tag or an explicit manual rebuild may start the Windows Actions job. - Linux desktop and `qd` binaries MUST be built locally and uploaded by `scripts/release.ts`; do not add a Linux GitHub Actions build. - Windows desktop installer and `qd.exe` MUST be built by `.github/workflows/release.yml` on `windows-latest`; do not attempt to cross-compile or publish them locally. - A release is complete only after the Linux assets are public, the Windows workflow succeeds, and every public download endpoint returns the matching release assets.