Skip to content
78 changes: 78 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ jobs:
run_quality: ${{ steps.classify.outputs.run_quality }}
run_isolation: ${{ steps.classify.outputs.run_isolation }}
run_packages: ${{ steps.classify.outputs.run_packages }}
run_windows: ${{ steps.classify.outputs.run_windows }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand All @@ -43,28 +44,36 @@ jobs:
run_quality=true
run_isolation=true
run_packages=true
run_windows=true
if [[ "$GITHUB_EVENT_NAME" == pull_request ]]; then
# Disable rename detection so both the old and new paths are checked.
if git diff --name-only --no-renames -z "$BASE_SHA...$HEAD_SHA" -- > "$RUNNER_TEMP/changed-files"; then
if [[ -s "$RUNNER_TEMP/changed-files" ]]; then
run_quality=false
run_isolation=false
run_packages=false
run_windows=false
while IFS= read -r -d '' path; do
case "$path" in
docs/development.md|docs/authoring.md|docs/rendering.md|packages/*/README.md|packages/create-cs-object/template/*.md)
run_windows=true
;;
*.md) ;;
examples/*|tests/integration/*)
run_quality=true
run_windows=true
;;
packages/*|apps/*|package.json|package-lock.json|.nvmrc|.npmrc|tsconfig*.json|biome.json|vitest.config.ts|vercel.json)
run_quality=true
run_isolation=true
run_windows=true
;;
*)
# Unknown paths and CI tooling changes retain the full suite.
run_quality=true
run_isolation=true
run_packages=true
run_windows=true
;;
esac
done < "$RUNNER_TEMP/changed-files"
Expand All @@ -74,6 +83,7 @@ jobs:
echo "run_quality=$run_quality" >> "$GITHUB_OUTPUT"
echo "run_isolation=$run_isolation" >> "$GITHUB_OUTPUT"
echo "run_packages=$run_packages" >> "$GITHUB_OUTPUT"
echo "run_windows=$run_windows" >> "$GITHUB_OUTPUT"

quality:
needs: changes
Expand Down Expand Up @@ -183,3 +193,71 @@ jobs:
${{ runner.temp }}/cso installed packages */full cli/*-results.json
${{ runner.temp }}/cso installed packages */full cli/output/*
${{ runner.temp }}/cso installed packages */full cli/pdf-output/**

windows-installed:
needs: changes
if: ${{ !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.run_windows != 'false') }}
name: windows-installed (${{ matrix.id }})
runs-on: windows-2025
timeout-minutes: 50
strategy:
fail-fast: false
matrix:
include:
- id: powershell-5.1
edition: Desktop
major: '5'
- id: pwsh-7
edition: Core
major: '7'
env:
CSO_WINDOWS_SHELL_ID: ${{ matrix.id }}
CSO_WINDOWS_EXPECTED_EDITION: ${{ matrix.edition }}
CSO_WINDOWS_EXPECTED_MAJOR: ${{ matrix.major }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
env:
GIT_CONFIG_COUNT: 1
GIT_CONFIG_KEY_0: core.autocrlf
GIT_CONFIG_VALUE_0: 'true'
with:
persist-credentials: false
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
cache: npm
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.11'
- name: Run the installed-user workflow in PowerShell 5.1
if: ${{ matrix.id == 'powershell-5.1' }}
shell: powershell
run: '& .\tests\integration\installed\windows-user-workflow.ps1'
- name: Run the installed-user workflow in PowerShell 7
if: ${{ matrix.id == 'pwsh-7' }}
shell: pwsh
run: '& .\tests\integration\installed\windows-user-workflow.ps1'
- name: Retain Windows workflow evidence
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: windows-installed-${{ matrix.id }}
retention-days: 14
include-hidden-files: true
path: |
${{ runner.temp }}/cso installed packages ${{ matrix.id }} é/*.json
${{ runner.temp }}/cso installed packages ${{ matrix.id }} é/*.log
${{ runner.temp }}/cso installed packages ${{ matrix.id }} é/*.png
${{ runner.temp }}/cso installed packages ${{ matrix.id }} é/*.html
${{ runner.temp }}/cso installed packages ${{ matrix.id }} é/*.pdf
${{ runner.temp }}/cso installed packages ${{ matrix.id }} é/my-report/.gitattributes
${{ runner.temp }}/cso installed packages ${{ matrix.id }} é/my-report/reports.json
${{ runner.temp }}/cso installed packages ${{ matrix.id }} é/my-report/references/*.json
${{ runner.temp }}/cso installed packages ${{ matrix.id }} é/my-report/calculations/reference.json
${{ runner.temp }}/cso installed packages ${{ matrix.id }} é/my-report/calculations/_cso_bindings/**/*.py
${{ runner.temp }}/cso installed packages ${{ matrix.id }} é/my-report/calculations/_cso_bindings/**/*.pyi
${{ runner.temp }}/cso installed packages ${{ matrix.id }} é/my-report/output/*.html
${{ runner.temp }}/cso installed packages ${{ matrix.id }} é/my-report/output/*.pdf
${{ github.workspace }}/output/panels.html
${{ github.workspace }}/output/panels.pdf
77 changes: 70 additions & 7 deletions scripts/check-ci.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,28 @@ const workflow = z.object({
quality: job,
isolation: job,
'installed-packages': job,
'windows-installed': job.extend({
name: z.string(),
'runs-on': z.literal('windows-2025'),
strategy: z.object({
'fail-fast': z.literal(false),
matrix: z.object({
include: z.array(z.object({
id: z.string(),
edition: z.string(),
major: z.string(),
})),
}),
}),
steps: z.array(z.object({
name: z.string().optional(),
if: z.string().optional(),
shell: z.string().optional(),
run: z.string().optional(),
uses: z.string().optional(),
with: z.record(z.string(), z.unknown()).optional(),
})),
}),
}),
}).parse(parse(readFileSync(new URL('../.github/workflows/ci.yml', import.meta.url), 'utf8')));
const script = z.string().min(1).parse(workflow.jobs.changes.steps.find((step) => step.id === 'classify')?.run);
Expand All @@ -29,18 +51,21 @@ type Change =
| { kind: 'delete'; path: string }
| { kind: 'empty' };

type Policy = { quality: boolean; isolation: boolean; 'installed-packages': boolean };
const full: Policy = { quality: true, isolation: true, 'installed-packages': true };
const projects: Policy = { quality: true, isolation: true, 'installed-packages': false };
const quality: Policy = { quality: true, isolation: false, 'installed-packages': false };
const skip: Policy = { quality: false, isolation: false, 'installed-packages': false };
const jobNames = ['quality', 'isolation', 'installed-packages'] satisfies (keyof Policy)[];
type Policy = { quality: boolean; isolation: boolean; 'installed-packages': boolean; 'windows-installed': boolean };
const full: Policy = { quality: true, isolation: true, 'installed-packages': true, 'windows-installed': true };
const projects: Policy = { quality: true, isolation: true, 'installed-packages': false, 'windows-installed': true };
const quality: Policy = { quality: true, isolation: false, 'installed-packages': false, 'windows-installed': true };
const windowsDocumentation: Policy = { quality: false, isolation: false, 'installed-packages': false, 'windows-installed': true };
const skip: Policy = { quality: false, isolation: false, 'installed-packages': false, 'windows-installed': false };
const jobNames = ['quality', 'isolation', 'installed-packages', 'windows-installed'] satisfies (keyof Policy)[];

function expression(source: string, context: object): unknown {
assert.ok(source.startsWith('${{') && source.endsWith('}}'));
return runInNewContext(source.slice(3, -2), context, { timeout: 1000 });
}

const githubExpression = (value: string) => `${String.fromCodePoint(36)}{{ ${value} }}`;

function policy({ outputs, result = 'success', cancelled = false }: {
outputs: Record<string, string>;
result?: string;
Expand All @@ -51,6 +76,7 @@ function policy({ outputs, result = 'success', cancelled = false }: {
quality: z.boolean().parse(expression(workflow.jobs.quality.if, context)),
isolation: z.boolean().parse(expression(workflow.jobs.isolation.if, context)),
'installed-packages': z.boolean().parse(expression(workflow.jobs['installed-packages'].if, context)),
'windows-installed': z.boolean().parse(expression(workflow.jobs['windows-installed'].if, context)),
};
}

Expand Down Expand Up @@ -125,6 +151,10 @@ function classify({ change, event = 'pull_request', missingBase = false, missing

for (const [name, change, expected] of [
['Markdown only', { kind: 'write', paths: ['README.md', 'guide with spaces.md', 'line\nbreak.md'] }, skip],
['Windows development guide', { kind: 'write', paths: ['docs/development.md'] }, windowsDocumentation],
['CLI guide', { kind: 'write', paths: ['packages/cso-cli/README.md'] }, windowsDocumentation],
['initializer guide', { kind: 'write', paths: ['packages/create-cs-object/README.md'] }, windowsDocumentation],
['generated-project guide', { kind: 'write', paths: ['packages/create-cs-object/template/authoring.md'] }, windowsDocumentation],
['package source', { kind: 'write', paths: ['packages/cso-core/src/index.ts'] }, projects],
['demo source', { kind: 'write', paths: ['apps/demo/app/page.tsx'] }, projects],
['Python package', { kind: 'write', paths: ['packages/cso-python/src/cso_python/cli.py'] }, projects],
Expand Down Expand Up @@ -174,7 +204,7 @@ test('failed detector and missing outputs run all checks', () => {
});

test('missing job output runs the affected check', () => {
assert.deepEqual(policy({ outputs: { run_quality: 'false', run_packages: 'false' } }), {
assert.deepEqual(policy({ outputs: { run_quality: 'false', run_packages: 'false', run_windows: 'false' } }), {
...skip, isolation: true,
});
});
Expand All @@ -188,6 +218,39 @@ test('required jobs remain reported without workflow path filters', () => {
for (const name of jobNames) assert.equal(workflow.jobs[name].needs, 'changes');
});

test('Windows installed-user cases use explicit native PowerShell hosts', () => {
assert.equal(workflow.jobs['windows-installed'].name, `windows-installed (${githubExpression('matrix.id')})`);
assert.deepEqual(workflow.jobs['windows-installed'].strategy.matrix.include, [
{ id: 'powershell-5.1', edition: 'Desktop', major: '5' },
{ id: 'pwsh-7', edition: 'Core', major: '7' },
]);
const workflowSteps = workflow.jobs['windows-installed'].steps.filter((step) => step.run);
assert.deepEqual(workflowSteps, [
{
name: 'Run the installed-user workflow in PowerShell 5.1',
if: githubExpression("matrix.id == 'powershell-5.1'"),
shell: 'powershell',
run: '& .\\tests\\integration\\installed\\windows-user-workflow.ps1',
},
{
name: 'Run the installed-user workflow in PowerShell 7',
if: githubExpression("matrix.id == 'pwsh-7'"),
shell: 'pwsh',
run: '& .\\tests\\integration\\installed\\windows-user-workflow.ps1',
},
]);
const node = workflow.jobs['windows-installed'].steps.find((step) => step.uses?.startsWith('actions/setup-node@'));
const python = workflow.jobs['windows-installed'].steps.find((step) => step.uses?.startsWith('actions/setup-python@'));
assert.equal(node?.with?.['node-version'], '24');
assert.equal(python?.with?.['python-version'], '3.11');
});

test('Windows PowerShell reads the Unicode workflow as UTF-8', () => {
const bytes = readFileSync(new URL('../tests/integration/installed/windows-user-workflow.ps1', import.meta.url));
assert.deepEqual([...bytes.subarray(0, 3)], [0xef, 0xbb, 0xbf]);
assert(bytes.includes(Buffer.from('é', 'utf8')));
});

test('release runs full reusable CI before both registries publish', () => {
const release = z.object({ jobs: z.object({
checks: z.object({ uses: z.string(), if: z.string() }),
Expand Down
109 changes: 109 additions & 0 deletions tests/integration/installed/windows-registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { spawn } from 'node:child_process';
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { z } from 'zod';
import { writeJson } from './_initializer-evidence.ts';
import type { CommandRecord, CommandRunner } from './_initializer-processes.ts';
import {
closeRegistry,
registryPackage,
startRegistry,
} from './_initializer-registry.ts';

const ConfigSchema = z.strictObject({
archives: z.string().min(1),
readyPath: z.string().min(1),
stopPath: z.string().min(1),
evidenceRoot: z.string().min(1),
});

const configPath = z.string().min(1).parse(process.env.CSO_WINDOWS_CONFIG);
const config = ConfigSchema.parse(JSON.parse(readFileSync(configPath, 'utf8')));
const commands: CommandRecord[] = [];

const run: CommandRunner = async ({ cwd, command, args, env, timeoutMs }) => {
const record: CommandRecord = { cwd, command, args, status: null };
const commandNumber = commands.push(record);
return await new Promise<string>((resolveRun, rejectRun) => {
const child = spawn(command, args, {
cwd,
env: env ?? process.env,
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk));
child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk));
let spawnError: Error | undefined;
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
child.kill();
}, timeoutMs ?? 60_000);
child.once('error', (error) => {
spawnError = error;
});
child.once('close', (status) => {
clearTimeout(timer);
record.status = status;
const output = Buffer.concat(stdout).toString('utf8');
const errorOutput = Buffer.concat(stderr).toString('utf8');
writeFileSync(
join(config.evidenceRoot, `registry-command-${commandNumber}.log`),
`${output}${errorOutput}`,
);
if (spawnError) rejectRun(spawnError);
else if (timedOut) rejectRun(new Error(`${command} timed out`));
else if (status !== 0)
rejectRun(
new Error(
`${command} ${args.join(' ')} exited ${status}\n${errorOutput}`,
),
);
else resolveRun(output);
});
});
};

let registry: Awaited<ReturnType<typeof startRegistry>> | undefined;
try {
const archivePaths = readdirSync(config.archives)
.filter((name) => name.endsWith('.tgz'))
.sort()
.map((name) => join(config.archives, name));
const packages = await Promise.all(
archivePaths.map((archive) =>
registryPackage({ archive, root: config.evidenceRoot, run }),
),
);
registry = await startRegistry(packages);
writeJson(config.readyPath, {
origin: registry.origin,
packages: packages.map(({ archive, manifest, integrity, shasum }) => ({
archive,
name: manifest.name,
version: manifest.version,
integrity,
shasum,
})),
});
await new Promise<void>((resolveStop) => {
const timer = setInterval(() => {
if (!existsSync(config.stopPath)) return;
clearInterval(timer);
resolveStop();
}, 250);
});
await closeRegistry(registry.server);
registry = undefined;
writeJson(join(config.evidenceRoot, 'registry-commands.json'), commands);
} catch (error: unknown) {
if (registry) await closeRegistry(registry.server);
writeJson(join(config.evidenceRoot, 'registry-failure.json'), {
message: error instanceof Error ? error.message : String(error),
commands,
});
throw error;
}
Loading
Loading