mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-12 17:16:20 -07:00
build(release): reduce package size and report release sizes (#244)
This commit is contained in:
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import {
|
||||
executableRunLines,
|
||||
jobSteps,
|
||||
readWorkflow,
|
||||
stepRunsCommand,
|
||||
@@ -12,7 +13,14 @@ import {
|
||||
|
||||
const prereleaseWorkflowPath = resolve(__dirname, '../.github/workflows/prerelease.yml');
|
||||
const prereleaseWorkflow = readFileSync(prereleaseWorkflowPath, 'utf8').replace(/\r\n/g, '\n');
|
||||
const packageWorkflow = readFileSync(
|
||||
resolve(__dirname, '../.github/workflows/package-release.yml'),
|
||||
'utf8',
|
||||
);
|
||||
const parsedPrereleaseWorkflow = readWorkflow(prereleaseWorkflowPath);
|
||||
const parsedPackageWorkflow = readWorkflow(
|
||||
resolve(__dirname, '../.github/workflows/package-release.yml'),
|
||||
);
|
||||
const packageJsonPath = resolve(__dirname, '../package.json');
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
|
||||
scripts: Record<string, string>;
|
||||
@@ -42,15 +50,10 @@ test('prerelease workflow uses committed prerelease notes and never calls claude
|
||||
});
|
||||
|
||||
test('prerelease delegates its quality gate instead of duplicating quality steps', () => {
|
||||
assert.match(
|
||||
prereleaseWorkflow,
|
||||
/quality-gate:\s*\n\s*permissions:\s*\n\s*contents: read\s*\n\s*uses: \.\/\.github\/workflows\/quality-gate\.yml/,
|
||||
);
|
||||
const qualityGateJob = prereleaseWorkflow.match(/quality-gate:[\s\S]*?(?=\n build-linux:)/)?.[0];
|
||||
assert.ok(qualityGateJob);
|
||||
assert.doesNotMatch(qualityGateJob, /oven-sh\/setup-bun/);
|
||||
assert.doesNotMatch(qualityGateJob, /bun run test:coverage:src/);
|
||||
assert.doesNotMatch(qualityGateJob, /bun run test:env/);
|
||||
assert.deepEqual(parsedPrereleaseWorkflow.jobs?.['quality-gate'], {
|
||||
permissions: { contents: 'read' },
|
||||
uses: './.github/workflows/quality-gate.yml',
|
||||
});
|
||||
});
|
||||
|
||||
test('prerelease workflow publishes GitHub prereleases and keeps them off latest', () => {
|
||||
@@ -60,10 +63,10 @@ test('prerelease workflow publishes GitHub prereleases and keeps them off latest
|
||||
});
|
||||
|
||||
test('prerelease packaging workflows scope dependency caches by runner architecture', () => {
|
||||
const archScopedCacheKeyMatches = prereleaseWorkflow.match(
|
||||
const archScopedCacheKeyMatches = (prereleaseWorkflow + packageWorkflow).match(
|
||||
/key:\s*\${{\s*runner\.os\s*}}-\${{\s*runner\.arch\s*}}-bun-/g,
|
||||
);
|
||||
const archScopedRestoreKeyMatches = prereleaseWorkflow.match(
|
||||
const archScopedRestoreKeyMatches = (prereleaseWorkflow + packageWorkflow).match(
|
||||
/\${{\s*runner\.os\s*}}-\${{\s*runner\.arch\s*}}-bun-/g,
|
||||
);
|
||||
assert.equal(archScopedCacheKeyMatches?.length, 4);
|
||||
@@ -71,12 +74,79 @@ test('prerelease packaging workflows scope dependency caches by runner architect
|
||||
});
|
||||
|
||||
test('prerelease workflow builds and uploads all release platforms', () => {
|
||||
assert.match(prereleaseWorkflow, /build-linux:/);
|
||||
assert.match(prereleaseWorkflow, /build-macos:/);
|
||||
assert.match(prereleaseWorkflow, /build-windows:/);
|
||||
assert.match(prereleaseWorkflow, /name: appimage/);
|
||||
assert.match(prereleaseWorkflow, /name: macos/);
|
||||
assert.match(prereleaseWorkflow, /name: windows/);
|
||||
assert.deepEqual(Object.keys(parsedPrereleaseWorkflow.jobs ?? {}).sort(), [
|
||||
'package',
|
||||
'quality-gate',
|
||||
'release',
|
||||
]);
|
||||
assert.equal(
|
||||
parsedPrereleaseWorkflow.jobs?.package?.uses,
|
||||
'./.github/workflows/package-release.yml',
|
||||
);
|
||||
assert.deepEqual(parsedPrereleaseWorkflow.jobs?.package?.needs, ['quality-gate']);
|
||||
assert.deepEqual(parsedPrereleaseWorkflow.jobs?.release?.needs, ['package']);
|
||||
assert.deepEqual(Object.keys(parsedPackageWorkflow.jobs ?? {}).sort(), [
|
||||
'build-linux',
|
||||
'build-macos',
|
||||
'build-windows',
|
||||
]);
|
||||
for (const [job, name, paths] of [
|
||||
['build-linux', 'appimage', ['release/*.AppImage']],
|
||||
['build-macos', 'macos', ['release/*.dmg', 'release/*.zip']],
|
||||
['build-windows', 'windows', ['release/*.exe', 'release/*.zip']],
|
||||
] as const) {
|
||||
const uploads = jobSteps(parsedPackageWorkflow, job).filter(
|
||||
(step) => step.uses === 'actions/upload-artifact@v4',
|
||||
);
|
||||
assert.equal(uploads.length, 1);
|
||||
const upload = uploads[0];
|
||||
assert.ok(upload);
|
||||
assert.equal(upload.with?.name, name);
|
||||
assert.equal(upload.with?.['if-no-files-found'], 'error');
|
||||
const uploadPath = upload.with?.path;
|
||||
assert.ok(typeof uploadPath === 'string');
|
||||
assert.deepEqual(uploadPath.trim().split('\n'), [
|
||||
...paths,
|
||||
'release/latest*.yml',
|
||||
'release/*.blockmap',
|
||||
'release/package-size-*.json',
|
||||
]);
|
||||
const download = jobSteps(parsedPrereleaseWorkflow, 'release').find(
|
||||
(step) => step.uses === 'actions/download-artifact@v4' && step.with?.name === name,
|
||||
);
|
||||
assert.equal(download?.with?.path, 'release');
|
||||
}
|
||||
const steps = jobSteps(parsedPrereleaseWorkflow, 'release');
|
||||
const checksum = steps.find((step) => step.name === 'Generate checksums');
|
||||
const publish = steps.find((step) => step.name === 'Publish Prerelease');
|
||||
assert.ok(checksum);
|
||||
assert.ok(publish);
|
||||
assert.ok(executableRunLines(checksum).includes('files+=(release/package-size-*.json)'));
|
||||
assert.ok(executableRunLines(publish).includes('release/package-size-*.json'));
|
||||
});
|
||||
|
||||
test('release callers pass only the declared macOS signing secrets to packaging', () => {
|
||||
const secrets = [
|
||||
'CSC_LINK',
|
||||
'CSC_KEY_PASSWORD',
|
||||
'APPLE_ID',
|
||||
'APPLE_APP_SPECIFIC_PASSWORD',
|
||||
'APPLE_TEAM_ID',
|
||||
];
|
||||
assert.deepEqual(
|
||||
parsedPackageWorkflow.on?.workflow_call?.secrets,
|
||||
Object.fromEntries(secrets.map((name) => [name, { required: true }])),
|
||||
);
|
||||
for (const workflow of [
|
||||
parsedPrereleaseWorkflow,
|
||||
readWorkflow(resolve(__dirname, '../.github/workflows/release.yml')),
|
||||
]) {
|
||||
assert.equal(workflow.jobs?.package?.uses, './.github/workflows/package-release.yml');
|
||||
assert.deepEqual(
|
||||
workflow.jobs?.package?.secrets,
|
||||
Object.fromEntries(secrets.map((name) => [name, '${{ secrets.' + name + ' }}'])),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('prerelease workflow publishes both launcher wrappers with the platform packages', () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import {
|
||||
jobSteps,
|
||||
readWorkflow,
|
||||
stepsMissingEnvDeclaration,
|
||||
templateExpressionsInRunBodies,
|
||||
@@ -10,6 +11,10 @@ import {
|
||||
|
||||
const releaseWorkflowPath = resolve(__dirname, '../.github/workflows/release.yml');
|
||||
const releaseWorkflow = readFileSync(releaseWorkflowPath, 'utf8');
|
||||
const packageWorkflow = readFileSync(
|
||||
resolve(__dirname, '../.github/workflows/package-release.yml'),
|
||||
'utf8',
|
||||
);
|
||||
const docsPagesWorkflowPath = resolve(__dirname, '../.github/workflows/docs-pages.yml');
|
||||
const docsPagesWorkflow = readFileSync(docsPagesWorkflowPath, 'utf8');
|
||||
const parsedReleaseWorkflow = readWorkflow(releaseWorkflowPath);
|
||||
@@ -96,20 +101,25 @@ test('release delegates its quality gate instead of duplicating quality steps',
|
||||
releaseWorkflow,
|
||||
/quality-gate:\s*\n\s*permissions:\s*\n\s*contents: read\s*\n\s*uses: \.\/\.github\/workflows\/quality-gate\.yml/,
|
||||
);
|
||||
const qualityGateJob = releaseWorkflow.match(/quality-gate:[\s\S]*?(?=\n build-linux:)/)?.[0];
|
||||
const qualityGateJob = releaseWorkflow.match(/quality-gate:[\s\S]*?(?=\n package:)/)?.[0];
|
||||
assert.ok(qualityGateJob);
|
||||
assert.doesNotMatch(qualityGateJob, /oven-sh\/setup-bun/);
|
||||
assert.doesNotMatch(qualityGateJob, /bun run test:coverage:src/);
|
||||
assert.doesNotMatch(qualityGateJob, /bun run test:env/);
|
||||
});
|
||||
|
||||
test('release build jobs install and cache stats dependencies before packaging', () => {
|
||||
assert.match(releaseWorkflow, /build-linux:[\s\S]*stats\/node_modules/);
|
||||
assert.match(releaseWorkflow, /build-macos:[\s\S]*stats\/node_modules/);
|
||||
assert.match(releaseWorkflow, /build-windows:[\s\S]*stats\/node_modules/);
|
||||
assert.match(releaseWorkflow, /build-linux:[\s\S]*cd stats && bun install --frozen-lockfile/);
|
||||
assert.match(releaseWorkflow, /build-macos:[\s\S]*cd stats && bun install --frozen-lockfile/);
|
||||
assert.match(releaseWorkflow, /build-windows:[\s\S]*cd stats && bun install --frozen-lockfile/);
|
||||
test('each release build job installs stats dependencies before packaging', () => {
|
||||
const workflow = readWorkflow(resolve(__dirname, '../.github/workflows/package-release.yml'));
|
||||
for (const job of ['build-linux', 'build-macos', 'build-windows']) {
|
||||
const steps = jobSteps(workflow, job);
|
||||
const install = steps.findIndex((step) =>
|
||||
step.run?.includes('cd stats && bun install --frozen-lockfile'),
|
||||
);
|
||||
const build = steps.findIndex((step) =>
|
||||
/bun run build:(appimage|mac|win)/.test(step.run ?? ''),
|
||||
);
|
||||
assert(install >= 0 && build > install, `${job} must install stats before packaging`);
|
||||
}
|
||||
});
|
||||
|
||||
test('release workflow generates release notes from committed changelog output', () => {
|
||||
@@ -163,42 +173,6 @@ test('top-level package metadata keeps Linux Electron runtime app identity canon
|
||||
assert.equal(packageJson.desktopName, 'SubMiner.desktop');
|
||||
});
|
||||
|
||||
test('release packaging keeps default file inclusion and excludes large source-only trees explicitly', () => {
|
||||
const files = packageJson.build?.files ?? [];
|
||||
assert.ok(files.includes('**/*'));
|
||||
assert.ok(files.includes('!src{,/**/*}'));
|
||||
assert.ok(files.includes('!launcher{,/**/*}'));
|
||||
assert.ok(files.includes('!stats/src{,/**/*}'));
|
||||
assert.ok(files.includes('!.tmp{,/**/*}'));
|
||||
assert.ok(files.includes('!release-*{,/**/*}'));
|
||||
assert.ok(files.includes('!vendor/subminer-yomitan{,/**/*}'));
|
||||
assert.ok(files.includes('!vendor/texthooker-ui/src{,/**/*}'));
|
||||
assert.ok(files.includes('!assets{,/**/*}'));
|
||||
assert.ok(files.includes('!plugin{,/**/*}'));
|
||||
assert.ok(files.includes('!vendor/yomitan-jlpt-vocab{,/**/*}'));
|
||||
assert.ok(files.includes('!docs{,/**/*}'));
|
||||
assert.ok(files.includes('!tests{,/**/*}'));
|
||||
assert.ok(files.includes('!packaging{,/**/*}'));
|
||||
assert.ok(files.includes('!README.md'));
|
||||
assert.ok(files.includes('!CHANGELOG.md'));
|
||||
assert.ok(files.includes('!AGENTS.md'));
|
||||
assert.ok(files.includes('!CLAUDE.md'));
|
||||
assert.ok(files.includes('!stats/public{,/**/*}'));
|
||||
assert.ok(files.includes('!stats/package.json'));
|
||||
assert.ok(files.includes('!stats/tsconfig.json'));
|
||||
assert.ok(files.includes('!stats/vite.config.ts'));
|
||||
assert.ok(files.includes('!dist/**/*.map'));
|
||||
assert.ok(files.includes('!dist/**/*.test.*'));
|
||||
assert.ok(files.includes('!dist/**/__tests__{,/**/*}'));
|
||||
assert.ok(files.includes('!scripts/**/*.test.*'));
|
||||
assert.ok(files.includes('!vendor/texthooker-ui/public{,/**/*}'));
|
||||
assert.ok(files.includes('!vendor/texthooker-ui/.vscode{,/**/*}'));
|
||||
assert.ok(files.includes('!vendor/texthooker-ui/README.md'));
|
||||
assert.ok(files.includes('!vendor/texthooker-ui/package.json'));
|
||||
assert.ok(files.includes('!vendor/texthooker-ui/tsconfig*.json'));
|
||||
assert.ok(files.includes('!node_modules/@libsql/linux-x64-musl{,/**/*}'));
|
||||
});
|
||||
|
||||
test('release packaging stages only the generated launcher runtime artifacts', () => {
|
||||
const launcherResource = packageJson.build?.extraResources?.find(
|
||||
(resource) => resource.from === 'dist/launcher' && resource.to === 'launcher',
|
||||
@@ -239,12 +213,12 @@ test('config example generation runs directly from source without unrelated bund
|
||||
});
|
||||
|
||||
test('windows release workflow publishes unsigned artifacts directly without SignPath', () => {
|
||||
assert.match(releaseWorkflow, /Build unsigned Windows artifacts/);
|
||||
assert.match(releaseWorkflow, /run: bun run build:win:unsigned/);
|
||||
assert.match(releaseWorkflow, /name: windows/);
|
||||
assert.match(releaseWorkflow, /path: \|\n\s+release\/\*\.exe\n\s+release\/\*\.zip/);
|
||||
assert.ok(!releaseWorkflow.includes('signpath/github-action-submit-signing-request'));
|
||||
assert.ok(!releaseWorkflow.includes('SIGNPATH_'));
|
||||
assert.match(packageWorkflow, /Build unsigned Windows artifacts/);
|
||||
assert.match(packageWorkflow, /run: bun run build:win:unsigned/);
|
||||
assert.match(packageWorkflow, /name: windows/);
|
||||
assert.match(packageWorkflow, /path: \|\n\s+release\/\*\.exe\n\s+release\/\*\.zip/);
|
||||
assert.ok(!packageWorkflow.includes('signpath/github-action-submit-signing-request'));
|
||||
assert.ok(!packageWorkflow.includes('SIGNPATH_'));
|
||||
});
|
||||
|
||||
test('release artifact names are distinct before upload', () => {
|
||||
@@ -306,3 +280,21 @@ test('release and docs workflows keep tag-derived values out of shell bodies', (
|
||||
// that would be substituted into the condition before the shell reads it.
|
||||
assert.match(docsPagesWorkflow, /if \[\[ ! "\$TAG_NAME" =~/);
|
||||
});
|
||||
|
||||
test('stable and prerelease builds use the same packaging gate', () => {
|
||||
const prerelease = readFileSync(
|
||||
resolve(__dirname, '../.github/workflows/prerelease.yml'),
|
||||
'utf8',
|
||||
);
|
||||
for (const workflow of [releaseWorkflow, prerelease]) {
|
||||
assert.match(workflow, /uses: \.\/\.github\/workflows\/package-release\.yml/);
|
||||
assert.match(workflow, /needs: \[package\]/);
|
||||
assert.match(workflow, /release\/package-size-\*\.json/);
|
||||
}
|
||||
assert.deepEqual(
|
||||
templateExpressionsInRunBodies(
|
||||
readWorkflow(resolve(__dirname, '../.github/workflows/package-release.yml')),
|
||||
),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
@font-face {
|
||||
font-family: 'M PLUS 1';
|
||||
src: url('./fonts/MPLUS1[wght].ttf') format('truetype');
|
||||
src: url('../fonts/MPLUS1[wght].ttf') format('truetype');
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@font-face {
|
||||
font-family: 'M PLUS 1';
|
||||
src: url('./fonts/MPLUS1[wght].ttf') format('truetype');
|
||||
src: url('../fonts/MPLUS1[wght].ttf') format('truetype');
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@font-face {
|
||||
font-family: 'M PLUS 1';
|
||||
src: url('./fonts/MPLUS1[wght].ttf') format('truetype');
|
||||
src: url('../fonts/MPLUS1[wght].ttf') format('truetype');
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@@ -4,10 +4,23 @@ export type WorkflowStep = {
|
||||
name?: string;
|
||||
run?: string;
|
||||
env?: Record<string, unknown>;
|
||||
uses?: string;
|
||||
with?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ParsedWorkflow = {
|
||||
jobs?: Record<string, { steps?: WorkflowStep[] } | undefined>;
|
||||
on?: { workflow_call?: { secrets?: Record<string, { required?: boolean }> } };
|
||||
jobs?: Record<
|
||||
string,
|
||||
| {
|
||||
steps?: WorkflowStep[];
|
||||
uses?: string;
|
||||
needs?: string | string[];
|
||||
permissions?: Record<string, string>;
|
||||
secrets?: string | Record<string, string>;
|
||||
}
|
||||
| undefined
|
||||
>;
|
||||
};
|
||||
|
||||
// Workflow tests only ever run under `bun test`, which parses YAML natively.
|
||||
|
||||
Reference in New Issue
Block a user