Gating Conflict Detection #177
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Gating Conflict Detection | |
| on: | |
| pull_request: | |
| branches: [common, '4.0', '5.0', '6.0'] | |
| paths: | |
| - 'SPECS/**' | |
| - 'build-config.json' | |
| workflow_dispatch: | |
| inputs: | |
| branches: | |
| description: 'Branches to check (comma-separated)' | |
| default: '4.0,5.0,6.0' | |
| check_urls: | |
| description: 'Enable snapshot URL validation' | |
| type: boolean | |
| default: true | |
| schedule: | |
| # Daily scan at 06:00 UTC | |
| - cron: '0 6 * * *' | |
| env: | |
| PHOTON_REPO: vmware/photon | |
| jobs: | |
| detect-conflicts: | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| steps: | |
| - name: Checkout CI tooling | |
| uses: actions/checkout@v6 | |
| with: | |
| sparse-checkout: | | |
| photon-gating-conflict-detection/.github | |
| path: ci | |
| - name: Checkout common branch | |
| uses: actions/checkout@v6 | |
| with: | |
| repository: ${{ env.PHOTON_REPO }} | |
| ref: common | |
| path: workspace/common | |
| - name: Checkout release branches | |
| run: | | |
| for branch in 4.0 5.0 6.0; do | |
| git clone --branch "$branch" --depth 1 \ | |
| "https://github.com/${{ env.PHOTON_REPO }}.git" "workspace/$branch" 2>/dev/null || \ | |
| echo "::warning::Branch $branch not found, skipping" | |
| done | |
| - name: Setup Python | |
| uses: actions/setup-python@v6 | |
| with: | |
| python-version: '3.11' | |
| - name: Install dependencies | |
| run: pip install requests jsonschema | |
| - name: Phase 0 -- Build Tree Inventory | |
| run: | | |
| python3 ci/photon-gating-conflict-detection/.github/scripts/photon-gating-agent.py \ | |
| --base-dir workspace \ | |
| --branches "${{ github.event.inputs.branches || '4.0,5.0,6.0' }}" \ | |
| --arch x86_64 \ | |
| --phase inventory \ | |
| --output gating-inventory.json | |
| - name: Phase 1 -- Conflict Detection | |
| run: | | |
| python3 ci/photon-gating-conflict-detection/.github/scripts/photon-gating-agent.py \ | |
| --base-dir workspace \ | |
| --branches "${{ github.event.inputs.branches || '4.0,5.0,6.0' }}" \ | |
| --arch x86_64 \ | |
| ${{ (github.event.inputs.check_urls || 'true') == 'true' && '--check-urls' || '' }} \ | |
| --inventory gating-inventory.json \ | |
| --json-output findings.json \ | |
| --md-output findings.md | |
| - name: Validate findings schema | |
| run: | | |
| python3 -c " | |
| import json, jsonschema | |
| schema = json.load(open('ci/photon-gating-conflict-detection/.github/gating-findings-schema.json')) | |
| findings = json.load(open('findings.json')) | |
| jsonschema.validate(findings, schema) | |
| print('Schema validation passed') | |
| " | |
| - name: Quality rubric check | |
| run: | | |
| python3 -c " | |
| import json, sys, os | |
| findings = json.load(open('findings.json')) | |
| errors = [] | |
| # D1: inventory was produced | |
| if not os.path.exists('gating-inventory.json'): | |
| errors.append('D1: gating-inventory.json not found') | |
| for f in findings.get('findings', []): | |
| fid = f.get('id', '?') | |
| # D2: spec_paths (C6 findings may have empty spec_paths) | |
| if not f.get('spec_paths') and f.get('constellation') != 'C6': | |
| errors.append(f'D2: {fid} missing spec_paths') | |
| # D3: branch + subrelease | |
| if not f.get('branch') or f.get('subrelease') is None: | |
| errors.append(f'D3: {fid} missing branch/subrelease') | |
| # D4: C1 subpackages | |
| if f.get('constellation') == 'C1' and not f.get('missing_subpackages'): | |
| errors.append(f'D4: {fid} C1 missing subpackages list') | |
| # D5: C5 canister | |
| if f.get('constellation') == 'C5' and not f.get('canister_version'): | |
| errors.append(f'D5: {fid} C5 missing canister_version') | |
| # D6: C6 url/status (http_status may be null when --check-urls is disabled) | |
| if f.get('constellation') == 'C6': | |
| if not f.get('url'): | |
| errors.append(f'D6: {fid} C6 missing url') | |
| # D7: remediation keys | |
| rem = f.get('remediation', {}) | |
| if not rem.get('config_keys'): | |
| errors.append(f'D7: {fid} missing remediation config_keys') | |
| if errors: | |
| print('Quality rubric FAILED:') | |
| for e in errors: | |
| print(f' {e}') | |
| sys.exit(1) | |
| print('Quality rubric passed') | |
| " | |
| - name: Evaluate findings | |
| id: evaluate | |
| run: | | |
| python3 << 'EVAL_EOF' | |
| import json, os | |
| findings = json.load(open('findings.json')) | |
| all_findings = findings.get('findings', []) | |
| metadata = findings.get('metadata', {}) | |
| summary = findings.get('summary', {}) | |
| blockers = [f for f in all_findings if f.get('severity') in ('BLOCKING', 'CRITICAL')] | |
| warnings = [f for f in all_findings if f.get('severity') in ('HIGH', 'WARNING')] | |
| total = summary.get('total_findings', len(all_findings)) | |
| can_proceed = len(blockers) == 0 | |
| # Annotations (visible in PR files tab and Actions log) | |
| for b in blockers: | |
| print(f"::error::[{b['constellation']}] {b.get('branch','?')}/{b.get('package','?')}: {b['description']}") | |
| for w in warnings: | |
| print(f"::warning::[{w['constellation']}] {w.get('branch','?')}/{w.get('package','?')}: {w['description']}") | |
| # Output variables for downstream jobs | |
| with open(os.environ['GITHUB_OUTPUT'], 'a') as gh_out: | |
| gh_out.write(f"has_blockers={'true' if blockers else 'false'}\n") | |
| gh_out.write(f"total_findings={total}\n") | |
| gh_out.write(f"blocker_count={len(blockers)}\n") | |
| gh_out.write(f"warning_count={len(warnings)}\n") | |
| # Job Summary (renders as markdown in the Actions run page) | |
| with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as gh_summary: | |
| icon = '🔴' if blockers else '🟢' | |
| gh_summary.write(f"# {icon} Gating Conflict Detection\n\n") | |
| gh_summary.write(f"**Scan time**: {metadata.get('timestamp', 'N/A')} \n") | |
| gh_summary.write(f"**Branches**: {', '.join(metadata.get('branches_scanned', []))} \n") | |
| gh_summary.write(f"**Build can proceed**: {'No' if blockers else 'Yes'}\n\n") | |
| gh_summary.write("## Summary\n\n") | |
| gh_summary.write("| Metric | Count |\n") | |
| gh_summary.write("|--------|-------|\n") | |
| gh_summary.write(f"| Total findings | {total} |\n") | |
| gh_summary.write(f"| Blockers/Critical | {len(blockers)} |\n") | |
| gh_summary.write(f"| High/Warning | {len(warnings)} |\n\n") | |
| by_con = summary.get('by_constellation', {}) | |
| if by_con: | |
| con_labels = { | |
| 'C1': 'Package split/merge', | |
| 'C2': 'Version bump deps', | |
| 'C3': 'Subrelease boundary', | |
| 'C4': 'Cross-branch contamination', | |
| 'C5': 'FIPS canister coupling', | |
| 'C6': 'Snapshot URL availability', | |
| } | |
| gh_summary.write("## By Constellation\n\n") | |
| gh_summary.write("| ID | Description | Count |\n") | |
| gh_summary.write("|----|-------------|-------|\n") | |
| for con in ['C1','C2','C3','C4','C5','C6']: | |
| count = by_con.get(con, 0) | |
| if count: | |
| gh_summary.write(f"| {con} | {con_labels.get(con, '')} | {count} |\n") | |
| gh_summary.write("\n") | |
| if blockers: | |
| gh_summary.write("## Blockers\n\n") | |
| gh_summary.write("| Constellation | Branch | Package | Description |\n") | |
| gh_summary.write("|--------------|--------|---------|-------------|\n") | |
| for b in blockers: | |
| # Render full description; markdown tables wrap long cells. | |
| # Only escape pipe characters that would break the row. | |
| desc = b['description'].replace('|', '\\|').replace('\n', ' ') | |
| gh_summary.write(f"| {b['constellation']} | {b.get('branch','?')} | {b.get('package','?')} | {desc} |\n") | |
| gh_summary.write("\n") | |
| if warnings: | |
| gh_summary.write("<details><summary>Warnings ({0})</summary>\n\n".format(len(warnings))) | |
| gh_summary.write("| Constellation | Branch | Package | Description |\n") | |
| gh_summary.write("|--------------|--------|---------|-------------|\n") | |
| for w in warnings: | |
| desc = w['description'].replace('|', '\\|').replace('\n', ' ') | |
| gh_summary.write(f"| {w['constellation']} | {w.get('branch','?')} | {w.get('package','?')} | {desc} |\n") | |
| gh_summary.write("\n</details>\n\n") | |
| gh_summary.write("---\n*Full findings available in the `gating-findings` artifact.*\n") | |
| print(f"Scan complete: {total} findings, {len(blockers)} blockers, {len(warnings)} warnings") | |
| print(f"Build can proceed: {can_proceed}") | |
| EVAL_EOF | |
| - name: Upload findings artifacts | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: gating-findings | |
| path: | | |
| findings.json | |
| findings.md | |
| gating-inventory.json |