mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] ci: dont call anything latest until both update feeds agree + resolve
This commit is contained in:
@@ -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<version>` 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 <downloaded-feeds> --expect-version <version> --base-url https://github.com/openswarm-ai/openswarm/releases/download/v<version>`
|
||||
(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<version>`; ship a new version.
|
||||
@@ -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`);
|
||||
@@ -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 <artifacts-dir> --expect-version 1.2.3
|
||||
// node scripts/release/verify-release.js --dir <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 <artifacts-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();
|
||||
Reference in New Issue
Block a user