Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 34 additions & 15 deletions docs/build/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<details>
<summary>Manual fallback (if you ever need to run the steps by hand)</summary>

# 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)
```

</details>

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.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
196 changes: 196 additions & 0 deletions scripts/release.mjs
Original file line number Diff line number Diff line change
@@ -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 <version|patch|minor|major>");

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}`);
Loading