test(release): make the signing-gate structural tests assert executed code, not text

The round-2 harness matched /\bthrow\b/ and /\bcatch\b/ against raw block text, so
the word satisfied the assertion wherever it appeared. Downgrading the resolver
throw to `Write-Host "...would normally throw..."` — the exact silent fail-open
this PR exists to kill — left all 11 tests green.

Every span is now classified once (code / string / comment) by the same walk that
pairs braces, and assertions run against the string-and-comment-blanked view.
Blanking preserves length, so indices still line up across views.

Also re-anchors the catch-ordering test: `blockAfter(step, '} catch {')` picked
the first catch in the step, which stopped being the gate's own once the
persistence helpers grew theirs — moving the policy throw inside the try was
passing again.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-07-26 15:23:23 -07:00
co-authored by Orca
parent 1338becde5
commit c8fb734fdd
@@ -14,54 +14,166 @@ function workflowSource(name) {
return readFileSync(join(workflowsDir, name), 'utf8')
}
// Why a scanner and not a regex: PowerShell here-docs in these gates contain
// braces inside strings (`"{0,-14} {1} <{2}>" -f ...`) and inside comments, so
// naive brace counting mis-pairs and every scope assertion below silently
// degrades into "some text appears somewhere in the file".
function findBlockEnd(source, openIndex) {
let depth = 0
let i = openIndex
// Why a scanner and not a regex: PowerShell gates here contain braces inside
// strings (`"{0,-14} {1} <{2}>" -f ...`) and inside comments, so naive brace
// counting mis-pairs and every scope assertion below silently degrades into
// "some text appears somewhere in the file". The same walk also lets assertions
// distinguish a keyword the shell executes from the same word sitting in a
// message string — downgrading `throw` to `Write-Host "...would throw..."`
// otherwise passes a `/\bthrow\b/` check while restoring the silent fail-open.
/** `source` split into `{start, end, kind}` spans, kind being 'code' | 'string' | 'comment'. */
function scanSpans(source) {
// Here-strings use different terminator rules; refusing them beats mis-pairing silently.
expect(source, 'here-strings are not understood by this scanner').not.toMatch(/@['"]/)
const spans = []
let i = 0
while (i < source.length) {
const char = source[i]
if (char === '#') {
const newline = source.indexOf('\n', i)
i = newline === -1 ? source.length : newline
const end = newline === -1 ? source.length : newline
spans.push({ start: i, end, kind: 'comment' })
i = end
continue
}
if (char === "'") {
const end = source.indexOf("'", i + 1)
i = end === -1 ? source.length : end + 1
let end = i + 1
while (end < source.length) {
if (source[end] !== "'") {
end += 1
} else if (source[end + 1] === "'") {
end += 2 // doubled '' escapes a quote rather than closing the string
} else {
break
}
}
end = Math.min(end + 1, source.length)
spans.push({ start: i, end, kind: 'string' })
i = end
continue
}
if (char === '"') {
i += 1
while (i < source.length && source[i] !== '"') {
i += source[i] === '`' ? 2 : 1
let end = i + 1
while (end < source.length && source[end] !== '"') {
end += source[end] === '`' ? 2 : 1
}
i += 1
end = Math.min(end + 1, source.length)
spans.push({ start: i, end, kind: 'string' })
i = end
continue
}
if (char === '{') {
depth += 1
} else if (char === '}') {
depth -= 1
if (depth === 0) {
return i
}
let end = i
while (end < source.length && !'#\'"'.includes(source[end])) {
end += 1
}
i += 1
spans.push({ start: i, end, kind: 'code' })
i = end
}
throw new Error(`unbalanced block starting at ${openIndex}`)
return spans
}
/** The `{ ... }` block that opens after `marker`, as `{ start, end, body }`. */
/** `source` with the named span kinds blanked to spaces — same length, so indices still line up. */
function blank(source, spans, kinds) {
const chars = [...source]
for (const span of spans) {
if (!kinds.includes(span.kind)) {
continue
}
for (let i = span.start; i < span.end; i += 1) {
if (chars[i] !== '\n') {
chars[i] = ' '
}
}
}
return chars.join('')
}
/** `source` with comments blanked — for assertions whose subject is a literal the gate prints. */
function withoutComments(source) {
return blank(source, scanSpans(source), ['comment'])
}
/** `source` with strings and comments blanked — for assertions about executed statements. */
function codeOf(source) {
return blank(source, scanSpans(source), ['string', 'comment'])
}
/**
* The `{ ... }` block opening after `marker`. `code` has strings and comments blanked, so a
* keyword assertion against it can only be satisfied by a keyword the shell would execute;
* `text` keeps strings but drops comments, for assertions about literals the gate emits.
*/
function blockAfter(source, marker, from = 0) {
const markerIndex = source.indexOf(marker, from)
const spans = scanSpans(source)
const code = blank(source, spans, ['string', 'comment'])
const markerIndex = code.indexOf(marker, from)
expect(markerIndex, `missing marker: ${marker}`).toBeGreaterThan(-1)
const start = source.indexOf('{', markerIndex)
const start = code.indexOf('{', markerIndex)
expect(start, `no block opens after: ${marker}`).toBeGreaterThan(-1)
const end = findBlockEnd(source, start)
return { start, end, body: source.slice(start, end + 1) }
let depth = 0
let end = -1
for (let i = start; i < source.length && end === -1; i += 1) {
if (code[i] === '{') {
depth += 1
} else if (code[i] === '}') {
depth -= 1
if (depth === 0) {
end = i
}
}
}
expect(end, `unbalanced block after: ${marker}`).toBeGreaterThan(-1)
return {
start,
end,
code: code.slice(start, end + 1),
text: blank(source, spans, ['comment']).slice(start, end + 1)
}
}
/**
* The innermost `{ ... }` block enclosing `marker`, plus the keyword introducing it.
* Needed where the anchor is the block's *contents*: `blockAfter(step, '} catch {')` picks
* whichever catch comes first in the file, which stopped being the gate's own once the
* persistence helpers grew their own try/catch.
*/
function blockEnclosing(source, marker) {
const spans = scanSpans(source)
const code = blank(source, spans, ['string', 'comment'])
// Located in the comment-stripped text (the marker may include a string literal),
// then paired in `code`; both blankings preserve length, so indices line up.
const markerIndex = blank(source, spans, ['comment']).indexOf(marker)
expect(markerIndex, `missing marker: ${marker}`).toBeGreaterThan(-1)
let depth = 0
let end = -1
for (let i = markerIndex; i < code.length && end === -1; i += 1) {
if (code[i] === '{') {
depth += 1
} else if (code[i] === '}') {
if (depth === 0) {
end = i
} else {
depth -= 1
}
}
}
depth = 0
let start = -1
for (let i = markerIndex; i >= 0 && start === -1; i -= 1) {
if (code[i] === '}') {
depth += 1
} else if (code[i] === '{') {
if (depth === 0) {
start = i
} else {
depth -= 1
}
}
}
expect(start, `no block encloses: ${marker}`).toBeGreaterThan(-1)
expect(end, `no block encloses: ${marker}`).toBeGreaterThan(-1)
return { start, end, keyword: code.slice(0, start).trimEnd().split(/\s+/).pop() }
}
describe('Windows signing gates resolve 7za through the toolset resolver (#6487)', () => {
@@ -81,7 +193,7 @@ describe('Windows signing gates resolve 7za through the toolset resolver (#6487)
it(`${name} aborts the gate when 7za cannot be resolved`, () => {
const source = workflowSource(name)
const guard = blockAfter(source, '$LASTEXITCODE -ne 0 -or -not (Test-Path $7za)')
expect(guard.body).toMatch(/\bthrow\b/)
expect(guard.code).toMatch(/\bthrow\b/)
})
}
@@ -101,14 +213,14 @@ describe('Windows signing gates resolve 7za through the toolset resolver (#6487)
// string 'Write-GateVerdict' appears somewhere passes even if the body is
// gutted to a Write-Host, which is exactly the silent degradation this gate
// exists to prevent.
function gateVerdictFunctionBody() {
return blockAfter(innerBinaryStep(), 'function Write-GateVerdict').body
function gateVerdictBlock() {
return blockAfter(innerBinaryStep(), 'function Write-GateVerdict')
}
it('persists the verdict to the evidence file the artifact upload collects', () => {
const body = gateVerdictFunctionBody()
expect(body).toMatch(/Set-Content\s+-Path\s+'inner-signing-evidence\.txt'/)
expect(body).toContain('Add-GateSummary')
const block = gateVerdictBlock()
expect(block.text).toMatch(/Set-Content\s+-Path\s+'inner-signing-evidence\.txt'/)
expect(block.code).toContain('Add-GateSummary')
})
it('never lets verdict persistence itself fail a warn-only release', () => {
@@ -116,15 +228,16 @@ describe('Windows signing gates resolve 7za through the toolset resolver (#6487)
// must not turn evidence-writing into the thing that fails the release.
const step = innerBinaryStep()
for (const helper of ['function Add-GateEvidence', 'function Add-GateSummary']) {
const body = blockAfter(step, helper).body
expect(body, helper).toContain('-ErrorAction Stop')
expect(body, helper).toMatch(/\bcatch\b/)
const code = blockAfter(step, helper).code
expect(code, helper).toContain('-ErrorAction Stop')
expect(code, helper).toMatch(/\bcatch\b/)
}
expect(gateVerdictFunctionBody()).toMatch(/\bcatch\b/)
expect(gateVerdictBlock().code).toMatch(/\bcatch\b/)
})
it('records a verdict on every terminal branch of the gate', () => {
const step = innerBinaryStep()
// Comments stripped: a `# VERDICT: PASSED` note must not stand in for the write.
const step = withoutComments(innerBinaryStep())
for (const verdict of ['NOT VERIFIED', 'ERRORED', 'VERDICT: FAILED', 'VERDICT: PASSED']) {
expect(step).toContain(verdict)
}
@@ -134,10 +247,13 @@ describe('Windows signing gates resolve 7za through the toolset resolver (#6487)
// Why: throwing inside `try` re-enters the catch, whose Set-Content
// replaces the per-file report with "ERRORED — <exception>".
const step = innerBinaryStep()
const policyThrow = step.indexOf('if ($policyFailure) { throw $policyFailure }')
const policyThrow = codeOf(step).indexOf('if ($policyFailure) { throw $policyFailure }')
expect(policyThrow).toBeGreaterThan(-1)
const catchBlock = blockAfter(step, '} catch {')
expect(policyThrow).toBeGreaterThan(catchBlock.end)
// Anchored on the gate's own handler, not the first `catch` in the step: the
// persistence helpers have their own, and they sit earlier in the file.
const gateCatch = blockEnclosing(step, 'Write-GateVerdict "ERRORED')
expect(gateCatch.keyword).toBe('catch')
expect(policyThrow).toBeGreaterThan(gateCatch.end)
})
// Why: the assignment is what survives a write failure. With it after the
@@ -145,7 +261,7 @@ describe('Windows signing gates resolve 7za through the toolset resolver (#6487)
// $policyFailure still null — required mode reports ERRORED and overwrites the
// per-file report, reintroducing exactly the loss the hoist prevents.
it('records the required-mode failure before attempting any evidence write', () => {
const branch = blockAfter(innerBinaryStep(), 'if ($failures.Count -gt 0)').body
const branch = blockAfter(innerBinaryStep(), 'if ($failures.Count -gt 0)').code
const assignment = branch.indexOf('$policyFailure = $message')
expect(assignment).toBeGreaterThan(-1)
for (const write of ['Add-GateEvidence', 'Add-GateSummary']) {