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
11 changes: 11 additions & 0 deletions .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -456,11 +456,22 @@ jobs:
git fetch --no-tags --depth=1 origin "${{ github.base_ref || github.event.repository.default_branch }}"
node .harness/scripts/ci/49-validate-gap-id-allocation.mjs --verbose

# GT-639: a board row carries a STATUS but not who is working it or where, so
# two sessions can both read `PENDING` and both start. On 2026-07-30 the same
# work was done twice, three times over. The claim set is DERIVED from open
# pull requests — never hand-written, which would go stale in the direction
# that matters — and an id claimed by two open PRs fails here, naming both.
- name: One gap, one claim
env:
GH_TOKEN: ${{ github.token }}
run: node .harness/scripts/ci/50-validate-gap-claim.mjs

- name: Self-tests for the governance guards
run: |
node --test .harness/scripts/ci/42-validate-guard-denominators.test.mjs
node --test .harness/scripts/ci/48-validate-security-publish-lag.test.mjs
node --test .harness/scripts/ci/49-validate-gap-id-allocation.test.mjs
node --test .harness/scripts/ci/50-validate-gap-claim.test.mjs
node --test .harness/scripts/ci/41-validate-evidence-commands.test.mjs
node --test .harness/scripts/ci/43-validate-guard-negative-fixtures.test.mjs
node --test .harness/scripts/ci/44-validate-adr-implementation-status.test.mjs
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,15 @@ jobs:
- name: Self-test for the joined-path guard
run: node --test .harness/scripts/ci/47-validate-joined-paths.test.mjs

# GT-597. The gate itself runs inside `openssf-scorecard.yml`, where the
# Scorecard JSON exists; only its logic is tested here. That logic is the
# difference between a weekly score that is published and a weekly score
# that is CHECKED, so it is tested on its failure surface: a dropped check,
# a vanished check, an inconclusive -1, an unseeded baseline, and a SARIF
# file handed to it by mistake must each be red.
- name: Self-test for the Scorecard regression gate
run: node --test .harness/scripts/ci/52-validate-scorecard-regression.test.mjs

- name: Validate machine contracts
run: node .harness/scripts/ci/10-validate-contract-conformance.mjs

Expand Down
46 changes: 45 additions & 1 deletion .github/workflows/openssf-scorecard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,17 +81,61 @@ jobs:
# self-asserted. Valid here because this repository is public.
publish_results: true

# A SECOND ANALYSIS, ON PURPOSE
# `scorecard-action` emits exactly one file in exactly one format per
# invocation. SARIF is what code scanning ingests; JSON is the only format
# that carries the numeric aggregate and the per-check scores, which is what
# a regression gate compares. Deriving the numbers from the SARIF was
# considered and rejected: the aggregate score is not in it, so the gate
# would be reading a field that does not exist and reporting green.
#
# The cost is a second pass over the same repository (GitHub API calls, a
# couple of minutes) once a week. `publish_results` is false here — the
# public score is published exactly once, by the run above.
- name: Re-run Scorecard for a machine-readable score
uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
with:
results_file: scorecard-results.json
results_format: json
publish_results: false

# Retained separately from code scanning because code-scanning alerts are
# deduplicated and aged out, while the artifact is the raw per-run evidence
# a score regression gets diffed against.
- name: Upload results as an artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: scorecard-results
path: scorecard-results.sarif
path: |
scorecard-results.sarif
scorecard-results.json
retention-days: 90

- name: Upload results to code scanning
uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
with:
sarif_file: scorecard-results.sarif

- name: Setup Node.js
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: '20'

# THE STEP THAT MAKES THIS A MEASURE RATHER THAN A REPORT
# Everything above publishes a number. Nothing above can fail. A score that
# falls from 7.1 to 4.2 produces an identical green run, which is the audit
# failure mode GT-597 exists to remove, reproduced weekly with nicer output.
#
# This step compares the run against floors committed in
# `.harness/security/scorecard-baseline.json` and exits non-zero on any
# regression. Because it runs on a schedule on the default branch, a failure
# is emailed to the repository owner by GitHub's own scheduled-workflow
# failure notification — no dashboard to remember to open.
#
# It is deliberately the LAST step: the SARIF upload and the public
# publication must happen whether or not the posture regressed. Losing the
# evidence on the run that most needs it would be a poor trade.
- name: Fail on a posture regression
run: >-
node .harness/scripts/ci/52-validate-scorecard-regression.mjs
--results scorecard-results.json
199 changes: 199 additions & 0 deletions .harness/scripts/ci/50-validate-gap-claim.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
#!/usr/bin/env node

/**
* GT-639 — two sessions must not work the same gap without knowing.
*
* ## The defect
*
* On 2026-07-30 the same work was done twice, three separate times, by sessions
* that could not see each other: GT-640 (then GT-633) was fixed by a standalone
* capture script on `main` and by an independent in-spec renderer on `develop`;
* the evidence guard's parser was fixed on both branches; and a GT id was
* allocated twice. The cost was not the merge conflict — it was the duplicated
* work before anyone reached it, plus a reconciliation that introduced a defect
* of its own.
*
* A board row carries a STATUS but not who is working it or where. Two sessions
* can both read `GT-640 · PENDING` and both start, correctly.
*
* ## What it checks
*
* The claim set is DERIVED from open pull requests — never hand-written, because
* a hand-written claim is stale in the direction that matters (someone forgets to
* remove it, and the next session works around a claim nobody holds). Every open
* PR claims the `GT-*` ids that appear in its title, its body or its branch name.
*
* An id claimed by MORE THAN ONE open pull request is a contested claim, and this
* guard fails naming both claimants.
*
* ## What it deliberately does NOT do
*
* It does not write a committed claim list. Such a file would be derived from
* live GitHub state, so it would be stale the moment anyone opened a PR, and the
* repository already has a guard (46) whose whole premise is that derived
* artifacts must reach a fixed point. A perpetually-stale artifact in that chain
* would be worse than none. The live view is this check's output, on the PR where
* it matters.
*
* It also cannot see work that has no open PR. A branch someone is working on
* privately claims nothing, and the guard says so rather than implying coverage.
*
* ## Anti-vacuous pass
*
* Zero open pull requests examined is reported, not silently passed: with no PRs
* there is nothing to contest, and a run that found none because the query broke
* looks identical unless it says which happened. A query that fails outright is a
* hard failure — unable to answer is not the same as nothing to report.
*
* USAGE
* node .harness/scripts/ci/50-validate-gap-claim.mjs
* node .harness/scripts/ci/50-validate-gap-claim.mjs --json
* node .harness/scripts/ci/50-validate-gap-claim.mjs --fixture <file> # offline
*
* EXIT CODES
* 0 no id is claimed by more than one open pull request
* 1 a contested claim, or the pull-request query could not be answered
*/

import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';

const GUARD = '50-validate-gap-claim';

// ---------------------------------------------------------------------------
// Pure core — takes pull requests as data, returns the claim map. No network.
// ---------------------------------------------------------------------------

/** Every `GT-NNN` mentioned in a pull request's title, body or branch name. */
export function claimedIds(pr) {
const haystack = `${pr.title ?? ''}\n${pr.body ?? ''}\n${pr.headRefName ?? ''}`;
return [...new Set(haystack.match(/GT-\d+/g) ?? [])].sort();
}

/**
* id -> the open pull requests claiming it.
*
* @param {Array<{number:number,title?:string,body?:string,headRefName?:string,url?:string}>} prs
* @returns {Map<string, Array<object>>}
*/
export function buildClaimMap(prs) {
const map = new Map();
for (const pr of prs) {
for (const id of claimedIds(pr)) {
if (!map.has(id)) map.set(id, []);
map.get(id).push(pr);
}
}
return map;
}

/** Ids claimed by more than one open pull request. */
export function findContested(claimMap) {
return [...claimMap.entries()]
.filter(([, prs]) => prs.length > 1)
.map(([id, prs]) => ({ id, prs }))
.sort((a, b) => a.id.localeCompare(b.id));
}

// ---------------------------------------------------------------------------
// I/O edges
// ---------------------------------------------------------------------------

function fail(lines) {
console.error(`\n✗ ${GUARD}: ${lines[0]}`);
for (const l of lines.slice(1)) console.error(` ${l}`);
process.exit(1);
}

/** Open pull requests, from `gh`. Returns null when the query cannot be answered. */
export function fetchOpenPullRequests() {
try {
const raw = execFileSync(
'gh',
['pr', 'list', '--state', 'open', '--limit', '200', '--json', 'number,title,body,headRefName,url'],
{ encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] },
);
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : null;
} catch {
return null;
}
}

function main(argv) {
const asJson = argv.includes('--json');
const fixtureIdx = argv.indexOf('--fixture');

const prs = fixtureIdx !== -1
? JSON.parse(fs.readFileSync(path.resolve(process.cwd(), argv[fixtureIdx + 1]), 'utf8'))
: fetchOpenPullRequests();

if (prs === null) {
fail([
'could not read the open pull requests, so no claim was checked.',
' `gh pr list` failed — in CI that usually means GH_TOKEN is not set on the step.',
'',
' Unable to answer is not the same as nothing to report. This guard exists',
' because two sessions could not see each other; a version of it that stays',
' quiet when it cannot look would reproduce exactly that.',
]);
}

const claimMap = buildClaimMap(prs);
const contested = findContested(claimMap);

if (asJson) {
process.stdout.write(JSON.stringify({
pullRequests: prs.length,
claims: Object.fromEntries([...claimMap].map(([id, list]) => [id, list.map((p) => p.number)])),
contested: contested.map((c) => ({ id: c.id, prs: c.prs.map((p) => p.number) })),
}) + '\n');
return contested.length > 0 ? 1 : 0;
}

console.log(`${GUARD} — one gap, one claim`);
console.log(` open pull requests .. ${prs.length}`);
console.log(` ids claimed ......... ${claimMap.size}`);
console.log(` contested ........... ${contested.length}`);

if (prs.length === 0) {
// Said out loud: with no open PRs there is nothing to contest, which is a
// legitimate state and looks identical to a broken query unless named.
console.log('\n No pull request is open, so nothing is claimed and nothing can be contested.');
}

for (const [id, list] of [...claimMap].sort()) {
if (list.length === 1) console.log(` · ${id} — #${list[0].number} ${list[0].headRefName ?? ''}`);
}

if (contested.length > 0) {
fail([
`${contested.length} gap id(s) are claimed by more than one open pull request:`,
...contested.flatMap((c) => [
` • ${c.id}`,
...c.prs.map((p) => ` #${p.number} ${p.headRefName ?? '?'} ${p.title ?? ''}`),
]),
'',
' Two sessions are working the same gap. That is not a merge conflict yet, and',
' it is much cheaper to resolve now than after both have landed: on 2026-07-30',
' the same fix was written twice and a third session spent a full reconciliation',
' collapsing them (GT-639).',
'',
' Decide which pull request owns the id, and say so in the other one.',
'',
' NOT COVERED by this check: a branch with no open pull request claims nothing.',
' Opening the PR early — draft is enough — is what makes the claim visible.',
]);
}

console.log(
`\n✓ ${GUARD}: ${claimMap.size} claimed id(s) across ${prs.length} open pull request(s), none contested.`,
);
return 0;
}

const invokedDirectly =
process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
if (invokedDirectly) process.exit(main(process.argv.slice(2)));
Loading
Loading