Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
49 changes: 49 additions & 0 deletions .github/workflows/squad-release-verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,55 @@ concurrency:
cancel-in-progress: false

jobs:
verify-windows-platform:
name: Verify Windows-only release tests
runs-on: windows-latest
timeout-minutes: 20
env:
SOURCE_SHA: ${{ inputs.source_sha || github.event.pull_request.head.sha }}
EVIDENCE_DIR: ${{ github.workspace }}\evidence-windows
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7
with:
ref: ${{ inputs.source_sha || github.event.pull_request.head.sha }}
fetch-depth: 0

- name: Assert checkout is exactly the requested SHA
shell: pwsh
run: |
if ($env:SOURCE_SHA -notmatch '^[0-9a-f]{40}$') { throw "source_sha must be a full lowercase 40-hex SHA, got '$env:SOURCE_SHA'" }
$head = git rev-parse HEAD
if ($head -ne $env:SOURCE_SHA) { throw "checkout HEAD $head != requested $env:SOURCE_SHA" }
New-Item -ItemType Directory -Force -Path $env:EVIDENCE_DIR | Out-Null

- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 #v6
with:
node-version: 22

- name: Install dependencies
run: npm ci

- name: Build workspace packages
env:
SKIP_BUILD_BUMP: '1'
run: npm run build

- name: Run Windows platform suite with JSON report
run: npx vitest run test/mcp-root-write.test.ts --reporter=default --reporter=json --outputFile="$env:EVIDENCE_DIR\vitest-report.json"

- name: Require the Windows-only test to execute
if: always()
run: node scripts/release/test-reconciliation.mjs --report "$env:EVIDENCE_DIR\vitest-report.json" --out "$env:EVIDENCE_DIR" --platform win32 --require-platform-executions --fail-on-unexplained

- name: Upload Windows platform evidence
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 #v7
with:
name: release-windows-platform-evidence-${{ env.SOURCE_SHA }}
path: evidence-windows/
retention-days: 90
if-no-files-found: warn

verify:
name: Verify exact SHA and build evidence
runs-on: ubuntu-latest
Expand Down
2 changes: 1 addition & 1 deletion scripts/release/build-manifest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ export function buildManifest(opts) {
},
packages: { '@bradygaster/squad-sdk': pkgEntry(sdk, '@bradygaster/squad-sdk'), '@bradygaster/squad-cli': pkgEntry(cli, '@bradygaster/squad-cli') },
lockfiles: { files: lockReport.files.length, registryEntries: lockReport.entries, violations: lockReport.violations.length, deniedPatterns: lockReport.deniedPatterns, ok: lockReport.ok, externalNpmCi: npmCi },
tests: recon ? { source: 'vitest JSON report of this run (scripts/release/test-reconciliation.mjs)', numTotalTests: recon.summary.numTotalTests, byStatus: recon.summary.byStatus, byCategory: recon.summary.byCategory, countsSum: recon.summary.countsSum, unlistedTests: recon.summary.unlistedTests, skippedFiles: recon.summary.skippedFiles, reconciliationFile: 'test-reconciliation.json' }
tests: recon ? { source: 'vitest JSON report of this run (scripts/release/test-reconciliation.mjs)', platform: recon.summary.platform, numTotalTests: recon.summary.numTotalTests, byStatus: recon.summary.byStatus, byCategory: recon.summary.byCategory, countsSum: recon.summary.countsSum, unlistedTests: recon.summary.unlistedTests, skippedFiles: recon.summary.skippedFiles, reconciliationFile: 'test-reconciliation.json' }
: { source: `console summary of cited CI run ${citedRun.id} (no vitest JSON report available to this generator)`, numTotalTests: citedRun.consoleSummary.testsTotal, byStatus: { passed: citedRun.consoleSummary.testsPassed, xfail: citedRun.consoleSummary.expectedFailures, skipped: citedRun.consoleSummary.testsSkipped, todo: citedRun.consoleSummary.todos }, byCategory: {}, countsSum: citedRun.consoleSummary.testsPassed + citedRun.consoleSummary.expectedFailures + citedRun.consoleSummary.testsSkipped + citedRun.consoleSummary.todos === citedRun.consoleSummary.testsTotal, unlistedTests: citedRun.consoleSummary.testsTotal - (citedRun.consoleSummary.testsPassed + citedRun.consoleSummary.expectedFailures + citedRun.consoleSummary.testsSkipped + citedRun.consoleSummary.todos), skippedFiles: [], reconciliationFile: null },
closure,
behavioralEvidence: {
Expand Down
2 changes: 1 addition & 1 deletion scripts/release/manifest.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
},
"tests": {
"type": "object", "additionalProperties": false, "required": ["source", "numTotalTests", "byStatus", "byCategory", "countsSum", "unlistedTests", "skippedFiles"],
"properties": { "source": { "type": "string" }, "numTotalTests": { "type": "integer" }, "byStatus": { "type": "object" }, "byCategory": { "type": "object" }, "countsSum": { "type": "boolean" }, "unlistedTests": { "type": "integer" }, "skippedFiles": { "type": "array", "items": { "type": "string" } }, "reconciliationFile": { "type": ["string", "null"] } }
"properties": { "source": { "type": "string" }, "platform": { "type": "string" }, "numTotalTests": { "type": "integer" }, "byStatus": { "type": "object" }, "byCategory": { "type": "object" }, "countsSum": { "type": "boolean" }, "unlistedTests": { "type": "integer" }, "skippedFiles": { "type": "array", "items": { "type": "string" } }, "reconciliationFile": { "type": ["string", "null"] } }
},
"closure": {
"type": "object", "additionalProperties": false,
Expand Down
85 changes: 76 additions & 9 deletions scripts/release/test-reconciliation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
* unexplained anything else, including tests with no terminal state
*
* node scripts/release/test-reconciliation.mjs --report vitest.json --out evidence [--baseline prior.json]
* [--fail-on-unexplained]
* [--platform <process.platform>] [--require-platform-executions] [--fail-on-unexplained]
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
Expand All @@ -37,6 +37,20 @@ export const CATEGORY_RULES = Object.freeze([
{ category: 'env-gated', file: /squad-20-real-clients-stdio\.e2e\.test\.ts$/, reason: 'SQUAD_REAL_CLI_TESTS=1 not set / host CLI binaries absent' },
{ category: 'env-gated', file: /squad-20-host-feature-matrix\.test\.ts$/, reason: 'live host evidence requires the real CLI binaries' },
{ category: 'env-gated', file: /mcp-spec-init\.test\.ts$/, title: /squad\.exe/, reason: 'it.runIf(win32): Windows-only PATH/.exe resolution' },
{
category: 'env-gated',
file: 'test/mcp-root-write.test.ts',
title: '[platform: win32] removes the legacy raw uppercase Windows drive hash while preserving unrelated entries',
runsOn: 'win32',
reason: "it.runIf(process.platform === 'win32'): Windows-only legacy drive-hash reconciliation",
},
{
category: 'env-gated',
file: 'test/mcp-root-write.test.ts',
title: '[platform: non-win32] removes the legacy raw POSIX symlink hash while preserving unrelated entries',
runsOn: 'non-win32',
reason: "it.runIf(process.platform !== 'win32'): POSIX-only legacy symlink-hash reconciliation",
},
{ category: 'env-gated', file: /profile-boundary\.test\.ts$/, reason: 'host lacks 8.3 short names or symlink/junction privilege' },
{ category: 'env-gated', file: /state-backend\.test\.ts$/, reason: 'platform-gated state backend case' },
{ category: 'env-gated', file: /docs-build\.test\.ts$/, reason: 'docs dependencies not installed' },
Expand All @@ -58,7 +72,26 @@ function relFile(name) {
return i >= 0 ? norm.slice(i + 1) : norm;
}

export function reconcile(report, { baseline } = {}) {
function matches(value, expected) {
return typeof expected === 'string' ? value === expected : expected.test(value);
}

function runsOnPlatform(rule, platform) {
return rule.runsOn === 'win32' ? platform === 'win32' : rule.runsOn === 'non-win32' ? platform !== 'win32' : true;
}

export function resolvePlatform(claimedPlatform, actualPlatform = process.platform) {
const platform = claimedPlatform ?? actualPlatform;
if (!['aix', 'darwin', 'freebsd', 'linux', 'openbsd', 'sunos', 'win32'].includes(platform)) {
throw new Error(`unsupported reconciliation platform '${platform}'`);
}
if (claimedPlatform && claimedPlatform !== actualPlatform) {
throw new Error(`claimed reconciliation platform '${claimedPlatform}' does not match execution platform '${actualPlatform}'`);
}
return platform;
}

export function reconcile(report, { baseline, platform = process.platform, requirePlatformExecutions = false } = {}) {
const tests = [];
const files = { total: report.testResults.length, byStatus: {} };
for (const tr of report.testResults) {
Expand All @@ -84,10 +117,14 @@ export function reconcile(report, { baseline } = {}) {
} else if (t.status === 'todo') { category = TODO_BLOCKERS.includes(t.title) ? 'todo-blocking' : 'todo'; reason = 'it.todo placeholder'; }
else {
for (const rule of CATEGORY_RULES) {
if (!rule.file.test(t.file)) continue;
if (!matches(t.file, rule.file)) continue;
if (rule.status && rule.status !== t.status) continue;
if (rule.title && !rule.title.test(t.fullName) && !rule.title.test(t.title)) continue;
if (rule.title && !matches(t.title, rule.title) && !matches(t.fullName, rule.title)) continue;
if (!rule.status && t.status === 'xfail') continue;
if (rule.runsOn && runsOnPlatform(rule, platform)) {
reason = `expected to execute on ${rule.runsOn}, but report status was '${t.status}'`;
break;
}
category = rule.category; reason = rule.reason; break;
}
if (t.status === 'failed') { category = 'failed'; reason = t.failureMessages[0]?.split('\n')[0] ?? 'failed'; }
Expand All @@ -100,13 +137,26 @@ export function reconcile(report, { baseline } = {}) {
const bucketSum = Object.values(byStatus).reduce((a, b) => a + b, 0);
const listed = tests.length;
const unexplainedTotal = report.numTotalTests - listed;
const requiredExecutions = requirePlatformExecutions
? CATEGORY_RULES.filter((rule) => rule.runsOn && runsOnPlatform(rule, platform)).map((rule) => {
const test = tests.find((candidate) => matches(candidate.file, rule.file) && matches(candidate.title, rule.title));
return {
file: rule.file,
title: rule.title,
platform: rule.runsOn,
status: test?.status ?? 'missing',
ok: test?.status === 'passed',
};
})
: [];
const summary = {
numTotalTests: report.numTotalTests, listed, bucketSum, byStatus, byCategory,
platform, numTotalTests: report.numTotalTests, listed, bucketSum, byStatus, byCategory,
countsSum: bucketSum === listed && listed === report.numTotalTests,
unlistedTests: unexplainedTotal,
requiredExecutions,
files,
skippedFiles: report.testResults.filter((tr) => tr.assertionResults.length > 0 && tr.assertionResults.every((a) => a.status === 'skipped' || a.status === 'todo')).map((tr) => relFile(tr.name)),
ok: report.numFailedTests === 0 && (byCategory.unexplained ?? 0) === 0 && (byCategory['todo-blocking'] ?? 0) === 0 && unexplainedTotal === 0 && bucketSum === report.numTotalTests,
ok: report.numFailedTests === 0 && (byCategory.unexplained ?? 0) === 0 && (byCategory['todo-blocking'] ?? 0) === 0 && unexplainedTotal === 0 && bucketSum === report.numTotalTests && requiredExecutions.every((entry) => entry.ok),
};
let diff = null;
if (baseline) {
Expand All @@ -118,20 +168,37 @@ export function reconcile(report, { baseline } = {}) {
return { generatedAt: new Date().toISOString(), summary, nonPassing: categorized.sort((a, b) => a.file.localeCompare(b.file) || a.fullName.localeCompare(b.fullName)), diffVsBaseline: diff };
}

export function shouldFail(result, { failOnUnexplained = false } = {}) {
const s = result.summary;
return failOnUnexplained ? !s.ok : (s.numTotalTests > 0 && (s.byStatus.failed ?? 0) > 0);
}

if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const arg = (k) => { const i = process.argv.indexOf(k); return i >= 0 ? process.argv[i + 1] : undefined; };
const reportPath = path.resolve(arg('--report') ?? 'vitest-report.json');
if (!existsSync(reportPath)) { console.error(`::error::vitest JSON report not found: ${reportPath}`); process.exit(2); }
const outDir = path.resolve(arg('--out') ?? 'evidence');
mkdirSync(outDir, { recursive: true });
const baseline = arg('--baseline') ? JSON.parse(readFileSync(path.resolve(arg('--baseline')), 'utf8')) : undefined;
const result = reconcile(JSON.parse(readFileSync(reportPath, 'utf8')), { baseline });
let platform;
try {
platform = resolvePlatform(arg('--platform'));
} catch (error) {
console.error(`::error::${error.message}`);
process.exit(2);
}
const result = reconcile(JSON.parse(readFileSync(reportPath, 'utf8')), {
baseline,
platform,
requirePlatformExecutions: process.argv.includes('--require-platform-executions'),
});
writeFileSync(path.join(outDir, 'test-reconciliation.json'), JSON.stringify(result, null, 2) + '\n');
const s = result.summary;
console.log(`tests=${s.numTotalTests} listed=${s.listed} byStatus=${JSON.stringify(s.byStatus)} byCategory=${JSON.stringify(s.byCategory)} unlisted=${s.unlistedTests} skippedFiles=${s.skippedFiles.join(',') || '-'}`);
console.log(`platform=${s.platform} tests=${s.numTotalTests} listed=${s.listed} byStatus=${JSON.stringify(s.byStatus)} byCategory=${JSON.stringify(s.byCategory)} unlisted=${s.unlistedTests} skippedFiles=${s.skippedFiles.join(',') || '-'}`);
for (const t of result.nonPassing.filter((t) => t.category === 'unexplained' || t.category === 'failed' || t.category === 'todo-blocking')) console.error(`::warning::${t.category}: ${t.file} :: ${t.fullName} [${t.status}]`);
for (const required of s.requiredExecutions.filter((entry) => !entry.ok)) console.error(`::error::required platform test did not pass: ${required.file} :: ${required.title} [${required.status}]`);
if (!s.countsSum || s.unlistedTests !== 0) console.error(`::error::${s.unlistedTests} test(s) are counted in numTotalTests but have no listed result (never reached a terminal state); see ${path.join(outDir, 'test-reconciliation.json')}`);
const fail = process.argv.includes('--fail-on-unexplained') ? !s.ok : (s.numTotalTests > 0 && (s.byStatus.failed ?? 0) > 0);
const fail = shouldFail(result, { failOnUnexplained: process.argv.includes('--fail-on-unexplained') });
console.log(`${s.ok ? '✅' : '⚠️'} reconciliation written to ${outDir}`);
process.exit(fail ? 1 : 0);
}
4 changes: 2 additions & 2 deletions test/mcp-root-write.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ describe('iter-8 mcp-root: repo-root .mcp.json writer + project tombstone', () =
});

it.runIf(process.platform === 'win32')(
'removes the legacy raw uppercase Windows drive hash while preserving unrelated entries',
'[platform: win32] removes the legacy raw uppercase Windows drive hash while preserving unrelated entries',
() => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-home-win-legacy-'));
try {
Expand Down Expand Up @@ -363,7 +363,7 @@ describe('iter-8 mcp-root: repo-root .mcp.json writer + project tombstone', () =
);

it.runIf(process.platform !== 'win32')(
'removes the legacy raw POSIX symlink hash while preserving unrelated entries',
'[platform: non-win32] removes the legacy raw POSIX symlink hash while preserving unrelated entries',
() => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-home-posix-legacy-'));
const links = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-links-posix-legacy-'));
Expand Down
Loading
Loading