chore: clean .gitignore cache

This commit is contained in:
GitHub Actions
2026-01-26 19:22:05 +00:00
parent e5f0fec5db
commit f64e3feef8
1448 changed files with 468101 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
name: Auto-add issues and PRs to Project
on:
issues:
types: [opened, reopened]
pull_request:
types: [opened, reopened]
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number }}
cancel-in-progress: false
jobs:
add-to-project:
runs-on: ubuntu-latest
steps:
- name: Determine project URL presence
id: project_check
run: |
if [ -n "${{ secrets.PROJECT_URL }}" ]; then
echo "has_project=true" >> $GITHUB_OUTPUT
else
echo "has_project=false" >> $GITHUB_OUTPUT
fi
- name: Add issue or PR to project
if: steps.project_check.outputs.has_project == 'true'
uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2
continue-on-error: true
with:
project-url: ${{ secrets.PROJECT_URL }}
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
- name: Skip summary
if: steps.project_check.outputs.has_project == 'false'
run: echo "PROJECT_URL secret missing; skipping project assignment." >> $GITHUB_STEP_SUMMARY

21
.github/workflows/auto-changelog.yml vendored Normal file
View File

@@ -0,0 +1,21 @@
name: Auto Changelog (Release Drafter)
on:
push:
branches: [ main ]
release:
types: [published]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
update-draft:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Draft Release
uses: release-drafter/release-drafter@6db134d15f3909ccc9eefd369f02bd1e9cffdf97 # v6
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

78
.github/workflows/auto-label-issues.yml vendored Normal file
View File

@@ -0,0 +1,78 @@
name: Auto-label Issues
on:
issues:
types: [opened, edited]
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number }}
cancel-in-progress: true
jobs:
auto-label:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Auto-label based on title and body
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const issue = context.payload.issue;
const title = issue.title.toLowerCase();
const body = issue.body ? issue.body.toLowerCase() : '';
const labels = [];
// Priority detection
if (title.includes('[critical]') || body.includes('priority: critical')) {
labels.push('critical');
} else if (title.includes('[high]') || body.includes('priority: high')) {
labels.push('high');
} else if (title.includes('[medium]') || body.includes('priority: medium')) {
labels.push('medium');
} else if (title.includes('[low]') || body.includes('priority: low')) {
labels.push('low');
}
// Milestone detection
if (title.includes('[alpha]') || body.includes('milestone: alpha')) {
labels.push('alpha');
} else if (title.includes('[beta]') || body.includes('milestone: beta')) {
labels.push('beta');
} else if (title.includes('[post-beta]') || body.includes('milestone: post-beta')) {
labels.push('post-beta');
}
// Category detection
if (title.includes('architecture') || body.includes('architecture')) labels.push('architecture');
if (title.includes('backend') || body.includes('backend')) labels.push('backend');
if (title.includes('frontend') || body.includes('frontend')) labels.push('frontend');
if (title.includes('security') || body.includes('security')) labels.push('security');
if (title.includes('ssl') || title.includes('tls') || body.includes('certificate')) labels.push('ssl');
if (title.includes('sso') || body.includes('single sign-on')) labels.push('sso');
if (title.includes('waf') || body.includes('web application firewall')) labels.push('waf');
if (title.includes('crowdsec') || body.includes('crowdsec')) labels.push('crowdsec');
if (title.includes('caddy') || body.includes('caddy')) labels.push('caddy');
if (title.includes('database') || body.includes('database')) labels.push('database');
if (title.includes('ui') || title.includes('interface')) labels.push('ui');
if (title.includes('docker') || title.includes('deployment')) labels.push('deployment');
if (title.includes('monitoring') || title.includes('logging')) labels.push('monitoring');
if (title.includes('documentation') || title.includes('docs')) labels.push('documentation');
if (title.includes('test') || body.includes('testing')) labels.push('testing');
if (title.includes('performance') || body.includes('optimization')) labels.push('performance');
if (title.includes('plus') || body.includes('premium feature')) labels.push('plus');
// Feature detection
if (title.includes('feature') || body.includes('feature request')) labels.push('feature');
// Only add labels if we detected any
if (labels.length > 0) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: labels
});
console.log(`Added labels: ${labels.join(', ')}`);
}

103
.github/workflows/auto-versioning.yml vendored Normal file
View File

@@ -0,0 +1,103 @@
name: Auto Versioning and Release
# SEMANTIC VERSIONING RULES:
# - PATCH (0.14.1 → 0.14.2): fix:, perf:, refactor:, docs:, style:, test:, build:, ci:
# - MINOR (0.14.1 → 0.15.0): feat:, feat(...):
# - MAJOR (0.14.1 → 1.0.0): MANUAL ONLY - Create git tag manually when ready for 1.0.0
#
# ⚠️ Major version bumps are intentionally disabled in automation to prevent accidents.
on:
push:
branches: [ main ]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false # Don't cancel in-progress releases
permissions:
contents: write # Required for creating releases via API (removed unused pull-requests: write)
jobs:
version:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
fetch-depth: 0
- name: Calculate Semantic Version
id: semver
uses: paulhatch/semantic-version@f29500c9d60a99ed5168e39ee367e0976884c46e # v6.0.1
with:
# The prefix to use to create tags
tag_prefix: "v"
# Regex pattern for major version bump - DISABLED (manual only)
# Use a pattern that will never match to prevent automated major bumps
major_pattern: "/__MANUAL_MAJOR_BUMP_ONLY__/"
# Regex pattern for minor version bump (new features)
# Matches: "feat:" prefix in commit messages (Conventional Commits)
minor_pattern: "/^feat(\\(.+\\))?:/"
# Patch bumps: All other commits (fix:, chore:, etc.) are treated as patches by default
# Pattern to determine formatting
version_format: "${major}.${minor}.${patch}"
# If no tags are found, this version is used
version_from_branch: "0.0.0"
# This helps it search through history to find the last tag
search_commit_body: true
# Important: This enables the output 'changed' which your other steps rely on
enable_prerelease_mode: false
- name: Show version
run: |
echo "Next version: ${{ steps.semver.outputs.version }}"
echo "Version changed: ${{ steps.semver.outputs.changed }}"
- name: Determine tag name
id: determine_tag
run: |
# Normalize the version: remove any leading 'v' so we don't end up with 'vvX.Y.Z'
RAW="${{ steps.semver.outputs.version }}"
VERSION_NO_V="${RAW#v}"
TAG="v${VERSION_NO_V}"
echo "Determined tag: $TAG"
echo "tag=$TAG" >> $GITHUB_OUTPUT
- name: Check for existing GitHub Release
id: check_release
run: |
TAG=${{ steps.determine_tag.outputs.tag }}
echo "Checking for release for tag: ${TAG}"
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}") || true
if [ "${STATUS}" = "200" ]; then
echo "exists=true" >> $GITHUB_OUTPUT
echo " Release already exists for tag: ${TAG}"
else
echo "exists=false" >> $GITHUB_OUTPUT
echo "✅ No existing release found for tag: ${TAG}"
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create GitHub Release (creates tag via API)
if: ${{ steps.semver.outputs.changed == 'true' && steps.check_release.outputs.exists == 'false' }}
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
with:
tag_name: ${{ steps.determine_tag.outputs.tag }}
name: Release ${{ steps.determine_tag.outputs.tag }}
generate_release_notes: true
make_latest: true
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Output release information
if: ${{ steps.semver.outputs.changed == 'true' && steps.check_release.outputs.exists == 'false' }}
run: |
echo "✅ Successfully created release: ${{ steps.determine_tag.outputs.tag }}"
echo "📦 Release URL: https://github.com/${{ github.repository }}/releases/tag/${{ steps.determine_tag.outputs.tag }}"

81
.github/workflows/benchmark.yml vendored Normal file
View File

@@ -0,0 +1,81 @@
name: Go Benchmark
on:
push:
branches:
- main
- development
paths:
- 'backend/**'
pull_request:
branches:
- main
- development
paths:
- 'backend/**'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
GO_VERSION: '1.25.6'
GOTOOLCHAIN: auto
# Minimal permissions at workflow level; write permissions granted at job level for push only
permissions:
contents: read
jobs:
benchmark:
name: Performance Regression Check
runs-on: ubuntu-latest
# Grant write permissions for storing benchmark results (only used on push via step condition)
# Note: GitHub Actions doesn't support dynamic expressions in permissions block
permissions:
contents: write
deployments: write
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Set up Go
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: backend/go.sum
- name: Run Benchmark
working-directory: backend
run: go test -bench=. -benchmem -run='^$' ./... | tee output.txt
- name: Store Benchmark Result
# Only store results on pushes to main - PRs just run benchmarks without storage
# This avoids gh-pages branch errors and permission issues on fork PRs
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
# Security: Pinned to full SHA for supply chain security
uses: benchmark-action/github-action-benchmark@4e0b38bc48375986542b13c0d8976b7b80c60c00 # v1
with:
name: Go Benchmark
tool: 'go'
output-file-path: backend/output.txt
github-token: ${{ secrets.GITHUB_TOKEN }}
auto-push: true
# Show alert with commit comment on detection of performance regression
# Threshold increased to 175% to account for CI variability
alert-threshold: '175%'
comment-on-alert: true
fail-on-alert: false
# Enable Job Summary
summary-always: true
- name: Run Perf Asserts
working-directory: backend
env:
PERF_MAX_MS_GETSTATUS_P95: 500ms
PERF_MAX_MS_GETSTATUS_P95_PARALLEL: 1500ms
PERF_MAX_MS_LISTDECISIONS_P95: 2000ms
run: |
echo "## 🔍 Running performance assertions (TestPerf)" >> $GITHUB_STEP_SUMMARY
go test -run TestPerf -v ./internal/api/handlers -count=1 | tee perf-output.txt
exit ${PIPESTATUS[0]}

View File

@@ -0,0 +1,66 @@
name: Monitor Caddy Major Release
on:
schedule:
- cron: '17 7 * * 1' # Mondays at 07:17 UTC
workflow_dispatch: {}
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
permissions:
contents: read
issues: write
jobs:
check-caddy-major:
runs-on: ubuntu-latest
steps:
- name: Check for Caddy v3 and open issue
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const upstream = { owner: 'caddyserver', repo: 'caddy' };
const { data: releases } = await github.rest.repos.listReleases({
...upstream,
per_page: 50,
});
const latestV3 = releases.find(r => /^v3\./.test(r.tag_name));
if (!latestV3) {
core.info('No Caddy v3 release detected.');
return;
}
const issueTitle = `Track upgrade to Caddy v3 (${latestV3.tag_name})`;
const { data: existing } = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
per_page: 100,
});
if (existing.some(i => i.title === issueTitle)) {
core.info('Issue already exists — nothing to do.');
return;
}
const body = [
'Caddy v3 has been released upstream and detected by the scheduled monitor.',
'',
`Detected release: ${latestV3.tag_name} (${latestV3.html_url})`,
'',
'- Create a feature branch to evaluate the v3 migration.',
'- Review breaking changes and update Docker base images/workflows.',
'- Validate Trivy scans and update any policies as needed.',
'',
'Current policy: remain on latest 2.x until v3 is validated.'
].join('\n');
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: issueTitle,
body,
});

View File

@@ -0,0 +1,119 @@
name: Cerberus Integration Tests
on:
push:
branches: [ main, development, 'feature/**' ]
paths:
- 'backend/internal/caddy/**'
- 'backend/internal/security/**'
- 'backend/internal/handlers/security*.go'
- 'backend/internal/models/security*.go'
- 'scripts/cerberus_integration.sh'
- 'Dockerfile'
- '.github/workflows/cerberus-integration.yml'
pull_request:
branches: [ main, development ]
paths:
- 'backend/internal/caddy/**'
- 'backend/internal/security/**'
- 'backend/internal/handlers/security*.go'
- 'backend/internal/models/security*.go'
- 'scripts/cerberus_integration.sh'
- 'Dockerfile'
- '.github/workflows/cerberus-integration.yml'
# Allow manual trigger
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
cerberus-integration:
name: Cerberus Security Stack Integration
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Build Docker image
run: |
docker build \
--no-cache \
--build-arg VCS_REF=${{ github.sha }} \
-t charon:local .
- name: Run Cerberus integration tests
id: cerberus-test
run: |
chmod +x scripts/cerberus_integration.sh
scripts/cerberus_integration.sh 2>&1 | tee cerberus-test-output.txt
exit ${PIPESTATUS[0]}
- name: Dump Debug Info on Failure
if: failure()
run: |
echo "## 🔍 Debug Information" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Container Status" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
docker ps -a --filter "name=charon" --filter "name=cerberus" --filter "name=backend" >> $GITHUB_STEP_SUMMARY 2>&1 || true
echo '```' >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Security Status API" >> $GITHUB_STEP_SUMMARY
echo '```json' >> $GITHUB_STEP_SUMMARY
curl -s http://localhost:8480/api/v1/security/status 2>/dev/null | head -100 >> $GITHUB_STEP_SUMMARY || echo "Could not retrieve security status" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Caddy Admin Config" >> $GITHUB_STEP_SUMMARY
echo '```json' >> $GITHUB_STEP_SUMMARY
curl -s http://localhost:2319/config 2>/dev/null | head -200 >> $GITHUB_STEP_SUMMARY || echo "Could not retrieve Caddy config" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Charon Container Logs (last 100 lines)" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
docker logs charon-cerberus-test 2>&1 | tail -100 >> $GITHUB_STEP_SUMMARY || echo "No container logs available" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
- name: Cerberus Integration Summary
if: always()
run: |
echo "## 🔱 Cerberus Integration Test Results" >> $GITHUB_STEP_SUMMARY
if [ "${{ steps.cerberus-test.outcome }}" == "success" ]; then
echo "✅ **All Cerberus tests passed**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Test Results:" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
grep -E "✓|PASS|TC-[0-9]|=== ALL" cerberus-test-output.txt || echo "See logs for details"
grep -E "✓|PASS|TC-[0-9]|=== ALL" cerberus-test-output.txt >> $GITHUB_STEP_SUMMARY || echo "See logs for details" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Features Tested:" >> $GITHUB_STEP_SUMMARY
echo "- WAF (Coraza) payload inspection" >> $GITHUB_STEP_SUMMARY
echo "- Rate limiting enforcement" >> $GITHUB_STEP_SUMMARY
echo "- Security handler ordering" >> $GITHUB_STEP_SUMMARY
echo "- Legitimate traffic flow" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **Cerberus tests failed**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Failure Details:" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
grep -E "✗|FAIL|Error|failed" cerberus-test-output.txt | head -30 >> $GITHUB_STEP_SUMMARY || echo "See logs for details" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
fi
- name: Cleanup
if: always()
run: |
docker rm -f charon-cerberus-test || true
docker rm -f cerberus-backend || true
docker volume rm charon_cerberus_test_data caddy_cerberus_test_data caddy_cerberus_test_config 2>/dev/null || true
docker network rm containers_default || true

88
.github/workflows/codecov-upload.yml vendored Normal file
View File

@@ -0,0 +1,88 @@
name: Upload Coverage to Codecov (Push only)
on:
push:
branches:
- main
- development
- 'feature/**'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
GO_VERSION: '1.25.6'
NODE_VERSION: '24.12.0'
GOTOOLCHAIN: auto
permissions:
contents: read
jobs:
backend-codecov:
name: Backend Codecov Upload
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: backend/go.sum
- name: Run Go tests with coverage
working-directory: ${{ github.workspace }}
env:
CGO_ENABLED: 1
run: |
bash scripts/go-test-coverage.sh 2>&1 | tee backend/test-output.txt
exit ${PIPESTATUS[0]}
- name: Upload backend coverage to Codecov
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./backend/coverage.txt
flags: backend
fail_ci_if_error: true
frontend-codecov:
name: Frontend Codecov Upload
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: frontend
run: npm ci
- name: Run frontend tests and coverage
working-directory: ${{ github.workspace }}
run: |
bash scripts/frontend-test-coverage.sh 2>&1 | tee frontend/test-output.txt
exit ${PIPESTATUS[0]}
- name: Upload frontend coverage to Codecov
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
directory: ./frontend/coverage
flags: frontend
fail_ci_if_error: true

122
.github/workflows/codeql.yml vendored Normal file
View File

@@ -0,0 +1,122 @@
name: CodeQL - Analyze
on:
push:
branches: [ main, development, 'feature/**' ]
pull_request:
branches: [ main, development ]
schedule:
- cron: '0 3 * * 1'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
GO_VERSION: '1.25.6'
GOTOOLCHAIN: auto
permissions:
contents: read
security-events: write
actions: read
pull-requests: read
jobs:
analyze:
name: CodeQL analysis (${{ matrix.language }})
runs-on: ubuntu-latest
# Skip forked PRs where CHARON_TOKEN lacks security-events permissions
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
permissions:
contents: read
security-events: write
actions: read
pull-requests: read
strategy:
fail-fast: false
matrix:
language: [ 'go', 'javascript-typescript' ]
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Initialize CodeQL
uses: github/codeql-action/init@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4
with:
languages: ${{ matrix.language }}
# Use CodeQL config to exclude documented false positives
# Go: Excludes go/request-forgery for url_testing.go (has 4-layer SSRF defense)
# See: .github/codeql/codeql-config.yml for full justification
config-file: ./.github/codeql/codeql-config.yml
- name: Setup Go
if: matrix.language == 'go'
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: backend/go.sum
- name: Autobuild
uses: github/codeql-action/autobuild@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4
with:
category: "/language:${{ matrix.language }}"
- name: Check CodeQL Results
if: always()
run: |
echo "## 🔒 CodeQL Security Analysis Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Language:** ${{ matrix.language }}" >> $GITHUB_STEP_SUMMARY
echo "**Query Suite:** security-and-quality" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Find SARIF file (CodeQL action creates it in various locations)
SARIF_FILE=$(find ${{ runner.temp }} -name "*${{ matrix.language }}*.sarif" -type f 2>/dev/null | head -1)
if [ -f "$SARIF_FILE" ]; then
echo "Found SARIF file: $SARIF_FILE"
RESULT_COUNT=$(jq '.runs[].results | length' "$SARIF_FILE" 2>/dev/null || echo 0)
ERROR_COUNT=$(jq '[.runs[].results[] | select(.level == "error")] | length' "$SARIF_FILE" 2>/dev/null || echo 0)
WARNING_COUNT=$(jq '[.runs[].results[] | select(.level == "warning")] | length' "$SARIF_FILE" 2>/dev/null || echo 0)
NOTE_COUNT=$(jq '[.runs[].results[] | select(.level == "note")] | length' "$SARIF_FILE" 2>/dev/null || echo 0)
echo "**Findings:**" >> $GITHUB_STEP_SUMMARY
echo "- 🔴 Errors: $ERROR_COUNT" >> $GITHUB_STEP_SUMMARY
echo "- 🟡 Warnings: $WARNING_COUNT" >> $GITHUB_STEP_SUMMARY
echo "- 🔵 Notes: $NOTE_COUNT" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ "$ERROR_COUNT" -gt 0 ]; then
echo "❌ **CRITICAL:** High-severity security issues found!" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Top Issues:" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
jq -r '.runs[].results[] | select(.level == "error") | "\(.ruleId): \(.message.text)"' "$SARIF_FILE" 2>/dev/null | head -5 >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
else
echo "✅ No high-severity issues found" >> $GITHUB_STEP_SUMMARY
fi
else
echo "⚠️ SARIF file not found - check analysis logs" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "View full results in the [Security tab](https://github.com/${{ github.repository }}/security/code-scanning)" >> $GITHUB_STEP_SUMMARY
- name: Fail on High-Severity Findings
if: always()
run: |
SARIF_FILE=$(find ${{ runner.temp }} -name "*${{ matrix.language }}*.sarif" -type f 2>/dev/null | head -1)
if [ -f "$SARIF_FILE" ]; then
ERROR_COUNT=$(jq '[.runs[].results[] | select(.level == "error")] | length' "$SARIF_FILE" 2>/dev/null || echo 0)
if [ "$ERROR_COUNT" -gt 0 ]; then
echo "::error::CodeQL found $ERROR_COUNT high-severity security issues. Fix before merging."
exit 1
fi
fi

82
.github/workflows/create-labels.yml vendored Normal file
View File

@@ -0,0 +1,82 @@
name: Create Project Labels
# This workflow only runs manually to set up labels
on:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
jobs:
create-labels:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Create all project labels
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const labels = [
// Priority labels
{ name: 'critical', color: 'B60205', description: 'Must have for the release, blocks other work' },
{ name: 'high', color: 'D93F0B', description: 'Important feature, should be included' },
{ name: 'medium', color: 'FBCA04', description: 'Nice to have, can be deferred' },
{ name: 'low', color: '0E8A16', description: 'Future enhancement, not urgent' },
// Milestone labels
{ name: 'alpha', color: '5319E7', description: 'Part of initial alpha release' },
{ name: 'beta', color: '0052CC', description: 'Part of beta release' },
{ name: 'post-beta', color: '006B75', description: 'Post-beta enhancement' },
// Category labels
{ name: 'architecture', color: 'C5DEF5', description: 'System design and structure' },
{ name: 'backend', color: '1D76DB', description: 'Server-side code' },
{ name: 'frontend', color: '5EBEFF', description: 'UI/UX code' },
{ name: 'feature', color: 'A2EEEF', description: 'New functionality' },
{ name: 'security', color: 'EE0701', description: 'Security-related' },
{ name: 'ssl', color: 'F9D0C4', description: 'SSL/TLS certificates' },
{ name: 'sso', color: 'D4C5F9', description: 'Single Sign-On' },
{ name: 'waf', color: 'B60205', description: 'Web Application Firewall' },
{ name: 'crowdsec', color: 'FF6B6B', description: 'CrowdSec integration' },
{ name: 'caddy', color: '1F6FEB', description: 'Caddy-specific' },
{ name: 'database', color: '006B75', description: 'Database-related' },
{ name: 'ui', color: '7057FF', description: 'User interface' },
{ name: 'deployment', color: '0E8A16', description: 'Docker, installation' },
{ name: 'monitoring', color: 'FEF2C0', description: 'Logging and statistics' },
{ name: 'documentation', color: '0075CA', description: 'Docs and guides' },
{ name: 'testing', color: 'BFD4F2', description: 'Test suite' },
{ name: 'performance', color: 'EDEDED', description: 'Optimization' },
{ name: 'community', color: 'D876E3', description: 'Community building' },
{ name: 'plus', color: 'FFD700', description: 'Premium/"Plus" feature' },
{ name: 'enterprise', color: '8B4513', description: 'Enterprise-grade feature' }
];
for (const label of labels) {
try {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: label.name,
color: label.color,
description: label.description
});
console.log(`✓ Created label: ${label.name}`);
} catch (error) {
if (error.status === 422) {
console.log(`⚠ Label already exists: ${label.name}`);
// Update the label if it exists
await github.rest.issues.updateLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: label.name,
color: label.color,
description: label.description
});
console.log(`✓ Updated label: ${label.name}`);
} else {
console.error(`✗ Error creating label ${label.name}:`, error.message);
}
}
}

View File

@@ -0,0 +1,122 @@
name: CrowdSec Integration Tests
on:
push:
branches: [ main, development, 'feature/**' ]
paths:
- 'backend/internal/crowdsec/**'
- 'backend/internal/models/crowdsec*.go'
- 'configs/crowdsec/**'
- 'scripts/crowdsec_integration.sh'
- 'scripts/crowdsec_decision_integration.sh'
- 'scripts/crowdsec_startup_test.sh'
- '.github/skills/integration-test-crowdsec*/**'
- 'Dockerfile'
- '.github/workflows/crowdsec-integration.yml'
pull_request:
branches: [ main, development ]
paths:
- 'backend/internal/crowdsec/**'
- 'backend/internal/models/crowdsec*.go'
- 'configs/crowdsec/**'
- 'scripts/crowdsec_integration.sh'
- 'scripts/crowdsec_decision_integration.sh'
- 'scripts/crowdsec_startup_test.sh'
- '.github/skills/integration-test-crowdsec*/**'
- 'Dockerfile'
- '.github/workflows/crowdsec-integration.yml'
# Allow manual trigger
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
crowdsec-integration:
name: CrowdSec Bouncer Integration
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Build Docker image
run: |
docker build \
--no-cache \
--build-arg VCS_REF=${{ github.sha }} \
-t charon:local .
- name: Run CrowdSec integration tests
id: crowdsec-test
run: |
chmod +x .github/skills/scripts/skill-runner.sh
.github/skills/scripts/skill-runner.sh integration-test-crowdsec 2>&1 | tee crowdsec-test-output.txt
exit ${PIPESTATUS[0]}
- name: Dump Debug Info on Failure
if: failure()
run: |
echo "## 🔍 Debug Information" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Container Status" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
docker ps -a --filter "name=charon" --filter "name=crowdsec" >> $GITHUB_STEP_SUMMARY 2>&1 || true
echo '```' >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### CrowdSec LAPI Status" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
docker exec crowdsec cscli bouncers list 2>/dev/null >> $GITHUB_STEP_SUMMARY || echo "Could not retrieve bouncer list" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### CrowdSec Decisions" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
docker exec crowdsec cscli decisions list 2>/dev/null >> $GITHUB_STEP_SUMMARY || echo "Could not retrieve decisions" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Charon Container Logs (last 100 lines)" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
docker logs charon-debug 2>&1 | tail -100 >> $GITHUB_STEP_SUMMARY || echo "No container logs available" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### CrowdSec Container Logs (last 50 lines)" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
docker logs crowdsec 2>&1 | tail -50 >> $GITHUB_STEP_SUMMARY || echo "No CrowdSec logs available" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
- name: CrowdSec Integration Summary
if: always()
run: |
echo "## 🛡️ CrowdSec Integration Test Results" >> $GITHUB_STEP_SUMMARY
if [ "${{ steps.crowdsec-test.outcome }}" == "success" ]; then
echo "✅ **All CrowdSec tests passed**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Test Results:" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
grep -E "^✓|^===|^Pull|^Apply" crowdsec-test-output.txt || echo "See logs for details"
grep -E "^✓|^===|^Pull|^Apply" crowdsec-test-output.txt >> $GITHUB_STEP_SUMMARY || echo "See logs for details" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
else
echo "❌ **CrowdSec tests failed**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Failure Details:" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
grep -E "^✗|Unexpected|Error|failed|FAIL" crowdsec-test-output.txt | head -20 >> $GITHUB_STEP_SUMMARY || echo "See logs for details" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
fi
- name: Cleanup
if: always()
run: |
docker rm -f charon-debug || true
docker rm -f crowdsec || true
docker network rm containers_default || true

550
.github/workflows/docker-build.yml vendored Normal file
View File

@@ -0,0 +1,550 @@
name: Docker Build, Publish & Test
# This workflow replaced .github/workflows/docker-publish.yml (deleted in commit f640524b on Dec 21, 2025)
# Enhancements over the previous workflow:
# - SBOM generation and attestation for supply chain security
# - CVE-2025-68156 verification for Caddy security patches
# - Enhanced PR handling with dedicated scanning
# - Improved workflow orchestration with supply-chain-verify.yml
on:
push:
branches:
- main
- development
- 'feature/**'
# Note: Tags are handled by release-goreleaser.yml to avoid duplicate builds
pull_request:
branches:
- main
- development
- 'feature/**'
workflow_dispatch:
workflow_call:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
GHCR_REGISTRY: ghcr.io
DOCKERHUB_REGISTRY: docker.io
IMAGE_NAME: wikid82/charon
SYFT_VERSION: v1.17.0
GRYPE_VERSION: v0.85.0
jobs:
build-and-push:
env:
HAS_DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN != '' }}
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
packages: write
security-events: write
id-token: write # Required for SBOM attestation
attestations: write # Required for SBOM attestation
outputs:
skip_build: ${{ steps.skip.outputs.skip_build }}
digest: ${{ steps.build-and-push.outputs.digest }}
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Normalize image name
run: |
IMAGE_NAME=$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]')
echo "IMAGE_NAME=${IMAGE_NAME}" >> $GITHUB_ENV
- name: Determine skip condition
id: skip
env:
ACTOR: ${{ github.actor }}
EVENT: ${{ github.event_name }}
HEAD_MSG: ${{ github.event.head_commit.message }}
REF: ${{ github.ref }}
HEAD_REF: ${{ github.head_ref }}
run: |
should_skip=false
pr_title=""
if [ "$EVENT" = "pull_request" ]; then
pr_title=$(jq -r '.pull_request.title' "$GITHUB_EVENT_PATH" 2>/dev/null || echo '')
fi
if [ "$ACTOR" = "renovate[bot]" ]; then should_skip=true; fi
if echo "$HEAD_MSG" | grep -Ei '^chore\(deps' >/dev/null 2>&1; then should_skip=true; fi
if echo "$HEAD_MSG" | grep -Ei '^chore:' >/dev/null 2>&1; then should_skip=true; fi
if echo "$pr_title" | grep -Ei '^chore\(deps' >/dev/null 2>&1; then should_skip=true; fi
if echo "$pr_title" | grep -Ei '^chore:' >/dev/null 2>&1; then should_skip=true; fi
# Always build on feature branches to ensure artifacts for testing
# For PRs: github.ref is refs/pull/N/merge, so check github.head_ref instead
# For pushes: github.ref is refs/heads/branch-name
is_feature_push=false
if [[ "$REF" == refs/heads/feature/* ]]; then
should_skip=false
is_feature_push=true
echo "Force building on feature branch (push)"
elif [[ "$HEAD_REF" == feature/* ]]; then
should_skip=false
echo "Force building on feature branch (PR)"
fi
echo "skip_build=$should_skip" >> $GITHUB_OUTPUT
echo "is_feature_push=$is_feature_push" >> $GITHUB_OUTPUT
- name: Set up QEMU
if: steps.skip.outputs.skip_build != 'true'
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
if: steps.skip.outputs.skip_build != 'true'
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Resolve Debian base image digest
if: steps.skip.outputs.skip_build != 'true'
id: caddy
run: |
docker pull debian:trixie-slim
DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' debian:trixie-slim)
echo "image=$DIGEST" >> $GITHUB_OUTPUT
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true'
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true' && env.HAS_DOCKERHUB_TOKEN == 'true'
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels)
if: steps.skip.outputs.skip_build != 'true'
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: |
${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=dev,enable=${{ github.ref == 'refs/heads/development' }}
type=ref,event=branch,enable=${{ startsWith(github.ref, 'refs/heads/feature/') }}
type=raw,value=pr-${{ github.event.pull_request.number }},enable=${{ github.event_name == 'pull_request' }}
type=sha,format=short,enable=${{ github.event_name != 'pull_request' }}
flavor: |
latest=false
# For feature branch pushes: build single-platform so we can load locally for artifact
# For main/development pushes: build multi-platform for production
# For PRs: build single-platform and load locally
- name: Build and push Docker image
if: steps.skip.outputs.skip_build != 'true'
id: build-and-push
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
with:
context: .
platforms: ${{ (github.event_name == 'pull_request' || steps.skip.outputs.is_feature_push == 'true') && 'linux/amd64' || 'linux/amd64,linux/arm64' }}
push: ${{ github.event_name != 'pull_request' }}
load: ${{ github.event_name == 'pull_request' || steps.skip.outputs.is_feature_push == 'true' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
no-cache: true # Prevent false positive vulnerabilities from cached layers
pull: true # Always pull fresh base images to get latest security patches
build-args: |
VERSION=${{ steps.meta.outputs.version }}
BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }}
VCS_REF=${{ github.sha }}
CADDY_IMAGE=${{ steps.caddy.outputs.image }}
# Critical Fix: Use exact tag from metadata instead of manual reconstruction
# WHY: docker/build-push-action with load:true applies the exact tags from
# docker/metadata-action. Manual reconstruction can cause mismatches due to:
# - Case sensitivity variations (owner name normalization)
# - Tag format differences in Buildx internal behavior
# - Registry prefix inconsistencies
#
# SOLUTION: Extract the first tag from metadata output (which is the PR tag)
# and use it directly with docker save. This guarantees we reference the
# exact image that was loaded into the local Docker daemon.
#
# VALIDATION: Added defensive checks to fail fast with diagnostics if:
# 1. No tag found in metadata output
# 2. Image doesn't exist locally after build
# 3. Artifact creation fails
- name: Save Docker Image as Artifact
if: github.event_name == 'pull_request' || steps.skip.outputs.is_feature_push == 'true'
run: |
# Extract the first tag from metadata action (PR tag)
IMAGE_TAG=$(echo "${{ steps.meta.outputs.tags }}" | head -n 1)
if [[ -z "${IMAGE_TAG}" ]]; then
echo "❌ ERROR: No image tag found in metadata output"
echo "Metadata tags output:"
echo "${{ steps.meta.outputs.tags }}"
exit 1
fi
echo "🔍 Detected image tag: ${IMAGE_TAG}"
# Verify the image exists locally
if ! docker image inspect "${IMAGE_TAG}" >/dev/null 2>&1; then
echo "❌ ERROR: Image ${IMAGE_TAG} not found locally"
echo "📋 Available images:"
docker images
exit 1
fi
# Save the image using the exact tag from metadata
echo "💾 Saving image: ${IMAGE_TAG}"
docker save "${IMAGE_TAG}" -o /tmp/charon-pr-image.tar
# Verify the artifact was created
echo "✅ Artifact created:"
ls -lh /tmp/charon-pr-image.tar
- name: Upload Image Artifact
if: github.event_name == 'pull_request' || steps.skip.outputs.is_feature_push == 'true'
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: ${{ github.event_name == 'pull_request' && format('pr-image-{0}', github.event.pull_request.number) || 'push-image' }}
path: /tmp/charon-pr-image.tar
retention-days: 1 # Only needed for workflow duration
- name: Verify Caddy Security Patches (CVE-2025-68156)
if: steps.skip.outputs.skip_build != 'true'
timeout-minutes: 2
continue-on-error: true
run: |
echo "🔍 Verifying Caddy binary contains patched expr-lang/expr@v1.17.7..."
echo ""
# Determine the image reference based on event type
if [ "${{ github.event_name }}" = "pull_request" ]; then
IMAGE_REF="${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:pr-${{ github.event.pull_request.number }}"
echo "Using PR image: $IMAGE_REF"
else
IMAGE_REF="${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}"
echo "Using digest: $IMAGE_REF"
fi
echo ""
echo "==> Caddy version:"
timeout 30s docker run --rm --pull=never $IMAGE_REF caddy version || echo "⚠️ Caddy version check timed out or failed"
echo ""
echo "==> Extracting Caddy binary for inspection..."
CONTAINER_ID=$(docker create --pull=never $IMAGE_REF)
docker cp ${CONTAINER_ID}:/usr/bin/caddy ./caddy_binary
docker rm ${CONTAINER_ID}
echo ""
echo "==> Checking if Go toolchain is available locally..."
if command -v go >/dev/null 2>&1; then
echo "✅ Go found locally, inspecting binary dependencies..."
go version -m ./caddy_binary > caddy_deps.txt
echo ""
echo "==> Searching for expr-lang/expr dependency:"
if grep -i "expr-lang/expr" caddy_deps.txt; then
EXPR_VERSION=$(grep "expr-lang/expr" caddy_deps.txt | awk '{print $3}')
echo ""
echo "✅ Found expr-lang/expr: $EXPR_VERSION"
# Check if version is v1.17.7 or higher (vulnerable version is v1.16.9)
if echo "$EXPR_VERSION" | grep -E "^v1\.(1[7-9]|[2-9][0-9])\.[0-9]+$" >/dev/null; then
echo "✅ PASS: expr-lang version $EXPR_VERSION is patched (>= v1.17.7)"
else
echo "⚠️ WARNING: expr-lang version $EXPR_VERSION may be vulnerable (< v1.17.7)"
echo "Expected: v1.17.7 or higher to mitigate CVE-2025-68156"
exit 1
fi
else
echo "⚠️ expr-lang/expr not found in binary dependencies"
echo "This could mean:"
echo " 1. The dependency was stripped/optimized out"
echo " 2. Caddy was built without the expression evaluator"
echo " 3. Binary inspection failed"
echo ""
echo "Displaying all dependencies for review:"
cat caddy_deps.txt
fi
else
echo "⚠️ Go toolchain not available in CI environment"
echo "Cannot inspect binary modules - skipping dependency verification"
echo "Note: Runtime image does not require Go as Caddy is a standalone binary"
fi
# Cleanup
rm -f ./caddy_binary caddy_deps.txt
echo ""
echo "==> Verification complete"
- name: Verify CrowdSec Security Patches (CVE-2025-68156)
if: success()
continue-on-error: true
run: |
echo "🔍 Verifying CrowdSec binaries contain patched expr-lang/expr@v1.17.7..."
echo ""
# Determine the image reference based on event type
if [ "${{ github.event_name }}" = "pull_request" ]; then
IMAGE_REF="${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:pr-${{ github.event.pull_request.number }}"
echo "Using PR image: $IMAGE_REF"
else
IMAGE_REF="${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}"
echo "Using digest: $IMAGE_REF"
fi
echo ""
echo "==> CrowdSec cscli version:"
timeout 30s docker run --rm --pull=never $IMAGE_REF cscli version || echo "⚠️ CrowdSec version check timed out or failed (may not be installed for this architecture)"
echo ""
echo "==> Extracting cscli binary for inspection..."
CONTAINER_ID=$(docker create --pull=never $IMAGE_REF)
docker cp ${CONTAINER_ID}:/usr/local/bin/cscli ./cscli_binary 2>/dev/null || {
echo "⚠️ cscli binary not found - CrowdSec may not be available for this architecture"
docker rm ${CONTAINER_ID}
exit 0
}
docker rm ${CONTAINER_ID}
echo ""
echo "==> Checking if Go toolchain is available locally..."
if command -v go >/dev/null 2>&1; then
echo "✅ Go found locally, inspecting binary dependencies..."
go version -m ./cscli_binary > cscli_deps.txt
echo ""
echo "==> Searching for expr-lang/expr dependency:"
if grep -i "expr-lang/expr" cscli_deps.txt; then
EXPR_VERSION=$(grep "expr-lang/expr" cscli_deps.txt | awk '{print $3}')
echo ""
echo "✅ Found expr-lang/expr: $EXPR_VERSION"
# Check if version is v1.17.7 or higher (vulnerable version is v1.17.2)
if echo "$EXPR_VERSION" | grep -E "^v1\.(1[7-9]|[2-9][0-9])\.[7-9][0-9]*$|^v1\.17\.([7-9]|[1-9][0-9]+)$" >/dev/null; then
echo "✅ PASS: expr-lang version $EXPR_VERSION is patched (>= v1.17.7)"
else
echo "❌ FAIL: expr-lang version $EXPR_VERSION is vulnerable (< v1.17.7)"
echo "⚠️ WARNING: expr-lang version $EXPR_VERSION may be vulnerable (< v1.17.7)"
echo "Expected: v1.17.7 or higher to mitigate CVE-2025-68156"
exit 1
fi
else
echo "⚠️ expr-lang/expr not found in binary dependencies"
echo "This could mean:"
echo " 1. The dependency was stripped/optimized out"
echo " 2. CrowdSec was built without the expression evaluator"
echo " 3. Binary inspection failed"
echo ""
echo "Displaying all dependencies for review:"
cat cscli_deps.txt
fi
else
echo "⚠️ Go toolchain not available in CI environment"
echo "Cannot inspect binary modules - skipping dependency verification"
echo "Note: Runtime image does not require Go as CrowdSec is a standalone binary"
fi
# Cleanup
rm -f ./cscli_binary cscli_deps.txt
echo ""
echo "==> CrowdSec verification complete"
- name: Run Trivy scan (table output)
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true'
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1
with:
image-ref: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
format: 'table'
severity: 'CRITICAL,HIGH'
exit-code: '0'
continue-on-error: true
- name: Run Trivy vulnerability scanner (SARIF)
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true'
id: trivy
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1
with:
image-ref: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
continue-on-error: true
- name: Check Trivy SARIF exists
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true'
id: trivy-check
run: |
if [ -f trivy-results.sarif ]; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Upload Trivy results
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.trivy-check.outputs.exists == 'true'
uses: github/codeql-action/upload-sarif@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4.31.11
with:
sarif_file: 'trivy-results.sarif'
token: ${{ secrets.GITHUB_TOKEN }}
# Generate SBOM (Software Bill of Materials) for supply chain security
# Only for production builds (main/development) - feature branches use downstream supply-chain-pr.yml
- name: Generate SBOM
uses: anchore/sbom-action@62ad5284b8ced813296287a0b63906cb364b73ee # v0.22.0
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true'
with:
image: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
format: cyclonedx-json
output-file: sbom.cyclonedx.json
# Create verifiable attestation for the SBOM
- name: Attest SBOM
uses: actions/attest-sbom@4651f806c01d8637787e274ac3bdf724ef169f34 # v3.0.0
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true'
with:
subject-name: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.build-and-push.outputs.digest }}
sbom-path: sbom.cyclonedx.json
push-to-registry: true
# Install Cosign for keyless signing
- name: Install Cosign
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true'
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
# Sign GHCR image with keyless signing (Sigstore/Fulcio)
- name: Sign GHCR Image
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true'
run: |
echo "Signing GHCR image with keyless signing..."
cosign sign --yes ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
echo "✅ GHCR image signed successfully"
# Sign Docker Hub image with keyless signing (Sigstore/Fulcio)
- name: Sign Docker Hub Image
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true' && env.HAS_DOCKERHUB_TOKEN == 'true'
run: |
echo "Signing Docker Hub image with keyless signing..."
cosign sign --yes ${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
echo "✅ Docker Hub image signed successfully"
# Attach SBOM to Docker Hub image
- name: Attach SBOM to Docker Hub
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true' && env.HAS_DOCKERHUB_TOKEN == 'true'
run: |
echo "Attaching SBOM to Docker Hub image..."
cosign attach sbom --sbom sbom.cyclonedx.json ${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
echo "✅ SBOM attached to Docker Hub image"
- name: Create summary
if: steps.skip.outputs.skip_build != 'true'
run: |
echo "## 🎉 Docker Image Built Successfully!" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 📦 Image Details" >> $GITHUB_STEP_SUMMARY
echo "- **GHCR**: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}" >> $GITHUB_STEP_SUMMARY
echo "- **Docker Hub**: ${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}" >> $GITHUB_STEP_SUMMARY
echo "- **Tags**: " >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
test-image:
name: Test Docker Image
needs: build-and-push
runs-on: ubuntu-latest
if: needs.build-and-push.outputs.skip_build != 'true' && github.event_name != 'pull_request'
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Normalize image name
run: |
raw="${{ github.repository_owner }}/${{ github.event.repository.name }}"
IMAGE_NAME=$(echo "$raw" | tr '[:upper:]' '[:lower:]')
echo "IMAGE_NAME=${IMAGE_NAME}" >> $GITHUB_ENV
- name: Determine image tag
id: tag
run: |
if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
echo "tag=latest" >> $GITHUB_OUTPUT
elif [[ "${{ github.ref }}" == "refs/heads/development" ]]; then
echo "tag=dev" >> $GITHUB_OUTPUT
elif [[ "${{ github.ref }}" == refs/tags/v* ]]; then
echo "tag=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
else
echo "tag=sha-$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT
fi
- name: Log in to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Pull Docker image
run: docker pull ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}
- name: Create Docker Network
run: docker network create charon-test-net
- name: Run Upstream Service (whoami)
run: |
docker run -d \
--name whoami \
--network charon-test-net \
traefik/whoami
- name: Run Charon Container
timeout-minutes: 3
run: |
docker run -d \
--name test-container \
--network charon-test-net \
-p 8080:8080 \
-p 80:80 \
${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}
# Wait for container to be healthy (max 3 minutes - Debian needs more startup time)
echo "Waiting for container to start..."
timeout 180s bash -c 'until docker exec test-container curl -sf http://localhost:8080/api/v1/health 2>/dev/null | grep -q "status"; do echo "Waiting..."; sleep 2; done' || {
echo "❌ Container failed to become healthy"
docker logs test-container
exit 1
}
echo "✅ Container is healthy"
- name: Run Integration Test
timeout-minutes: 5
run: ./scripts/integration-test.sh
- name: Check container logs
if: always()
run: docker logs test-container
- name: Stop container
if: always()
run: |
docker stop test-container whoami || true
docker rm test-container whoami || true
docker network rm charon-test-net || true
- name: Create test summary
if: always()
run: |
echo "## 🧪 Docker Image Test Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- **Image**: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}" >> $GITHUB_STEP_SUMMARY
echo "- **Integration Test**: ${{ job.status == 'success' && '✅ Passed' || '❌ Failed' }}" >> $GITHUB_STEP_SUMMARY

31
.github/workflows/docker-lint.yml vendored Normal file
View File

@@ -0,0 +1,31 @@
name: Docker Lint
on:
push:
branches: [ main, development, 'feature/**' ]
paths:
- 'Dockerfile'
pull_request:
branches: [ main, development ]
paths:
- 'Dockerfile'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
hadolint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Run Hadolint
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0
with:
dockerfile: Dockerfile
config: .hadolint.yaml
failure-threshold: error

379
.github/workflows/docs-to-issues.yml vendored Normal file
View File

@@ -0,0 +1,379 @@
name: Convert Docs to Issues
on:
push:
branches:
- main
- development
- feature/**
paths:
- 'docs/issues/**/*.md'
- '!docs/issues/created/**'
- '!docs/issues/_TEMPLATE.md'
- '!docs/issues/README.md'
# Allow manual trigger
workflow_dispatch:
inputs:
dry_run:
description: 'Dry run (no issues created)'
required: false
default: false
type: boolean
file_path:
description: 'Specific file to process (optional)'
required: false
type: string
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
env:
NODE_VERSION: '24.12.0'
permissions:
contents: write
issues: write
pull-requests: write
jobs:
convert-docs:
name: Convert Markdown to Issues
runs-on: ubuntu-latest
if: github.actor != 'github-actions[bot]'
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
fetch-depth: 2
- name: Set up Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install dependencies
run: npm install gray-matter
- name: Detect changed files
id: changes
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const fs = require('fs');
const path = require('path');
// Manual file specification
const manualFile = '${{ github.event.inputs.file_path }}';
if (manualFile) {
if (fs.existsSync(manualFile)) {
core.setOutput('files', JSON.stringify([manualFile]));
return;
} else {
core.setFailed(`File not found: ${manualFile}`);
return;
}
}
// Get changed files from commit
const { data: commit } = await github.rest.repos.getCommit({
owner: context.repo.owner,
repo: context.repo.repo,
ref: context.sha
});
const changedFiles = (commit.files || [])
.filter(f => f.filename.startsWith('docs/issues/'))
.filter(f => !f.filename.startsWith('docs/issues/created/'))
.filter(f => !f.filename.includes('_TEMPLATE'))
.filter(f => !f.filename.includes('README'))
.filter(f => f.filename.endsWith('.md'))
.filter(f => f.status !== 'removed')
.map(f => f.filename);
console.log('Changed issue files:', changedFiles);
core.setOutput('files', JSON.stringify(changedFiles));
- name: Process issue files
id: process
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
with:
script: |
const fs = require('fs');
const path = require('path');
const matter = require('gray-matter');
const files = JSON.parse('${{ steps.changes.outputs.files }}');
const isDryRun = process.env.DRY_RUN === 'true';
const createdIssues = [];
const errors = [];
if (files.length === 0) {
console.log('No issue files to process');
core.setOutput('created_count', 0);
core.setOutput('created_issues', '[]');
core.setOutput('errors', '[]');
return;
}
// Label color map
const labelColors = {
testing: 'BFD4F2',
feature: 'A2EEEF',
enhancement: '84B6EB',
bug: 'D73A4A',
documentation: '0075CA',
backend: '1D76DB',
frontend: '5EBEFF',
security: 'EE0701',
ui: '7057FF',
caddy: '1F6FEB',
'needs-triage': 'FBCA04',
acl: 'C5DEF5',
regression: 'D93F0B',
'manual-testing': 'BFD4F2',
'bulk-acl': '006B75',
'error-handling': 'D93F0B',
'ui-ux': '7057FF',
integration: '0E8A16',
performance: 'EDEDED',
'cross-browser': '5319E7',
plus: 'FFD700',
beta: '0052CC',
alpha: '5319E7',
high: 'D93F0B',
medium: 'FBCA04',
low: '0E8A16',
critical: 'B60205',
architecture: '006B75',
database: '006B75',
'post-beta': '006B75'
};
// Helper: Ensure label exists
async function ensureLabel(name) {
try {
await github.rest.issues.getLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: name
});
} catch (e) {
if (e.status === 404) {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: name,
color: labelColors[name.toLowerCase()] || '666666'
});
console.log(`Created label: ${name}`);
}
}
}
// Helper: Parse markdown file
function parseIssueFile(filePath) {
const content = fs.readFileSync(filePath, 'utf8');
const { data: frontmatter, content: body } = matter(content);
// Extract title: frontmatter > first H1 > filename
let title = frontmatter.title;
if (!title) {
const h1Match = body.match(/^#\s+(.+)$/m);
title = h1Match ? h1Match[1] : path.basename(filePath, '.md').replace(/-/g, ' ');
}
// Build labels array
const labels = [...(frontmatter.labels || [])];
if (frontmatter.priority) labels.push(frontmatter.priority);
if (frontmatter.type) labels.push(frontmatter.type);
return {
title,
body: body.trim(),
labels: [...new Set(labels)],
assignees: frontmatter.assignees || [],
milestone: frontmatter.milestone,
parent_issue: frontmatter.parent_issue,
create_sub_issues: frontmatter.create_sub_issues || false
};
}
// Helper: Extract sub-issues from H2 sections
function extractSubIssues(body, parentLabels) {
const sections = [];
const lines = body.split('\n');
let currentSection = null;
let currentBody = [];
for (const line of lines) {
const h2Match = line.match(/^##\s+(?:Sub-Issue\s*#?\d*:?\s*)?(.+)$/);
if (h2Match) {
if (currentSection) {
sections.push({
title: currentSection,
body: currentBody.join('\n').trim(),
labels: [...parentLabels]
});
}
currentSection = h2Match[1].trim();
currentBody = [];
} else if (currentSection) {
currentBody.push(line);
}
}
if (currentSection) {
sections.push({
title: currentSection,
body: currentBody.join('\n').trim(),
labels: [...parentLabels]
});
}
return sections;
}
// Process each file
for (const filePath of files) {
console.log(`\nProcessing: ${filePath}`);
try {
const parsed = parseIssueFile(filePath);
console.log(` Title: ${parsed.title}`);
console.log(` Labels: ${parsed.labels.join(', ')}`);
if (isDryRun) {
console.log(' [DRY RUN] Would create issue');
createdIssues.push({ file: filePath, title: parsed.title, dryRun: true });
continue;
}
// Ensure labels exist
for (const label of parsed.labels) {
await ensureLabel(label);
}
// Create the main issue
const issueBody = parsed.body +
`\n\n---\n*Auto-created from [${path.basename(filePath)}](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${context.sha}/${filePath})*`;
const issueResponse = await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: parsed.title,
body: issueBody,
labels: parsed.labels,
assignees: parsed.assignees
});
const issueNumber = issueResponse.data.number;
console.log(` Created issue #${issueNumber}`);
// Handle sub-issues
if (parsed.create_sub_issues) {
const subIssues = extractSubIssues(parsed.body, parsed.labels);
for (const sub of subIssues) {
for (const label of sub.labels) {
await ensureLabel(label);
}
const subResponse = await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `[${parsed.title}] ${sub.title}`,
body: sub.body + `\n\n---\n*Sub-issue of #${issueNumber}*`,
labels: sub.labels,
assignees: parsed.assignees
});
console.log(` Created sub-issue #${subResponse.data.number}: ${sub.title}`);
}
}
// Link to parent issue if specified
if (parsed.parent_issue) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: parsed.parent_issue,
body: `Sub-issue created: #${issueNumber}`
});
}
createdIssues.push({
file: filePath,
title: parsed.title,
issueNumber
});
} catch (error) {
console.error(` Error processing ${filePath}: ${error.message}`);
errors.push({ file: filePath, error: error.message });
}
}
core.setOutput('created_count', createdIssues.length);
core.setOutput('created_issues', JSON.stringify(createdIssues));
core.setOutput('errors', JSON.stringify(errors));
if (errors.length > 0) {
core.warning(`${errors.length} file(s) had errors`);
}
- name: Move processed files
if: steps.process.outputs.created_count != '0' && github.event.inputs.dry_run != 'true'
run: |
mkdir -p docs/issues/created
CREATED_ISSUES='${{ steps.process.outputs.created_issues }}'
echo "$CREATED_ISSUES" | jq -r '.[].file' | while read file; do
if [ -f "$file" ] && [ ! -z "$file" ]; then
filename=$(basename "$file")
timestamp=$(date +%Y%m%d)
mv "$file" "docs/issues/created/${timestamp}-${filename}"
echo "Moved: $file -> docs/issues/created/${timestamp}-${filename}"
fi
done
- name: Commit moved files
if: steps.process.outputs.created_count != '0' && github.event.inputs.dry_run != 'true'
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add docs/issues/
# Removed [skip ci] to allow CI checks to run on PRs
# Infinite loop protection: path filter excludes docs/issues/created/** AND github.actor guard prevents bot loops
git diff --staged --quiet || git commit -m "chore: move processed issue files to created/"
git push
- name: Summary
if: always()
run: |
echo "## Docs to Issues Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
CREATED='${{ steps.process.outputs.created_issues }}'
ERRORS='${{ steps.process.outputs.errors }}'
DRY_RUN='${{ github.event.inputs.dry_run }}'
if [ "$DRY_RUN" = "true" ]; then
echo "🔍 **Dry Run Mode** - No issues were actually created" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
fi
echo "### Created Issues" >> $GITHUB_STEP_SUMMARY
if [ -n "$CREATED" ] && [ "$CREATED" != "[]" ] && [ "$CREATED" != "null" ]; then
echo "$CREATED" | jq -r '.[] | "- \(.title) (#\(.issueNumber // "dry-run"))"' >> $GITHUB_STEP_SUMMARY || echo "_Parse error_" >> $GITHUB_STEP_SUMMARY
else
echo "_No issues created_" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Errors" >> $GITHUB_STEP_SUMMARY
if [ -n "$ERRORS" ] && [ "$ERRORS" != "[]" ] && [ "$ERRORS" != "null" ]; then
echo "$ERRORS" | jq -r '.[] | "- ❌ \(.file): \(.error)"' >> $GITHUB_STEP_SUMMARY || echo "_Parse error_" >> $GITHUB_STEP_SUMMARY
else
echo "_No errors_" >> $GITHUB_STEP_SUMMARY
fi

358
.github/workflows/docs.yml vendored Normal file
View File

@@ -0,0 +1,358 @@
name: Deploy Documentation to GitHub Pages
on:
push:
branches:
- main # Deploy docs when pushing to main
paths:
- 'docs/**' # Only run if docs folder changes
- 'README.md' # Or if README changes
- '.github/workflows/docs.yml' # Or if this workflow changes
workflow_dispatch: # Allow manual trigger
# Sets permissions to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# Allow only one concurrent deployment
concurrency:
group: "pages"
cancel-in-progress: false
env:
NODE_VERSION: '24.12.0'
jobs:
build:
name: Build Documentation
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
# Step 1: Get the code
- name: 📥 Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
# Step 2: Set up Node.js (for building any JS-based doc tools)
- name: 🔧 Set up Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with:
node-version: ${{ env.NODE_VERSION }}
# Step 3: Create a beautiful docs site structure
- name: 📝 Build documentation site
run: |
# Create output directory
mkdir -p _site
# Copy all markdown files
cp README.md _site/
cp -r docs _site/
# Create a simple HTML index that looks nice
cat > _site/index.html << 'EOF'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Charon - Documentation</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<style>
:root {
--primary: #1d4ed8;
--primary-hover: #1e40af;
}
body {
background-color: #0f172a;
color: #e2e8f0;
}
header {
background: linear-gradient(135deg, #1e3a8a 0%, #1d4ed8 100%);
padding: 3rem 0;
text-align: center;
margin-bottom: 2rem;
}
header h1 {
color: white;
font-size: 2.5rem;
margin-bottom: 0.5rem;
}
header p {
color: #e0e7ff;
font-size: 1.25rem;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.card {
background: #1e293b;
border: 1px solid #334155;
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.5rem;
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
}
.card h3 {
color: #60a5fa;
margin-top: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.card p {
color: #cbd5e1;
margin-bottom: 1rem;
}
.card a {
color: #60a5fa;
text-decoration: none;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.card a:hover {
color: #93c5fd;
}
.badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 600;
margin-left: 0.5rem;
}
.badge-beginner {
background: #10b981;
color: white;
}
.badge-advanced {
background: #f59e0b;
color: white;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
footer {
text-align: center;
padding: 3rem 0;
color: #64748b;
border-top: 1px solid #334155;
margin-top: 4rem;
}
</style>
</head>
<body>
<header>
<h1>🚀 Charon</h1>
<p>Make your websites easy to reach - No coding required!</p>
</header>
<div class="container">
<section>
<h2>👋 Welcome!</h2>
<p style="font-size: 1.1rem; color: #cbd5e1;">
This documentation will help you get started with Charon.
Whether you're a complete beginner or an experienced developer, we've got you covered!
</p>
</section>
<h2 style="margin-top: 3rem;">📚 Getting Started</h2>
<div class="grid">
<div class="card">
<h3>🏠 Getting Started Guide <span class="badge badge-beginner">Start Here</span></h3>
<p>Your first setup in just 5 minutes! We'll walk you through everything step by step.</p>
<a href="docs/getting-started.html">Read the Guide →</a>
</div>
<div class="card">
<h3>📖 README <span class="badge badge-beginner">Essential</span></h3>
<p>Learn what the app does, how to install it, and see examples of what you can build.</p>
<a href="README.html">Read More →</a>
</div>
<div class="card">
<h3>📥 Import Guide</h3>
<p>Already using Caddy? Learn how to bring your existing configuration into the app.</p>
<a href="docs/import-guide.html">Import Your Configs →</a>
</div>
</div>
<h2 style="margin-top: 3rem;">🔧 Developer Documentation</h2>
<div class="grid">
<div class="card">
<h3>🔌 API Reference <span class="badge badge-advanced">Advanced</span></h3>
<p>Complete REST API documentation with examples in JavaScript and Python.</p>
<a href="docs/api.html">View API Docs →</a>
</div>
<div class="card">
<h3>💾 Database Schema <span class="badge badge-advanced">Advanced</span></h3>
<p>Understand how data is stored, relationships, and backup strategies.</p>
<a href="docs/database-schema.html">View Schema →</a>
</div>
<div class="card">
<h3>✨ Contributing Guide</h3>
<p>Want to help make this better? Learn how to contribute code, docs, or ideas.</p>
<a href="CONTRIBUTING.html">Start Contributing →</a>
</div>
</div>
<h2 style="margin-top: 3rem;">📋 All Documentation</h2>
<div class="card">
<h3>📚 Documentation Index</h3>
<p>Browse all available documentation organized by topic and skill level.</p>
<a href="docs/index.html">View Full Index →</a>
</div>
<h2 style="margin-top: 3rem;">🆘 Need Help?</h2>
<div class="card" style="background: #1e3a8a; border-color: #1e40af;">
<h3 style="color: #dbeafe;">Get Support</h3>
<p style="color: #bfdbfe;">
Stuck? Have questions? We're here to help!
</p>
<div style="display: flex; gap: 1rem; flex-wrap: wrap; margin-top: 1rem;">
<a href="https://github.com/Wikid82/charon/discussions"
style="background: white; color: #1e40af; padding: 0.5rem 1rem; border-radius: 6px; text-decoration: none;">
💬 Ask a Question
</a>
<a href="https://github.com/Wikid82/charon/issues"
style="background: white; color: #1e40af; padding: 0.5rem 1rem; border-radius: 6px; text-decoration: none;">
🐛 Report a Bug
</a>
<a href="https://github.com/Wikid82/charon"
style="background: white; color: #1e40af; padding: 0.5rem 1rem; border-radius: 6px; text-decoration: none;">
⭐ View on GitHub
</a>
</div>
</div>
</div>
<footer>
<p>Built with ❤️ by <a href="https://github.com/Wikid82" style="color: #60a5fa;">@Wikid82</a></p>
<p>Made for humans, not just techies!</p>
</footer>
</body>
</html>
EOF
# Convert markdown files to HTML using a simple converter
npm install -g marked
# Convert each markdown file
for file in _site/docs/*.md; do
if [ -f "$file" ]; then
filename=$(basename "$file" .md)
marked "$file" -o "_site/docs/${filename}.html" --gfm
fi
done
# Convert README and CONTRIBUTING
marked _site/README.md -o _site/README.html --gfm
if [ -f "CONTRIBUTING.md" ]; then
cp CONTRIBUTING.md _site/
marked _site/CONTRIBUTING.md -o _site/CONTRIBUTING.html --gfm
fi
# Add simple styling to all HTML files
for html_file in _site/*.html _site/docs/*.html; do
if [ -f "$html_file" ] && [ "$html_file" != "_site/index.html" ]; then
# Add a header with navigation to each page
temp_file="${html_file}.tmp"
cat > "$temp_file" << 'HEADER'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Caddy Proxy Manager Plus - Documentation</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<style>
body { background-color: #0f172a; color: #e2e8f0; }
nav { background: #1e293b; padding: 1rem; margin-bottom: 2rem; }
nav a { color: #60a5fa; margin-right: 1rem; text-decoration: none; }
nav a:hover { color: #93c5fd; }
main { max-width: 900px; margin: 0 auto; padding: 2rem; }
a { color: #60a5fa; }
code { background: #1e293b; color: #fbbf24; padding: 0.2rem 0.4rem; border-radius: 4px; }
pre { background: #1e293b; padding: 1rem; border-radius: 8px; overflow-x: auto; }
pre code { background: none; padding: 0; }
</style>
</head>
<body>
<nav>
<a href="/charon/">🏠 Home</a>
<a href="/charon/docs/index.html">📚 Docs</a>
<a href="/charon/docs/getting-started.html">🚀 Get Started</a>
<a href="https://github.com/Wikid82/charon">⭐ GitHub</a>
</nav>
<main>
HEADER
# Append original content
cat "$html_file" >> "$temp_file"
# Add footer
cat >> "$temp_file" << 'FOOTER'
</main>
<footer style="text-align: center; padding: 2rem; color: #64748b;">
<p>Caddy Proxy Manager Plus - Built with ❤️ for the community</p>
</footer>
</body>
</html>
FOOTER
mv "$temp_file" "$html_file"
fi
done
echo "✅ Documentation site built successfully!"
# Step 4: Upload the built site
- name: 📤 Upload artifact
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4
with:
path: '_site'
deploy:
name: Deploy to GitHub Pages
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
timeout-minutes: 5
needs: build
steps:
# Deploy to GitHub Pages
- name: 🚀 Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
# Create a summary
- name: 📋 Create deployment summary
run: |
echo "## 🎉 Documentation Deployed!" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Your documentation is now live at:" >> $GITHUB_STEP_SUMMARY
echo "🔗 ${{ steps.deployment.outputs.page_url }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 📚 What's Included" >> $GITHUB_STEP_SUMMARY
echo "- Getting Started Guide" >> $GITHUB_STEP_SUMMARY
echo "- Complete README" >> $GITHUB_STEP_SUMMARY
echo "- API Documentation" >> $GITHUB_STEP_SUMMARY
echo "- Database Schema" >> $GITHUB_STEP_SUMMARY
echo "- Import Guide" >> $GITHUB_STEP_SUMMARY
echo "- Contributing Guidelines" >> $GITHUB_STEP_SUMMARY

View File

@@ -0,0 +1,38 @@
name: History Rewrite Dry-Run
on:
pull_request:
types: [opened, synchronize, reopened]
schedule:
- cron: '0 2 * * *' # daily at 02:00 UTC
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
preview-history:
name: Dry-run preview for history rewrite
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
fetch-depth: 0
- name: Debug git info
run: |
git --version
git rev-parse --is-shallow-repository || true
git status --porcelain
- name: Make CI script executable
run: chmod +x scripts/ci/dry_run_history_rewrite.sh
- name: Run dry-run history check
run: |
scripts/ci/dry_run_history_rewrite.sh --paths 'backend/codeql-db,codeql-db,codeql-db-js,codeql-db-go' --strip-size 50

563
.github/workflows/e2e-tests.yml vendored Normal file
View File

@@ -0,0 +1,563 @@
# E2E Tests Workflow
# Runs Playwright E2E tests with sharding for faster execution
# and collects frontend code coverage via @bgotink/playwright-coverage
#
# Coverage Architecture:
# - Backend: Docker container at localhost:8080 (API)
# - Frontend: Vite dev server at localhost:3000 (serves source files)
# - Tests hit Vite, which proxies API calls to Docker
# - V8 coverage maps directly to source files for accurate reporting
#
# Triggers:
# - Pull requests to main/develop (with path filters)
# - Push to main branch
# - Manual dispatch with browser selection
#
# Jobs:
# 1. build: Build Docker image and upload as artifact
# 2. e2e-tests: Run tests in parallel shards with coverage
# 3. merge-reports: Combine HTML reports from all shards
# 4. comment-results: Post test results as PR comment
# 5. upload-coverage: Merge and upload E2E coverage to Codecov
# 6. e2e-results: Status check to block merge on failure
name: E2E Tests
on:
pull_request:
branches:
- main
- development
- 'feature/**'
paths:
- 'frontend/**'
- 'backend/**'
- 'tests/**'
- 'playwright.config.js'
- '.github/workflows/e2e-tests.yml'
push:
branches:
- main
- development
- 'feature/**'
paths:
- 'frontend/**'
- 'backend/**'
- 'tests/**'
- 'playwright.config.js'
workflow_dispatch:
inputs:
browser:
description: 'Browser to test'
required: false
default: 'chromium'
type: choice
options:
- chromium
- firefox
- webkit
- all
env:
NODE_VERSION: '20'
GO_VERSION: '1.25.6'
GOTOOLCHAIN: auto
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository_owner }}/charon
concurrency:
group: e2e-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
# Build application once, share across test shards
build:
name: Build Application
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Set up Go
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- name: Set up Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Cache npm dependencies
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5
with:
path: ~/.npm
key: npm-${{ hashFiles('package-lock.json') }}
restore-keys: npm-
- name: Install dependencies
run: npm ci
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Build Docker image
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
with:
context: .
file: ./Dockerfile
push: false
load: true
tags: charon:e2e-test
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Save Docker image
run: docker save charon:e2e-test -o charon-e2e-image.tar
- name: Upload Docker image artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: docker-image
path: charon-e2e-image.tar
retention-days: 1
# Run tests in parallel shards
e2e-tests:
name: E2E Tests (Shard ${{ matrix.shard }}/${{ matrix.total-shards }})
runs-on: ubuntu-latest
needs: build
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
total-shards: [4]
browser: [chromium]
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Set up Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Download Docker image
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: docker-image
- name: Load Docker image
run: |
docker load -i charon-e2e-image.tar
docker images | grep charon
- name: Generate ephemeral encryption key
run: |
# Generate a unique, ephemeral encryption key for this CI run
# Key is 32 bytes, base64-encoded as required by CHARON_ENCRYPTION_KEY
echo "CHARON_ENCRYPTION_KEY=$(openssl rand -base64 32)" >> $GITHUB_ENV
echo "✅ Generated ephemeral encryption key for E2E tests"
- name: Start test environment
run: |
# Use the committed docker-compose.playwright.yml for E2E testing
# Note: Using pre-built image loaded from artifact - no rebuild needed
docker compose -f .docker/compose/docker-compose.playwright.yml up -d
echo "✅ Container started via docker-compose.playwright.yml"
- name: Wait for service health
run: |
echo "⏳ Waiting for Charon to be healthy..."
MAX_ATTEMPTS=30
ATTEMPT=0
while [[ ${ATTEMPT} -lt ${MAX_ATTEMPTS} ]]; do
ATTEMPT=$((ATTEMPT + 1))
echo "Attempt ${ATTEMPT}/${MAX_ATTEMPTS}..."
if curl -sf http://localhost:8080/api/v1/health > /dev/null 2>&1; then
echo "✅ Charon is healthy!"
curl -s http://localhost:8080/api/v1/health | jq .
exit 0
fi
sleep 2
done
echo "❌ Health check failed"
docker compose -f .docker/compose/docker-compose.playwright.yml logs
exit 1
- name: Install dependencies
run: npm ci
- name: Cache Playwright browsers
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5
with:
path: ~/.cache/ms-playwright
key: playwright-${{ matrix.browser }}-${{ hashFiles('package-lock.json') }}
restore-keys: playwright-${{ matrix.browser }}-
- name: Install Playwright browsers
run: npx playwright install --with-deps ${{ matrix.browser }}
- name: Install Frontend Dependencies
run: |
echo "📦 Installing frontend dependencies..."
cd frontend
npm ci
- name: Start Vite dev server for coverage
run: |
echo "🚀 Starting Vite dev server for E2E coverage..."
cd frontend
# Use port 5173 (Vite default) with --strictPort to fail if busy
VITE_PORT=5173
npx vite --port ${VITE_PORT} --strictPort > /tmp/vite.log 2>&1 &
VITE_PID=$!
echo "VITE_PID=${VITE_PID}" >> $GITHUB_ENV
echo "VITE_PORT=${VITE_PORT}" >> $GITHUB_ENV
# Wait for Vite to be ready
echo "⏳ Waiting for Vite to start on port ${VITE_PORT}..."
MAX_WAIT=60
WAITED=0
while [[ ${WAITED} -lt ${MAX_WAIT} ]]; do
if curl -sf http://localhost:${VITE_PORT} > /dev/null 2>&1; then
echo "✅ Vite dev server ready at http://localhost:${VITE_PORT}"
exit 0
fi
sleep 1
WAITED=$((WAITED + 1))
done
echo "❌ Vite failed to start"
cat /tmp/vite.log
exit 1
- name: Run E2E tests (Shard ${{ matrix.shard }}/${{ matrix.total-shards }})
run: |
npx playwright test \
--project=${{ matrix.browser }} \
--shard=${{ matrix.shard }}/${{ matrix.total-shards }} \
--reporter=html,json,github
env:
# Use Vite dev server for coverage (proxies API to Docker at 8080)
PLAYWRIGHT_BASE_URL: http://localhost:${{ env.VITE_PORT }}
CI: true
TEST_WORKER_INDEX: ${{ matrix.shard }}
- name: Stop Vite dev server
if: always()
run: |
if [[ -n "${VITE_PID}" ]]; then
kill ${VITE_PID} 2>/dev/null || true
fi
- name: Upload test results
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: test-results-${{ matrix.browser }}-shard-${{ matrix.shard }}
path: |
playwright-report/
test-results/
retention-days: 7
- name: Upload E2E coverage artifact
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: e2e-coverage-shard-${{ matrix.shard }}
path: coverage/e2e/
retention-days: 7
- name: Upload test traces on failure
if: failure()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: traces-${{ matrix.browser }}-shard-${{ matrix.shard }}
path: test-results/**/*.zip
retention-days: 7
- name: Collect Docker logs on failure
if: failure()
run: |
echo "📋 Container logs:"
docker compose -f .docker/compose/docker-compose.playwright.yml logs > docker-logs-shard-${{ matrix.shard }}.txt 2>&1
- name: Upload Docker logs on failure
if: failure()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: docker-logs-shard-${{ matrix.shard }}
path: docker-logs-shard-${{ matrix.shard }}.txt
retention-days: 7
- name: Cleanup
if: always()
run: |
docker compose -f .docker/compose/docker-compose.playwright.yml down -v 2>/dev/null || true
# Merge reports from all shards
merge-reports:
name: Merge Test Reports
runs-on: ubuntu-latest
needs: e2e-tests
if: always()
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Set up Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Download all test results
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
pattern: test-results-*
path: all-results
merge-multiple: false
- name: Merge Playwright HTML reports
run: |
# Create directory for merged results
mkdir -p merged-results
# Find and copy all blob reports
find all-results -name "*.zip" -exec cp {} merged-results/ \; 2>/dev/null || true
# Merge reports if blobs exist
if ls merged-results/*.zip 1> /dev/null 2>&1; then
npx playwright merge-reports --reporter html merged-results
else
echo "No blob reports found, copying individual reports"
cp -r all-results/test-results-chromium-shard-1/playwright-report playwright-report 2>/dev/null || mkdir -p playwright-report
fi
- name: Upload merged report
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: merged-playwright-report
path: playwright-report/
retention-days: 30
- name: Generate job summary
run: |
echo "## E2E Test Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Count results from all shards
TOTAL=0
PASSED=0
FAILED=0
for dir in all-results/test-results-*/; do
if [[ -f "${dir}test-results/.last-run.json" ]]; then
SHARD_STATS=$(cat "${dir}test-results/.last-run.json" 2>/dev/null || echo '{}')
# Parse stats if available
fi
done
echo "| Shard | Status |" >> $GITHUB_STEP_SUMMARY
echo "|-------|--------|" >> $GITHUB_STEP_SUMMARY
for i in 1 2 3 4; do
if [[ -d "all-results/test-results-chromium-shard-${i}" ]]; then
echo "| Shard ${i} | ✅ Complete |" >> $GITHUB_STEP_SUMMARY
else
echo "| Shard ${i} | ❌ Failed |" >> $GITHUB_STEP_SUMMARY
fi
done
echo "" >> $GITHUB_STEP_SUMMARY
echo "[View full Playwright report](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})" >> $GITHUB_STEP_SUMMARY
# Comment on PR with results
comment-results:
name: Comment Test Results
runs-on: ubuntu-latest
needs: [e2e-tests, merge-reports]
if: github.event_name == 'pull_request' && always()
permissions:
pull-requests: write
steps:
- name: Download merged report
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: merged-playwright-report
path: playwright-report
continue-on-error: true
- name: Determine test status
id: status
run: |
if [[ "${{ needs.e2e-tests.result }}" == "success" ]]; then
echo "emoji=✅" >> $GITHUB_OUTPUT
echo "status=PASSED" >> $GITHUB_OUTPUT
echo "message=All E2E tests passed!" >> $GITHUB_OUTPUT
elif [[ "${{ needs.e2e-tests.result }}" == "failure" ]]; then
echo "emoji=❌" >> $GITHUB_OUTPUT
echo "status=FAILED" >> $GITHUB_OUTPUT
echo "message=Some E2E tests failed. Please review the failures." >> $GITHUB_OUTPUT
else
echo "emoji=⚠️" >> $GITHUB_OUTPUT
echo "status=UNKNOWN" >> $GITHUB_OUTPUT
echo "message=E2E tests did not complete successfully." >> $GITHUB_OUTPUT
fi
- name: Comment on PR
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const emoji = '${{ steps.status.outputs.emoji }}';
const status = '${{ steps.status.outputs.status }}';
const message = '${{ steps.status.outputs.message }}';
const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const body = `## ${emoji} E2E Test Results: ${status}
${message}
| Metric | Result |
|--------|--------|
| Browser | Chromium |
| Shards | 4 |
| Status | ${status} |
[📊 View full Playwright report](${runUrl})
---
<sub>🤖 This comment was automatically generated by the E2E Tests workflow.</sub>`;
// Find existing comment
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const botComment = comments.find(comment =>
comment.user.type === 'Bot' &&
comment.body.includes('E2E Test Results')
);
if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: body
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: body
});
}
# Upload merged E2E coverage to Codecov
upload-coverage:
name: Upload E2E Coverage
runs-on: ubuntu-latest
needs: e2e-tests
if: always() && needs.e2e-tests.result == 'success'
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Set up Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Download all coverage artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
pattern: e2e-coverage-*
path: all-coverage
merge-multiple: false
- name: Merge LCOV coverage files
run: |
# Install lcov for merging
sudo apt-get update && sudo apt-get install -y lcov
# Create merged coverage directory
mkdir -p coverage/e2e-merged
# Find all lcov.info files and merge them
LCOV_FILES=$(find all-coverage -name "lcov.info" -type f)
if [[ -n "$LCOV_FILES" ]]; then
# Build merge command
MERGE_ARGS=""
for file in $LCOV_FILES; do
MERGE_ARGS="$MERGE_ARGS -a $file"
done
lcov $MERGE_ARGS -o coverage/e2e-merged/lcov.info
echo "✅ Merged $(echo "$LCOV_FILES" | wc -w) coverage files"
else
echo "⚠️ No coverage files found to merge"
exit 0
fi
- name: Upload E2E coverage to Codecov
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage/e2e-merged/lcov.info
flags: e2e
name: e2e-coverage
fail_ci_if_error: false
- name: Upload merged coverage artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: e2e-coverage-merged
path: coverage/e2e-merged/
retention-days: 30
# Final status check - blocks merge if tests fail
e2e-results:
name: E2E Test Results
runs-on: ubuntu-latest
needs: e2e-tests
if: always()
steps:
- name: Check test results
run: |
if [[ "${{ needs.e2e-tests.result }}" == "success" ]]; then
echo "✅ All E2E tests passed"
exit 0
elif [[ "${{ needs.e2e-tests.result }}" == "skipped" ]]; then
echo "⏭️ E2E tests were skipped"
exit 0
else
echo "❌ E2E tests failed or were cancelled"
echo "Result: ${{ needs.e2e-tests.result }}"
exit 1
fi

View File

@@ -0,0 +1,36 @@
name: History Rewrite Tests
on:
push:
paths:
- 'scripts/history-rewrite/**'
- '.github/workflows/history-rewrite-tests.yml'
pull_request:
paths:
- 'scripts/history-rewrite/**'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout with full history
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
fetch-depth: 0
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y bats shellcheck
- name: Run Bats tests
run: |
bats ./scripts/history-rewrite/tests || exit 1
- name: ShellCheck scripts
run: |
shellcheck scripts/history-rewrite/*.sh || true

328
.github/workflows/nightly-build.yml vendored Normal file
View File

@@ -0,0 +1,328 @@
name: Nightly Build & Package
on:
schedule:
# Daily at 09:00 UTC (4am EST / 5am EDT)
- cron: '0 9 * * *'
workflow_dispatch:
inputs:
reason:
description: "Why are you running this manually?"
required: true
default: "manual trigger"
skip_tests:
description: "Skip test-nightly-image job?"
required: false
default: "false"
env:
GO_VERSION: '1.25.6'
NODE_VERSION: '24.12.0'
GOTOOLCHAIN: auto
GHCR_REGISTRY: ghcr.io
DOCKERHUB_REGISTRY: docker.io
IMAGE_NAME: wikid82/charon
jobs:
sync-development-to-nightly:
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
has_changes: ${{ steps.sync.outputs.has_changes }}
steps:
- name: Checkout nightly branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: nightly
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Configure Git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Sync development to nightly
id: sync
run: |
# Fetch development branch
git fetch origin development
# Check if there are differences
if git diff --quiet nightly origin/development; then
echo "No changes to sync from development to nightly"
echo "has_changes=false" >> $GITHUB_OUTPUT
else
echo "Syncing changes from development to nightly"
# Fast-forward merge development into nightly
git merge origin/development --ff-only -m "chore: sync from development branch [skip ci]" || {
# If fast-forward fails, force reset to development
echo "Fast-forward not possible, resetting nightly to development"
git reset --hard origin/development
}
git push origin nightly
echo "has_changes=true" >> $GITHUB_OUTPUT
fi
build-and-push-nightly:
needs: sync-development-to-nightly
runs-on: ubuntu-latest
env:
HAS_DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN != '' }}
permissions:
contents: read
packages: write
id-token: write
outputs:
version: ${{ steps.meta.outputs.version }}
tags: ${{ steps.meta.outputs.tags }}
digest: ${{ steps.build.outputs.digest }}
steps:
- name: Checkout nightly branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: nightly
fetch-depth: 0
- name: Set lowercase image name
run: echo "IMAGE_NAME_LC=${IMAGE_NAME,,}" >> $GITHUB_ENV
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Log in to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
if: env.HAS_DOCKERHUB_TOKEN == 'true'
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: |
${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=nightly
type=raw,value=nightly-{{date 'YYYY-MM-DD'}}
type=sha,prefix=nightly-,format=short
labels: |
org.opencontainers.image.title=Charon Nightly
org.opencontainers.image.description=Nightly build of Charon
- name: Build and push Docker image
id: build
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=nightly-${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: true
sbom: true
- name: Generate SBOM
uses: anchore/sbom-action@62ad5284b8ced813296287a0b63906cb364b73ee # v0.22.0
with:
image: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:nightly
format: cyclonedx-json
output-file: sbom-nightly.json
- name: Upload SBOM artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: sbom-nightly
path: sbom-nightly.json
retention-days: 30
# Install Cosign for keyless signing
- name: Install Cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
# Sign GHCR image with keyless signing (Sigstore/Fulcio)
- name: Sign GHCR Image
run: |
echo "Signing GHCR nightly image with keyless signing..."
cosign sign --yes ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
echo "✅ GHCR nightly image signed successfully"
# Sign Docker Hub image with keyless signing (Sigstore/Fulcio)
- name: Sign Docker Hub Image
if: env.HAS_DOCKERHUB_TOKEN == 'true'
run: |
echo "Signing Docker Hub nightly image with keyless signing..."
cosign sign --yes ${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
echo "✅ Docker Hub nightly image signed successfully"
# Attach SBOM to Docker Hub image
- name: Attach SBOM to Docker Hub
if: env.HAS_DOCKERHUB_TOKEN == 'true'
run: |
echo "Attaching SBOM to Docker Hub nightly image..."
cosign attach sbom --sbom sbom-nightly.json ${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
echo "✅ SBOM attached to Docker Hub nightly image"
test-nightly-image:
needs: build-and-push-nightly
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
steps:
- name: Checkout nightly branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: nightly
- name: Set lowercase image name
run: echo "IMAGE_NAME_LC=${IMAGE_NAME,,}" >> $GITHUB_ENV
- name: Log in to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Pull nightly image
run: docker pull ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:nightly
- name: Run container smoke test
run: |
docker run --name charon-nightly -d \
-p 8080:8080 \
${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:nightly
# Wait for container to start
sleep 10
# Check container is running
docker ps | grep charon-nightly
# Basic health check
curl -f http://localhost:8080/health || exit 1
# Cleanup
docker stop charon-nightly
docker rm charon-nightly
build-nightly-release:
needs: test-nightly-image
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout nightly branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: nightly
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
with:
go-version: '1.25.6'
- name: Set up Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: '24.13.0'
- name: Set up Zig (for cross-compilation)
uses: goto-bus-stop/setup-zig@abea47f85e598557f500fa1fd2ab7464fcb39406 # v2.2.1
with:
version: 0.11.0
- name: Build frontend
working-directory: ./frontend
run: |
npm ci
npm run build
- name: Run GoReleaser (snapshot mode)
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0
with:
distribution: goreleaser
version: '~> v2'
args: release --snapshot --skip=publish --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload nightly binaries
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: nightly-binaries
path: dist/*
retention-days: 30
verify-nightly-supply-chain:
needs: build-and-push-nightly
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
security-events: write
steps:
- name: Checkout nightly branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: nightly
- name: Set lowercase image name
run: echo "IMAGE_NAME_LC=${IMAGE_NAME,,}" >> $GITHUB_ENV
- name: Download SBOM
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: sbom-nightly
- name: Scan with Grype
uses: anchore/scan-action@0d444ed77d83ee2ba7f5ced0d90d640a1281d762 # v7.3.0
with:
sbom: sbom-nightly.json
fail-build: false
severity-cutoff: high
- name: Scan with Trivy
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1
with:
image-ref: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:nightly
format: 'sarif'
output: 'trivy-nightly.sarif'
- name: Upload Trivy results
uses: github/codeql-action/upload-sarif@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4.31.11
with:
sarif_file: 'trivy-nightly.sarif'
category: 'trivy-nightly'
- name: Check for critical CVEs
run: |
if grep -q "CRITICAL" trivy-nightly.sarif; then
echo "❌ Critical vulnerabilities found in nightly build"
exit 1
fi
echo "✅ No critical vulnerabilities found"

261
.github/workflows/playwright.yml vendored Normal file
View File

@@ -0,0 +1,261 @@
# Playwright E2E Tests
# Runs Playwright tests against PR Docker images after the build workflow completes
name: Playwright E2E Tests
on:
workflow_run:
workflows: ["Docker Build, Publish & Test"]
types:
- completed
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to test (optional)'
required: false
type: string
concurrency:
group: playwright-${{ github.event.workflow_run.head_branch || github.ref }}
cancel-in-progress: true
jobs:
playwright:
name: E2E Tests
runs-on: ubuntu-latest
timeout-minutes: 20
# Run for: manual dispatch, PR builds, or any push builds from docker-build
if: >-
github.event_name == 'workflow_dispatch' ||
((github.event.workflow_run.event == 'pull_request' || github.event.workflow_run.event == 'push') &&
github.event.workflow_run.conclusion == 'success')
env:
CHARON_ENV: development
CHARON_DEBUG: "1"
CHARON_ENCRYPTION_KEY: ${{ secrets.CHARON_CI_ENCRYPTION_KEY }}
steps:
- name: Checkout repository
# actions/checkout v4.2.2
uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98
- name: Extract PR number from workflow_run
id: pr-info
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
# Manual dispatch - use input or fail gracefully
if [[ -n "${{ inputs.pr_number }}" ]]; then
echo "pr_number=${{ inputs.pr_number }}" >> "$GITHUB_OUTPUT"
echo "✅ Using manually provided PR number: ${{ inputs.pr_number }}"
else
echo "⚠️ No PR number provided for manual dispatch"
echo "pr_number=" >> "$GITHUB_OUTPUT"
fi
exit 0
fi
# Extract PR number from workflow_run context
HEAD_SHA="${{ github.event.workflow_run.head_sha }}"
echo "🔍 Looking for PR with head SHA: ${HEAD_SHA}"
# Query GitHub API for PR associated with this commit
PR_NUMBER=$(gh api \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"/repos/${{ github.repository }}/commits/${HEAD_SHA}/pulls" \
--jq '.[0].number // empty' 2>/dev/null || echo "")
if [[ -n "${PR_NUMBER}" ]]; then
echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT"
echo "✅ Found PR number: ${PR_NUMBER}"
else
echo "⚠️ Could not find PR number for SHA: ${HEAD_SHA}"
echo "pr_number=" >> "$GITHUB_OUTPUT"
fi
# Check if this is a push event (not a PR)
if [[ "${{ github.event.workflow_run.event }}" == "push" ]]; then
echo "is_push=true" >> "$GITHUB_OUTPUT"
echo "✅ Detected push build from branch: ${{ github.event.workflow_run.head_branch }}"
else
echo "is_push=false" >> "$GITHUB_OUTPUT"
fi
- name: Sanitize branch name
id: sanitize
run: |
# Sanitize branch name for use in Docker tags and artifact names
# Replace / with - to avoid invalid reference format errors
BRANCH="${{ github.event.workflow_run.head_branch || github.head_ref || github.ref_name }}"
SANITIZED=$(echo "$BRANCH" | tr '/' '-')
echo "branch=${SANITIZED}" >> "$GITHUB_OUTPUT"
echo "📋 Sanitized branch name: ${BRANCH} -> ${SANITIZED}"
- name: Check for PR image artifact
id: check-artifact
if: steps.pr-info.outputs.pr_number != '' || steps.pr-info.outputs.is_push == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Determine artifact name based on event type
if [[ "${{ steps.pr-info.outputs.is_push }}" == "true" ]]; then
ARTIFACT_NAME="push-image"
else
PR_NUMBER="${{ steps.pr-info.outputs.pr_number }}"
ARTIFACT_NAME="pr-image-${PR_NUMBER}"
fi
RUN_ID="${{ github.event.workflow_run.id }}"
echo "🔍 Checking for artifact: ${ARTIFACT_NAME}"
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
# For manual dispatch, find the most recent workflow run with this artifact
RUN_ID=$(gh api \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"/repos/${{ github.repository }}/actions/workflows/docker-build.yml/runs?status=success&per_page=10" \
--jq '.workflow_runs[0].id // empty' 2>/dev/null || echo "")
if [[ -z "${RUN_ID}" ]]; then
echo "⚠️ No successful workflow runs found"
echo "artifact_exists=false" >> "$GITHUB_OUTPUT"
exit 0
fi
fi
echo "run_id=${RUN_ID}" >> "$GITHUB_OUTPUT"
# Check if the artifact exists in the workflow run
ARTIFACT_ID=$(gh api \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"/repos/${{ github.repository }}/actions/runs/${RUN_ID}/artifacts" \
--jq ".artifacts[] | select(.name == \"${ARTIFACT_NAME}\") | .id" 2>/dev/null || echo "")
if [[ -n "${ARTIFACT_ID}" ]]; then
echo "artifact_exists=true" >> "$GITHUB_OUTPUT"
echo "artifact_id=${ARTIFACT_ID}" >> "$GITHUB_OUTPUT"
echo "✅ Found artifact: ${ARTIFACT_NAME} (ID: ${ARTIFACT_ID})"
else
echo "artifact_exists=false" >> "$GITHUB_OUTPUT"
echo "⚠️ Artifact not found: ${ARTIFACT_NAME}"
echo " This is expected for non-PR builds or if the image was not uploaded"
fi
- name: Skip if no artifact
if: (steps.pr-info.outputs.pr_number == '' && steps.pr-info.outputs.is_push != 'true') || steps.check-artifact.outputs.artifact_exists != 'true'
run: |
echo " Skipping Playwright tests - no PR image artifact available"
echo "This is expected for:"
echo " - Pushes to main/release branches"
echo " - PRs where Docker build failed"
echo " - Manual dispatch without PR number"
exit 0
- name: Download PR image artifact
if: steps.check-artifact.outputs.artifact_exists == 'true'
# actions/download-artifact v4.1.8
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
with:
name: ${{ steps.pr-info.outputs.is_push == 'true' && 'push-image' || format('pr-image-{0}', steps.pr-info.outputs.pr_number) }}
run-id: ${{ steps.check-artifact.outputs.run_id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Load Docker image
if: steps.check-artifact.outputs.artifact_exists == 'true'
run: |
echo "📦 Loading Docker image..."
docker load < charon-pr-image.tar
echo "✅ Docker image loaded"
docker images | grep charon
- name: Start Charon container
if: steps.check-artifact.outputs.artifact_exists == 'true'
run: |
echo "🚀 Starting Charon container..."
# Normalize image name (GitHub lowercases repository owner names in GHCR)
IMAGE_NAME=$(echo "${{ github.repository_owner }}/charon" | tr '[:upper:]' '[:lower:]')
if [[ "${{ steps.pr-info.outputs.is_push }}" == "true" ]]; then
# Use sanitized branch name for Docker tag (/ is invalid in tags)
IMAGE_REF="ghcr.io/${IMAGE_NAME}:${{ steps.sanitize.outputs.branch }}"
else
IMAGE_REF="ghcr.io/${IMAGE_NAME}:pr-${{ steps.pr-info.outputs.pr_number }}"
fi
echo "📦 Starting container with image: ${IMAGE_REF}"
docker run -d \
--name charon-test \
-p 8080:8080 \
-e CHARON_ENV="${CHARON_ENV}" \
-e CHARON_DEBUG="${CHARON_DEBUG}" \
-e CHARON_ENCRYPTION_KEY="${CHARON_ENCRYPTION_KEY}" \
"${IMAGE_REF}"
echo "✅ Container started"
- name: Wait for health endpoint
if: steps.check-artifact.outputs.artifact_exists == 'true'
run: |
echo "⏳ Waiting for Charon to be healthy..."
MAX_ATTEMPTS=30
ATTEMPT=0
while [[ ${ATTEMPT} -lt ${MAX_ATTEMPTS} ]]; do
ATTEMPT=$((ATTEMPT + 1))
echo "Attempt ${ATTEMPT}/${MAX_ATTEMPTS}..."
if curl -sf http://localhost:8080/api/v1/health > /dev/null 2>&1; then
echo "✅ Charon is healthy!"
exit 0
fi
sleep 2
done
echo "❌ Health check failed after ${MAX_ATTEMPTS} attempts"
echo "📋 Container logs:"
docker logs charon-test
exit 1
- name: Setup Node.js
if: steps.check-artifact.outputs.artifact_exists == 'true'
# actions/setup-node v4.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238
with:
node-version: 'lts/*'
cache: 'npm'
- name: Install dependencies
if: steps.check-artifact.outputs.artifact_exists == 'true'
run: npm ci
- name: Install Playwright browsers
if: steps.check-artifact.outputs.artifact_exists == 'true'
run: npx playwright install --with-deps chromium
- name: Run Playwright tests
if: steps.check-artifact.outputs.artifact_exists == 'true'
env:
PLAYWRIGHT_BASE_URL: http://localhost:8080
run: npx playwright test --project=chromium
- name: Upload Playwright report
if: always() && steps.check-artifact.outputs.artifact_exists == 'true'
# actions/upload-artifact v4.4.3
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
with:
name: ${{ steps.pr-info.outputs.is_push == 'true' && format('playwright-report-{0}', steps.sanitize.outputs.branch) || format('playwright-report-pr-{0}', steps.pr-info.outputs.pr_number) }}
path: playwright-report/
retention-days: 14
- name: Cleanup
if: always() && steps.check-artifact.outputs.artifact_exists == 'true'
run: |
echo "🧹 Cleaning up..."
docker stop charon-test 2>/dev/null || true
docker rm charon-test 2>/dev/null || true
echo "✅ Cleanup complete"

58
.github/workflows/pr-checklist.yml vendored Normal file
View File

@@ -0,0 +1,58 @@
name: PR Checklist Validation (History Rewrite)
on:
pull_request:
types: [opened, edited, synchronize]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
validate:
name: Validate history-rewrite checklist (conditional)
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Validate PR checklist (only for history-rewrite changes)
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const prNumber = context.issue.number;
const pr = await github.rest.pulls.get({owner, repo, pull_number: prNumber});
const body = (pr.data && pr.data.body) || '';
// Determine if this PR modifies history-rewrite related files
// Exclude the template file itself - it shouldn't trigger its own validation
const filesResp = await github.rest.pulls.listFiles({ owner, repo, pull_number: prNumber });
const files = filesResp.data.map(f => f.filename.toLowerCase());
const relevant = files.some(fn => {
// Skip the PR template itself
if (fn === '.github/pull_request_template/history-rewrite.md') return false;
// Check for actual history-rewrite implementation files
return fn.startsWith('scripts/history-rewrite/') || fn === 'docs/plans/history_rewrite.md';
});
if (!relevant) {
core.info('No history-rewrite related files changed; skipping checklist validation.');
return;
}
// Use a set of named checks with robust regex patterns for checkbox and phrase variants
const checks = [
{ name: 'preview_removals.sh mention', pattern: /preview_removals\.sh/i },
{ name: 'data/backups mention', pattern: /data\/?backups/i },
// Accept checked checkbox variants and inline code/backtick usage for the '--force' phrase
{ name: 'explicit non-run of --force', pattern: /(?:\[\s*[xX]\s*\]\s*)?(?:i will not run|will not run|do not run|don'?t run|won'?t run)\b[^\n]*--force/i },
];
const missing = checks.filter(c => !c.pattern.test(body)).map(c => c.name);
if (missing.length > 0) {
// Post a comment to the PR with instructions for filling the checklist
const commentBody = `Hi! This PR touches history-rewrite artifacts and requires the checklist in .github/PULL_REQUEST_TEMPLATE/history-rewrite.md. The following items are missing in your PR body: ${missing.join(', ')}\n\nPlease update the PR description using the history-rewrite template and re-run checks.`;
await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body: commentBody });
core.setFailed('Missing required checklist items: ' + missing.join(', '));
}

168
.github/workflows/propagate-changes.yml vendored Normal file
View File

@@ -0,0 +1,168 @@
name: Propagate Changes Between Branches
on:
push:
branches:
- main
- development
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
env:
NODE_VERSION: '24.12.0'
permissions:
contents: write
pull-requests: write
issues: write
jobs:
propagate:
name: Create PR to synchronize branches
runs-on: ubuntu-latest
if: github.actor != 'github-actions[bot]' && github.event.pusher != null
steps:
- name: Set up Node (for github-script)
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with:
node-version: ${{ env.NODE_VERSION }}
- name: Propagate Changes
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const currentBranch = context.ref.replace('refs/heads/', '');
async function createPR(src, base) {
if (src === base) return;
core.info(`Checking propagation from ${src} to ${base}...`);
// Check for existing open PRs
const { data: pulls } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: `${context.repo.owner}:${src}`,
base: base,
});
if (pulls.length > 0) {
core.info(`Existing PR found for ${src} -> ${base}. Skipping.`);
return;
}
// Compare commits to see if src is ahead of base
try {
const compare = await github.rest.repos.compareCommits({
owner: context.repo.owner,
repo: context.repo.repo,
base: base,
head: src,
});
// If src is not ahead, nothing to merge
if (compare.data.ahead_by === 0) {
core.info(`${src} is not ahead of ${base}. No propagation needed.`);
return;
}
// If files changed include history-rewrite or other sensitive scripts,
// avoid automatic propagation. This prevents bypassing checklist validation
// and manual review for potentially destructive changes.
let files = (compare.data.files || []).map(f => (f.filename || '').toLowerCase());
// Fallback: if compare.files is empty/truncated, aggregate files from the commit list
if (files.length === 0 && Array.isArray(compare.data.commits) && compare.data.commits.length > 0) {
for (const commit of compare.data.commits) {
const commitData = await github.rest.repos.getCommit({ owner: context.repo.owner, repo: context.repo.repo, ref: commit.sha });
for (const f of (commitData.data.files || [])) {
files.push((f.filename || '').toLowerCase());
}
}
files = Array.from(new Set(files));
}
// Load propagation config (list of sensitive paths) from .github/propagate-config.yml when available
let configPaths = ['scripts/history-rewrite/', 'data/backups', 'docs/plans/history_rewrite.md', '.github/workflows/'];
try {
const configResp = await github.rest.repos.getContent({ owner: context.repo.owner, repo: context.repo.repo, path: '.github/propagate-config.yml', ref: src });
const contentStr = Buffer.from(configResp.data.content, 'base64').toString('utf8');
const lines = contentStr.split(/\r?\n/);
let inSensitive = false;
const parsedPaths = [];
for (const line of lines) {
const trimmed = line.trim();
if (!inSensitive && trimmed.startsWith('sensitive_paths:')) { inSensitive = true; continue; }
if (inSensitive) {
if (trimmed.startsWith('-')) parsedPaths.push(trimmed.substring(1).trim());
else if (trimmed.length === 0) continue; else break;
}
}
if (parsedPaths.length > 0) configPaths = parsedPaths.map(p => p.toLowerCase());
} catch (err) { core.info('No .github/propagate-config.yml or parse failure; using defaults.'); }
const sensitive = files.some(fn => configPaths.some(sp => fn.startsWith(sp) || fn.includes(sp)));
if (sensitive) {
core.info(`${src} -> ${base} contains sensitive changes (${files.join(', ')}). Skipping automatic propagation.`);
return;
}
} catch (error) {
// If base branch doesn't exist, etc.
core.warning(`Error comparing ${src} to ${base}: ${error.message}`);
return;
}
// Create PR
try {
const pr = await github.rest.pulls.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `Propagate changes from ${src} into ${base}`,
head: src,
base: base,
body: `Automated PR to propagate changes from ${src} into ${base}.\n\nTriggered by push to ${currentBranch}.`,
draft: true,
});
core.info(`Created PR #${pr.data.number} to merge ${src} into ${base}`);
// Add an 'auto-propagate' label to the created PR and create the label if missing
try {
try {
await github.rest.issues.getLabel({ owner: context.repo.owner, repo: context.repo.repo, name: 'auto-propagate' });
} catch (e) {
await github.rest.issues.createLabel({ owner: context.repo.owner, repo: context.repo.repo, name: 'auto-propagate', color: '7dd3fc', description: 'Automatically created propagate PRs' });
}
await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: pr.data.number, labels: ['auto-propagate'] });
} catch (labelErr) {
core.warning('Failed to ensure or add auto-propagate label: ' + labelErr.message);
}
} catch (error) {
core.warning(`Failed to create PR from ${src} to ${base}: ${error.message}`);
}
}
if (currentBranch === 'main') {
// Main -> Development
await createPR('main', 'development');
} else if (currentBranch === 'development') {
// Development -> Feature branches (direct, no nightly intermediary)
const branches = await github.paginate(github.rest.repos.listBranches, {
owner: context.repo.owner,
repo: context.repo.repo,
});
const featureBranches = branches
.map(b => b.name)
.filter(name => name.startsWith('feature/'));
core.info(`Found ${featureBranches.length} feature branches: ${featureBranches.join(', ')}`);
for (const featureBranch of featureBranches) {
await createPR('development', featureBranch);
}
}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CHARON_TOKEN: ${{ secrets.CHARON_TOKEN }}

183
.github/workflows/quality-checks.yml vendored Normal file
View File

@@ -0,0 +1,183 @@
name: Quality Checks
on:
push:
branches: [ main, development, 'feature/**' ]
pull_request:
branches: [ main, development ]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
checks: write
env:
GO_VERSION: '1.25.6'
NODE_VERSION: '24.12.0'
GOTOOLCHAIN: auto
jobs:
backend-quality:
name: Backend (Go)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Set up Go
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
with:
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: backend/go.sum
- name: Repo health check
run: |
bash scripts/repo_health_check.sh
- name: Run Go tests
id: go-tests
working-directory: ${{ github.workspace }}
env:
CGO_ENABLED: 1
run: |
bash scripts/go-test-coverage.sh 2>&1 | tee backend/test-output.txt
exit ${PIPESTATUS[0]}
- name: Go Test Summary
if: always()
working-directory: backend
run: |
echo "## 🔧 Backend Test Results" >> $GITHUB_STEP_SUMMARY
if [ "${{ steps.go-tests.outcome }}" == "success" ]; then
echo "✅ **All tests passed**" >> $GITHUB_STEP_SUMMARY
PASS_COUNT=$(grep -c "^--- PASS" test-output.txt || echo "0")
echo "- Tests passed: $PASS_COUNT" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **Tests failed**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Failed Tests:" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
grep -E "^--- FAIL|FAIL\s+github" test-output.txt || echo "See logs for details"
grep -E "^--- FAIL|FAIL\s+github" test-output.txt >> $GITHUB_STEP_SUMMARY || echo "See logs for details" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
fi
# Codecov upload moved to `codecov-upload.yml` which is push-only.
- name: Run golangci-lint
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
with:
version: latest
working-directory: backend
args: --timeout=5m
continue-on-error: true
- name: Run Perf Asserts
working-directory: backend
env:
# Conservative defaults to avoid flakiness on CI; tune as necessary
PERF_MAX_MS_GETSTATUS_P95: 500ms
PERF_MAX_MS_GETSTATUS_P95_PARALLEL: 1500ms
PERF_MAX_MS_LISTDECISIONS_P95: 2000ms
run: |
echo "## 🔍 Running performance assertions (TestPerf)" >> $GITHUB_STEP_SUMMARY
go test -run TestPerf -v ./internal/api/handlers -count=1 | tee perf-output.txt
exit ${PIPESTATUS[0]}
frontend-quality:
name: Frontend (React)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
fetch-depth: 0
- name: Repo health check
run: |
bash scripts/repo_health_check.sh
- name: Set up Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Check if frontend was modified in PR
id: check-frontend
run: |
if [ "${{ github.event_name }}" = "push" ]; then
echo "frontend_changed=true" >> $GITHUB_OUTPUT
exit 0
fi
# Try to fetch the PR base ref. This may fail for forked PRs or other cases.
git fetch origin ${{ github.event.pull_request.base.ref }} --depth=1 || true
# Compute changed files against the PR base ref, fallback to origin/main, then fallback to last 10 commits
CHANGED=$(git diff --name-only origin/${{ github.event.pull_request.base.ref }}...HEAD 2>/dev/null || echo "")
echo "Changed files (base ref):\n$CHANGED"
if [ -z "$CHANGED" ]; then
echo "Base ref diff empty or failed; fetching origin/main for fallback..."
git fetch origin main --depth=1 || true
CHANGED=$(git diff --name-only origin/main...HEAD 2>/dev/null || echo "")
echo "Changed files (main fallback):\n$CHANGED"
fi
if [ -z "$CHANGED" ]; then
echo "Still empty; falling back to diffing last 10 commits from HEAD..."
CHANGED=$(git diff --name-only HEAD~10...HEAD 2>/dev/null || echo "")
echo "Changed files (HEAD~10 fallback):\n$CHANGED"
fi
if echo "$CHANGED" | grep -q '^frontend/'; then
echo "frontend_changed=true" >> $GITHUB_OUTPUT
else
echo "frontend_changed=false" >> $GITHUB_OUTPUT
fi
- name: Install dependencies
working-directory: frontend
if: ${{ github.event_name == 'push' || steps.check-frontend.outputs.frontend_changed == 'true' }}
run: npm ci
- name: Run frontend tests and coverage
id: frontend-tests
working-directory: ${{ github.workspace }}
if: ${{ github.event_name == 'push' || steps.check-frontend.outputs.frontend_changed == 'true' }}
run: |
bash scripts/frontend-test-coverage.sh 2>&1 | tee frontend/test-output.txt
exit ${PIPESTATUS[0]}
- name: Frontend Test Summary
if: always()
working-directory: frontend
run: |
echo "## ⚛️ Frontend Test Results" >> $GITHUB_STEP_SUMMARY
if [ "${{ steps.frontend-tests.outcome }}" == "success" ]; then
echo "✅ **All tests passed**" >> $GITHUB_STEP_SUMMARY
# Extract test counts from vitest output
if grep -q "Tests:" test-output.txt; then
grep "Tests:" test-output.txt | tail -1 >> $GITHUB_STEP_SUMMARY
fi
else
echo "❌ **Tests failed**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Failed Tests:" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
# Extract failed test info from vitest output
grep -E "FAIL|✕|×|AssertionError|Error:" test-output.txt | head -30 >> $GITHUB_STEP_SUMMARY || echo "See logs for details" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
fi
# Codecov upload moved to `codecov-upload.yml` which is push-only.
- name: Run frontend lint
working-directory: frontend
run: npm run lint
continue-on-error: true

View File

@@ -0,0 +1,125 @@
name: Rate Limit Integration Tests
on:
push:
branches: [ main, development, 'feature/**' ]
paths:
- 'backend/internal/caddy/**'
- 'backend/internal/security/**'
- 'backend/internal/handlers/security*.go'
- 'backend/internal/models/security*.go'
- 'scripts/rate_limit_integration.sh'
- 'Dockerfile'
- '.github/workflows/rate-limit-integration.yml'
pull_request:
branches: [ main, development ]
paths:
- 'backend/internal/caddy/**'
- 'backend/internal/security/**'
- 'backend/internal/handlers/security*.go'
- 'backend/internal/models/security*.go'
- 'scripts/rate_limit_integration.sh'
- 'Dockerfile'
- '.github/workflows/rate-limit-integration.yml'
# Allow manual trigger
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
rate-limit-integration:
name: Rate Limiting Integration
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Build Docker image
run: |
docker build \
--no-cache \
--build-arg VCS_REF=${{ github.sha }} \
-t charon:local .
- name: Run rate limit integration tests
id: ratelimit-test
run: |
chmod +x scripts/rate_limit_integration.sh
scripts/rate_limit_integration.sh 2>&1 | tee ratelimit-test-output.txt
exit ${PIPESTATUS[0]}
- name: Dump Debug Info on Failure
if: failure()
run: |
echo "## 🔍 Debug Information" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Container Status" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
docker ps -a --filter "name=charon" --filter "name=ratelimit" --filter "name=backend" >> $GITHUB_STEP_SUMMARY 2>&1 || true
echo '```' >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Security Config API" >> $GITHUB_STEP_SUMMARY
echo '```json' >> $GITHUB_STEP_SUMMARY
curl -s http://localhost:8280/api/v1/security/config 2>/dev/null | head -100 >> $GITHUB_STEP_SUMMARY || echo "Could not retrieve security config" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Security Status API" >> $GITHUB_STEP_SUMMARY
echo '```json' >> $GITHUB_STEP_SUMMARY
curl -s http://localhost:8280/api/v1/security/status 2>/dev/null | head -100 >> $GITHUB_STEP_SUMMARY || echo "Could not retrieve security status" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Caddy Admin Config (rate_limit handlers)" >> $GITHUB_STEP_SUMMARY
echo '```json' >> $GITHUB_STEP_SUMMARY
curl -s http://localhost:2119/config 2>/dev/null | grep -A 20 '"handler":"rate_limit"' | head -30 >> $GITHUB_STEP_SUMMARY || echo "Could not retrieve Caddy config" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Charon Container Logs (last 100 lines)" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
docker logs charon-ratelimit-test 2>&1 | tail -100 >> $GITHUB_STEP_SUMMARY || echo "No container logs available" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
- name: Rate Limit Integration Summary
if: always()
run: |
echo "## ⏱️ Rate Limit Integration Test Results" >> $GITHUB_STEP_SUMMARY
if [ "${{ steps.ratelimit-test.outcome }}" == "success" ]; then
echo "✅ **All rate limit tests passed**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Test Results:" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
grep -E "✓|=== ALL|HTTP 429|HTTP 200" ratelimit-test-output.txt | head -30 || echo "See logs for details"
grep -E "✓|=== ALL|HTTP 429|HTTP 200" ratelimit-test-output.txt | head -30 >> $GITHUB_STEP_SUMMARY || echo "See logs for details" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Verified Behaviors:" >> $GITHUB_STEP_SUMMARY
echo "- Requests within limit return HTTP 200" >> $GITHUB_STEP_SUMMARY
echo "- Requests exceeding limit return HTTP 429" >> $GITHUB_STEP_SUMMARY
echo "- Retry-After header present on blocked responses" >> $GITHUB_STEP_SUMMARY
echo "- Rate limit window resets correctly" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **Rate limit tests failed**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Failure Details:" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
grep -E "✗|FAIL|Error|failed|expected" ratelimit-test-output.txt | head -30 >> $GITHUB_STEP_SUMMARY || echo "See logs for details" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
fi
- name: Cleanup
if: always()
run: |
docker rm -f charon-ratelimit-test || true
docker rm -f ratelimit-backend || true
docker volume rm charon_ratelimit_data caddy_ratelimit_data caddy_ratelimit_config 2>/dev/null || true
docker network rm containers_default || true

View File

@@ -0,0 +1,71 @@
name: Release (GoReleaser)
on:
push:
tags:
- 'v*'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
env:
GO_VERSION: '1.25.6'
NODE_VERSION: '24.12.0'
GOTOOLCHAIN: auto
permissions:
contents: write
packages: write
jobs:
goreleaser:
runs-on: ubuntu-latest
env:
# Use the built-in GITHUB_TOKEN by default for GitHub API operations.
# If you need to provide a PAT with elevated permissions, add a GITHUB_TOKEN secret
# at the repo or organization level and update the env here accordingly.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version: ${{ env.GO_VERSION }}
- name: Set up Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with:
node-version: ${{ env.NODE_VERSION }}
- name: Build Frontend
working-directory: frontend
run: |
# Inject version into frontend build from tag (if present)
VERSION=${GITHUB_REF#refs/tags/}
echo "VITE_APP_VERSION=${VERSION}" >> $GITHUB_ENV
npm ci
npm run build
- name: Install Cross-Compilation Tools (Zig)
# Security: Pinned to full SHA for supply chain security
uses: goto-bus-stop/setup-zig@abea47f85e598557f500fa1fd2ab7464fcb39406 # v2
with:
version: 0.13.0
# GITHUB_TOKEN is set from GITHUB_TOKEN or CHARON_TOKEN (fallback), defaulting to GITHUB_TOKEN
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
with:
distribution: goreleaser
version: '~> v2.5'
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# CGO settings are handled in .goreleaser.yaml via Zig

33
.github/workflows/renovate.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
name: Renovate
on:
schedule:
- cron: '0 5 * * *' # daily 05:00 UTC
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
permissions:
contents: write
pull-requests: write
issues: write
jobs:
renovate:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
fetch-depth: 1
- name: Run Renovate
uses: renovatebot/github-action@eaf12548c13069dcc28bb75c4ee4610cdbe400c5 # v44.2.6
with:
configurationFile: .github/renovate.json
token: ${{ secrets.RENOVATE_TOKEN || secrets.GITHUB_TOKEN }}
env:
LOG_LEVEL: debug

103
.github/workflows/renovate_prune.yml vendored Normal file
View File

@@ -0,0 +1,103 @@
name: "Prune Renovate Branches"
on:
workflow_dispatch:
schedule:
- cron: '0 3 * * *' # daily at 03:00 UTC
pull_request:
types: [closed] # also run when any PR is closed (makes pruning near-real-time)
permissions:
contents: write # required to delete branch refs
pull-requests: read
jobs:
prune:
runs-on: ubuntu-latest
concurrency:
group: prune-renovate-branches
cancel-in-progress: true
env:
BRANCH_PREFIX: "renovate/" # adjust if you use a different prefix
steps:
- name: Choose GitHub Token
run: |
if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then
echo "Using GITHUB_TOKEN" >&2
echo "GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> $GITHUB_ENV
else
echo "Using CHARON_TOKEN fallback" >&2
echo "GITHUB_TOKEN=${{ secrets.CHARON_TOKEN }}" >> $GITHUB_ENV
fi
- name: Prune renovate branches
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
github-token: ${{ env.GITHUB_TOKEN }}
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const branchPrefix = (process.env.BRANCH_PREFIX || 'renovate/').replace(/^refs\/heads\//, '');
const refPrefix = `heads/${branchPrefix}`; // e.g. "heads/renovate/"
core.info(`Searching for refs with prefix: ${refPrefix}`);
// List matching refs (branches) under the prefix
let refs;
try {
refs = await github.rest.git.listMatchingRefs({
owner,
repo,
ref: refPrefix
});
} catch (err) {
core.info(`No matching refs or API error: ${err.message}`);
refs = { data: [] };
}
for (const r of refs.data) {
const fullRef = r.ref; // "refs/heads/renovate/..."
const branchName = fullRef.replace('refs/heads/', '');
core.info(`Evaluating branch: ${branchName}`);
// Find PRs for this branch (head = "owner:branch")
const prs = await github.rest.pulls.list({
owner,
repo,
head: `${owner}:${branchName}`,
state: 'all',
per_page: 100
});
let shouldDelete = false;
if (!prs.data || prs.data.length === 0) {
core.info(`No PRs found for ${branchName} — marking for deletion.`);
shouldDelete = true;
} else {
// If none of the PRs are open, safe to delete
const hasOpen = prs.data.some(p => p.state === 'open');
if (!hasOpen) {
core.info(`All PRs for ${branchName} are closed — marking for deletion.`);
shouldDelete = true;
} else {
core.info(`Open PR(s) exist for ${branchName} — skipping deletion.`);
}
}
if (shouldDelete) {
try {
await github.rest.git.deleteRef({
owner,
repo,
ref: `heads/${branchName}`
});
core.info(`Deleted branch: ${branchName}`);
} catch (delErr) {
core.warning(`Failed to delete ${branchName}: ${delErr.message}`);
}
}
}
- name: Done
run: echo "Prune run completed."

43
.github/workflows/repo-health.yml vendored Normal file
View File

@@ -0,0 +1,43 @@
name: Repo Health Check
on:
schedule:
- cron: '0 0 * * *'
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch: {}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
repo_health:
name: Repo health
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
fetch-depth: 0
lfs: true
- name: Set up Git
run: |
git --version
git lfs install --local || true
- name: Run repo health check
env:
MAX_MB: 100
LFS_ALLOW_MB: 50
run: |
bash scripts/repo_health_check.sh
- name: Upload health output
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: repo-health-output
path: |
/tmp/repo_big_files.txt

270
.github/workflows/security-pr.yml vendored Normal file
View File

@@ -0,0 +1,270 @@
# Security Scan for Pull Requests
# Runs Trivy security scanning on PR Docker images after the build workflow completes
# This workflow extracts the charon binary from the container and performs filesystem scanning
name: Security Scan (PR)
on:
workflow_run:
workflows: ["Docker Build, Publish & Test"]
types:
- completed
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to scan (optional)'
required: false
type: string
concurrency:
group: security-pr-${{ github.event.workflow_run.head_branch || github.ref }}
cancel-in-progress: true
jobs:
security-scan:
name: Trivy Binary Scan
runs-on: ubuntu-latest
timeout-minutes: 10
# Run for: manual dispatch, PR builds, or any push builds from docker-build
if: >-
github.event_name == 'workflow_dispatch' ||
((github.event.workflow_run.event == 'pull_request' || github.event.workflow_run.event == 'push') &&
github.event.workflow_run.conclusion == 'success')
permissions:
contents: read
pull-requests: write
security-events: write
actions: read
steps:
- name: Checkout repository
# actions/checkout v4.2.2
uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98
- name: Extract PR number from workflow_run
id: pr-info
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
# Manual dispatch - use input or fail gracefully
if [[ -n "${{ inputs.pr_number }}" ]]; then
echo "pr_number=${{ inputs.pr_number }}" >> "$GITHUB_OUTPUT"
echo "✅ Using manually provided PR number: ${{ inputs.pr_number }}"
else
echo "⚠️ No PR number provided for manual dispatch"
echo "pr_number=" >> "$GITHUB_OUTPUT"
fi
exit 0
fi
# Extract PR number from workflow_run context
HEAD_SHA="${{ github.event.workflow_run.head_sha }}"
echo "🔍 Looking for PR with head SHA: ${HEAD_SHA}"
# Query GitHub API for PR associated with this commit
PR_NUMBER=$(gh api \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"/repos/${{ github.repository }}/commits/${HEAD_SHA}/pulls" \
--jq '.[0].number // empty' 2>/dev/null || echo "")
if [[ -n "${PR_NUMBER}" ]]; then
echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT"
echo "✅ Found PR number: ${PR_NUMBER}"
else
echo "⚠️ Could not find PR number for SHA: ${HEAD_SHA}"
echo "pr_number=" >> "$GITHUB_OUTPUT"
fi
# Check if this is a push event (not a PR)
if [[ "${{ github.event.workflow_run.event }}" == "push" ]]; then
echo "is_push=true" >> "$GITHUB_OUTPUT"
echo "✅ Detected push build from branch: ${{ github.event.workflow_run.head_branch }}"
else
echo "is_push=false" >> "$GITHUB_OUTPUT"
fi
- name: Check for PR image artifact
id: check-artifact
if: steps.pr-info.outputs.pr_number != '' || steps.pr-info.outputs.is_push == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Determine artifact name based on event type
if [[ "${{ steps.pr-info.outputs.is_push }}" == "true" ]]; then
ARTIFACT_NAME="push-image"
else
PR_NUMBER="${{ steps.pr-info.outputs.pr_number }}"
ARTIFACT_NAME="pr-image-${PR_NUMBER}"
fi
RUN_ID="${{ github.event.workflow_run.id }}"
echo "🔍 Checking for artifact: ${ARTIFACT_NAME}"
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
# For manual dispatch, find the most recent workflow run with this artifact
RUN_ID=$(gh api \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"/repos/${{ github.repository }}/actions/workflows/docker-build.yml/runs?status=success&per_page=10" \
--jq '.workflow_runs[0].id // empty' 2>/dev/null || echo "")
if [[ -z "${RUN_ID}" ]]; then
echo "⚠️ No successful workflow runs found"
echo "artifact_exists=false" >> "$GITHUB_OUTPUT"
exit 0
fi
fi
echo "run_id=${RUN_ID}" >> "$GITHUB_OUTPUT"
# Check if the artifact exists in the workflow run
ARTIFACT_ID=$(gh api \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"/repos/${{ github.repository }}/actions/runs/${RUN_ID}/artifacts" \
--jq ".artifacts[] | select(.name == \"${ARTIFACT_NAME}\") | .id" 2>/dev/null || echo "")
if [[ -n "${ARTIFACT_ID}" ]]; then
echo "artifact_exists=true" >> "$GITHUB_OUTPUT"
echo "artifact_id=${ARTIFACT_ID}" >> "$GITHUB_OUTPUT"
echo "✅ Found artifact: ${ARTIFACT_NAME} (ID: ${ARTIFACT_ID})"
else
echo "artifact_exists=false" >> "$GITHUB_OUTPUT"
echo "⚠️ Artifact not found: ${ARTIFACT_NAME}"
echo " This is expected for non-PR builds or if the image was not uploaded"
fi
- name: Skip if no artifact
if: (steps.pr-info.outputs.pr_number == '' && steps.pr-info.outputs.is_push != 'true') || steps.check-artifact.outputs.artifact_exists != 'true'
run: |
echo " Skipping security scan - no PR image artifact available"
echo "This is expected for:"
echo " - Pushes to main/release branches"
echo " - PRs where Docker build failed"
echo " - Manual dispatch without PR number"
exit 0
- name: Download PR image artifact
if: steps.check-artifact.outputs.artifact_exists == 'true'
# actions/download-artifact v4.1.8
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
with:
name: ${{ steps.pr-info.outputs.is_push == 'true' && 'push-image' || format('pr-image-{0}', steps.pr-info.outputs.pr_number) }}
run-id: ${{ steps.check-artifact.outputs.run_id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Load Docker image
if: steps.check-artifact.outputs.artifact_exists == 'true'
run: |
echo "📦 Loading Docker image..."
docker load < charon-pr-image.tar
echo "✅ Docker image loaded"
docker images | grep charon
- name: Extract charon binary from container
if: steps.check-artifact.outputs.artifact_exists == 'true'
id: extract
run: |
# Normalize image name for reference
IMAGE_NAME=$(echo "${{ github.repository_owner }}/charon" | tr '[:upper:]' '[:lower:]')
if [[ "${{ steps.pr-info.outputs.is_push }}" == "true" ]]; then
IMAGE_REF="ghcr.io/${IMAGE_NAME}:${{ github.event.workflow_run.head_branch }}"
else
IMAGE_REF="ghcr.io/${IMAGE_NAME}:pr-${{ steps.pr-info.outputs.pr_number }}"
fi
echo "🔍 Extracting binary from: ${IMAGE_REF}"
# Create container without starting it
CONTAINER_ID=$(docker create "${IMAGE_REF}")
echo "container_id=${CONTAINER_ID}" >> "$GITHUB_OUTPUT"
# Extract the charon binary
mkdir -p ./scan-target
docker cp "${CONTAINER_ID}:/app/charon" ./scan-target/charon
# Cleanup container
docker rm "${CONTAINER_ID}"
# Verify extraction
if [[ -f "./scan-target/charon" ]]; then
echo "✅ Binary extracted successfully"
ls -lh ./scan-target/charon
echo "binary_path=./scan-target" >> "$GITHUB_OUTPUT"
else
echo "❌ Failed to extract binary"
exit 1
fi
- name: Run Trivy filesystem scan (SARIF output)
if: steps.check-artifact.outputs.artifact_exists == 'true'
# aquasecurity/trivy-action v0.33.1
uses: aquasecurity/trivy-action@22438a435773de8c97dc0958cc0b823c45b064ac
with:
scan-type: 'fs'
scan-ref: ${{ steps.extract.outputs.binary_path }}
format: 'sarif'
output: 'trivy-binary-results.sarif'
severity: 'CRITICAL,HIGH,MEDIUM'
continue-on-error: true
- name: Upload Trivy SARIF to GitHub Security
if: steps.check-artifact.outputs.artifact_exists == 'true'
# github/codeql-action v4
uses: github/codeql-action/upload-sarif@55252c7a3a47fea1e0fdd923b269f4be8a5ad9a0
with:
sarif_file: 'trivy-binary-results.sarif'
category: ${{ steps.pr-info.outputs.is_push == 'true' && format('security-scan-{0}', github.event.workflow_run.head_branch) || format('security-scan-pr-{0}', steps.pr-info.outputs.pr_number) }}
continue-on-error: true
- name: Run Trivy filesystem scan (fail on CRITICAL/HIGH)
if: steps.check-artifact.outputs.artifact_exists == 'true'
# aquasecurity/trivy-action v0.33.1
uses: aquasecurity/trivy-action@22438a435773de8c97dc0958cc0b823c45b064ac
with:
scan-type: 'fs'
scan-ref: ${{ steps.extract.outputs.binary_path }}
format: 'table'
severity: 'CRITICAL,HIGH'
exit-code: '1'
- name: Upload scan artifacts
if: always() && steps.check-artifact.outputs.artifact_exists == 'true'
# actions/upload-artifact v4.4.3
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
with:
name: ${{ steps.pr-info.outputs.is_push == 'true' && format('security-scan-{0}', github.event.workflow_run.head_branch) || format('security-scan-pr-{0}', steps.pr-info.outputs.pr_number) }}
path: |
trivy-binary-results.sarif
retention-days: 14
- name: Create job summary
if: always() && steps.check-artifact.outputs.artifact_exists == 'true'
run: |
if [[ "${{ steps.pr-info.outputs.is_push }}" == "true" ]]; then
echo "## 🔒 Security Scan Results - Branch: ${{ github.event.workflow_run.head_branch }}" >> $GITHUB_STEP_SUMMARY
else
echo "## 🔒 Security Scan Results - PR #${{ steps.pr-info.outputs.pr_number }}" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Scan Type**: Trivy Filesystem Scan" >> $GITHUB_STEP_SUMMARY
echo "**Target**: \`/app/charon\` binary" >> $GITHUB_STEP_SUMMARY
echo "**Severity Filter**: CRITICAL, HIGH" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [[ "${{ job.status }}" == "success" ]]; then
echo "✅ **PASSED**: No CRITICAL or HIGH vulnerabilities found" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **FAILED**: CRITICAL or HIGH vulnerabilities detected" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Please review the Trivy scan output and address the vulnerabilities." >> $GITHUB_STEP_SUMMARY
fi
- name: Cleanup
if: always() && steps.check-artifact.outputs.artifact_exists == 'true'
run: |
echo "🧹 Cleaning up..."
rm -rf ./scan-target
echo "✅ Cleanup complete"

View File

@@ -0,0 +1,156 @@
name: Weekly Security Rebuild
# Note: This workflow filename has remained consistent. The related docker-publish.yml
# was replaced by docker-build.yml in commit f640524b (Dec 21, 2025).
# GitHub Advanced Security may show warnings about the old filename until its tracking updates.
on:
schedule:
- cron: '0 2 * * 0' # Sundays at 02:00 UTC
workflow_dispatch:
inputs:
force_rebuild:
description: 'Force rebuild without cache'
required: false
type: boolean
default: true
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository_owner }}/charon
jobs:
security-rebuild:
name: Security Rebuild & Scan
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: read
packages: write
security-events: write
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Normalize image name
run: |
echo "IMAGE_NAME=$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Resolve Debian base image digest
id: base-image
run: |
docker pull debian:trixie-slim
DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' debian:trixie-slim)
echo "digest=$DIGEST" >> $GITHUB_OUTPUT
echo "Base image digest: $DIGEST"
- name: Log in to Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=security-scan-{{date 'YYYYMMDD'}}
- name: Build Docker image (NO CACHE)
id: build
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
with:
context: .
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
no-cache: ${{ github.event_name == 'schedule' || inputs.force_rebuild }}
pull: true # Always pull fresh base images to get latest security patches
build-args: |
VERSION=security-scan
BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }}
VCS_REF=${{ github.sha }}
BASE_IMAGE=${{ steps.base-image.outputs.digest }}
- name: Run Trivy vulnerability scanner (CRITICAL+HIGH)
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
format: 'table'
severity: 'CRITICAL,HIGH'
exit-code: '1' # Fail workflow if vulnerabilities found
continue-on-error: true
- name: Run Trivy vulnerability scanner (SARIF)
id: trivy-sarif
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
format: 'sarif'
output: 'trivy-weekly-results.sarif'
severity: 'CRITICAL,HIGH,MEDIUM'
- name: Upload Trivy results to GitHub Security
uses: github/codeql-action/upload-sarif@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4.31.11
with:
sarif_file: 'trivy-weekly-results.sarif'
- name: Run Trivy vulnerability scanner (JSON for artifact)
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
format: 'json'
output: 'trivy-weekly-results.json'
severity: 'CRITICAL,HIGH,MEDIUM,LOW'
- name: Upload Trivy JSON results
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: trivy-weekly-scan-${{ github.run_number }}
path: trivy-weekly-results.json
retention-days: 90
- name: Check Debian package versions
run: |
echo "## 📦 Installed Package Versions" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Checking key security packages:" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
docker run --rm --entrypoint "" ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }} \
sh -c "dpkg -l | grep -E 'libc-ares|curl|libcurl|openssl|libssl' || echo 'No matching packages found'" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
- name: Create security scan summary
if: always()
run: |
echo "## 🔒 Weekly Security Rebuild Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- **Build Date:** $(date -u +"%Y-%m-%d %H:%M:%S UTC")" >> $GITHUB_STEP_SUMMARY
echo "- **Image:** ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}" >> $GITHUB_STEP_SUMMARY
echo "- **Cache Used:** No (forced fresh build)" >> $GITHUB_STEP_SUMMARY
echo "- **Trivy Scan:** Completed (see Security tab for details)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Next Steps:" >> $GITHUB_STEP_SUMMARY
echo "1. Review Security tab for new vulnerabilities" >> $GITHUB_STEP_SUMMARY
echo "2. Check Trivy JSON artifact for detailed package info" >> $GITHUB_STEP_SUMMARY
echo "3. If critical CVEs found, trigger production rebuild" >> $GITHUB_STEP_SUMMARY
- name: Notify on security issues (optional)
if: failure()
run: |
echo "::warning::Weekly security scan found HIGH or CRITICAL vulnerabilities. Review the Security tab."

404
.github/workflows/supply-chain-pr.yml vendored Normal file
View File

@@ -0,0 +1,404 @@
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
---
name: Supply Chain Verification (PR)
on:
workflow_run:
workflows: ["Docker Build, Publish & Test"]
types:
- completed
workflow_dispatch:
inputs:
pr_number:
description: "PR number to verify (optional, will auto-detect from workflow_run)"
required: false
type: string
concurrency:
group: supply-chain-pr-${{ github.event.workflow_run.head_branch || github.ref }}
cancel-in-progress: true
env:
SYFT_VERSION: v1.17.0
GRYPE_VERSION: v0.85.0
permissions:
contents: read
pull-requests: write
security-events: write
actions: read
jobs:
verify-supply-chain:
name: Verify Supply Chain
runs-on: ubuntu-latest
timeout-minutes: 15
# Run for: manual dispatch, PR builds, or any push builds from docker-build
if: >
github.event_name == 'workflow_dispatch' ||
((github.event.workflow_run.event == 'pull_request' || github.event.workflow_run.event == 'push') &&
github.event.workflow_run.conclusion == 'success')
steps:
- name: Checkout repository
# actions/checkout v4.2.2
uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98
with:
sparse-checkout: |
.github
sparse-checkout-cone-mode: false
- name: Extract PR number from workflow_run
id: pr-number
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [[ -n "${{ inputs.pr_number }}" ]]; then
echo "pr_number=${{ inputs.pr_number }}" >> "$GITHUB_OUTPUT"
echo "📋 Using manually provided PR number: ${{ inputs.pr_number }}"
exit 0
fi
if [[ "${{ github.event_name }}" != "workflow_run" ]]; then
echo "❌ No PR number provided and not triggered by workflow_run"
echo "pr_number=" >> "$GITHUB_OUTPUT"
exit 0
fi
# Extract PR number from workflow_run context
HEAD_SHA="${{ github.event.workflow_run.head_sha }}"
HEAD_BRANCH="${{ github.event.workflow_run.head_branch }}"
echo "🔍 Looking for PR with head SHA: ${HEAD_SHA}"
echo "🔍 Head branch: ${HEAD_BRANCH}"
# Search for PR by head SHA
PR_NUMBER=$(gh api \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"/repos/${{ github.repository }}/pulls?state=open&head=${{ github.repository_owner }}:${HEAD_BRANCH}" \
--jq '.[0].number // empty' 2>/dev/null || echo "")
if [[ -z "${PR_NUMBER}" ]]; then
# Fallback: search by commit SHA
PR_NUMBER=$(gh api \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"/repos/${{ github.repository }}/commits/${HEAD_SHA}/pulls" \
--jq '.[0].number // empty' 2>/dev/null || echo "")
fi
if [[ -z "${PR_NUMBER}" ]]; then
echo "⚠️ Could not find PR number for this workflow run"
echo "pr_number=" >> "$GITHUB_OUTPUT"
else
echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT"
echo "✅ Found PR number: ${PR_NUMBER}"
fi
# Check if this is a push event (not a PR)
if [[ "${{ github.event.workflow_run.event }}" == "push" ]]; then
echo "is_push=true" >> "$GITHUB_OUTPUT"
echo "✅ Detected push build from branch: ${{ github.event.workflow_run.head_branch }}"
else
echo "is_push=false" >> "$GITHUB_OUTPUT"
fi
- name: Sanitize branch name
id: sanitize
run: |
# Sanitize branch name for use in artifact names
# Replace / with - to avoid invalid reference format errors
BRANCH="${{ github.event.workflow_run.head_branch || github.head_ref || github.ref_name }}"
SANITIZED=$(echo "$BRANCH" | tr '/' '-')
echo "branch=${SANITIZED}" >> "$GITHUB_OUTPUT"
echo "📋 Sanitized branch name: ${BRANCH} -> ${SANITIZED}"
- name: Check for PR image artifact
id: check-artifact
if: steps.pr-number.outputs.pr_number != '' || steps.pr-number.outputs.is_push == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Determine artifact name based on event type
if [[ "${{ steps.pr-number.outputs.is_push }}" == "true" ]]; then
ARTIFACT_NAME="push-image"
else
PR_NUMBER="${{ steps.pr-number.outputs.pr_number }}"
ARTIFACT_NAME="pr-image-${PR_NUMBER}"
fi
RUN_ID="${{ github.event.workflow_run.id }}"
echo "🔍 Looking for artifact: ${ARTIFACT_NAME}"
if [[ -n "${RUN_ID}" ]]; then
# Search in the triggering workflow run
ARTIFACT_ID=$(gh api \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"/repos/${{ github.repository }}/actions/runs/${RUN_ID}/artifacts" \
--jq ".artifacts[] | select(.name == \"${ARTIFACT_NAME}\") | .id" 2>/dev/null || echo "")
fi
if [[ -z "${ARTIFACT_ID}" ]]; then
# Fallback: search recent artifacts
ARTIFACT_ID=$(gh api \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"/repos/${{ github.repository }}/actions/artifacts?name=${ARTIFACT_NAME}" \
--jq '.artifacts[0].id // empty' 2>/dev/null || echo "")
fi
if [[ -z "${ARTIFACT_ID}" ]]; then
echo "⚠️ No artifact found: ${ARTIFACT_NAME}"
echo "artifact_found=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "artifact_found=true" >> "$GITHUB_OUTPUT"
echo "artifact_id=${ARTIFACT_ID}" >> "$GITHUB_OUTPUT"
echo "artifact_name=${ARTIFACT_NAME}" >> "$GITHUB_OUTPUT"
echo "✅ Found artifact: ${ARTIFACT_NAME} (ID: ${ARTIFACT_ID})"
- name: Skip if no artifact
if: (steps.pr-number.outputs.pr_number == '' && steps.pr-number.outputs.is_push != 'true') || steps.check-artifact.outputs.artifact_found != 'true'
run: |
echo " No PR image artifact found - skipping supply chain verification"
echo "This is expected if the Docker build did not produce an artifact for this PR"
exit 0
- name: Download PR image artifact
if: steps.check-artifact.outputs.artifact_found == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
ARTIFACT_ID="${{ steps.check-artifact.outputs.artifact_id }}"
ARTIFACT_NAME="${{ steps.check-artifact.outputs.artifact_name }}"
echo "📦 Downloading artifact: ${ARTIFACT_NAME}"
gh api \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"/repos/${{ github.repository }}/actions/artifacts/${ARTIFACT_ID}/zip" \
> artifact.zip
unzip -o artifact.zip
echo "✅ Artifact downloaded and extracted"
- name: Load Docker image
if: steps.check-artifact.outputs.artifact_found == 'true'
id: load-image
run: |
if [[ ! -f "charon-pr-image.tar" ]]; then
echo "❌ charon-pr-image.tar not found in artifact"
ls -la
exit 1
fi
echo "🐳 Loading Docker image..."
LOAD_OUTPUT=$(docker load -i charon-pr-image.tar)
echo "${LOAD_OUTPUT}"
# Extract image name from load output
IMAGE_NAME=$(echo "${LOAD_OUTPUT}" | grep -oP 'Loaded image: \K.*' || echo "")
if [[ -z "${IMAGE_NAME}" ]]; then
# Try alternative format
IMAGE_NAME=$(echo "${LOAD_OUTPUT}" | grep -oP 'Loaded image ID: \K.*' || echo "")
fi
if [[ -z "${IMAGE_NAME}" ]]; then
# Fallback: list recent images
IMAGE_NAME=$(docker images --format "{{.Repository}}:{{.Tag}}" | head -1)
fi
echo "image_name=${IMAGE_NAME}" >> "$GITHUB_OUTPUT"
echo "✅ Loaded image: ${IMAGE_NAME}"
- name: Install Syft
if: steps.check-artifact.outputs.artifact_found == 'true'
run: |
echo "📦 Installing Syft ${SYFT_VERSION}..."
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | \
sh -s -- -b /usr/local/bin "${SYFT_VERSION}"
syft version
- name: Install Grype
if: steps.check-artifact.outputs.artifact_found == 'true'
run: |
echo "📦 Installing Grype ${GRYPE_VERSION}..."
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | \
sh -s -- -b /usr/local/bin "${GRYPE_VERSION}"
grype version
- name: Generate SBOM
if: steps.check-artifact.outputs.artifact_found == 'true'
id: sbom
run: |
IMAGE_NAME="${{ steps.load-image.outputs.image_name }}"
echo "📋 Generating SBOM for: ${IMAGE_NAME}"
syft "${IMAGE_NAME}" \
--output cyclonedx-json=sbom.cyclonedx.json \
--output table
# Count components
COMPONENT_COUNT=$(jq '.components | length' sbom.cyclonedx.json 2>/dev/null || echo "0")
echo "component_count=${COMPONENT_COUNT}" >> "$GITHUB_OUTPUT"
echo "✅ SBOM generated with ${COMPONENT_COUNT} components"
- name: Scan for vulnerabilities
if: steps.check-artifact.outputs.artifact_found == 'true'
id: grype-scan
run: |
echo "🔍 Scanning SBOM for vulnerabilities..."
# Run Grype against the SBOM
grype sbom:sbom.cyclonedx.json \
--output json \
--file grype-results.json || true
# Generate SARIF output for GitHub Security
grype sbom:sbom.cyclonedx.json \
--output sarif \
--file grype-results.sarif || true
# Count vulnerabilities by severity
if [[ -f grype-results.json ]]; then
CRITICAL_COUNT=$(jq '[.matches[] | select(.vulnerability.severity == "Critical")] | length' grype-results.json 2>/dev/null || echo "0")
HIGH_COUNT=$(jq '[.matches[] | select(.vulnerability.severity == "High")] | length' grype-results.json 2>/dev/null || echo "0")
MEDIUM_COUNT=$(jq '[.matches[] | select(.vulnerability.severity == "Medium")] | length' grype-results.json 2>/dev/null || echo "0")
LOW_COUNT=$(jq '[.matches[] | select(.vulnerability.severity == "Low")] | length' grype-results.json 2>/dev/null || echo "0")
TOTAL_COUNT=$(jq '.matches | length' grype-results.json 2>/dev/null || echo "0")
else
CRITICAL_COUNT=0
HIGH_COUNT=0
MEDIUM_COUNT=0
LOW_COUNT=0
TOTAL_COUNT=0
fi
echo "critical_count=${CRITICAL_COUNT}" >> "$GITHUB_OUTPUT"
echo "high_count=${HIGH_COUNT}" >> "$GITHUB_OUTPUT"
echo "medium_count=${MEDIUM_COUNT}" >> "$GITHUB_OUTPUT"
echo "low_count=${LOW_COUNT}" >> "$GITHUB_OUTPUT"
echo "total_count=${TOTAL_COUNT}" >> "$GITHUB_OUTPUT"
echo "📊 Vulnerability Summary:"
echo " Critical: ${CRITICAL_COUNT}"
echo " High: ${HIGH_COUNT}"
echo " Medium: ${MEDIUM_COUNT}"
echo " Low: ${LOW_COUNT}"
echo " Total: ${TOTAL_COUNT}"
- name: Upload SARIF to GitHub Security
if: steps.check-artifact.outputs.artifact_found == 'true'
# github/codeql-action v4
uses: github/codeql-action/upload-sarif@55252c7a3a47fea1e0fdd923b269f4be8a5ad9a0
continue-on-error: true
with:
sarif_file: grype-results.sarif
category: supply-chain-pr
- name: Upload supply chain artifacts
if: steps.check-artifact.outputs.artifact_found == 'true'
# actions/upload-artifact v4.6.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
with:
name: ${{ steps.pr-number.outputs.is_push == 'true' && format('supply-chain-{0}', steps.sanitize.outputs.branch) || format('supply-chain-pr-{0}', steps.pr-number.outputs.pr_number) }}
path: |
sbom.cyclonedx.json
grype-results.json
retention-days: 14
- name: Comment on PR
if: steps.check-artifact.outputs.artifact_found == 'true' && steps.pr-number.outputs.is_push != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUMBER="${{ steps.pr-number.outputs.pr_number }}"
COMPONENT_COUNT="${{ steps.sbom.outputs.component_count }}"
CRITICAL_COUNT="${{ steps.grype-scan.outputs.critical_count }}"
HIGH_COUNT="${{ steps.grype-scan.outputs.high_count }}"
MEDIUM_COUNT="${{ steps.grype-scan.outputs.medium_count }}"
LOW_COUNT="${{ steps.grype-scan.outputs.low_count }}"
TOTAL_COUNT="${{ steps.grype-scan.outputs.total_count }}"
# Determine status emoji
if [[ "${CRITICAL_COUNT}" -gt 0 ]]; then
STATUS="❌ **FAILED**"
STATUS_EMOJI="🚨"
elif [[ "${HIGH_COUNT}" -gt 0 ]]; then
STATUS="⚠️ **WARNING**"
STATUS_EMOJI="⚠️"
else
STATUS="✅ **PASSED**"
STATUS_EMOJI="✅"
fi
COMMENT_BODY=$(cat <<EOF
## ${STATUS_EMOJI} Supply Chain Verification Results
${STATUS}
### 📦 SBOM Summary
- **Components**: ${COMPONENT_COUNT}
### 🔍 Vulnerability Scan
| Severity | Count |
|----------|-------|
| 🔴 Critical | ${CRITICAL_COUNT} |
| 🟠 High | ${HIGH_COUNT} |
| 🟡 Medium | ${MEDIUM_COUNT} |
| 🟢 Low | ${LOW_COUNT} |
| **Total** | **${TOTAL_COUNT}** |
### 📎 Artifacts
- SBOM (CycloneDX JSON) and Grype results available in workflow artifacts
---
<sub>Generated by Supply Chain Verification workflow • [View Details](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})</sub>
EOF
)
# Find and update existing comment or create new one
COMMENT_ID=$(gh api \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"/repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \
--jq '.[] | select(.body | contains("Supply Chain Verification Results")) | .id' | head -1)
if [[ -n "${COMMENT_ID}" ]]; then
echo "📝 Updating existing comment..."
gh api \
--method PATCH \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"/repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" \
-f body="${COMMENT_BODY}"
else
echo "📝 Creating new comment..."
gh api \
--method POST \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"/repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \
-f body="${COMMENT_BODY}"
fi
echo "✅ PR comment posted"
- name: Fail on critical vulnerabilities
if: steps.check-artifact.outputs.artifact_found == 'true'
run: |
CRITICAL_COUNT="${{ steps.grype-scan.outputs.critical_count }}"
if [[ "${CRITICAL_COUNT}" -gt 0 ]]; then
echo "🚨 Found ${CRITICAL_COUNT} CRITICAL vulnerabilities!"
echo "Please review the vulnerability report and address critical issues before merging."
exit 1
fi
echo "✅ No critical vulnerabilities found"

View File

@@ -0,0 +1,824 @@
name: Supply Chain Verification
on:
release:
types: [published]
# Triggered after docker-build workflow completes
# Note: workflow_run can only chain 3 levels deep; we're at level 2 (safe)
#
# IMPORTANT: No branches filter here by design
# GitHub Actions limitation: branches filter in workflow_run only matches the default branch.
# Without a filter, this workflow triggers for ALL branches where docker-build completes,
# providing proper supply chain verification coverage for feature branches and PRs.
# Security: The workflow file must exist on the branch to execute, preventing untrusted code.
workflow_run:
workflows: ["Docker Build, Publish & Test"]
types: [completed]
schedule:
# Run weekly on Mondays at 00:00 UTC
- cron: '0 0 * * 1'
workflow_dispatch:
permissions:
contents: read
packages: read
id-token: write # OIDC token for keyless verification
attestations: write # Create/verify attestations
security-events: write
pull-requests: write # Comment on PRs
jobs:
verify-sbom:
name: Verify SBOM
runs-on: ubuntu-latest
# Only run on scheduled scans for main branch, or if workflow_run completed successfully
# Critical Fix #5: Exclude PR builds to prevent duplicate verification (now handled inline in docker-build.yml)
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'))
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# Debug: Log workflow_run context for initial validation (can be removed after confidence)
- name: Debug Workflow Run Context
if: github.event_name == 'workflow_run'
run: |
echo "Workflow Run Event Details:"
echo " Workflow: ${{ github.event.workflow_run.name }}"
echo " Conclusion: ${{ github.event.workflow_run.conclusion }}"
echo " Head Branch: ${{ github.event.workflow_run.head_branch }}"
echo " Head SHA: ${{ github.event.workflow_run.head_sha }}"
echo " Event: ${{ github.event.workflow_run.event }}"
echo " PR Count: ${{ toJson(github.event.workflow_run.pull_requests) }}"
- name: Install Verification Tools
run: |
# Install Syft
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
# Install Grype
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
- name: Determine Image Tag
id: tag
run: |
if [[ "${{ github.event_name }}" == "release" ]]; then
TAG="${{ github.event.release.tag_name }}"
elif [[ "${{ github.event_name }}" == "workflow_run" ]]; then
BRANCH="${{ github.event.workflow_run.head_branch }}"
# Extract tag from the workflow that triggered us
if [[ "${BRANCH}" == "main" ]]; then
TAG="latest"
elif [[ "${BRANCH}" == "development" ]]; then
TAG="dev"
elif [[ "${BRANCH}" == "nightly" ]]; then
TAG="nightly"
elif [[ "${{ github.event.workflow_run.event }}" == "pull_request" ]]; then
# Extract PR number from workflow_run context with null handling
PR_NUMBER=$(jq -r '.pull_requests[0].number // empty' <<< '${{ toJson(github.event.workflow_run.pull_requests) }}')
if [[ -n "${PR_NUMBER}" ]]; then
TAG="pr-${PR_NUMBER}"
else
# Fallback to SHA-based tag if PR number not available
TAG="sha-$(echo ${{ github.event.workflow_run.head_sha }} | cut -c1-7)"
fi
else
# For feature branches and other pushes, sanitize branch name for Docker tag
# Replace / with - to avoid invalid reference format errors
TAG=$(echo "${BRANCH}" | tr '/' '-')
fi
else
TAG="latest"
fi
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "Determined image tag: ${TAG}"
- name: Check Image Availability
id: image-check
env:
IMAGE: ghcr.io/${{ github.repository_owner }}/charon:${{ steps.tag.outputs.tag }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "Checking if image exists: ${IMAGE}"
# Authenticate with GHCR using GitHub token
echo "${GH_TOKEN}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
if docker manifest inspect ${IMAGE} >/dev/null 2>&1; then
echo "✅ Image exists and is accessible"
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "⚠️ Image not found - likely not built yet"
echo "This is normal for PR workflows before docker-build completes"
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Verify SBOM Completeness
if: steps.image-check.outputs.exists == 'true'
env:
IMAGE: ghcr.io/${{ github.repository_owner }}/charon:${{ steps.tag.outputs.tag }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "Verifying SBOM for ${IMAGE}..."
echo ""
# Log Syft version for debugging
echo "Syft version:"
syft version
echo ""
# Generate fresh SBOM in CycloneDX format (aligned with docker-build.yml)
echo "Generating SBOM in CycloneDX JSON format..."
if ! syft ${IMAGE} -o cyclonedx-json > sbom-generated.json; then
echo "❌ Failed to generate SBOM"
echo ""
echo "Debug information:"
echo "Image: ${IMAGE}"
echo "Syft exit code: $?"
exit 1 # Fail on real errors, not silent exit
fi
# Check SBOM content
GENERATED_COUNT=$(jq '.components | length' sbom-generated.json 2>/dev/null || echo "0")
echo "Generated SBOM components: ${GENERATED_COUNT}"
if [[ ${GENERATED_COUNT} -eq 0 ]]; then
echo "⚠️ SBOM contains no components - may indicate an issue"
else
echo "✅ SBOM contains ${GENERATED_COUNT} components"
fi
- name: Upload SBOM Artifact
if: steps.image-check.outputs.exists == 'true' && always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: sbom-${{ steps.tag.outputs.tag }}
path: sbom-generated.json
retention-days: 30
- name: Validate SBOM File
id: validate-sbom
if: steps.image-check.outputs.exists == 'true'
run: |
echo "Validating SBOM file..."
echo ""
# Check jq availability
if ! command -v jq &> /dev/null; then
echo "❌ jq is not available"
echo "valid=false" >> $GITHUB_OUTPUT
exit 1
fi
# Check file exists
if [[ ! -f sbom-generated.json ]]; then
echo "❌ SBOM file does not exist"
echo "valid=false" >> $GITHUB_OUTPUT
exit 0
fi
# Check file is non-empty
if [[ ! -s sbom-generated.json ]]; then
echo "❌ SBOM file is empty"
echo "valid=false" >> $GITHUB_OUTPUT
exit 0
fi
# Validate JSON structure
if ! jq empty sbom-generated.json 2>/dev/null; then
echo "❌ SBOM file contains invalid JSON"
echo "SBOM content:"
cat sbom-generated.json
echo "valid=false" >> $GITHUB_OUTPUT
exit 0
fi
# Validate CycloneDX structure
BOMFORMAT=$(jq -r '.bomFormat // "missing"' sbom-generated.json)
SPECVERSION=$(jq -r '.specVersion // "missing"' sbom-generated.json)
COMPONENTS=$(jq '.components // [] | length' sbom-generated.json)
echo "SBOM Format: ${BOMFORMAT}"
echo "Spec Version: ${SPECVERSION}"
echo "Components: ${COMPONENTS}"
echo ""
if [[ "${BOMFORMAT}" != "CycloneDX" ]]; then
echo "❌ Invalid bomFormat: expected 'CycloneDX', got '${BOMFORMAT}'"
echo "valid=false" >> $GITHUB_OUTPUT
exit 0
fi
if [[ "${COMPONENTS}" == "0" ]]; then
echo "⚠️ SBOM has no components - may indicate incomplete scan"
echo "valid=partial" >> $GITHUB_OUTPUT
else
echo "✅ SBOM is valid with ${COMPONENTS} components"
echo "valid=true" >> $GITHUB_OUTPUT
fi
- name: Scan for Vulnerabilities
if: steps.validate-sbom.outputs.valid == 'true'
env:
IMAGE: ghcr.io/${{ github.repository_owner }}/charon:${{ steps.tag.outputs.tag }}
run: |
echo "Scanning for vulnerabilities with Grype..."
echo "SBOM format: CycloneDX JSON"
echo "SBOM size: $(wc -c < sbom-generated.json) bytes"
echo ""
# Update Grype vulnerability database
echo "Updating Grype vulnerability database..."
grype db update
echo ""
# Run Grype with explicit path and better error handling
if ! grype sbom:./sbom-generated.json --output json --file vuln-scan.json; then
echo ""
echo "❌ Grype scan failed"
echo ""
echo "Debug information:"
echo "Grype version:"
grype version
echo ""
echo "SBOM preview (first 1000 characters):"
head -c 1000 sbom-generated.json
echo ""
exit 1 # Fail the step to surface the issue
fi
echo "✅ Grype scan completed successfully"
echo ""
# Display human-readable results
echo "Vulnerability summary:"
grype sbom:./sbom-generated.json --output table || true
# Parse and categorize results
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 counts:"
echo " Critical: ${CRITICAL}"
echo " High: ${HIGH}"
echo " Medium: ${MEDIUM}"
echo " Low: ${LOW}"
# Set warnings for critical vulnerabilities
if [[ ${CRITICAL} -gt 0 ]]; then
echo "::warning::${CRITICAL} critical vulnerabilities found"
fi
# Store for PR comment
echo "CRITICAL_VULNS=${CRITICAL}" >> $GITHUB_ENV
echo "HIGH_VULNS=${HIGH}" >> $GITHUB_ENV
echo "MEDIUM_VULNS=${MEDIUM}" >> $GITHUB_ENV
echo "LOW_VULNS=${LOW}" >> $GITHUB_ENV
- name: Parse Vulnerability Details
if: steps.validate-sbom.outputs.valid == 'true'
run: |
echo "Parsing detailed vulnerability information..."
# Generate detailed vulnerability tables grouped by severity
# Limit to first 20 per severity to keep PR comment readable
# Critical vulnerabilities
jq -r '
[.matches[] | select(.vulnerability.severity == "Critical")] |
sort_by(.vulnerability.id) |
limit(20; .[]) |
"| \(.vulnerability.id) | \(.artifact.name) | \(.artifact.version) | \(.vulnerability.fix.versions[0] // "No fix available") | \(.vulnerability.description[0:80] // "N/A") |"
' vuln-scan.json > critical-vulns.txt
# High severity vulnerabilities
jq -r '
[.matches[] | select(.vulnerability.severity == "High")] |
sort_by(.vulnerability.id) |
limit(20; .[]) |
"| \(.vulnerability.id) | \(.artifact.name) | \(.artifact.version) | \(.vulnerability.fix.versions[0] // "No fix available") | \(.vulnerability.description[0:80] // "N/A") |"
' vuln-scan.json > high-vulns.txt
# Medium severity vulnerabilities
jq -r '
[.matches[] | select(.vulnerability.severity == "Medium")] |
sort_by(.vulnerability.id) |
limit(20; .[]) |
"| \(.vulnerability.id) | \(.artifact.name) | \(.artifact.version) | \(.vulnerability.fix.versions[0] // "No fix available") | \(.vulnerability.description[0:80] // "N/A") |"
' vuln-scan.json > medium-vulns.txt
# Low severity vulnerabilities
jq -r '
[.matches[] | select(.vulnerability.severity == "Low")] |
sort_by(.vulnerability.id) |
limit(20; .[]) |
"| \(.vulnerability.id) | \(.artifact.name) | \(.artifact.version) | \(.vulnerability.fix.versions[0] // "No fix available") | \(.vulnerability.description[0:80] // "N/A") |"
' vuln-scan.json > low-vulns.txt
echo "✅ Vulnerability details parsed and saved"
- name: Upload Vulnerability Scan Artifact
if: steps.validate-sbom.outputs.valid == 'true' && always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: vulnerability-scan-${{ steps.tag.outputs.tag }}
path: |
vuln-scan.json
critical-vulns.txt
high-vulns.txt
medium-vulns.txt
low-vulns.txt
retention-days: 30
- name: Report Skipped Scan
if: steps.image-check.outputs.exists != 'true' || steps.validate-sbom.outputs.valid != 'true'
run: |
echo "## ⚠️ Vulnerability Scan Skipped" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [[ "${{ steps.image-check.outputs.exists }}" != "true" ]]; then
echo "**Reason**: Docker image not available yet" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "This is expected for PR workflows. The image will be scanned" >> $GITHUB_STEP_SUMMARY
echo "after it's built by the docker-build workflow." >> $GITHUB_STEP_SUMMARY
elif [[ "${{ steps.validate-sbom.outputs.valid }}" != "true" ]]; then
echo "**Reason**: SBOM validation failed" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Check the 'Validate SBOM File' step for details." >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "✅ Workflow completed successfully (scan skipped)" >> $GITHUB_STEP_SUMMARY
- name: Determine PR Number
id: pr-number
if: |
github.event_name == 'pull_request' ||
(github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request')
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
result-encoding: string
script: |
// Determine PR number from context
let prNumber;
if (context.eventName === 'pull_request') {
prNumber = context.issue.number;
} else if (context.eventName === 'workflow_run') {
const pullRequests = context.payload.workflow_run.pull_requests;
if (pullRequests && pullRequests.length > 0) {
prNumber = pullRequests[0].number;
}
}
if (!prNumber) {
console.log('No PR number found');
return '';
}
console.log(`Found PR number: ${prNumber}`);
return prNumber;
- name: Build PR Comment Body
id: comment-body
if: steps.pr-number.outputs.result != ''
run: |
TIMESTAMP=$(date -u +"%Y-%m-%d %H:%M:%S UTC")
IMAGE_EXISTS="${{ steps.image-check.outputs.exists }}"
SBOM_VALID="${{ steps.validate-sbom.outputs.valid }}"
CRITICAL="${CRITICAL_VULNS:-0}"
HIGH="${HIGH_VULNS:-0}"
MEDIUM="${MEDIUM_VULNS:-0}"
LOW="${LOW_VULNS:-0}"
TOTAL=$((CRITICAL + HIGH + MEDIUM + LOW))
# Build comment body
COMMENT_BODY="## 🔒 Supply Chain Security Scan
**Last Updated**: ${TIMESTAMP}
**Workflow Run**: [#${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
---
"
if [[ "${IMAGE_EXISTS}" != "true" ]]; then
COMMENT_BODY+="### ⏳ Status: Waiting for Image
The Docker image has not been built yet. This scan will run automatically once the docker-build workflow completes.
_This is normal for PR workflows._
"
elif [[ "${SBOM_VALID}" != "true" ]]; then
COMMENT_BODY+="### ⚠️ Status: SBOM Validation Failed
The Software Bill of Materials (SBOM) could not be validated. Please check the [workflow logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details.
**Action Required**: Review and resolve SBOM generation issues.
"
else
# Scan completed successfully
if [[ ${TOTAL} -eq 0 ]]; then
COMMENT_BODY+="### ✅ Status: No Vulnerabilities Detected
🎉 Great news! No security vulnerabilities were found in this image.
| Severity | Count |
|----------|-------|
| 🔴 Critical | 0 |
| 🟠 High | 0 |
| 🟡 Medium | 0 |
| 🔵 Low | 0 |
"
else
# Vulnerabilities found
if [[ ${CRITICAL} -gt 0 ]]; then
COMMENT_BODY+="### 🚨 Status: Critical Vulnerabilities Detected
⚠️ **Action Required**: ${CRITICAL} critical vulnerabilities require immediate attention!
"
elif [[ ${HIGH} -gt 0 ]]; then
COMMENT_BODY+="### ⚠️ Status: High-Severity Vulnerabilities Detected
${HIGH} high-severity vulnerabilities found. Please review and address.
"
else
COMMENT_BODY+="### 📊 Status: Vulnerabilities Detected
Security scan found ${TOTAL} vulnerabilities.
"
fi
COMMENT_BODY+="
| Severity | Count |
|----------|-------|
| 🔴 Critical | ${CRITICAL} |
| 🟠 High | ${HIGH} |
| 🟡 Medium | ${MEDIUM} |
| 🔵 Low | ${LOW} |
| **Total** | **${TOTAL}** |
## 🔍 Detailed Findings
"
# Add detailed vulnerability tables by severity
# Critical Vulnerabilities
if [[ ${CRITICAL} -gt 0 ]]; then
COMMENT_BODY+="<details>
<summary>🔴 <b>Critical Vulnerabilities (${CRITICAL})</b></summary>
| CVE | Package | Current Version | Fixed Version | Description |
|-----|---------|----------------|---------------|-------------|
"
if [[ -f critical-vulns.txt && -s critical-vulns.txt ]]; then
# Count lines in the file
CRIT_COUNT=$(wc -l < critical-vulns.txt)
COMMENT_BODY+="$(cat critical-vulns.txt)"
# If more than 20, add truncation message
if [[ ${CRITICAL} -gt 20 ]]; then
REMAINING=$((CRITICAL - 20))
COMMENT_BODY+="
_...and ${REMAINING} more. View the [full scan results](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for complete details._
"
fi
else
COMMENT_BODY+="| N/A | N/A | N/A | N/A | Details unavailable |
"
fi
COMMENT_BODY+="
</details>
"
fi
# High Severity Vulnerabilities
if [[ ${HIGH} -gt 0 ]]; then
COMMENT_BODY+="<details>
<summary>🟠 <b>High Severity Vulnerabilities (${HIGH})</b></summary>
| CVE | Package | Current Version | Fixed Version | Description |
|-----|---------|----------------|---------------|-------------|
"
if [[ -f high-vulns.txt && -s high-vulns.txt ]]; then
COMMENT_BODY+="$(cat high-vulns.txt)"
if [[ ${HIGH} -gt 20 ]]; then
REMAINING=$((HIGH - 20))
COMMENT_BODY+="
_...and ${REMAINING} more. View the [full scan results](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for complete details._
"
fi
else
COMMENT_BODY+="| N/A | N/A | N/A | N/A | Details unavailable |
"
fi
COMMENT_BODY+="
</details>
"
fi
# Medium Severity Vulnerabilities
if [[ ${MEDIUM} -gt 0 ]]; then
COMMENT_BODY+="<details>
<summary>🟡 <b>Medium Severity Vulnerabilities (${MEDIUM})</b></summary>
| CVE | Package | Current Version | Fixed Version | Description |
|-----|---------|----------------|---------------|-------------|
"
if [[ -f medium-vulns.txt && -s medium-vulns.txt ]]; then
COMMENT_BODY+="$(cat medium-vulns.txt)"
if [[ ${MEDIUM} -gt 20 ]]; then
REMAINING=$((MEDIUM - 20))
COMMENT_BODY+="
_...and ${REMAINING} more. View the [full scan results](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for complete details._
"
fi
else
COMMENT_BODY+="| N/A | N/A | N/A | N/A | Details unavailable |
"
fi
COMMENT_BODY+="
</details>
"
fi
# Low Severity Vulnerabilities
if [[ ${LOW} -gt 0 ]]; then
COMMENT_BODY+="<details>
<summary>🔵 <b>Low Severity Vulnerabilities (${LOW})</b></summary>
| CVE | Package | Current Version | Fixed Version | Description |
|-----|---------|----------------|---------------|-------------|
"
if [[ -f low-vulns.txt && -s low-vulns.txt ]]; then
COMMENT_BODY+="$(cat low-vulns.txt)"
if [[ ${LOW} -gt 20 ]]; then
REMAINING=$((LOW - 20))
COMMENT_BODY+="
_...and ${REMAINING} more. View the [full scan results](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for complete details._
"
fi
else
COMMENT_BODY+="| N/A | N/A | N/A | N/A | Details unavailable |
"
fi
COMMENT_BODY+="
</details>
"
fi
COMMENT_BODY+="
📋 [View detailed vulnerability report](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
"
fi
fi
COMMENT_BODY+="
---
<sub><!-- supply-chain-security-comment --></sub>
"
# Save to file for the next step (handles multi-line)
echo "$COMMENT_BODY" > /tmp/comment-body.txt
# Also output for debugging
echo "Generated comment body:"
cat /tmp/comment-body.txt
- name: Update or Create PR Comment
if: steps.pr-number.outputs.result != ''
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
with:
issue-number: ${{ steps.pr-number.outputs.result }}
body-path: /tmp/comment-body.txt
edit-mode: replace
comment-author: 'github-actions[bot]'
body-includes: '<!-- supply-chain-security-comment -->'
verify-docker-image:
name: Verify Docker Image Supply Chain
runs-on: ubuntu-latest
if: github.event_name == 'release'
needs: verify-sbom
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Verification Tools
run: |
# Install Cosign
curl -sLO https://github.com/sigstore/cosign/releases/download/v2.4.1/cosign-linux-amd64
echo "4e84f155f98be2c2d3e63dea0e80b0ca5b4d843f5f4b1d3e8c9b7e4e7c0e0e0e cosign-linux-amd64" | sha256sum -c || {
echo "⚠️ Checksum verification skipped (update with actual hash)"
}
sudo install cosign-linux-amd64 /usr/local/bin/cosign
rm cosign-linux-amd64
# Install SLSA Verifier
curl -sLO https://github.com/slsa-framework/slsa-verifier/releases/download/v2.6.0/slsa-verifier-linux-amd64
sudo install slsa-verifier-linux-amd64 /usr/local/bin/slsa-verifier
rm slsa-verifier-linux-amd64
- name: Determine Image Tag
id: tag
run: |
TAG="${{ github.event.release.tag_name }}"
echo "tag=${TAG}" >> $GITHUB_OUTPUT
- name: Verify Cosign Signature with Rekor Fallback
env:
IMAGE: ghcr.io/${{ github.repository_owner }}/charon:${{ steps.tag.outputs.tag }}
run: |
echo "Verifying Cosign signature for ${IMAGE}..."
# Try with Rekor
if cosign verify ${IMAGE} \
--certificate-identity-regexp="https://github.com/${{ github.repository }}" \
--certificate-oidc-issuer="https://token.actions.githubusercontent.com" 2>&1; then
echo "✅ Cosign signature verified (with Rekor)"
else
echo "⚠️ Rekor verification failed, trying offline verification..."
# Fallback: verify without Rekor
if cosign verify ${IMAGE} \
--certificate-identity-regexp="https://github.com/${{ github.repository }}" \
--certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
--insecure-ignore-tlog 2>&1; then
echo "✅ Cosign signature verified (offline mode)"
echo "::warning::Verified without Rekor - transparency log unavailable"
else
echo "❌ Signature verification failed"
exit 1
fi
fi
- name: Verify Docker Hub Image Signature
if: steps.image-check.outputs.exists == 'true'
continue-on-error: true
run: |
echo "Verifying Docker Hub image signature..."
cosign verify docker.io/wikid82/charon:${{ steps.tag.outputs.tag }} \
--certificate-identity-regexp="https://github.com/Wikid82/Charon" \
--certificate-oidc-issuer="https://token.actions.githubusercontent.com" && \
echo "✅ Docker Hub signature verified" || \
echo "⚠️ Docker Hub signature verification failed (image may not exist or not signed)"
- name: Verify SLSA Provenance
env:
IMAGE: ghcr.io/${{ github.repository_owner }}/charon:${{ steps.tag.outputs.tag }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "Verifying SLSA provenance for ${IMAGE}..."
# This will be enabled once provenance generation is added
echo "⚠️ SLSA provenance verification not yet implemented"
echo "Will be enabled after Phase 3 workflow updates"
- name: Create Verification Report
if: always()
run: |
cat << EOF > verification-report.md
# Supply Chain Verification Report
**Image**: ghcr.io/${{ github.repository_owner }}/charon:${{ steps.tag.outputs.tag }}
**Date**: $(date -u +"%Y-%m-%d %H:%M:%S UTC")
**Workflow**: ${{ github.workflow }}
**Run**: ${{ github.run_id }}
## Results
- **SBOM Verification**: ${{ needs.verify-sbom.result }}
- **Cosign Signature**: ${{ job.status }}
- **SLSA Provenance**: Not yet implemented (Phase 3)
## Verification Failure Recovery
If verification failed:
1. Check workflow logs for detailed error messages
2. Verify signing steps ran successfully in build workflow
3. Confirm attestations were pushed to registry
4. Check Rekor status: https://status.sigstore.dev
5. For Rekor outages, manual verification may be required
6. Re-run build if signatures/provenance are missing
EOF
cat verification-report.md >> $GITHUB_STEP_SUMMARY
verify-release-artifacts:
name: Verify Release Artifacts
runs-on: ubuntu-latest
if: github.event_name == 'release'
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Verification Tools
run: |
# Install Cosign
curl -sLO https://github.com/sigstore/cosign/releases/download/v2.4.1/cosign-linux-amd64
sudo install cosign-linux-amd64 /usr/local/bin/cosign
rm cosign-linux-amd64
- name: Download Release Assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG=${{ github.event.release.tag_name }}
mkdir -p ./release-assets
gh release download ${TAG} --dir ./release-assets || {
echo "⚠️ No release assets found or download failed"
exit 0
}
- name: Verify Artifact Signatures with Fallback
continue-on-error: true
run: |
if [[ ! -d ./release-assets ]] || [[ -z "$(ls -A ./release-assets 2>/dev/null)" ]]; then
echo "⚠️ No release assets to verify"
exit 0
fi
echo "Verifying Cosign signatures for release artifacts..."
VERIFIED_COUNT=0
FAILED_COUNT=0
for artifact in ./release-assets/*; do
# Skip signature and certificate files
if [[ "$artifact" == *.sig || "$artifact" == *.pem || "$artifact" == *provenance* || "$artifact" == *.txt || "$artifact" == *.md ]]; then
continue
fi
if [[ -f "$artifact" ]]; then
echo "Verifying: $(basename $artifact)"
# Check if signature files exist
if [[ ! -f "${artifact}.sig" ]] || [[ ! -f "${artifact}.pem" ]]; then
echo "⚠️ No signature files found for $(basename $artifact)"
FAILED_COUNT=$((FAILED_COUNT + 1))
continue
fi
# Try with Rekor
if cosign verify-blob "$artifact" \
--signature "${artifact}.sig" \
--certificate "${artifact}.pem" \
--certificate-identity-regexp="https://github.com/${{ github.repository }}" \
--certificate-oidc-issuer="https://token.actions.githubusercontent.com" 2>&1; then
echo "✅ Verified with Rekor"
VERIFIED_COUNT=$((VERIFIED_COUNT + 1))
else
echo "⚠️ Rekor unavailable, trying offline..."
if cosign verify-blob "$artifact" \
--signature "${artifact}.sig" \
--certificate "${artifact}.pem" \
--certificate-identity-regexp="https://github.com/${{ github.repository }}" \
--certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
--insecure-ignore-tlog 2>&1; then
echo "✅ Verified offline"
VERIFIED_COUNT=$((VERIFIED_COUNT + 1))
else
echo "❌ Verification failed"
FAILED_COUNT=$((FAILED_COUNT + 1))
fi
fi
fi
done
echo ""
echo "Verification summary: ${VERIFIED_COUNT} verified, ${FAILED_COUNT} failed"
if [[ ${FAILED_COUNT} -gt 0 ]]; then
echo "⚠️ Some artifacts failed verification"
else
echo "✅ All artifacts verified successfully"
fi

108
.github/workflows/waf-integration.yml vendored Normal file
View File

@@ -0,0 +1,108 @@
name: WAF Integration Tests
on:
push:
branches: [ main, development, 'feature/**' ]
paths:
- 'backend/internal/caddy/**'
- 'backend/internal/models/security*.go'
- 'scripts/coraza_integration.sh'
- 'Dockerfile'
- '.github/workflows/waf-integration.yml'
pull_request:
branches: [ main, development ]
paths:
- 'backend/internal/caddy/**'
- 'backend/internal/models/security*.go'
- 'scripts/coraza_integration.sh'
- 'Dockerfile'
- '.github/workflows/waf-integration.yml'
# Allow manual trigger
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
waf-integration:
name: Coraza WAF Integration
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Build Docker image
run: |
docker build \
--no-cache \
--build-arg VCS_REF=${{ github.sha }} \
-t charon:local .
- name: Run WAF integration tests
id: waf-test
run: |
chmod +x scripts/coraza_integration.sh
scripts/coraza_integration.sh 2>&1 | tee waf-test-output.txt
exit ${PIPESTATUS[0]}
- name: Dump Debug Info on Failure
if: failure()
run: |
echo "## 🔍 Debug Information" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Container Status" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
docker ps -a --filter "name=charon" --filter "name=coraza" >> $GITHUB_STEP_SUMMARY 2>&1 || true
echo '```' >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Caddy Admin Config" >> $GITHUB_STEP_SUMMARY
echo '```json' >> $GITHUB_STEP_SUMMARY
curl -s http://localhost:2019/config 2>/dev/null | head -200 >> $GITHUB_STEP_SUMMARY || echo "Could not retrieve Caddy config" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Charon Container Logs (last 100 lines)" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
docker logs charon-debug 2>&1 | tail -100 >> $GITHUB_STEP_SUMMARY || echo "No container logs available" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### WAF Ruleset Files" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
docker exec charon-debug sh -c 'ls -la /app/data/caddy/coraza/rulesets/ 2>/dev/null && echo "---" && cat /app/data/caddy/coraza/rulesets/*.conf 2>/dev/null' >> $GITHUB_STEP_SUMMARY || echo "No ruleset files found" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
- name: WAF Integration Summary
if: always()
run: |
echo "## 🛡️ WAF Integration Test Results" >> $GITHUB_STEP_SUMMARY
if [ "${{ steps.waf-test.outcome }}" == "success" ]; then
echo "✅ **All WAF tests passed**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Test Results:" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
grep -E "^✓|^===|^Coraza" waf-test-output.txt || echo "See logs for details"
grep -E "^✓|^===|^Coraza" waf-test-output.txt >> $GITHUB_STEP_SUMMARY || echo "See logs for details" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
else
echo "❌ **WAF tests failed**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Failure Details:" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
grep -E "^✗|Unexpected|Error|failed" waf-test-output.txt | head -20 >> $GITHUB_STEP_SUMMARY || echo "See logs for details" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
fi
- name: Cleanup
if: always()
run: |
docker rm -f charon-debug || true
docker rm -f coraza-backend || true
docker network rm containers_default || true