26 KiB
Implementation Plan: Inline Supply Chain Verification for PR Builds
Feature: Add inline supply chain verification job to docker-build.yml for PR builds Branch: feature/beta-release Date: 2026-01-11 Status: Ready for Implementation Updated: 2026-01-11 (Critical Fixes Applied)
Critical Fixes Applied
This specification has been updated to address 7 critical issues identified in the Supervisor's review:
- ✅ Missing Image Access: Added artifact upload/download/load steps to share the PR image between jobs
- ✅ Incomplete Conditionals: Enhanced job condition to check
needs.build-and-push.result == 'success' - ✅ SARIF Category Collision: Added
github.shato SARIF category to prevent concurrent PR conflicts - ✅ Missing Null Checks: Added null checks and fallbacks in job summary and PR comment steps
- ✅ Workflow Conflict: Documented required update to
supply-chain-verify.ymlto disable PR verification - ✅ Job Dependencies: Added clarifying comments explaining the dependency chain
- ✅ Skipped Build Feedback: Added new job
verify-supply-chain-pr-skippedto provide user feedback
Additional Improvements:
- Extracted tool versions to workflow-level environment variables
- Added commit SHA to PR comment header for traceability
- Documented expected ~50-60% increase in PR build time
Executive Summary
Add a new job verify-supply-chain-pr to .github/workflows/docker-build.yml that performs immediate supply chain verification (SBOM generation, vulnerability scanning) for PR builds immediately after the Docker image is built. This fixes the current gap where Supply Chain Verification only runs on pushed images (main/tags), not PRs.
Key Constraint: PR builds use load: true (local image only), not push: true. The verification job must work with locally built images that aren't pushed to the registry. The image will be shared between jobs using GitHub Actions artifacts.
Performance Impact: This feature will increase PR build time by approximately 50-60% (from ~8 minutes to ~12-13 minutes) due to SBOM generation and vulnerability scanning.
Research Findings
1. Current docker-build.yml Structure Analysis
Key Observations:
- Lines 94-101:
build-and-pushjob outputsskip_buildanddigest - Lines 103-113: Build step uses conditional
pushvsloadbased on event type- PRs:
push: false, load: true(local only, single platform: linux/amd64) - Main/tags:
push: true, load: false(registry push, multi-platform: linux/amd64,linux/arm64)
- PRs:
- Lines 150-151: Tag extraction uses
pr-${{ github.event.pull_request.number }}for PR builds - Line 199: Existing
trivy-pr-app-onlyjob runs for PRs but only scans the extracted binary, not the full image SBOM
Current PR Flow:
PR Event → build-and-push (load=true) → trivy-pr-app-only (binary scan only)
Desired PR Flow:
PR Event → build-and-push (load=true) → verify-supply-chain-pr (full SBOM + vuln scan)
2. Existing Supply Chain Verification Logic
From .github/workflows/supply-chain-verify.yml:
Tools Used:
- Syft v1.17.0+: SBOM generation (CycloneDX JSON format)
- Grype v0.85.0+: Vulnerability scanning with severity categorization
- jq: JSON processing for result parsing
Key Steps (Lines 81-228 of supply-chain-verify.yml):
- Install Syft and Grype (Lines 81-90)
- Determine image tag (Lines 92-121)
- Check image availability (Lines 123-144)
- Generate SBOM with Syft (Lines 146-178)
- Validate SBOM structure (Lines 180-228)
- Scan with Grype (Lines 230-277)
- Comment on PR with results (Lines 330-387)
Critical Difference: supply-chain-verify.yml expects a pushed image in the registry. For PRs, it checks docker manifest inspect and skips if unavailable (Lines 123-144).
3. Solution: Image Artifact Sharing
Problem: PR images are built with load: true, stored locally as charon:pr-<number>. They don't exist in the registry and are not accessible to subsequent jobs.
Solution: Save the Docker image as a tar archive and share it between jobs using GitHub Actions artifacts.
Evidence from docker-build.yml:
- Line 150:
type=raw,value=pr-${{ github.event.pull_request.number }},enable=${{ github.event_name == 'pull_request' }} - Lines 111-113:
load: ${{ github.event_name == 'pull_request' }}
Implementation Strategy:
- In
build-and-pushjob (after build): Save image to tar file usingdocker save - Upload tar file as artifact with 1-day retention (ephemeral, PR-specific)
- In
verify-supply-chain-prjob: Download artifact and load image usingdocker load - Reference the loaded image directly for SBOM/vulnerability scanning
This approach:
- ✅ Avoids rebuild (uses exact same image artifact)
- ✅ No registry dependency
- ✅ Minimal storage impact (1-day retention, ~150-200MB per PR)
- ✅ Works with GitHub Actions' job isolation model
Technical Design
Workflow-Level Configuration
Tool Versions (extracted as environment variables):
SYFT_VERSION: v1.17.0GRYPE_VERSION: v0.85.0
These will be defined at the workflow level to ensure consistency and easier updates.
Job Definitions
Job 1: Image Artifact Upload (modification to existing build-and-push job)
Trigger: Only for pull_request events
Purpose: Save and upload the built Docker image as an artifact
Job 2: verify-supply-chain-pr
Trigger: Only for pull_request events
Dependency: needs: build-and-push
Purpose: Download image artifact, perform SBOM generation and vulnerability scanning
Skip Conditions:
- If
build-and-pushoutputskip_build == 'true' - If
build-and-pushdid not succeed
Job 3: verify-supply-chain-pr-skipped
Trigger: Only for pull_request events
Dependency: needs: build-and-push
Purpose: Provide user feedback when build is skipped
Run Condition: If build-and-push output skip_build == 'true'
Key Technical Decisions
Decision 1: Image Sharing Strategy
Chosen Approach: Save image as tar archive and share via GitHub Actions artifacts Why:
- Jobs run in isolated environments; local Docker images are not shared by default
- Artifacts provide reliable cross-job data sharing
- Avoids registry push for PR builds (maintains current security model)
- 1-day retention minimizes storage costs Alternative Considered: Push to registry with ephemeral tags (rejected: requires registry permissions, security concerns, cleanup complexity)
Decision 2: Tool Versions
Syft: v1.17.0 (matches existing security-verify-sbom skill) Grype: v0.85.0 (matches existing security-verify-sbom skill) Why: Consistent with existing workflows, tested versions
Decision 3: Failure Behavior
Critical Vulnerabilities: Fail the job (exit code 1) High Vulnerabilities: Warn but don't fail Why: Aligns with project standards (see security-verify-sbom.SKILL.md)
Decision 4: SARIF Category Strategy
Category Format: supply-chain-pr-${{ github.event.pull_request.number }}-${{ github.sha }}
Why: Including SHA prevents conflicts when multiple commits are pushed to the same PR concurrently
Without SHA: Concurrent uploads to the same category would overwrite each other
Decision 5: Null Safety in Outputs
Approach: Add explicit null checks and fallback values for all step outputs Why:
- Step outputs may be undefined if steps are skipped or fail
- Prevents workflow failures in reporting steps
- Ensures graceful degradation of user feedback
Decision 6: Workflow Conflict Resolution
Issue: supply-chain-verify.yml currently handles PR workflow_run events, creating duplicate verification
Solution: Update supply-chain-verify.yml to exclude PR builds from workflow_run triggers
Why: Inline verification in docker-build.yml provides faster feedback; workflow_run is unnecessary for PRs
Implementation Steps
Step 1: Update Workflow Environment Variables
File: .github/workflows/docker-build.yml
Location: After line 22 (after existing env: section start)
Action: Add tool version variables
env:
# ... existing variables ...
SYFT_VERSION: v1.17.0
GRYPE_VERSION: v0.85.0
Step 2: Add Artifact Upload to build-and-push Job
File: .github/workflows/docker-build.yml
Location: After the "Build and push Docker image" step (after line 113)
Action: Insert two new steps for image artifact handling
- name: Save Docker Image as Artifact
if: github.event_name == 'pull_request'
run: |
IMAGE_NAME=$(echo "${{ github.repository_owner }}/charon" | tr '[:upper:]' '[:lower:]')
docker save ghcr.io/${IMAGE_NAME}:pr-${{ github.event.pull_request.number }} -o /tmp/charon-pr-image.tar
ls -lh /tmp/charon-pr-image.tar
- name: Upload Image Artifact
if: github.event_name == 'pull_request'
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
with:
name: pr-image-${{ github.event.pull_request.number }}
path: /tmp/charon-pr-image.tar
retention-days: 1
Rationale: These steps execute only for PRs and share the built image with downstream jobs.
Step 3: Add verify-supply-chain-pr Job
File: .github/workflows/docker-build.yml
Location: After line 229 (end of trivy-pr-app-only job)
Action: Insert complete job definition
See complete YAML in Appendix A.
Step 4: Add verify-supply-chain-pr-skipped Job
File: .github/workflows/docker-build.yml
Location: After the verify-supply-chain-pr job
Action: Insert complete job definition
See complete YAML in Appendix B.
Step 5: Update supply-chain-verify.yml to Avoid PR Conflicts
File: .github/workflows/supply-chain-verify.yml
Location: Update the verify-sbom job condition (around line 68)
Current:
if: |
(github.event_name != 'schedule' || github.ref == 'refs/heads/main') &&
(github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success')
Updated:
if: |
(github.event_name != 'schedule' || github.ref == 'refs/heads/main') &&
(github.event_name != 'workflow_run' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event != 'pull_request'))
Rationale: Prevents duplicate supply chain verification for PRs. The inline job in docker-build.yml now handles PR verification.
Generate:
- SBOM file (CycloneDX JSON)
- Vulnerability scan results (JSON)
- GitHub SARIF report (for Security tab integration)
Upload: All as workflow artifacts with 30-day retention
Detailed Implementation
This implementation includes 3 main components:
- Workflow-level environment variables for tool versions
- Modifications to
build-and-pushjob to upload image artifact - Two new jobs:
verify-supply-chain-pr(main verification) andverify-supply-chain-pr-skipped(feedback) - Update to
supply-chain-verify.ymlto prevent duplicate verification
See complete YAML job definitions in Appendix A and B.
Insertion Instructions
Location in docker-build.yml:
- Environment variables: After line 22
- Image artifact upload: After line 113 (in build-and-push job)
- New jobs: After line 229 (end of
trivy-pr-app-onlyjob)
No modifications needed to other existing jobs. The build-and-push job already outputs everything we need.
Testing Plan
Phase 1: Basic Validation
- Create test PR on
feature/beta-release - Verify artifact upload/download works correctly
- Verify image loads successfully in verification job
- Check image reference is correct (no "image not found")
- Validate SBOM generation (component count >0)
- Validate vulnerability scanning
- Check PR comment is posted with status/table (including commit SHA)
- Verify SARIF upload to Security tab with unique category
- Verify job summary is created with all null checks working
Phase 2: Critical Fixes Validation
- Image Access: Verify artifact contains image tar, verify download succeeds, verify docker load works
- Conditionals: Test that job skips when build-and-push fails or is skipped
- SARIF Category: Push multiple commits to same PR, verify no SARIF conflicts in Security tab
- Null Checks: Force step failure, verify job summary and PR comment still generate gracefully
- Workflow Conflict: Verify supply-chain-verify.yml does NOT trigger for PR builds
- Skipped Feedback: Create chore commit, verify skipped feedback job posts comment
Phase 3: Edge Cases
- Test with intentionally vulnerable dependency
- Test with build skip (chore commit)
- Test concurrent PRs (verify artifacts don't collide)
- Test rapid successive commits to same PR
Phase 4: Performance Validation
- Measure baseline PR build time (without feature)
- Measure new PR build time (with feature)
- Verify increase is within expected 50-60% range
- Monitor artifact storage usage
Phase 5: Rollback
If issues arise, revert the commit. No impact on main/tag builds.
Success Criteria
Functional
- ✅ Artifacts are uploaded/downloaded correctly for all PR builds
- ✅ Image loads successfully in verification job
- ✅ Job runs for all PR builds (when not skipped)
- ✅ Job correctly skips when build-and-push fails or is skipped
- ✅ Generates valid SBOM
- ✅ Performs vulnerability scan
- ✅ Uploads artifacts with appropriate retention
- ✅ Comments on PR with commit SHA and vulnerability table
- ✅ Fails on critical vulnerabilities
- ✅ Uploads SARIF with unique category (no conflicts)
- ✅ Skipped build feedback is posted when build is skipped
- ✅ No duplicate verification from supply-chain-verify.yml
Performance
- ⏱️ Completes in <15 minutes
- 📦 Artifact size <250MB
- 📈 Total PR build time increase: 50-60% (acceptable)
Reliability
- 🔒 All null checks in place (no undefined variable errors)
- 🔄 Handles concurrent PR commits without conflicts
- ✅ Graceful degradation if steps fail
Appendix A: Complete verify-supply-chain-pr Job YAML
# ============================================================================
# Supply Chain Verification for PR Builds
# ============================================================================
# This job performs SBOM generation and vulnerability scanning for PR builds.
# It depends on the build-and-push job completing successfully and uses the
# Docker image artifact uploaded by that job.
#
# Dependency Chain: build-and-push (builds & uploads) → verify-supply-chain-pr (downloads & scans)
# ============================================================================
verify-supply-chain-pr:
name: Supply Chain Verification (PR)
needs: build-and-push
runs-on: ubuntu-latest
timeout-minutes: 15
# Critical Fix #2: Enhanced conditional with result check
if: |
github.event_name == 'pull_request' &&
needs.build-and-push.outputs.skip_build != 'true' &&
needs.build-and-push.result == 'success'
permissions:
contents: read
pull-requests: write
security-events: write
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
# Critical Fix #1: Download image artifact
- name: Download Image Artifact
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
with:
name: pr-image-${{ github.event.pull_request.number }}
# Critical Fix #1: Load Docker image
- name: Load Docker Image
run: |
docker load -i charon-pr-image.tar
docker images
echo "✅ Image loaded successfully"
- name: Normalize image name
run: |
IMAGE_NAME=$(echo "${{ github.repository_owner }}/charon" | tr '[:upper:]' '[:lower:]')
echo "IMAGE_NAME=${IMAGE_NAME}" >> $GITHUB_ENV
- name: Set PR image reference
id: image
run: |
IMAGE_REF="ghcr.io/${{ env.IMAGE_NAME }}:pr-${{ github.event.pull_request.number }}"
echo "ref=${IMAGE_REF}" >> $GITHUB_OUTPUT
echo "📦 Will verify: ${IMAGE_REF}"
- name: Install Verification Tools
run: |
# Use workflow-level environment variables for versions
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin ${{ env.SYFT_VERSION }}
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin ${{ env.GRYPE_VERSION }}
syft version
grype version
- name: Generate SBOM
id: sbom
run: |
echo "🔍 Generating SBOM for ${{ steps.image.outputs.ref }}..."
if ! syft ${{ steps.image.outputs.ref }} -o cyclonedx-json > sbom-pr.cyclonedx.json; then
echo "❌ SBOM generation failed"
exit 1
fi
COMPONENT_COUNT=$(jq '.components | length' sbom-pr.cyclonedx.json 2>/dev/null || echo "0")
echo "📦 SBOM contains ${COMPONENT_COUNT} components"
if [[ ${COMPONENT_COUNT} -eq 0 ]]; then
echo "⚠️ WARNING: SBOM contains no components"
exit 1
fi
echo "component_count=${COMPONENT_COUNT}" >> $GITHUB_OUTPUT
- name: Scan for Vulnerabilities
id: scan
run: |
echo "🔍 Scanning for vulnerabilities..."
grype db update
if ! grype sbom:./sbom-pr.cyclonedx.json --output json --file vuln-scan.json; then
echo "❌ Vulnerability scan failed"
exit 1
fi
echo ""
echo "=== Vulnerability Summary ==="
grype sbom:./sbom-pr.cyclonedx.json --output table || true
CRITICAL=$(jq '[.matches[] | select(.vulnerability.severity == "Critical")] | length' vuln-scan.json 2>/dev/null || echo "0")
HIGH=$(jq '[.matches[] | select(.vulnerability.severity == "High")] | length' vuln-scan.json 2>/dev/null || echo "0")
MEDIUM=$(jq '[.matches[] | select(.vulnerability.severity == "Medium")] | length' vuln-scan.json 2>/dev/null || echo "0")
LOW=$(jq '[.matches[] | select(.vulnerability.severity == "Low")] | length' vuln-scan.json 2>/dev/null || echo "0")
echo ""
echo "📊 Vulnerability Breakdown:"
echo " 🔴 Critical: ${CRITICAL}"
echo " 🟠 High: ${HIGH}"
echo " 🟡 Medium: ${MEDIUM}"
echo " 🟢 Low: ${LOW}"
echo "critical=${CRITICAL}" >> $GITHUB_OUTPUT
echo "high=${HIGH}" >> $GITHUB_OUTPUT
echo "medium=${MEDIUM}" >> $GITHUB_OUTPUT
echo "low=${LOW}" >> $GITHUB_OUTPUT
if [[ ${CRITICAL} -gt 0 ]]; then
echo "::error::${CRITICAL} CRITICAL vulnerabilities found - BLOCKING"
fi
if [[ ${HIGH} -gt 0 ]]; then
echo "::warning::${HIGH} HIGH vulnerabilities found"
fi
- name: Generate SARIF Report
if: always()
run: |
echo "📋 Generating SARIF report..."
grype sbom:./sbom-pr.cyclonedx.json --output sarif --file grype-results.sarif || true
# Critical Fix #3: SARIF category includes SHA to prevent conflicts
- name: Upload SARIF to GitHub Security
if: always()
uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
with:
sarif_file: grype-results.sarif
category: supply-chain-pr-${{ github.event.pull_request.number }}-${{ github.sha }}
continue-on-error: true
- name: Upload Artifacts
if: always()
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
with:
name: supply-chain-pr-${{ github.event.pull_request.number }}
path: |
sbom-pr.cyclonedx.json
vuln-scan.json
grype-results.sarif
retention-days: 30
# Critical Fix #4: Null checks in PR comment
- name: Comment on PR
if: always()
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const critical = '${{ steps.scan.outputs.critical }}' || '0';
const high = '${{ steps.scan.outputs.high }}' || '0';
const medium = '${{ steps.scan.outputs.medium }}' || '0';
const low = '${{ steps.scan.outputs.low }}' || '0';
const components = '${{ steps.sbom.outputs.component_count }}' || 'N/A';
const commitSha = '${{ github.sha }}'.substring(0, 7);
let status = '✅ **PASSED**';
let statusEmoji = '✅';
if (parseInt(critical) > 0) {
status = '❌ **BLOCKED** - Critical vulnerabilities found';
statusEmoji = '❌';
} else if (parseInt(high) > 0) {
status = '⚠️ **WARNING** - High vulnerabilities found';
statusEmoji = '⚠️';
}
const body = `## ${statusEmoji} Supply Chain Verification (PR Build)
**Status**: ${status}
**Commit**: \`${commitSha}\`
**Image**: \`${{ steps.image.outputs.ref }}\`
**Components Scanned**: ${components}
### 📊 Vulnerability Summary
| Severity | Count |
|----------|-------|
| 🔴 Critical | ${critical} |
| 🟠 High | ${high} |
| 🟡 Medium | ${medium} |
| 🟢 Low | ${low} |
${parseInt(critical) > 0 ? '### ❌ Critical Vulnerabilities Detected\n\n**Action Required**: This PR cannot be merged until critical vulnerabilities are resolved.\n\n' : ''}
${parseInt(high) > 0 ? '### ⚠️ High Vulnerabilities Detected\n\n**Recommendation**: Review and address high-severity vulnerabilities before merging.\n\n' : ''}
📋 [View Full Report](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})
📦 [Download Artifacts](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}#artifacts)
`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: body
});
- name: Fail on Critical Vulnerabilities
if: steps.scan.outputs.critical != '0'
run: |
echo "❌ CRITICAL: ${{ steps.scan.outputs.critical }} critical vulnerabilities found"
echo "This PR is blocked from merging until critical vulnerabilities are resolved."
exit 1
# Critical Fix #4: Null checks in job summary
- name: Create Job Summary
if: always()
run: |
# Use default values if outputs are not set
COMPONENT_COUNT="${{ steps.sbom.outputs.component_count }}"
CRITICAL="${{ steps.scan.outputs.critical }}"
HIGH="${{ steps.scan.outputs.high }}"
MEDIUM="${{ steps.scan.outputs.medium }}"
LOW="${{ steps.scan.outputs.low }}"
# Apply defaults
COMPONENT_COUNT="${COMPONENT_COUNT:-N/A}"
CRITICAL="${CRITICAL:-0}"
HIGH="${HIGH:-0}"
MEDIUM="${MEDIUM:-0}"
LOW="${LOW:-0}"
echo "## 🔒 Supply Chain Verification - PR #${{ github.event.pull_request.number }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Image**: \`${{ steps.image.outputs.ref }}\`" >> $GITHUB_STEP_SUMMARY
echo "**Components**: ${COMPONENT_COUNT}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Vulnerability Breakdown" >> $GITHUB_STEP_SUMMARY
echo "- 🔴 Critical: ${CRITICAL}" >> $GITHUB_STEP_SUMMARY
echo "- 🟠 High: ${HIGH}" >> $GITHUB_STEP_SUMMARY
echo "- 🟡 Medium: ${MEDIUM}" >> $GITHUB_STEP_SUMMARY
echo "- 🟢 Low: ${LOW}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [[ ${CRITICAL} -gt 0 ]]; then
echo "❌ **BLOCKED**: Critical vulnerabilities must be resolved" >> $GITHUB_STEP_SUMMARY
elif [[ ${HIGH} -gt 0 ]]; then
echo "⚠️ **WARNING**: High vulnerabilities detected" >> $GITHUB_STEP_SUMMARY
else
echo "✅ **PASSED**: No critical or high vulnerabilities" >> $GITHUB_STEP_SUMMARY
fi
Appendix B: verify-supply-chain-pr-skipped Job YAML
# ============================================================================
# Supply Chain Verification - Skipped Feedback
# ============================================================================
# This job provides user feedback when the build is skipped (e.g., chore commits).
# Critical Fix #7: User feedback for skipped builds
# ============================================================================
verify-supply-chain-pr-skipped:
name: Supply Chain Verification (Skipped)
needs: build-and-push
runs-on: ubuntu-latest
if: |
github.event_name == 'pull_request' &&
needs.build-and-push.outputs.skip_build == 'true'
permissions:
pull-requests: write
steps:
- name: Comment on PR - Build Skipped
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const commitSha = '${{ github.sha }}'.substring(0, 7);
const body = `## ⏭️ Supply Chain Verification (Skipped)
**Commit**: \`${commitSha}\`
**Reason**: Build was skipped (likely a documentation-only or chore commit)
Supply chain verification is not performed for skipped builds. If this commit should trigger a build, ensure it includes changes to application code or dependencies.
`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: body
});
END OF IMPLEMENTATION PLAN