From df2a766a022f4337ac03ed5c96bf802caaf2bd7d Mon Sep 17 00:00:00 2001 From: Brandon Temple Date: Tue, 25 Aug 2026 20:15:04 -0500 Subject: [PATCH] Add repository attribution policy gate --- .github/workflows/repository-policy.yml | 26 +++++ package.json | 1 + scripts/checkRepositoryPolicy.mjs | 125 ++++++++++++++++++++++++ tests/architecture/repositoryPolicy.mjs | 21 ++++ 4 files changed, 173 insertions(+) create mode 100644 .github/workflows/repository-policy.yml create mode 100644 scripts/checkRepositoryPolicy.mjs create mode 100644 tests/architecture/repositoryPolicy.mjs diff --git a/.github/workflows/repository-policy.yml b/.github/workflows/repository-policy.yml new file mode 100644 index 00000000..cb20998e --- /dev/null +++ b/.github/workflows/repository-policy.yml @@ -0,0 +1,26 @@ +name: Repository Policy + +on: + push: + pull_request: + types: [opened, edited, reopened, synchronize] + release: + types: [created, edited, published] + workflow_dispatch: + +permissions: + contents: read + +jobs: + policy: + name: Repository policy + runs-on: ubuntu-latest + + steps: + - name: Checkout complete history + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Enforce repository attribution policy + run: node scripts/checkRepositoryPolicy.mjs diff --git a/package.json b/package.json index d9203571..ef988053 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "benchmark:workbook": "node scripts/benchmarkWorkbookExport.mjs", "cache:bust": "node scripts/updateCacheBusting.mjs", "cache:bust:check": "node scripts/updateCacheBusting.mjs --check", + "check:repository-policy": "node scripts/checkRepositoryPolicy.mjs", "demo": "node scripts/runDemo.mjs", "example:backend": "node examples/minimal-backend/server.mjs", "architecture:metrics": "node scripts/reportArchitectureMetrics.mjs", diff --git a/scripts/checkRepositoryPolicy.mjs b/scripts/checkRepositoryPolicy.mjs new file mode 100644 index 00000000..aef570f4 --- /dev/null +++ b/scripts/checkRepositoryPolicy.mjs @@ -0,0 +1,125 @@ +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const BLOCKED_TERMS = Object.freeze([ + [99, 111, 100, 101, 120], + [99, 104, 97, 116, 103, 112, 116], +].map(codePoints => String.fromCodePoint(...codePoints))); + +function normalizeAsciiByte(byte) { + return byte >= 65 && byte <= 90 ? byte + 32 : byte; +} + +export function containsBlockedTerm(value) { + const normalized = String(value ?? '').toLowerCase(); + return BLOCKED_TERMS.some(term => normalized.includes(term)); +} + +export function bufferContainsBlockedTerm(buffer) { + return BLOCKED_TERMS.some(term => { + const needle = Buffer.from(term); + for (let offset = 0; offset <= buffer.length - needle.length; offset += 1) { + let matches = true; + for (let index = 0; index < needle.length; index += 1) { + if (normalizeAsciiByte(buffer[offset + index]) !== needle[index]) { + matches = false; + break; + } + } + if (matches) return true; + } + return false; + }); +} + +function runGit(args, options = {}) { + return execFileSync('git', args, { + encoding: options.encoding ?? 'utf8', + maxBuffer: 512 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +function readEvent() { + if (!process.env.GITHUB_EVENT_PATH) return {}; + try { + return JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8')); + } catch { + return {}; + } +} + +function revisionRange(event) { + const before = event.before; + const after = event.after; + if (after && /^0+$/u.test(after)) return before || '--all'; + if (after) return after; + + const base = event.pull_request?.base?.sha; + const head = event.pull_request?.head?.sha; + if (base && head) return `${base}..${head}`; + + return '--all'; +} + +function metadataValues(event) { + return [ + process.env.GITHUB_REF, + process.env.GITHUB_REF_NAME, + process.env.GITHUB_HEAD_REF, + process.env.GITHUB_BASE_REF, + event.pull_request?.title, + event.pull_request?.body, + event.pull_request?.head?.ref, + event.release?.name, + event.release?.body, + event.release?.tag_name, + ].filter(Boolean); +} + +export function checkRepositoryPolicy() { + const violations = []; + const event = readEvent(); + + for (const value of metadataValues(event)) { + if (containsBlockedTerm(value)) violations.push('GitHub metadata'); + } + + const refs = runGit(['for-each-ref', '--format=%(refname)']); + if (containsBlockedTerm(refs)) violations.push('Git reference'); + + const trackedPaths = runGit(['ls-files', '-z']).split('\0').filter(Boolean); + for (const path of trackedPaths) { + if (containsBlockedTerm(path)) violations.push(`Tracked path: ${path}`); + if (bufferContainsBlockedTerm(readFileSync(path))) violations.push(`Tracked file: ${path}`); + } + + const range = revisionRange(event); + const history = runGit([ + 'log', + '--format=%B', + '--name-only', + '--patch', + '--text', + '--no-color', + range, + ]); + if (containsBlockedTerm(history)) violations.push('Proposed commit history'); + + return [...new Set(violations)]; +} + +function main() { + const violations = checkRepositoryPolicy(); + if (violations.length === 0) { + console.log('Repository attribution policy passed.'); + return; + } + + console.error('Repository attribution policy failed. Remove blocked attribution terms from:'); + for (const violation of violations) console.error(`- ${violation}`); + process.exitCode = 1; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) main(); diff --git a/tests/architecture/repositoryPolicy.mjs b/tests/architecture/repositoryPolicy.mjs new file mode 100644 index 00000000..e32e92a4 --- /dev/null +++ b/tests/architecture/repositoryPolicy.mjs @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + bufferContainsBlockedTerm, + containsBlockedTerm, +} from '../../scripts/checkRepositoryPolicy.mjs'; + +const firstBlockedTerm = String.fromCodePoint(99, 111, 100, 101, 120); +const secondBlockedTerm = String.fromCodePoint(99, 104, 97, 116, 103, 112, 116); + +test('repository policy accepts ordinary text', () => { + assert.equal(containsBlockedTerm('Library Item Reports'), false); + assert.equal(bufferContainsBlockedTerm(Buffer.from('decodeEntities')), false); +}); + +test('repository policy rejects blocked terms regardless of case', () => { + assert.equal(containsBlockedTerm(firstBlockedTerm.toUpperCase()), true); + assert.equal(containsBlockedTerm(`prefix-${secondBlockedTerm}-suffix`), true); + assert.equal(bufferContainsBlockedTerm(Buffer.from(firstBlockedTerm)), true); +});