Releases on git.crt.fyi from a workflow: a v* tag push runs .forgejo/workflows/release.yml (tag = package.json version and every version copy, the full CI, Linux AppImage + deb and Windows NSIS on the VDS runners, privacy scan with the private terms, one Forgejo release with short CHANGELOG-summary notes, the installers and SHA256SUMS, older release entries deleted, tags kept); workflow_dispatch dry run makes a draft test-<sha> and deletes it; desktop.yml folded in, ci.yml and release.yml share .forgejo/actions/linux-setup and ci-run; tools/forgejo-release.ts (notes, upsert, prune, delete; uploads over 100 MB go to the origin because Cloudflare refuses them); desktop-release.ts upload-forgejo adds the notarized DMG and updater bundle from the Mac; docs/desktop-release.md release order

This commit is contained in:
CRTkafa 2026-10-13 07:44:17 +08:00
parent e5f148e534
commit 2590b2e828
11 changed files with 871 additions and 229 deletions

View file

@ -12,7 +12,7 @@
// never run, Rosetta), the native self-test (sidecar handshake) from the mounted DMG and from the unpacked updater bundle, the privacy scan, minisign signatures with
// the updater key (~/.storebound-keys/updater.key), SHA256SUMS, latest.json and evidence.json.
// bun tools/desktop-release.ts add --out <dir> [--commit <sha>] [--run <url>] <files...> [--code-signed]
// Adds Windows / Linux installers built by the Forgejo workflow (.forgejo/workflows/desktop.yml) to the version
// Adds Windows / Linux installers built by the Forgejo workflow (.forgejo/workflows/release.yml) to the version
// folder: privacy scan, minisign signature here (the key never goes to a runner), latest.json, SHA256SUMS and
// evidence-<linux|windows>-<version>.json (the run and commit they came from; a commit other than the one of the
// folder's other evidence is refused: every artifact of a version comes from one commit).
@ -21,6 +21,10 @@
// script there (tmp -> mv, latest.json last). Without --yes it only prints the plan (dry run).
// The folder's version must be package.json's: the npm CLI and the app are released together, the app first,
// then `npm publish` of the same version.
// bun tools/desktop-release.ts upload-forgejo --out <dir> [--tag v<version>] [--yes]
// After `mac` (signed, notarized): attaches the DMG and the updater bundle with their minisign signatures to the
// Forgejo release of the tag that the release workflow made (.forgejo/workflows/release.yml); SHA256SUMS is merged.
// Without --yes only the plan. Token: FORGEJO_TOKEN or the git credential helper (tools/forgejo-release.ts).
// bun tools/desktop-release.ts verify --out <dir>
// The checks of publish without uploading.
// bun tools/desktop-release.ts exec -- <command...>
@ -36,6 +40,7 @@ import { type Download, type DownloadKey, type Manifest, manifestProblems } from
import { verify as verifySig } from '../cli/lib/minisign';
import { buildOne, OUT_DIR as SIDECAR_DIR } from './build-sidecars';
import { versions } from './desktop-version';
import { upsert as forgejoUpsert } from './forgejo-release';
import { scan } from './privacy-scan';
export const ROOT = resolve(import.meta.dir, '..');
@ -213,7 +218,7 @@ function gitState(): { commit: string; dirty: boolean } {
function args(argv: string[]) {
const get = (n: string) => { const i = argv.indexOf(`--${n}`); return i >= 0 ? argv[i + 1] : undefined; };
return { get, has: (n: string) => argv.includes(`--${n}`), rest: argv.filter((a, i) => !a.startsWith('--') && !(i > 0 && argv[i - 1]!.startsWith('--') && !['--allow-dirty', '--no-notarize', '--yes', '--code-signed'].includes(argv[i - 1]!))) };
return { get, has: (n: string) => argv.includes(`--${n}`), rest: argv.filter((a, i) => !a.startsWith('--') && !(i > 0 && argv[i - 1]!.startsWith('--') && !['--allow-dirty', '--no-notarize', '--yes', '--code-signed', '--allow-unsigned'].includes(argv[i - 1]!))) };
}
// ---------------------------------------------------------------------------------------------------------------------
@ -673,6 +678,31 @@ function publish(argv: string[]): void {
say(`next, the same release: npm publish of storebound ${v} (CONTRIBUTING.md). \`npx storebound desktop\` of ${v} installs or updates to this app.`);
}
// ---------------------------------------------------------------------------------------------------------------------
// upload-forgejo (the Mac's files join the release the workflow made)
/** The files of a version folder that go to the Forgejo release from the Mac: the DMG and the updater bundle, each
* with its .sig (pure). Linux and Windows files come from the workflow itself. */
export function forgejoMacFiles(names: string[], version: string): string[] {
return names.filter(n => n.startsWith(`${PRODUCT}_${version}_universal`) && /\.(dmg|app\.tar\.gz)(\.sig)?$/.test(n)).sort();
}
async function uploadForgejo(argv: string[]): Promise<void> {
const a = args(argv);
const out = resolve(a.get('out') ?? fail('--out <version folder> is required'));
const { manifest } = verifyFolder(out);
const v = manifest.version;
const together = releaseTogetherProblems(v, versions().version);
if (together.length) fail(together.join('; '));
const tag = a.get('tag') ?? `v${v}`;
if (tag !== `v${v}`) fail(`--tag ${tag} is not v${v}: the folder is version ${v}`);
const files = forgejoMacFiles(readdirSync(out), v);
if (!files.some(f => f.endsWith('.dmg'))) fail(`${out}: no DMG of ${v} (run mac first)`);
if (files.some(f => f.includes('-unsigned')) && !a.has('allow-unsigned')) fail('the DMG is unsigned (-unsigned): only a notarized build goes to the release (--allow-unsigned for a preview)');
await forgejoUpsert({ tag, files: files.map(f => join(out, f)), create: false, yes: a.has('yes') });
if (a.has('yes')) say(`next: publish --out ${out.replace(homedir(), '~')} --host <user@host> (storebound.dev), then npm publish of ${v}`);
}
if (import.meta.main) {
const argv = process.argv.slice(2);
const cmd = argv[0];
@ -680,6 +710,7 @@ if (import.meta.main) {
else if (cmd === 'add') add(argv);
else if (cmd === 'verify') verifyFolder(resolve(args(argv).get('out') ?? fail('--out <version folder>')));
else if (cmd === 'publish') publish(argv.slice(1));
else if (cmd === 'upload-forgejo') await uploadForgejo(argv.slice(1));
else if (cmd === 'exec') {
// `exec -- <command...>`: runs a build command with the path-free environment (the Forgejo workflow uses it)
const cmdline = argv.slice(argv.indexOf('--') + 1);
@ -687,6 +718,6 @@ if (import.meta.main) {
const r = spawnSync(cmdline[0]!, cmdline.slice(1), { stdio: 'inherit', env: { ...process.env, ...pathFreeEnv(ROOT, process.env) }, shell: process.platform === 'win32' });
process.exit(r.status ?? 1);
}
else { console.log(readFileSync(import.meta.path, 'utf8').split('\n').filter(l => l.startsWith('//')).slice(0, 26).map(l => l.slice(3)).join('\n')); process.exit(cmd ? 2 : 0); }
else { console.log(readFileSync(import.meta.path, 'utf8').split('\n').filter(l => l.startsWith('//')).slice(0, 30).map(l => l.slice(3)).join('\n')); process.exit(cmd ? 2 : 0); }
}