Skip to content
145 changes: 142 additions & 3 deletions .github/workflows/node-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,9 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
os: [ubuntu-latest, macos-latest]
node: ["22.13.0"]
include:
- os: windows-latest
node: "24"
- os: ubuntu-latest
node: "24.0.0"
- os: ubuntu-latest
Expand Down Expand Up @@ -121,3 +119,144 @@ jobs:
'
node bin/codex-security.mjs --version
node bin/codex-security.mjs --help

windows-test:
name: windows-latest / node-${{ matrix.node == '22.13.0' && '22' || matrix.node }} / tests-${{ matrix.shard }}
runs-on: windows-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
node: ["22.13.0", "24"]
shard: [1, 2, 3, 4, 5, 6, 7]

steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false

- name: Set up Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
node-version: ${{ matrix.node }}

- name: Set up pnpm
run: npm install --global pnpm@11.9.0 --no-audit --no-fund

- name: Set up Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.3.14"

- name: Install dependencies
working-directory: sdk/typescript
run: pnpm install --frozen-lockfile

- name: Prepare private Windows test root
id: windows-temp
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$path = Join-Path $env:USERPROFILE '.codex-security-ci-temp'
New-Item -ItemType Directory -Path $path -Force | Out-Null
$sid = (whoami /user /fo csv /nh | ConvertFrom-Csv -Header Name, Sid).Sid
& icacls $path /inheritance:r /grant:r "*${sid}:(OI)(CI)F" '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'Could not secure the Windows test root' }
"path=$path" >> $env:GITHUB_OUTPUT

- name: Test shard ${{ matrix.shard }}
timeout-minutes: 10
env:
TEMP: ${{ steps.windows-temp.outputs.path }}
TMP: ${{ steps.windows-temp.outputs.path }}
TMPDIR: ${{ steps.windows-temp.outputs.path }}
CODEX_SECURITY_ALLOW_MACHINE_POLICY_TEST: ${{ runner.environment == 'github-hosted' && 'true' || 'false' }}
run: node sdk/typescript/scripts/run-windows-ci-tests.mjs ${{ matrix.shard }}

- name: Typecheck
if: matrix.shard == 7
working-directory: sdk/typescript
run: pnpm run types

- name: Check formatting
if: matrix.shard == 7
working-directory: sdk/typescript
run: pnpm run format

windows-verify:
name: windows-latest / node-${{ matrix.node == '22.13.0' && '22' || matrix.node }} / verify
runs-on: windows-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
node: ["22.13.0", "24"]

steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false

- name: Set up Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
node-version: ${{ matrix.node }}
cache: npm
cache-dependency-path: sdk/typescript/pnpm-lock.yaml

- name: Set up pnpm
run: npm install --global pnpm@11.9.0 --no-audit --no-fund

- name: Set up Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.3.14"

- name: Install dependencies
working-directory: sdk/typescript
run: pnpm install --frozen-lockfile

- name: Build
working-directory: sdk/typescript
run: pnpm run build

- name: Pack
working-directory: sdk/typescript
run: pnpm pack --pack-destination ../../dist

- name: Inspect package
working-directory: sdk/typescript
shell: bash
run: pnpm run check:package ../../dist/*.tgz

- name: Smoke-test Node.js runtime
working-directory: sdk/typescript
shell: bash
run: |
set -euo pipefail
node --input-type=module --eval '
import { CodexSecurity } from "@openai/codex-security";

if (typeof CodexSecurity !== "function") {
throw new Error("The SDK does not export CodexSecurity.");
}
'
node bin/codex-security.mjs --version
node bin/codex-security.mjs --help

windows:
name: windows-latest / node-${{ matrix.node == '22.13.0' && '22' || matrix.node }}
runs-on: ubuntu-latest
if: always()
needs: [windows-test, windows-verify]
strategy:
fail-fast: false
matrix:
node: ["22.13.0", "24"]

steps:
- name: Require every Windows coverage job
if: needs.windows-test.result != 'success' || needs.windows-verify.result != 'success'
run: exit 1
36 changes: 25 additions & 11 deletions sdk/typescript/scripts/check-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ function tar(args, encoding = "buffer") {
}

let offset = 0;
const archiveFiles = new Map();
for (; offset + 512 <= archiveBytes.byteLength; ) {
const header = archiveBytes.subarray(offset, offset + 512);
if (header.every((byte) => byte === 0)) {
Expand All @@ -65,12 +66,31 @@ for (; offset + 512 <= archiveBytes.byteLength; ) {
throw new Error("npm tarball contains an invalid tar entry.");
}
const size = Number.parseInt(sizeField || "0", 8);
offset += 512 + Math.ceil(size / 512) * 512;
const contentsStart = offset + 512;
const nextOffset = contentsStart + Math.ceil(size / 512) * 512;
if (nextOffset > archiveBytes.byteLength) {
throw new Error("npm tarball contains an invalid tar entry.");
}
if (header[156] === 0 || header[156] === 0x30) {
archiveFiles.set(
path,
archiveBytes.subarray(contentsStart, contentsStart + size),
);
}
offset = nextOffset;
}
if (archiveBytes.subarray(offset).some((byte) => byte !== 0)) {
throw new Error("npm tarball contains trailing tar data.");
}

function archiveFile(path) {
const contents = archiveFiles.get(path);
if (contents === undefined) {
throw new Error("npm tarball contains an invalid tar entry: " + path + ".");
}
return contents;
}

const entries = tar(["-tzf", archive], "utf8").split(/\r?\n/u).filter(Boolean);
const files = new Set(entries);
if (files.size !== entries.length) {
Expand Down Expand Up @@ -212,7 +232,7 @@ if ([3, 6, 9].some((index) => launcherPermissions[index] !== "x")) {
throw new Error("npm package CLI launcher is not executable.");
}
const packageJson = JSON.parse(
tar(["-xOf", archive, "package/package.json"]).toString("utf8"),
archiveFile("package/package.json").toString("utf8"),
);
if (
packageJson.name !== "@openai/codex-security" ||
Expand Down Expand Up @@ -252,22 +272,16 @@ function brotliPayload(bytes, file) {
}

for (const file of compressedFiles) {
payloads.push(
brotliPayload(tar(["-xOf", archive, file]), file).toString("utf8"),
);
payloads.push(brotliPayload(archiveFile(file), file).toString("utf8"));
}
for (const parts of compressedParts.values()) {
parts.sort((left, right) => left.part - right.part);
const bytes = Buffer.concat(
parts.map(({ file }) => tar(["-xOf", archive, file])),
);
const bytes = Buffer.concat(parts.map(({ file }) => archiveFile(file)));
payloads.push(brotliPayload(bytes, parts[0].file).toString("utf8"));
}
for (const file of files) {
if (/\.png$/iu.test(file)) {
const digest = createHash("sha256")
.update(tar(["-xOf", archive, file]))
.digest("hex");
const digest = createHash("sha256").update(archiveFile(file)).digest("hex");
if (digest !== PUBLIC_LOGO_SHA256) {
throw new Error(`npm tarball contains an unexpected PNG asset: ${file}.`);
}
Expand Down
126 changes: 126 additions & 0 deletions sdk/typescript/scripts/run-windows-ci-tests.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { spawn } from "node:child_process";
import { readFile, readdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";

const testsDirectory = new URL("../tests-ts/", import.meta.url);
const packageDirectory = fileURLToPath(new URL("../", import.meta.url));
const tests = (await readdir(testsDirectory))
.filter((file) => file.endsWith(".test.ts"))
.sort();
const slowApiTestNames = [
"keeps a private preflight snapshot isolated from persistent credentials",
"reuses keyring-compatible credentials across separate scan clients",
"serializes parallel scans sharing a managed credential home",
"reuses the managed runtime when scan authentication changes",
"does not reimport ambient credentials after an explicit logout",
];
const apiTestSource = await readFile(
new URL("../tests-ts/api.test.ts", import.meta.url),
"utf8",
);
for (const testName of slowApiTestNames) {
if (!apiTestSource.includes(`test("${testName}"`)) {
throw new Error("Windows CI slow API shard references a missing test.");
}
}
const slowApiTests = slowApiTestNames.join("|");
const shardSeeds = [
{
files: ["api.test.ts"],
testNamePattern: slowApiTests,
},
{
files: ["api.test.ts"],
testNamePattern: `^(?!.*(?:${slowApiTests})).*$`,
},
{ files: ["runtime.test.ts"] },
{ files: ["cli-authentication.test.ts"] },
{ files: ["scan-recovery.test.ts"] },
{ files: [] },
{ files: [] },
];
const assigned = new Set(shardSeeds.flatMap(({ files }) => files));
for (const file of assigned) {
if (!tests.includes(file)) {
throw new Error("Windows CI test shard references a missing file: " + file);
}
}
const unassigned = tests.filter((file) => !assigned.has(file));
const slowRemainderFiles = new Set([
"deep-scan-workbench.test.ts",
"release-automation.test.ts",
"scan-comparison.test.ts",
]);
for (const [index, file] of unassigned.entries()) {
shardSeeds[slowRemainderFiles.has(file) ? 6 : 5 + (index % 2)].files.push(
file,
);
}

const assignments = new Map();
for (const { files } of shardSeeds) {
for (const file of files) {
assignments.set(file, (assignments.get(file) ?? 0) + 1);
}
}
for (const file of tests) {
const expectedAssignments = file === "api.test.ts" ? 2 : 1;
if (assignments.get(file) !== expectedAssignments) {
throw new Error("Windows CI test shards must run every test file.");
}
}

const requestedShard =
process.argv[2] === undefined
? undefined
: Number.parseInt(process.argv[2], 10);
if (
requestedShard !== undefined &&
(!Number.isSafeInteger(requestedShard) ||
requestedShard < 1 ||
requestedShard > shardSeeds.length)
) {
throw new Error("Usage: node scripts/run-windows-ci-tests.mjs [1-7]");
}
const selectedShards =
requestedShard === undefined
? shardSeeds.map((shard, index) => ({ ...shard, index }))
: [{ ...shardSeeds[requestedShard - 1], index: requestedShard - 1 }];

const results = await Promise.all(
selectedShards.map(
({ files, index, testNamePattern }) =>
new Promise((resolve, reject) => {
const paths = files.map((file) => "./tests-ts/" + file);
console.log(
"Windows CI test shard " +
(index + 1) +
"/" +
shardSeeds.length +
": " +
paths.join(" ") +
(testNamePattern === undefined
? ""
: " --test-name-pattern " + testNamePattern),
);
const args = ["test", "--timeout", "30000"];
if (testNamePattern !== undefined) {
args.push("--test-name-pattern", testNamePattern);
}
args.push(...paths);
const child = spawn("bun", args, {
cwd: packageDirectory,
stdio: "inherit",
windowsHide: true,
});
child.once("error", reject);
child.once("close", (code) => {
resolve(code ?? 1);
});
}),
),
);

if (results.some((code) => code !== 0)) {
process.exitCode = 1;
}
1 change: 1 addition & 0 deletions sdk/typescript/scripts/smoke-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ try {
"--prefer-offline",
"--include=optional",
"--ignore-scripts",
"--package-lock=false",
"--no-audit",
"--no-fund",
archive,
Expand Down
8 changes: 7 additions & 1 deletion sdk/typescript/tests-ts/skeleton.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,13 @@ describe("TypeScript package skeleton", () => {
expect(packageJson.scripts.test).toBe(
"bun test --timeout 30000 ./tests-ts",
);
expect(ciWorkflow).toContain("run: pnpm --dir sdk/typescript run test\n");
expect(ciWorkflow).toContain(
"run: node sdk/typescript/scripts/run-windows-ci-tests.mjs ${{ matrix.shard }}",
);
expect(ciWorkflow).toContain(
"name: windows-latest / node-${{ matrix.node == '22.13.0' && '22' || matrix.node }}",
);
expect(ciWorkflow).toContain("run: pnpm --dir sdk/typescript run test");
expect(ciWorkflow).not.toContain("--timeout 60000");
});

Expand Down
Loading