Skip to content

Gating Conflict Detection #189

Gating Conflict Detection

Gating Conflict Detection #189

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