Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
2 changes: 1 addition & 1 deletion .github/idd/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
"chatgpt-codex-connector[bot]"
],
"ciGate": {
"trustSourcePinnedRequiredChecks": false
"trustSourcePinnedRequiredChecks": true
Comment thread
kurone-kito marked this conversation as resolved.
},
"autopilotSuitability": {
"floor": 3
Expand Down
25 changes: 10 additions & 15 deletions docs/idd-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,21 +112,16 @@ blanket non-IDD-PR exemption).

## External CI-Check Trust

**`ciGate.trustSourcePinnedRequiredChecks`**: explicitly `false`
(matching the schema default, recorded rather than left implicit so
the decision stays stable and self-documenting against a future
upstream default change), not enabled. This flag only matters once a
required-check
Ruleset entry is itself source-pinned (an optional choice in GitHub's
Ruleset UI, picking a specific reporting App from a dropdown rather than
a bare check-name match). Issue #51 — which will register
`idd-advisory-convergence` as a required status check — describes only
the standard check-name registration flow, with no mention of pinning a
specific integration source, and has not run yet, so there is no live
ruleset entry to inspect and confirm either way. Revisit this decision
once #51 actually executes and the resulting entry can be inspected via
`gh api repos/{owner}/{repo}/rules/branches/main`; enable only if that
entry turns out to be source-pinned.
**`ciGate.trustSourcePinnedRequiredChecks`**: `true`, enabled.
`idd-advisory-convergence` is registered as a required status check
via a repository Ruleset (`gh api
repos/kurone-kito/builder-config/rules/branches/main`; the maintainer
action tracked by #51, which remains open pending its own remaining
acceptance criteria), and the resulting entry is source-pinned to a
specific reporting App (`integration_id: 15368`, GitHub Actions)
rather than a bare check-name match. Confirmed via a live PR (#117)
that the pinned integration correctly resolves to this repository's
own `idd-advisory-convergence.yml` workflow, so trusting it is safe.

## Credential Scope

Expand Down
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)',
));
});
17 changes: 13 additions & 4 deletions packages/sea-builder/src/tasks/createCacheTasks.mts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
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 +34,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.
* @returns A promise that resolves to the configured cache tasks.
*/
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
46 changes: 46 additions & 0 deletions packages/sea-builder/src/utils/filterSupportedLts.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
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}`
];
if (!entry?.lts || !entry.end) {
return false;
}
// `entry.end` is a date-only string (e.g. "2026-04-30"), which
// Date parses as that day's UTC midnight; add a day so the entire
// end date itself still counts as supported.
const endOfSupportWindow = new Date(entry.end).getTime() + 86_400_000;
return endOfSupportWindow > now.getTime();
});
61 changes: 61 additions & 0 deletions packages/sea-builder/src/utils/filterSupportedLts.spec.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
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 }));
});

it('still counts the scheduled end date itself as supported', () => {
// Node 20 (Iron) ends 2026-04-30; a date-only string parses as that
// day's UTC midnight, so a naive `> now` comparison would treat the
// entire end date as already unsupported.
const stillOnEndDate = new Date('2026-04-30T18:00:00.000Z');
expect(filterSupportedLts(majors, stillOnEndDate)).toContainEqual(
expect.objectContaining({ major: 20 }),
);
});

it('excludes it starting the day after the scheduled end date', () => {
const dayAfterEnd = new Date('2026-05-01T00:00:00.001Z');
expect(filterSupportedLts(majors, dayAfterEnd)).not.toContainEqual(
expect.objectContaining({ major: 20 }),
);
});
});
Loading
Loading