diff --git a/docs/build/releasing.md b/docs/build/releasing.md
index 23909f3..400a715 100644
--- a/docs/build/releasing.md
+++ b/docs/build/releasing.md
@@ -49,27 +49,46 @@ flowchart LR
## Cutting a release
-From a clean `main` branch:
+From a clean `main` branch, **one command does everything**:
```bash
-# 1. Bump version (keeps package.json + creates git tag)
-npm version patch # or minor / major
-# → commits "1.0.4" and tags it as "v1.0.4"
-
-# 2. Push commit + tag together
-git push --follow-tags
-
-# 3. Watch the workflow
-gh run watch
+npm run release 1.0.8 # or: patch / minor / major
+```
-# 4. After CI publishes the draft, notarize the macOS DMGs locally
-node scripts/notarize-release.mjs
-# → downloads DMGs, submits to Apple, staples, re-uploads. ~5–15 min per DMG.
+[`scripts/release.mjs`](../../scripts/release.mjs) runs the whole pipeline end to
+end — no other manual steps:
+
+1. **Preflight** — verifies `gh` is authenticated and you're on a clean `main`
+ that matches `origin`.
+2. **Bump → PR → merge** — bumps the version on a `release-vX` branch, opens a
+ PR, waits for CI to pass, and squash-merges it (satisfies branch protection).
+3. **Tag** — tags the merged commit and pushes it, triggering the build.
+4. **Build** — waits for the macOS/Windows/Linux build + draft release to finish.
+5. **Publish** — un-drafts the release.
+6. **Notarize** — runs [`scripts/notarize-release.mjs`](../../scripts/notarize-release.mjs)
+ on the macOS DMGs (submit → staple → re-upload in place). This is the **last**
+ step, strictly after the builds are done and the release is published.
+
+Prerequisites: `gh` authenticated, the `out-loud-notary` keychain profile (see
+[Code signing](#code-signing)), and — importantly — the tag ruleset must
+**allow tag creation** by you. If the tag step fails with _"Cannot create ref
+due to creations being restricted,"_ remove the **Restrict creations** rule on
+`refs/tags/**` (or add yourself as a bypass actor) in the repo/org ruleset
+settings. The script prints the exact recovery commands if this happens.
+
+
+Manual fallback (if you ever need to run the steps by hand)
-# 5. Test, then publish
-gh release edit v1.0.4 --repo light-cloud-com/out-loud --draft=false
+```bash
+npm version patch --no-git-tag-version # bump, then PR + merge it normally
+git tag v1.0.8 && git push origin v1.0.8 # build → draft
+gh run watch # wait for the build to finish
+gh release edit v1.0.8 --repo light-cloud-com/out-loud --draft=false # publish
+node scripts/notarize-release.mjs # notarize the DMGs (last)
```
+
+
The tag push triggers [`.github/workflows/release.yml`](../../.github/workflows/release.yml), which:
1. Builds the macOS app on `macos-latest` — **signed but NOT notarized**. CI never blocks on Apple's notary queue, so this job consistently completes in ~5 min.
diff --git a/package.json b/package.json
index 83ce4b8..cbc51d6 100755
--- a/package.json
+++ b/package.json
@@ -40,6 +40,7 @@
"knip": "knip",
"test": "vitest run --passWithNoTests",
"check": "npm run lint && npm run fmt:check && npm run knip && npm run electron:compile",
+ "release": "node scripts/release.mjs",
"prepare": "husky",
"electron:compile": "tsc -p electron/tsconfig.json",
"electron-ui:install": "cd electron-ui && npm install",
diff --git a/scripts/release.mjs b/scripts/release.mjs
new file mode 100644
index 0000000..31e353f
--- /dev/null
+++ b/scripts/release.mjs
@@ -0,0 +1,196 @@
+#!/usr/bin/env node
+// One-command release for Out Loud.
+//
+// node scripts/release.mjs 1.0.8 # explicit version
+// node scripts/release.mjs patch # or minor / major
+// npm run release 1.0.8
+//
+// What it does, start to finish — no other manual steps:
+// 1. Preflight: gh authed, on a clean `main` that matches origin.
+// 2. Bump version on a release branch, open a PR, wait for CI, squash-merge.
+// 3. Tag the merged commit and push → triggers the Release build workflow.
+// 4. Wait for the macOS/Windows/Linux builds + draft release to finish.
+// 5. Publish the release (un-draft).
+// 6. Notarize the macOS DMGs (scripts/notarize-release.mjs) — the LAST step,
+// strictly after the builds are done and the release is published.
+//
+// Prerequisites (one-time):
+// - `gh` authenticated with write access to the repo.
+// - macOS notary keychain profile "out-loud-notary" (see notarize-release.mjs).
+// - The tag ruleset must ALLOW tag creation by you. If you hit
+// "Cannot create ref due to creations being restricted" at the tag step,
+// remove the "Restrict creations" rule on refs/tags/** (or add yourself as
+// a bypass actor) in the repo/org ruleset settings.
+
+import { spawnSync } from "node:child_process";
+import { readFileSync } from "node:fs";
+import { join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const REPO = "light-cloud-com/out-loud";
+const projectRoot = resolve(fileURLToPath(import.meta.url), "..", "..");
+
+function run(cmd, args, opts = {}) {
+ console.log(`\n$ ${cmd} ${args.join(" ")}`);
+ const res = spawnSync(cmd, args, { stdio: "inherit", cwd: projectRoot, ...opts });
+ if (res.status !== 0) throw new Error(`${cmd} exited with code ${res.status}`);
+ return res;
+}
+
+function capture(cmd, args) {
+ const res = spawnSync(cmd, args, { cwd: projectRoot, encoding: "utf8" });
+ if (res.status !== 0) throw new Error(`${cmd} ${args.join(" ")} failed: ${res.stderr || ""}`);
+ return res.stdout.trim();
+}
+
+function tryCapture(cmd, args) {
+ const res = spawnSync(cmd, args, { cwd: projectRoot, encoding: "utf8" });
+ return res.status === 0 ? res.stdout.trim() : null;
+}
+
+function fail(msg) {
+ console.error(`\n✖ ${msg}`);
+ process.exit(1);
+}
+
+function sleep(seconds) {
+ spawnSync("sleep", [String(seconds)], { stdio: "ignore" });
+}
+
+function currentVersion() {
+ return JSON.parse(readFileSync(join(projectRoot, "package.json"), "utf8")).version;
+}
+
+// Resolve "1.2.3" | "patch" | "minor" | "major" → "1.2.3"
+function resolveVersion(arg) {
+ if (/^\d+\.\d+\.\d+$/.test(arg)) return arg;
+ const [maj, min, pat] = currentVersion().split(".").map(Number);
+ if (arg === "major") return `${maj + 1}.0.0`;
+ if (arg === "minor") return `${maj}.${min + 1}.0`;
+ if (arg === "patch") return `${maj}.${min}.${pat + 1}`;
+ fail(`Invalid version "${arg}" — use a semver like 1.0.8, or patch/minor/major.`);
+}
+
+// ---- main -------------------------------------------------------------------
+
+const arg = process.argv[2];
+if (!arg) fail("Usage: node scripts/release.mjs ");
+
+const version = resolveVersion(arg);
+const tag = `v${version}`;
+const branch = `release-${tag}`;
+
+console.log(`\n=== Releasing ${tag} (current: ${currentVersion()}) ===`);
+
+// 1. Preflight ----------------------------------------------------------------
+if (spawnSync("gh", ["auth", "status"], { stdio: "ignore" }).status !== 0) {
+ fail("GitHub CLI not authenticated. Run: gh auth login");
+}
+const onBranch = capture("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
+if (onBranch !== "main") fail(`Switch to main first (on "${onBranch}").`);
+if (capture("git", ["status", "--porcelain"]))
+ fail("Working tree is not clean — commit or stash first.");
+if (
+ tryCapture("git", ["rev-parse", "-q", "--verify", `refs/tags/${tag}`]) ||
+ capture("git", ["ls-remote", "--tags", "origin", tag])
+) {
+ fail(
+ `Tag ${tag} already exists (locally or on origin). Delete it first, or pick another version.`
+ );
+}
+run("git", ["fetch", "origin", "main", "--quiet"]);
+if (capture("git", ["rev-parse", "main"]) !== capture("git", ["rev-parse", "origin/main"])) {
+ fail("Local main differs from origin/main. Run: git pull --ff-only");
+}
+
+// 2. Bump → PR → CI → merge ---------------------------------------------------
+run("git", ["checkout", "-b", branch]);
+run("npm", ["version", version, "--no-git-tag-version"]);
+run("git", ["commit", "-am", `release: ${version}`]);
+run("git", ["push", "-u", "origin", branch]);
+
+run("gh", [
+ "pr",
+ "create",
+ "--repo",
+ REPO,
+ "--base",
+ "main",
+ "--head",
+ branch,
+ "--title",
+ `release: ${version}`,
+ "--body",
+ `Release ${tag}.`,
+]);
+
+console.log("\n⏳ Waiting for CI checks to pass…");
+run("gh", ["pr", "checks", branch, "--repo", REPO, "--watch", "--fail-fast"]);
+run("gh", ["pr", "merge", branch, "--repo", REPO, "--squash", "--delete-branch"]);
+
+// 3. Tag the merged commit → triggers the Release build workflow --------------
+run("git", ["checkout", "main"]);
+run("git", ["pull", "--ff-only", "origin", "main"]);
+run("git", ["tag", tag]);
+console.log("\nPushing tag (needs tag-creation to be allowed by repo rules)…");
+const push = spawnSync("git", ["push", "origin", tag], {
+ cwd: projectRoot,
+ encoding: "utf8",
+ stdio: ["inherit", "inherit", "pipe"],
+});
+if (push.status !== 0) {
+ process.stderr.write(push.stderr || "");
+ if (/creations being restricted|rule violations/i.test(push.stderr || "")) {
+ fail(
+ `Tag push blocked by a repository ruleset. Remove the "Restrict creations" rule on\n` +
+ ` refs/tags/** (or add yourself as a bypass actor) in the repo/org ruleset settings,\n` +
+ ` then finish the release manually (in order):\n` +
+ ` git push origin ${tag}\n` +
+ ` gh run watch # wait for the build\n` +
+ ` gh release edit ${tag} --repo ${REPO} --draft=false # publish\n` +
+ ` node scripts/notarize-release.mjs ${tag} # notarize (last)`
+ );
+ }
+ fail("git push of the tag failed.");
+}
+
+// 4. Wait for the Release workflow run for this tag ---------------------------
+console.log("\n⏳ Waiting for the Release build to start…");
+let runId = null;
+for (let i = 0; i < 20 && !runId; i++) {
+ sleep(6);
+ const json = tryCapture("gh", [
+ "run",
+ "list",
+ "--repo",
+ REPO,
+ "--workflow",
+ "Release",
+ "--branch",
+ tag,
+ "--json",
+ "databaseId,status",
+ "--limit",
+ "1",
+ ]);
+ if (json) {
+ const runs = JSON.parse(json);
+ if (runs.length) runId = runs[0].databaseId;
+ }
+}
+if (!runId)
+ fail(
+ `Couldn't find the Release run for ${tag}. Check the Actions tab, then notarize + publish manually.`
+ );
+console.log(`\n⏳ Building (run ${runId}) — this is the slow part (~8–10 min)…`);
+run("gh", ["run", "watch", String(runId), "--repo", REPO, "--exit-status"]);
+
+// 5. Publish (un-draft) — only now that the builds are done and assets exist --
+run("gh", ["release", "edit", tag, "--repo", REPO, "--draft=false"]);
+
+// 6. Notarize the macOS DMGs — the LAST step, after publish -------------------
+// notarize-release.mjs re-uploads the stapled DMGs in place (gh ... --clobber),
+// so it works fine against an already-published release.
+run("node", [join(projectRoot, "scripts", "notarize-release.mjs"), tag]);
+
+console.log(`\n✅ Released ${tag}: https://github.com/${REPO}/releases/tag/${tag}`);