Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/sea-builder/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ project adheres to
### Added

- `CHANGELOG.md` (#112).
- Added the `node-releases` dependency, used to determine whether an
LTS line's support window has already ended (#59).

### Changed

Expand All @@ -38,6 +40,19 @@ project adheres to
permanent download cache (#72).
- Stopped `devPreinstall` from executing unpinned `pnpm dlx` packages
(#76).
- Made `--node`'s omitted default match its documentation: `sea-builder`
now actually resolves the latest patch of the oldest **currently
supported** (not end-of-life) LTS line when the option isn't passed,
instead of silently embedding whatever Node.js version happened to
run the build, making SEA builds reproducible across machines. An
earlier version of this fix still picked the oldest LTS line in
`all-node-versions`' history regardless of whether it was still
supported, which resolved to Node.js 4 (end-of-life since 2018); the
`node-releases` support-window check above closes that gap. The
resolved version is now also shown in the build output.
`sea-cache`'s own omitted-version default now goes through the same
resolution as `sea-builder`'s, so the two commands agree on which
archive to fetch when neither specifies an explicit version (#59).

## [0.21.0] - 2025-10-03

Expand Down
3 changes: 2 additions & 1 deletion packages/sea-builder/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ as `linux-x64` or `win32-x64`. `sea-builder` automatically invokes
`sea-builder` also accepts a `--node` option to choose the Node.js version.
Omitting this option uses the latest patch of the oldest supported LTS line.
Passing `--node=22` is equivalent to specifying `^22`, while
`--node=22.23` behaves like `~22.23`.
`--node=22.23` behaves like `~22.23`. The resolved version is shown in
the build output.

### Cache directory

Expand Down
1 change: 1 addition & 0 deletions packages/sea-builder/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"all-node-versions": "^13.0.1",
"execa": "^9.6.0",
"listr2": "^9.0.4",
"node-releases": "^2.0.53",
Comment thread
kurone-kito marked this conversation as resolved.
"semver": "^7.8.1"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion packages/sea-builder/src/cache.mts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@ import { runIfMain } from './utils/runIfMain.mjs';
* @returns A promise that resolves when the tasks are completed.
*/
export const main = async (...targets: readonly string[]): Promise<void> =>
createListrCacheTasks({ targets }).run();
(await createListrCacheTasks({ targets })).run();

runIfMain(import.meta.url, main);
4 changes: 2 additions & 2 deletions packages/sea-builder/src/listr2/createBuildTasks.mts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const createBuildTasks = async (
options: BuildTasksOptions,
): Promise<Listr> => {
const { arch, platform = process.platform, projectRoot } = options;
const { basename, download, execa, existsSync, mkdir, nodeVersion, targets } =
const { basename, download, execa, existsSync, mkdir, targets } =
await normalizeBuildOptions(options);
return new Listr([
createBuildTask(execa),
Expand All @@ -36,7 +36,7 @@ export const createBuildTasks = async (
download,
existsSync,
mkdir,
nodeVersion: await resolveNodeVersion(nodeVersion),
nodeVersion: await resolveNodeVersion(options.nodeVersion),
Comment thread
kurone-kito marked this conversation as resolved.
Comment thread
kurone-kito marked this conversation as resolved.
platform,
projectRoot,
targets,
Expand Down
66 changes: 66 additions & 0 deletions packages/sea-builder/src/listr2/createBuildTasks.spec.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createBuildTasks } from './createBuildTasks.mjs';

const mocks = vi.hoisted(() => ({
createBuildTask: vi.fn(() => ({ task: vi.fn(), title: 'Build' })),
createCacheTask: vi.fn(() => ({ task: vi.fn(), title: 'Cache' })),
createSeaTask: vi.fn(() => ({ task: vi.fn(), title: 'Sea' })),
normalizeBuildOptions: vi.fn(),
resolveNodeVersion: vi.fn(),
}));

vi.mock('../tasks/createBuildTask.mjs', () => ({
createBuildTask: mocks.createBuildTask,
}));

vi.mock('../tasks/createCacheTask.mjs', () => ({
createCacheTask: mocks.createCacheTask,
}));

vi.mock('../tasks/createSeaTask.mjs', () => ({
createSeaTask: mocks.createSeaTask,
}));

vi.mock('../tasks/normalizeBuildOptions.mjs', () => ({
normalizeBuildOptions: mocks.normalizeBuildOptions,
}));

vi.mock('../utils/resolveNodeVersion.mjs', () => ({
resolveNodeVersion: mocks.resolveNodeVersion,
}));

describe('createBuildTasks', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.normalizeBuildOptions.mockResolvedValue({
basename: 'foo',
download: vi.fn(),
execa: vi.fn(),
existsSync: vi.fn(),
mkdir: vi.fn(),
// A pre-defaulted value, distinct from the raw option, so a
// regression that resolves this instead of the raw option is
// caught by the assertions below.
nodeVersion: 'v20.19.5',
targets: ['linux-x64'],
});
mocks.resolveNodeVersion.mockResolvedValue('v22.23.2');
});

it('resolves the node version from the raw option, not the pre-defaulted one', async () => {
await createBuildTasks({ basename: 'foo' });
expect(mocks.resolveNodeVersion).toHaveBeenCalledWith(undefined);
});

it('passes an explicit --node spec through untouched', async () => {
await createBuildTasks({ basename: 'foo', nodeVersion: '20' });
expect(mocks.resolveNodeVersion).toHaveBeenCalledWith('20');
});

it('passes the resolved node version to createCacheTask', async () => {
await createBuildTasks({ basename: 'foo' });
expect(mocks.createCacheTask).toHaveBeenCalledWith(
expect.objectContaining({ nodeVersion: 'v22.23.2' }),
);
});
});
6 changes: 4 additions & 2 deletions packages/sea-builder/src/listr2/createCacheTasks.mts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,7 @@ import { createCacheTasks } from '../tasks/createCacheTasks.mjs';
* @param options Options controlling the task generation.
* @returns Configured {@link Listr} instance.
*/
export const createListrCacheTasks = (options: CacheOptions = {}): Listr =>
new Listr(createCacheTasks(options), { concurrent: true });
export const createListrCacheTasks = async (
options: CacheOptions = {},
): Promise<Listr> =>
new Listr(await createCacheTasks(options), { concurrent: true });
6 changes: 4 additions & 2 deletions packages/sea-builder/src/tasks/createCacheTask.mts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type { Task } from './createTaskFactory.mjs';
* @returns Listr task object.
*/
export const createCacheTask = (opts: CacheOptions): Task => ({
title: 'Download the Node.js archives',
task: () => createListrCacheTasks(opts).run(),
title: opts.nodeVersion
? `Download the Node.js archives (${opts.nodeVersion})`
: 'Download the Node.js archives',
task: async () => (await createListrCacheTasks(opts)).run(),
});
5 changes: 5 additions & 0 deletions packages/sea-builder/src/tasks/createCacheTask.spec.mts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,9 @@ describe('createCacheTask', () => {

it('sets title', () =>
expect(createCacheTask(opts).title).toBe('Download the Node.js archives'));

it('includes the resolved Node.js version in the title when provided', () =>
expect(createCacheTask({ ...opts, nodeVersion: 'v22.23.2' }).title).toBe(
'Download the Node.js archives (v22.23.2)',
));
});
14 changes: 12 additions & 2 deletions packages/sea-builder/src/tasks/createCacheTasks.mts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Listr } from 'listr2';
import { resolveNodeVersion } from '../utils/resolveNodeVersion.mjs';
import type { DownloadFunction, ExistsSync, Mkdir } from '../utils/types.mjs';
import { createMetaFactory } from './createMetaFactory.mjs';
import type { Task } from './createTaskFactory.mjs';
Expand Down Expand Up @@ -34,12 +35,21 @@ export interface CacheOptions {

/**
* Create Listr tasks for downloading Node.js archives.
*
* Resolves an omitted or spec-only `nodeVersion` the same way
* {@link createBuildTasks} does, so `sea-cache` and `sea-builder` agree on
* which archive to fetch when neither specifies an explicit version.
* @param options Options controlling the task generation.
* @returns Configured {@link Listr} instance.
*/
Comment thread
kurone-kito marked this conversation as resolved.
export const createCacheTasks = (options: CacheOptions = {}): Task[] => {
export const createCacheTasks = async (
options: CacheOptions = {},
): Promise<Task[]> => {
const { cacheDir, download, existsSync, mkdir, nodeVersion, targets } =
normalizeCacheOptions(options);
normalizeCacheOptions({
...options,
nodeVersion: await resolveNodeVersion(options.nodeVersion),
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const metaFor = createMetaFactory(cacheDir, nodeVersion);
const toTask = createTaskFactory(download, existsSync, mkdir, cacheDir);
return targets.map((t) => toTask(metaFor(t)));
Expand Down
6 changes: 3 additions & 3 deletions packages/sea-builder/src/tasks/createCacheTasks.spec.mts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import { createCacheTasks } from './createCacheTasks.mjs';
const nodeVersion = 'v18.0.0';

describe('createCacheTasks', () => {
it('creates tasks for each target', () => {
const tasks = createCacheTasks({
it('creates tasks for each target', async () => {
const tasks = await createCacheTasks({
download: vi.fn(),
existsSync: () => false,
mkdir: vi.fn(async () => undefined),
Expand All @@ -21,7 +21,7 @@ describe('createCacheTasks', () => {

it('passes correct metadata to download', async () => {
const downloads: Array<readonly [string, string]> = [];
const tasks = createCacheTasks({
const tasks = await createCacheTasks({
download: async (url, dest) => {
downloads.push([url, dest]);
},
Expand Down
41 changes: 41 additions & 0 deletions packages/sea-builder/src/utils/filterSupportedLts.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { MajorNodeVersion } from 'all-node-versions';
import releaseSchedule from 'node-releases/data/release-schedule/release-schedule.json' with {
type: 'json',
};

/** A single major's entry in node-releases' release schedule data. */
interface ScheduleEntry {
/** ISO date the major's support window ends, if scheduled. */
readonly end?: string;

/** ISO date the major entered LTS, if it ever did. */
readonly lts?: string;
}

/**
* Filter out majors whose LTS support window has already ended, per
* `node-releases`' release schedule. `all-node-versions`' own `lts` flag
* marks every major that *ever* had an LTS codename, going back to Node 4
* — this narrows that down to the ones still within their scheduled
* support window.
* @param majors Majors to filter.
* @param now Current time, used to determine end-of-life status.
* @returns Majors that are LTS and not yet past their scheduled `end` date.
*/
export const filterSupportedLts = <
T extends Pick<MajorNodeVersion, 'lts' | 'major'>,
>(
majors: readonly T[],
now: Date = new Date(),
): readonly T[] =>
majors.filter(({ lts, major }) => {
if (!lts) {
return false;
}
const entry = (releaseSchedule as Record<string, ScheduleEntry>)[
`v${major}`
];
return Boolean(
entry?.lts && entry.end && new Date(entry.end).getTime() > now.getTime(),
);
});
44 changes: 44 additions & 0 deletions packages/sea-builder/src/utils/filterSupportedLts.spec.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { filterSupportedLts } from './filterSupportedLts.mjs';

const majors = [
{ major: 26, latest: '26.0.0' },
{ major: 25, latest: '25.9.0' },
{ major: 24, latest: '24.18.1', lts: 'krypton' },
{ major: 23, latest: '23.11.1' },
{ major: 22, latest: '22.23.2', lts: 'jod' },
{ major: 20, latest: '20.20.2', lts: 'iron' },
{ major: 18, latest: '18.20.8', lts: 'hydrogen' },
{ major: 4, latest: '4.9.1', lts: 'argon' },
] as const;

describe('filterSupportedLts', () => {
it('excludes non-LTS majors', () => {
const now = new Date('2026-08-11');
expect(filterSupportedLts(majors, now)).not.toContainEqual(
expect.objectContaining({ major: 26 }),
);
});

it('excludes an LTS major whose support window has already ended', () => {
// Node 20 (Iron) ends 2026-04-30; Node 18 (Hydrogen) ends 2025-04-30.
const now = new Date('2026-08-11');
const result = filterSupportedLts(majors, now);
expect(result).not.toContainEqual(expect.objectContaining({ major: 20 }));
expect(result).not.toContainEqual(expect.objectContaining({ major: 18 }));
});

it('excludes a long-retired LTS major', () => {
const now = new Date('2026-08-11');
expect(filterSupportedLts(majors, now)).not.toContainEqual(
expect.objectContaining({ major: 4 }),
);
});

it('keeps LTS majors still within their support window', () => {
const now = new Date('2026-08-11');
const result = filterSupportedLts(majors, now);
expect(result).toContainEqual(expect.objectContaining({ major: 24 }));
expect(result).toContainEqual(expect.objectContaining({ major: 22 }));
});
});
6 changes: 4 additions & 2 deletions packages/sea-builder/src/utils/resolveNodeVersion.mts
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import type { SemverVersion } from 'all-node-versions';
import allNodeVersions from 'all-node-versions';
import { rcompare, satisfies } from 'semver';
import { filterSupportedLts } from './filterSupportedLts.mjs';
import { toSemver } from './toSemver.mjs';

/**
* Resolve Node.js version from a version specification.
* @param spec Version specification, e.g. `20`, `20.11`, `22.1.0`, etc.
* If not specified, the latest patch version of the oldest LTS is used.
* If not specified, the latest patch version of the oldest currently
* supported LTS line is used.
* @returns Resolved Node.js version, e.g. `v20.12.0`.
*/
export const resolveNodeVersion = async (
spec?: string | undefined,
): Promise<`v${SemverVersion}`> => {
const { majors, versions } = await allNodeVersions({ fetch: false });
const range = toSemver(spec, majors);
const range = toSemver(spec, filterSupportedLts(majors));
const resolved = versions
.map(({ node }) => node)
.filter((v) => satisfies(v, range))
Expand Down
13 changes: 9 additions & 4 deletions packages/sea-builder/src/utils/resolveNodeVersion.spec.mts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,19 @@ vi.mock('all-node-versions');
const mockData = {
versions: [
{ node: '24.0.0' },
{ node: '22.5.0' },
{ node: '22.1.0' },
{ node: '20.12.0' },
{ node: '20.11.1' },
{ node: '20.11.0' },
],
majors: [
{ major: 24, latest: '24.0.0' },
{ major: 22, latest: '22.1.0', lts: 'jod' },
{ major: 24, latest: '24.0.0', lts: 'krypton' },
{ major: 22, latest: '22.5.0', lts: 'jod' },
// Node 20 (Iron) is genuinely past end-of-life (2026-04-30 per
// node-releases' real schedule data) despite all-node-versions still
// marking it `lts` — proves the default skips an already-EOL LTS
// rather than picking the oldest one that ever had a codename.
{ major: 20, latest: '20.12.0', lts: 'iron' },
],
} as const satisfies AllNodeVersions;
Expand All @@ -25,8 +30,8 @@ beforeEach(() => {
});

describe('resolveNodeVersion', () => {
it('resolves latest patch of oldest LTS by default', async () => {
await expect(resolveNodeVersion()).resolves.toBe('v20.12.0');
it('resolves latest patch of oldest currently-supported LTS by default, skipping an EOL one', async () => {
await expect(resolveNodeVersion()).resolves.toBe('v22.5.0');
Comment thread
kurone-kito marked this conversation as resolved.
Outdated
});

it('handles major range', async () => {
Expand Down
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading