mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-24 05:16:19 -07:00
chore(release): prepare v0.20.0
- Build v0.20.0 changelog and release notes from changes/ fragments - Remove package-size JSON reports and previous-release size comparisons from CI - Download AUR release assets via curl with retries and skip publish on failure - Update release docs, verification lanes, and workflow tests to match
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { test } from 'bun:test';
|
||||
|
||||
test.each([false, true])(
|
||||
'AUR downloads handle empty release metadata, unavailable=%s',
|
||||
async (unavailable) => {
|
||||
const workflow = await readFile(
|
||||
new URL('../.github/workflows/release.yml', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const step = workflow
|
||||
.split(' - name: Download release assets for AUR\n')[1]
|
||||
?.split('\n - name:')[0];
|
||||
const script = step?.split(' run: |\n')[1]?.replace(/^ /gm, '');
|
||||
assert.ok(script, 'AUR download step must have a shell script');
|
||||
|
||||
const workspace = await mkdtemp(path.join(os.tmpdir(), 'subminer-aur-download-'));
|
||||
const requests: string[] = [];
|
||||
const files = new Map([
|
||||
['SubMiner-0.20.0.AppImage', 'appimage bytes'],
|
||||
['subminer', 'launcher bytes'],
|
||||
['subminer-assets.tar.gz', 'optional assets bytes'],
|
||||
]);
|
||||
const server = Bun.serve({
|
||||
hostname: '127.0.0.1',
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const pathname = new URL(request.url).pathname;
|
||||
requests.push(pathname);
|
||||
if (unavailable || requests.length === 1) return new Response('try again', { status: 503 });
|
||||
const name = pathname.split('/').at(-1);
|
||||
const body = name ? files.get(name) : undefined;
|
||||
return new Response(body ?? 'not found', { status: body ? 200 : 404 });
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const bin = path.join(workspace, 'bin');
|
||||
await mkdir(bin);
|
||||
await writeFile(
|
||||
path.join(bin, 'gh'),
|
||||
'#!/bin/sh\necho "no assets to download" >&2\nexit 1\n',
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const output = path.join(workspace, 'output');
|
||||
const proc = Bun.spawn(['bash', '-c', script], {
|
||||
cwd: workspace,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${bin}${path.delimiter}${process.env.PATH}`,
|
||||
RELEASE_VERSION: 'v0.20.0',
|
||||
GITHUB_SERVER_URL: server.url.origin,
|
||||
GITHUB_REPOSITORY: 'ksyasuda/SubMiner',
|
||||
GITHUB_OUTPUT: output,
|
||||
},
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
const [status, stderr, stdout] = await Promise.all([
|
||||
proc.exited,
|
||||
new Response(proc.stderr).text(),
|
||||
new Response(proc.stdout).text(),
|
||||
]);
|
||||
assert.equal(status, 0, stderr);
|
||||
if (unavailable) {
|
||||
assert.equal(requests.length, 4, 'failed downloads stop after three retries');
|
||||
assert.match(await readFile(output, 'utf8'), /^skip=true$/m);
|
||||
assert.match(stdout, /::warning::Unable to download/);
|
||||
await assert.rejects(
|
||||
readFile(path.join(workspace, '.tmp/aur-release-assets/SubMiner-0.20.0.AppImage')),
|
||||
{ code: 'ENOENT' },
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (const [name, body] of files) {
|
||||
assert.equal(
|
||||
await readFile(path.join(workspace, '.tmp/aur-release-assets', name), 'utf8'),
|
||||
body,
|
||||
);
|
||||
assert.ok(requests.includes(`/ksyasuda/SubMiner/releases/download/v0.20.0/${name}`));
|
||||
}
|
||||
assert.equal(requests.length, 4, 'the first failed download must be retried');
|
||||
assert.match(await readFile(output, 'utf8'), /^skip=false$/m);
|
||||
} finally {
|
||||
server.stop(true);
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
15_000,
|
||||
);
|
||||
@@ -436,7 +436,9 @@ function readChangeFragments(cwd: string, deps?: ChangelogFsDeps): ChangeFragmen
|
||||
const CLAUDE_CLI_ARGS = [
|
||||
'-p',
|
||||
'--model',
|
||||
'sonnet',
|
||||
'opus',
|
||||
'--effort',
|
||||
'medium',
|
||||
'--permission-mode',
|
||||
'bypassPermissions',
|
||||
'--output-format',
|
||||
|
||||
@@ -4,8 +4,6 @@ const assert = require('node:assert/strict');
|
||||
const asar = require('@electron/asar');
|
||||
const { Arch } = require('builder-util');
|
||||
|
||||
const MIB = 1024 * 1024;
|
||||
const currentReports = new Set();
|
||||
const REQUIRED_APP_FILES = [
|
||||
'package.json',
|
||||
'LICENSE',
|
||||
@@ -49,13 +47,13 @@ const REQUIRED_RESOURCES = [
|
||||
'CHANGELOG.md',
|
||||
];
|
||||
|
||||
// Do not follow framework symlinks or count ASAR unpacked entries twice.
|
||||
// Skip symlinks when checking resource contents.
|
||||
function listFiles(root, prefix = '') {
|
||||
return fs.readdirSync(path.join(root, prefix), { withFileTypes: true }).flatMap((entry) => {
|
||||
const name = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
if (entry.isSymbolicLink()) return [];
|
||||
if (entry.isDirectory()) return listFiles(root, name);
|
||||
return [{ path: name, bytes: fs.statSync(path.join(root, name)).size }];
|
||||
return [name];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -66,7 +64,7 @@ function listAppFiles(archive) {
|
||||
const native = entry.replace(/^[\\/]/, '');
|
||||
const stat = asar.statFile(archive, native);
|
||||
const name = native.replaceAll('\\', '/');
|
||||
return 'size' in stat ? [{ path: name, bytes: stat.size }] : [];
|
||||
return 'size' in stat ? [name] : [];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -107,13 +105,13 @@ function verifyAppPath(name, platform, arch) {
|
||||
|
||||
function verifyContents(archive, resources, platform, arch) {
|
||||
const entries = listAppFiles(archive);
|
||||
const names = new Set(entries.map((entry) => entry.path));
|
||||
const names = new Set(entries);
|
||||
for (const name of REQUIRED_APP_FILES) assert(names.has(name), `Missing app file: ${name}`);
|
||||
for (const name of REQUIRED_RESOURCES) {
|
||||
assert(fs.statSync(path.join(resources, name)).size > 0, `Empty resource: ${name}`);
|
||||
}
|
||||
assert(listFiles(path.join(resources, 'yomitan-jlpt-vocab')).length > 0, 'Missing JLPT data');
|
||||
for (const { path: name } of entries) verifyAppPath(name, platform, arch);
|
||||
for (const name of entries) verifyAppPath(name, platform, arch);
|
||||
const libsqlPlatform = {
|
||||
linux: `linux-${arch}-gnu`,
|
||||
darwin: `darwin-${arch}`,
|
||||
@@ -137,7 +135,7 @@ function verifyContents(archive, resources, platform, arch) {
|
||||
}
|
||||
}
|
||||
for (const name of listFiles(path.join(resources, 'assets'))) {
|
||||
assert(!name.path.startsWith('minecard'), `Demo media shipped: ${name.path}`);
|
||||
assert(!name.startsWith('minecard'), `Demo media shipped: ${name}`);
|
||||
}
|
||||
for (const ui of ['renderer', 'settings', 'syncui']) {
|
||||
const css = asar.extractFile(archive, path.join('dist', ui, 'style.css')).toString();
|
||||
@@ -155,92 +153,8 @@ async function auditPackage(context) {
|
||||
? path.join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`)
|
||||
: context.appOutDir;
|
||||
const resources = path.join(appRoot, platform === 'darwin' ? 'Contents/Resources' : 'resources');
|
||||
const appFiles = verifyContents(path.join(resources, 'app.asar'), resources, platform, arch);
|
||||
const files = listFiles(appRoot);
|
||||
const unpackedBytes = files.reduce((sum, entry) => sum + entry.bytes, 0);
|
||||
const report = {
|
||||
version: context.packager.appInfo.version,
|
||||
platform,
|
||||
arch,
|
||||
unpackedBytes,
|
||||
appDirectory: path.relative(context.outDir, appRoot),
|
||||
largestFiles: [...files].sort((a, b) => b.bytes - a.bytes).slice(0, 25),
|
||||
largestAppFiles: [...appFiles].sort((a, b) => b.bytes - a.bytes).slice(0, 25),
|
||||
nativeBinaries: files.filter((entry) => /\.(node|dll|dylib)$|\.so(?:\.|$)/.test(entry.path)),
|
||||
artifacts: [],
|
||||
};
|
||||
const output = path.join(context.outDir, `package-size-${key}.json`);
|
||||
fs.mkdirSync(path.dirname(output), { recursive: true });
|
||||
fs.writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`);
|
||||
currentReports.add(output);
|
||||
console.log(
|
||||
`Package contents verified: ${key}, ${(unpackedBytes / MIB).toFixed(2)} MiB unpacked`,
|
||||
);
|
||||
}
|
||||
|
||||
function artifactKind(name) {
|
||||
if (name.endsWith('-mac.zip')) return 'mac.zip';
|
||||
if (name.endsWith('-win.zip')) return 'win.zip';
|
||||
const extension = path.extname(name).slice(1);
|
||||
return ['AppImage', 'dmg', 'exe'].includes(extension) ? extension : undefined;
|
||||
}
|
||||
|
||||
function compareSizes(report, previous) {
|
||||
assert.equal(previous.platform, report.platform);
|
||||
assert.equal(previous.arch, report.arch);
|
||||
assert(Number.isFinite(previous.unpackedBytes), 'Invalid previous size report');
|
||||
const previousArtifacts = Array.isArray(previous.artifacts) ? previous.artifacts : [];
|
||||
return {
|
||||
version: previous.version,
|
||||
unpackedDeltaBytes: report.unpackedBytes - previous.unpackedBytes,
|
||||
artifacts: report.artifacts.flatMap((artifact) => {
|
||||
const old = previousArtifacts.find(
|
||||
(entry) => entry && entry.kind === artifact.kind && Number.isFinite(entry.bytes),
|
||||
);
|
||||
return old ? [{ kind: artifact.kind, deltaBytes: artifact.bytes - old.bytes }] : [];
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Runs after signing and installer creation, before release upload.
|
||||
async function afterAllArtifactBuild(result) {
|
||||
const reports = [];
|
||||
for (const reportPath of currentReports) {
|
||||
const filename = path.basename(reportPath);
|
||||
const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
|
||||
const key = `${report.platform}-${report.arch}`;
|
||||
const files = listFiles(path.join(result.outDir, report.appDirectory));
|
||||
report.unpackedBytes = files.reduce((sum, entry) => sum + entry.bytes, 0);
|
||||
report.largestFiles = [...files].sort((a, b) => b.bytes - a.bytes).slice(0, 25);
|
||||
report.artifacts = result.artifactPaths.flatMap((file) => {
|
||||
const kind = artifactKind(file);
|
||||
if (!kind) return [];
|
||||
const bytes = fs.statSync(file).size;
|
||||
return [{ name: path.basename(file), kind, bytes }];
|
||||
});
|
||||
const previousPath = path.join(result.outDir, '..', '.tmp', 'package-baseline', filename);
|
||||
if (fs.existsSync(previousPath)) {
|
||||
const previous = JSON.parse(fs.readFileSync(previousPath, 'utf8'));
|
||||
report.comparison = compareSizes(report, previous);
|
||||
}
|
||||
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
const summary = [
|
||||
`### Package size: ${key}`,
|
||||
'',
|
||||
`Unpacked: ${(report.unpackedBytes / MIB).toFixed(2)} MiB`,
|
||||
...report.artifacts.map((entry) => `${entry.name}: ${(entry.bytes / MIB).toFixed(2)} MiB`),
|
||||
report.comparison
|
||||
? `Change from ${report.comparison.version}: ${(report.comparison.unpackedDeltaBytes / MIB).toFixed(2)} MiB unpacked`
|
||||
: 'No previous size report available.',
|
||||
'',
|
||||
].join('\n');
|
||||
console.log(summary);
|
||||
if (process.env.GITHUB_STEP_SUMMARY)
|
||||
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary);
|
||||
reports.push(reportPath);
|
||||
}
|
||||
assert(reports.length > 0, 'No package size reports generated by afterPack');
|
||||
return reports;
|
||||
verifyContents(path.join(resources, 'app.asar'), resources, platform, arch);
|
||||
console.log(`Package contents verified: ${key}`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -249,6 +163,4 @@ module.exports = {
|
||||
verifyAppPath,
|
||||
listFiles,
|
||||
listAppFiles,
|
||||
compareSizes,
|
||||
default: afterAllArtifactBuild,
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ import test from 'node:test';
|
||||
import { createPackageFromStreams } from '@electron/asar';
|
||||
import { FileMatcher, getFileMatchers } from 'app-builder-lib/out/fileMatcher';
|
||||
import config from '../package.json';
|
||||
import { listAppFiles, listFiles, compareSizes, verifyAppPath } from './package-audit.cjs';
|
||||
import { listAppFiles, listFiles, verifyAppPath } from './package-audit.cjs';
|
||||
|
||||
test('platform packaging preserves the runtime allowlist after builder normalizes global filters', () => {
|
||||
const root = process.cwd();
|
||||
@@ -125,7 +125,7 @@ test('content audit rejects development files beneath approved roots', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('archive inventory handles native files without counting them twice on disk', async () => {
|
||||
test('content inventory includes packed and unpacked native files', async () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'subminer-audit-'));
|
||||
try {
|
||||
const input = path.join(root, 'input');
|
||||
@@ -147,35 +147,9 @@ test('archive inventory handles native files without counting them twice on disk
|
||||
streamGenerator: () => createReadStream(path.join(input, name)),
|
||||
})),
|
||||
);
|
||||
assert.deepEqual(listAppFiles(archive), [
|
||||
{ path: 'main.js', bytes: 5 },
|
||||
{ path: 'native.node', bytes: 6 },
|
||||
{ path: 'dist/ai/client.js', bytes: 6 },
|
||||
]);
|
||||
assert.equal(
|
||||
listFiles(output).reduce((sum: number, entry: { bytes: number }) => sum + entry.bytes, 0),
|
||||
statSync(archive).size + 6,
|
||||
);
|
||||
assert.deepEqual(listAppFiles(archive), ['main.js', 'native.node', 'dist/ai/client.js']);
|
||||
assert.deepEqual(listFiles(output).sort(), ['app.asar', 'app.asar.unpacked/native.node']);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('size comparison tolerates older reports without artifact measurements', () => {
|
||||
const previous = { version: '0.19.6', platform: 'linux', arch: 'x64', unpackedBytes: 100 };
|
||||
const current = { ...previous, unpackedBytes: 80, artifacts: [{ kind: 'AppImage', bytes: 40 }] };
|
||||
assert.deepEqual(compareSizes(current, previous), {
|
||||
version: '0.19.6',
|
||||
unpackedDeltaBytes: -20,
|
||||
artifacts: [],
|
||||
});
|
||||
assert.deepEqual(
|
||||
compareSizes(current, { ...previous, artifacts: [null, { kind: 'AppImage', bytes: 50 }] })
|
||||
.artifacts,
|
||||
[{ kind: 'AppImage', deltaBytes: -10 }],
|
||||
);
|
||||
assert.throws(
|
||||
() => compareSizes(current, { ...previous, unpackedBytes: 'unknown' }),
|
||||
/Invalid previous size report/,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user