diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md new file mode 100644 index 00000000..10fb08ba --- /dev/null +++ b/RELEASE_CHECKLIST.md @@ -0,0 +1,49 @@ +# Release Checklist + +Copy this into the release PR/issue and tick every box before promoting a draft +release to `latest`. The goal: no broken build ever reaches users on either +platform. See `RELEASE_RUNBOOK.md` for the how; this is the gate. + +## Pre-build +- [ ] `dev` is green and dogfooded; the release commit is chosen. +- [ ] `electron/package.json` `version` bumped per semver (CONTRIBUTING.md). +- [ ] `backend/requirements.lock` regenerated if `requirements.txt` changed, and + committed alongside it. +- [ ] Both `package-lock.json` files committed (frontend + electron). + +## Build (both platforms, same commit) +- [ ] macOS DMG built from the release commit (`bash publish.sh`), signed + + notarized, both arches (arm64 + x64). +- [ ] Windows EXE built from the same commit (push `v*` tag → CI, or + `pwsh publish-win.ps1`), signed. +- [ ] Provenance matches: launch each artifact, Settings → About → **Build** sha + equals `git rev-parse HEAD` of the release commit (and they equal each other). + +## Artifacts + feeds (promotion gate) +- [ ] GitHub draft release for `v` has: `OpenSwarm-Setup-x64.exe`, + `OpenSwarm-arm64.dmg`, `OpenSwarm-x64.dmg`, `latest.yml`, `latest-mac.yml`. +- [ ] Promotion gate passes: + `node scripts/release/verify-release.js --dir --expect-version --base-url https://github.com/openswarm-ai/openswarm/releases/download/v` + (both feeds present, versions agree with each other and with package.json, + every asset HEAD-resolves to 200). + +## Dogfood on real target OSes (in production, signed) +- [ ] Windows 11 x64: fresh install of the signed EXE, no SmartScreen block after + signing, app boots, backend reaches ready, send one agent message (gets a + response). Check `backend.log` `[provenance]` + `[perf]` lines. +- [ ] Windows 10 x64: same. +- [ ] macOS Apple Silicon (arm64), macOS 12+: fresh DMG install, no Gatekeeper + block, boots, backend ready, one agent turn. +- [ ] macOS Intel (x64), macOS 12+: same. +- [ ] Auto-update: previous stable installed → this release detected, downloads, + installs on quit, relaunches on the new version. Verify on both platforms. + +## Promote +- [ ] All boxes above ticked. +- [ ] Remove the draft flag (publish the release) — this is the only manual + promote step; nothing auto-promotes. +- [ ] Confirm `latest.yml` / `latest-mac.yml` are live (HEAD 200) post-publish. + +## Rollback (if a regression surfaces post-promote) +- [ ] Re-publish the previous release's feeds as latest, or cut a patch. +- [ ] Tags are immutable (ruleset) — never move `v`; ship a new version. diff --git a/scripts/release/test-verify-release.js b/scripts/release/test-verify-release.js new file mode 100644 index 00000000..ba918a0c --- /dev/null +++ b/scripts/release/test-verify-release.js @@ -0,0 +1,81 @@ +#!/usr/bin/env node +// Phase 5a test: hermetic checks of the promotion gate. Builds throwaway +// latest*.yml fixtures and asserts the gate promotes a good release and blocks +// the two failures the gate exists for: a missing feed and a version mismatch. +// No network (URL checking is exercised separately in CI with --base-url). +// +// node scripts/release/test-verify-release.js + +'use strict'; +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const HERE = __dirname; +const NODE = process.execPath; +let passed = 0; + +function assert(cond, msg) { + if (!cond) { process.stderr.write(`\nASSERT FAILED: ${msg}\n`); process.exit(1); } + passed++; +} + +function run(args) { + try { + const stdout = execFileSync(NODE, [path.join(HERE, 'verify-release.js'), ...args], { encoding: 'utf8' }); + return { code: 0, stdout }; + } catch (e) { + return { code: e.status == null ? -1 : e.status, stdout: e.stdout || '', stderr: e.stderr || '' }; + } +} + +function feed(version, asset) { + return `version: ${version}\nfiles:\n - url: ${asset}\n sha512: deadbeef\n size: 123\npath: ${asset}\nsha512: deadbeef\nreleaseDate: '2026-05-27T00:00:00.000Z'\n`; +} + +function mkdir() { return fs.mkdtempSync(path.join(os.tmpdir(), 'osw-rel-')); } + +// (1) good release: both feeds, same version, matches expected -> promotable +(() => { + const d = mkdir(); + fs.writeFileSync(path.join(d, 'latest.yml'), feed('1.2.3', 'OpenSwarm-Setup-x64.exe')); + fs.writeFileSync(path.join(d, 'latest-mac.yml'), feed('1.2.3', 'OpenSwarm-arm64.dmg')); + const r = run(['--dir', d, '--expect-version', '1.2.3', '--json']); + assert(r.code === 0, `good release should pass, got ${r.code}`); + assert(JSON.parse(r.stdout).version === '1.2.3', 'should report version 1.2.3'); + // tolerate a leading v on expected + assert(run(['--dir', d, '--expect-version', 'v1.2.3', '--json']).code === 0, 'leading-v expected should pass'); + fs.rmSync(d, { recursive: true, force: true }); +})(); + +// (2) missing latest-mac.yml -> blocked +(() => { + const d = mkdir(); + fs.writeFileSync(path.join(d, 'latest.yml'), feed('1.2.3', 'OpenSwarm-Setup-x64.exe')); + const r = run(['--dir', d, '--expect-version', '1.2.3']); + assert(r.code === 1, 'missing mac feed should block'); + fs.rmSync(d, { recursive: true, force: true }); +})(); + +// (3) version mismatch across feeds -> blocked +(() => { + const d = mkdir(); + fs.writeFileSync(path.join(d, 'latest.yml'), feed('1.2.3', 'OpenSwarm-Setup-x64.exe')); + fs.writeFileSync(path.join(d, 'latest-mac.yml'), feed('1.2.2', 'OpenSwarm-arm64.dmg')); + const r = run(['--dir', d, '--expect-version', '1.2.3']); + assert(r.code === 1, 'version mismatch should block'); + fs.rmSync(d, { recursive: true, force: true }); +})(); + +// (4) feeds agree with each other but not with expected version -> blocked +(() => { + const d = mkdir(); + fs.writeFileSync(path.join(d, 'latest.yml'), feed('1.2.0', 'OpenSwarm-Setup-x64.exe')); + fs.writeFileSync(path.join(d, 'latest-mac.yml'), feed('1.2.0', 'OpenSwarm-arm64.dmg')); + const r = run(['--dir', d, '--expect-version', '1.2.3']); + assert(r.code === 1, 'expected-version mismatch should block'); + fs.rmSync(d, { recursive: true, force: true }); +})(); + +process.stdout.write(`\nPhase 5a promotion gate: ${passed} assertions passed.\n`); diff --git a/scripts/release/verify-release.js b/scripts/release/verify-release.js new file mode 100644 index 00000000..e8291610 --- /dev/null +++ b/scripts/release/verify-release.js @@ -0,0 +1,118 @@ +#!/usr/bin/env node +// Phase 5a promotion gate: before a draft release is allowed to become "latest", +// prove both auto-updater feeds exist, agree on version, and that their assets +// actually resolve. A release that ships latest.yml but not latest-mac.yml (or +// with mismatched versions) silently strands one platform's users on the old +// build, which is the exact "broken latest" failure this gate exists to stop. +// +// Usage: +// node scripts/release/verify-release.js --dir --expect-version 1.2.3 +// node scripts/release/verify-release.js --dir --expect-version 1.2.3 \ +// --base-url https://github.com/openswarm-ai/openswarm/releases/download/v1.2.3 +// +// --dir directory containing latest.yml + latest-mac.yml +// --expect-version version both feeds (and their filenames) must match +// --base-url if given, HEAD-check every referenced asset resolves (200) +// +// Exit 0 = promotable. Exit 1 = blocked (prints the first blocking reason). + +'use strict'; +const fs = require('fs'); +const path = require('path'); +const https = require('https'); + +const FEEDS = ['latest.yml', 'latest-mac.yml']; + +function parseArgs(argv) { + const out = { dir: null, expectVersion: null, baseUrl: null, json: false }; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--dir') out.dir = argv[++i]; + else if (argv[i] === '--expect-version') out.expectVersion = argv[++i]; + else if (argv[i] === '--base-url') out.baseUrl = argv[++i]; + else if (argv[i] === '--json') out.json = true; + } + return out; +} + +// Minimal electron-builder-feed parser. We only need `version:` and the asset +// filenames (top-level `path:` plus each `- url:` under `files:`). Avoiding a +// YAML dependency keeps this runnable on a bare CI node with no install step. +function parseFeed(text) { + const version = (text.match(/^version:\s*(.+)$/m) || [])[1]; + const assets = new Set(); + const topPath = (text.match(/^path:\s*(.+)$/m) || [])[1]; + if (topPath) assets.add(topPath.trim()); + const urlRe = /^\s*-?\s*url:\s*(.+)$/gm; + let m; + while ((m = urlRe.exec(text)) !== null) assets.add(m[1].trim()); + return { version: version ? version.trim() : null, assets: [...assets] }; +} + +function headOk(url) { + return new Promise((resolve) => { + const req = https.request(url, { method: 'HEAD' }, (res) => { + // GitHub release assets 302 to a signed CDN URL; follow one hop. + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + headOk(res.headers.location).then(resolve); + } else { + resolve(res.statusCode === 200); + } + }); + req.on('error', () => resolve(false)); + req.setTimeout(15000, () => { req.destroy(); resolve(false); }); + req.end(); + }); +} + +function fail(msg, json) { + if (json) process.stdout.write(JSON.stringify({ ok: false, error: msg }) + '\n'); + else process.stderr.write(`BLOCKED: ${msg}\n`); + process.exit(1); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (!args.dir) fail('--dir is required', args.json); + + const feeds = {}; + for (const name of FEEDS) { + const p = path.join(args.dir, name); + if (!fs.existsSync(p)) fail(`missing feed: ${name} (one platform would be stranded on the old build)`, args.json); + feeds[name] = parseFeed(fs.readFileSync(p, 'utf8')); + if (!feeds[name].version) fail(`${name} has no version: field`, args.json); + } + + const versions = FEEDS.map((n) => feeds[n].version); + if (new Set(versions).size !== 1) { + fail(`version mismatch across feeds: ${FEEDS.map((n) => `${n}=${feeds[n].version}`).join(', ')}`, args.json); + } + const releaseVersion = versions[0]; + + if (args.expectVersion) { + const want = args.expectVersion.replace(/^v/, ''); + if (releaseVersion !== want) fail(`feeds say ${releaseVersion} but expected ${want}`, args.json); + } + + if (args.baseUrl) { + const base = args.baseUrl.replace(/\/+$/, ''); + for (const name of FEEDS) { + // The .yml itself must resolve, plus every asset it points at. + const toCheck = [name, ...feeds[name].assets]; + for (const asset of toCheck) { + const url = `${base}/${asset}`; + // eslint-disable-next-line no-await-in-loop + const ok = await headOk(url); + if (!ok) fail(`asset does not resolve (HEAD != 200): ${url}`, args.json); + } + } + } + + const result = { ok: true, version: releaseVersion, feeds: FEEDS, checkedUrls: !!args.baseUrl }; + if (args.json) process.stdout.write(JSON.stringify(result) + '\n'); + else { + process.stdout.write(`\nPROMOTABLE: both feeds present, version ${releaseVersion} agrees`); + process.stdout.write(args.baseUrl ? ', all assets resolve.\n\n' : ' (URL check skipped; pass --base-url to enable).\n\n'); + } +} + +main();