mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-11-03 01:44:17 +00:00
4e23d5478e
Add fail-closed path classification and aggregate-result checks, checked-in web parity policy, AST source censuses, and human-readable PG trace streaming. The strict PG census currently rejects scripts/diagnose-version-ties: its main can reach PostgreSQL, it has no tests, and it is absent from the package list. Keep that list unchanged as instructed. Its classification needs a ruling before this branch can merge; no silent exception is added.
104 lines
4.0 KiB
Python
104 lines
4.0 KiB
Python
#!/usr/bin/env python3
|
|
"""Fail-closed CI scope. PRs compare exact head against merge-base(base, head).
|
|
|
|
GO: everything outside web/. PARITY: any web/ path. WEB: always.
|
|
NIX: dependency/packaging policy, deliberately not every derivation source.
|
|
Workflows and scripts/ci-* force all jobs. Pushes always run everything.
|
|
"""
|
|
import fnmatch
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
|
|
NIX_FILES = {'go.mod', 'go.sum', 'web/package.json', 'web/package-lock.json',
|
|
'web/npm-shrinkwrap.json', 'web/yarn.lock', 'web/pnpm-lock.yaml',
|
|
'web/bun.lock', 'web/bun.lockb'}
|
|
|
|
|
|
def classify(paths):
|
|
outputs = dict(go=False, web=True, nix=False, parity=False)
|
|
for path in paths:
|
|
if path.startswith(('.github/workflows/', 'scripts/ci-')):
|
|
return dict.fromkeys(outputs, True)
|
|
outputs['go'] |= not path.startswith('web/')
|
|
outputs['parity'] |= path.startswith('web/')
|
|
outputs['nix'] |= (path.startswith('nix/') or
|
|
('/' not in path and fnmatch.fnmatchcase(path, 'flake.*')) or
|
|
path in NIX_FILES)
|
|
return outputs
|
|
|
|
|
|
def git(*args, cwd=None):
|
|
return subprocess.check_output(['git', *args], cwd=cwd)
|
|
|
|
|
|
def decode_paths(data):
|
|
if not data:
|
|
return [] # A successful, genuinely empty diff is valid.
|
|
if not data.endswith(b'\0'):
|
|
raise ValueError('diff is not NUL terminated')
|
|
paths = [os.fsdecode(p) for p in data[:-1].split(b'\0')]
|
|
if any(not p or p.startswith('/') or any(s in ('.', '..') for s in p.split('/'))
|
|
for p in paths):
|
|
raise ValueError('invalid repository-relative diff path')
|
|
return paths
|
|
|
|
|
|
def pr_paths(base, head, cwd=None):
|
|
for sha in (base, head):
|
|
if not isinstance(sha, str) or not re.fullmatch(r'[0-9a-fA-F]{40}', sha):
|
|
raise ValueError('PR base and head must be exact commit SHAs')
|
|
git('cat-file', '-e', sha + '^{commit}', cwd=cwd)
|
|
ancestor = git('merge-base', base, head, cwd=cwd).decode('ascii').strip()
|
|
if not re.fullmatch(r'[0-9a-fA-F]{40}', ancestor):
|
|
raise ValueError('missing or malformed merge base')
|
|
return decode_paths(git('diff', '--name-only', '-z', '--no-renames', ancestor, head, cwd=cwd))
|
|
|
|
|
|
def has_local_dependencies(go_mod, package):
|
|
# A replacement can introduce extra local inputs. Until classified, keep
|
|
# Nix on for every PR; do the same for npm workspaces/file dependencies.
|
|
if re.search(r'^\s*replace\b', go_mod, re.MULTILINE) or package.get('workspaces'):
|
|
return True
|
|
return any(version.startswith(('file:', 'link:', 'workspace:'))
|
|
for kind in ('dependencies', 'devDependencies', 'optionalDependencies')
|
|
for version in package.get(kind, {}).values())
|
|
|
|
|
|
def for_event(event, payload):
|
|
if event == 'push':
|
|
return dict.fromkeys(('go', 'web', 'nix', 'parity'), True)
|
|
if event != 'pull_request':
|
|
raise ValueError('unsupported CI event: ' + event)
|
|
try:
|
|
pr = payload['pull_request']
|
|
outputs = classify(pr_paths(pr['base']['sha'], pr['head']['sha']))
|
|
root = Path(__file__).resolve().parent.parent
|
|
if has_local_dependencies((root / 'go.mod').read_text(),
|
|
json.loads((root / 'web/package.json').read_text())):
|
|
outputs['nix'] = True
|
|
return outputs
|
|
except (KeyError, TypeError) as err:
|
|
raise ValueError('PR event lacks exact base/head SHAs') from err
|
|
|
|
|
|
def main():
|
|
try:
|
|
payload = json.loads(Path(os.environ['GITHUB_EVENT_PATH']).read_text())
|
|
outputs = for_event(os.environ['GITHUB_EVENT_NAME'], payload)
|
|
text = ''.join(f'{key}={str(value).lower()}\n' for key, value in outputs.items())
|
|
print(text, end='')
|
|
with open(os.environ['GITHUB_OUTPUT'], 'a') as output:
|
|
output.write(text)
|
|
except (OSError, ValueError, KeyError, subprocess.CalledProcessError) as err:
|
|
print(f'::error::CI scope failed: {err}', file=sys.stderr)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|