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.26.1' 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.CHARON_CI_TRIGGER_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 env: HAS_TRIGGER_TOKEN: ${{ secrets.CHARON_CI_TRIGGER_TOKEN != '' }} run: | # Fetch both branches to ensure we have the latest remote state git fetch origin development git fetch origin nightly # Sync local nightly with remote nightly to prevent non-fast-forward errors echo "Syncing local nightly with remote nightly..." git reset --hard origin/nightly # Check if there are differences between remote branches if git diff --quiet origin/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 } if [[ "$HAS_TRIGGER_TOKEN" != "true" ]]; then echo "::warning title=Using GITHUB_TOKEN fallback::Set CHARON_CI_TRIGGER_TOKEN to ensure push-triggered workflows run on nightly." fi # Force push to handle cases where nightly diverged from development git push --force origin nightly echo "has_changes=true" >> "$GITHUB_OUTPUT" fi trigger-nightly-validation: name: Trigger Nightly Validation Workflows needs: sync-development-to-nightly if: needs.sync-development-to-nightly.outputs.has_changes == 'true' runs-on: ubuntu-latest permissions: actions: write contents: read steps: - name: Dispatch Missing Nightly Validation Workflows uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: script: | const owner = context.repo.owner; const repo = context.repo.repo; const { data: nightlyBranch } = await github.rest.repos.getBranch({ owner, repo, branch: 'nightly', }); const nightlyHeadSha = nightlyBranch.commit.sha; core.info(`Current nightly HEAD: ${nightlyHeadSha}`); const workflows = [ { id: 'e2e-tests-split.yml' }, { id: 'codecov-upload.yml', inputs: { run_backend: 'true', run_frontend: 'true' } }, { id: 'supply-chain-verify.yml' }, { id: 'codeql.yml' }, ]; core.info('Skipping security-pr.yml: PR-only workflow intentionally excluded from nightly non-PR dispatch'); for (const workflow of workflows) { const { data: workflowRuns } = await github.rest.actions.listWorkflowRuns({ owner, repo, workflow_id: workflow.id, branch: 'nightly', per_page: 50, }); const hasRunForHead = workflowRuns.workflow_runs.some( (run) => run.head_sha === nightlyHeadSha, ); if (hasRunForHead) { core.info(`Skipping dispatch for ${workflow.id}; run already exists for nightly HEAD`); continue; } await github.rest.actions.createWorkflowDispatch({ owner, repo, workflow_id: workflow.id, ref: 'nightly', ...(workflow.inputs ? { inputs: workflow.inputs } : {}), }); core.info(`Dispatched ${workflow.id} on nightly (missing run for HEAD)`); } 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 }} digest: ${{ steps.resolve_digest.outputs.digest }} steps: - name: Checkout nightly branch uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.event_name == 'workflow_dispatch' && github.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@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Resolve Alpine base image digest id: alpine run: | ALPINE_IMAGE_REF=$(grep -m1 'ARG ALPINE_IMAGE=' Dockerfile | cut -d'=' -f2-) if [[ -z "$ALPINE_IMAGE_REF" ]]; then echo "::error::Failed to parse ALPINE_IMAGE from Dockerfile" exit 1 fi echo "Resolved Alpine image: ${ALPINE_IMAGE_REF}" echo "image=${ALPINE_IMAGE_REF}" >> "$GITHUB_OUTPUT" - name: Log in to GitHub Container Registry uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.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@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: docker.io username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Extract metadata id: meta uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.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@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.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 }} VCS_REF=${{ github.sha }} BUILD_DATE=${{ github.event.repository.pushed_at }} ALPINE_IMAGE=${{ steps.alpine.outputs.image }} cache-from: type=gha cache-to: type=gha,mode=max provenance: true sbom: true - name: Resolve and export image digest id: resolve_digest run: | set -euo pipefail DIGEST="${{ steps.build.outputs.digest }}" if [[ -z "$DIGEST" ]]; then echo "Build action digest empty; querying GHCR registry API..." GHCR_TOKEN=$(curl -sf \ -u "${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}" \ "https://ghcr.io/token?scope=repository:${{ env.IMAGE_NAME }}:pull&service=ghcr.io" \ | jq -r '.token') DIGEST=$(curl -sfI \ -H "Authorization: Bearer ${GHCR_TOKEN}" \ -H "Accept: application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json" \ "https://ghcr.io/v2/${{ env.IMAGE_NAME }}/manifests/nightly" \ | grep -i '^docker-content-digest:' | awk '{print $2}' | tr -d '\r' || true) [[ -n "$DIGEST" ]] && echo "Resolved from GHCR API: ${DIGEST}" fi if [[ -z "$DIGEST" ]]; then echo "::error::Could not determine image digest from step output or GHCR registry API" exit 1 fi echo "RESOLVED_DIGEST=${DIGEST}" >> "$GITHUB_ENV" echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT" echo "Exported digest: ${DIGEST}" - name: Record nightly image digest run: | echo "## ๐Ÿงพ Nightly Image Digest" >> "$GITHUB_STEP_SUMMARY" echo "- ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:nightly@${{ steps.resolve_digest.outputs.digest }}" >> "$GITHUB_STEP_SUMMARY" - name: Generate SBOM id: sbom_primary continue-on-error: true uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 with: image: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.resolve_digest.outputs.digest }} format: cyclonedx-json output-file: sbom-nightly.json syft-version: v1.42.1 - name: Generate SBOM fallback with pinned Syft if: always() run: | set -euo pipefail if [[ "${{ steps.sbom_primary.outcome }}" == "success" ]] && [[ -s sbom-nightly.json ]] && jq -e . sbom-nightly.json >/dev/null 2>&1; then echo "Primary SBOM generation succeeded with valid JSON; skipping fallback" exit 0 fi echo "Primary SBOM generation failed or produced missing/invalid output; using deterministic Syft fallback" SYFT_VERSION="v1.42.3" OS="$(uname -s | tr '[:upper:]' '[:lower:]')" ARCH="$(uname -m)" case "$ARCH" in x86_64) ARCH="amd64" ;; aarch64|arm64) ARCH="arm64" ;; *) echo "Unsupported architecture: $ARCH"; exit 1 ;; esac TARBALL="syft_${SYFT_VERSION#v}_${OS}_${ARCH}.tar.gz" BASE_URL="https://github.com/anchore/syft/releases/download/${SYFT_VERSION}" curl -fsSLo "$TARBALL" "${BASE_URL}/${TARBALL}" curl -fsSLo checksums.txt "${BASE_URL}/syft_${SYFT_VERSION#v}_checksums.txt" grep " ${TARBALL}$" checksums.txt > checksum_line.txt sha256sum -c checksum_line.txt tar -xzf "$TARBALL" syft chmod +x syft DIGEST="${{ steps.resolve_digest.outputs.digest }}" if [[ -z "$DIGEST" ]]; then echo "::error::Digest from resolve_digest step is empty; the digest-resolution step did not complete successfully" exit 1 fi ./syft "${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${DIGEST}" -o cyclonedx-json=sbom-nightly.json - name: Verify SBOM artifact if: always() run: | set -euo pipefail test -s sbom-nightly.json jq -e . sbom-nightly.json >/dev/null jq -e ' .bomFormat == "CycloneDX" and (.specVersion | type == "string" and length > 0) and has("version") and has("metadata") and (.components | type == "array") ' sbom-nightly.json >/dev/null - name: Upload SBOM artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.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@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.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.resolve_digest.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.resolve_digest.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.resolve_digest.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: ${{ github.event_name == 'workflow_dispatch' && github.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@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.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@${{ needs.build-and-push-nightly.outputs.digest }}" - name: Run container smoke test run: | IMAGE_REF="${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:nightly@${{ needs.build-and-push-nightly.outputs.digest }}" docker run --name charon-nightly -d \ -p 8080:8080 \ "${IMAGE_REF}" # 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 # NOTE: Standalone binary builds removed - Charon uses Docker-only deployment # The build-nightly-release job that ran GoReleaser for Windows/macOS/Linux binaries # was removed because: # 1. Charon is distributed exclusively via Docker images # 2. Cross-compilation was failing due to Unix-specific syscalls # 3. No users download standalone binaries (all use Docker) # If standalone binaries are needed in the future, re-add the job with Linux-only targets 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: ${{ github.event_name == 'workflow_dispatch' && github.ref || 'nightly' }} - name: Set lowercase image name run: echo "IMAGE_NAME_LC=${IMAGE_NAME,,}" >> "$GITHUB_ENV" - name: Download SBOM uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: sbom-nightly - name: Scan with Grype uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 # v7.4.0 with: sbom: sbom-nightly.json fail-build: false severity-cutoff: high - name: Scan with Trivy uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # 0.35.0 with: image-ref: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:nightly@${{ needs.build-and-push-nightly.outputs.digest }} format: 'sarif' output: 'trivy-nightly.sarif' version: 'v0.69.3' trivyignores: '.trivyignore' - name: Upload Trivy results uses: github/codeql-action/upload-sarif@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1 with: sarif_file: 'trivy-nightly.sarif' category: 'trivy-nightly' - name: Security severity policy summary run: | { echo "## ๐Ÿ” Nightly Supply Chain Severity Policy" echo "" echo "- Blocking: Critical, High" echo "- Medium: non-blocking by default (report + triage SLA)" echo "- Policy file: .github/security-severity-policy.yml" } >> "$GITHUB_STEP_SUMMARY" - name: Check for Critical/High CVEs run: | set -euo pipefail jq -e . trivy-nightly.sarif >/dev/null CRITICAL_COUNT=$(jq -r ' [ .runs[] as $run | ($run.tool.driver.rules // []) as $rules | $run.results[]? | . as $result | ( ( if (($result.ruleIndex | type) == "number") then ($rules[$result.ruleIndex].properties["security-severity"] // empty) else empty end ) // ([ $rules[]? | select((.id // "") == ($result.ruleId // "")) | .properties["security-severity"] ][0] // empty) // empty ) as $securitySeverity | (try ($securitySeverity | tonumber) catch empty) as $score | select($score != null and $score >= 9.0) ] | length ' trivy-nightly.sarif) HIGH_COUNT=$(jq -r ' [ .runs[] as $run | ($run.tool.driver.rules // []) as $rules | $run.results[]? | . as $result | ( ( if (($result.ruleIndex | type) == "number") then ($rules[$result.ruleIndex].properties["security-severity"] // empty) else empty end ) // ([ $rules[]? | select((.id // "") == ($result.ruleId // "")) | .properties["security-severity"] ][0] // empty) // empty ) as $securitySeverity | (try ($securitySeverity | tonumber) catch empty) as $score | select($score != null and $score >= 7.0 and $score < 9.0) ] | length ' trivy-nightly.sarif) MEDIUM_COUNT=$(jq -r ' [ .runs[] as $run | ($run.tool.driver.rules // []) as $rules | $run.results[]? | . as $result | ( ( if (($result.ruleIndex | type) == "number") then ($rules[$result.ruleIndex].properties["security-severity"] // empty) else empty end ) // ([ $rules[]? | select((.id // "") == ($result.ruleId // "")) | .properties["security-severity"] ][0] // empty) // empty ) as $securitySeverity | (try ($securitySeverity | tonumber) catch empty) as $score | select($score != null and $score >= 4.0 and $score < 7.0) ] | length ' trivy-nightly.sarif) { echo "- Structured SARIF counts: CRITICAL=${CRITICAL_COUNT}, HIGH=${HIGH_COUNT}, MEDIUM=${MEDIUM_COUNT}" } >> "$GITHUB_STEP_SUMMARY" # List all Critical/High/Medium findings with details for triage # shellcheck disable=SC2016 LIST_FINDINGS=' .runs[] as $run | ($run.tool.driver.rules // []) as $rules | $run.results[]? | . as $result | ( ( if (($result.ruleIndex | type) == "number") then ($rules[$result.ruleIndex] // {}) else {} end ) as $ruleByIndex | ( [$rules[]? | select((.id // "") == ($result.ruleId // ""))][0] // {} ) as $ruleById | ($ruleByIndex // $ruleById) as $rule | ($rule.properties["security-severity"] // null) as $sev | (try ($sev | tonumber) catch null) as $score | select($score != null and $score >= 4.0) | { id: ($result.ruleId // "unknown"), score: $score, severity: ( if $score >= 9.0 then "CRITICAL" elif $score >= 7.0 then "HIGH" else "MEDIUM" end ), message: ($result.message.text // $rule.shortDescription.text // "no description")[0:120] } ) ' echo "" echo "=== Vulnerability Details ===" jq -r "[ ${LIST_FINDINGS} ] | sort_by(-.score) | .[] | \"\\(.severity) (\\(.score)): \\(.id) โ€” \\(.message)\"" trivy-nightly.sarif || true echo "=============================" echo "" if [ "$CRITICAL_COUNT" -gt 0 ]; then echo "โŒ Critical vulnerabilities found in nightly build (${CRITICAL_COUNT})" { echo "" echo "### โŒ Critical CVEs blocking nightly" echo '```' jq -r "[ ${LIST_FINDINGS} | select(.severity == \"CRITICAL\") ] | sort_by(-.score) | .[] | \"\\(.id) (score: \\(.score)): \\(.message)\"" trivy-nightly.sarif || true echo '```' } >> "$GITHUB_STEP_SUMMARY" exit 1 fi if [ "$HIGH_COUNT" -gt 0 ]; then echo "โŒ High vulnerabilities found in nightly build (${HIGH_COUNT})" { echo "" echo "### โŒ High CVEs blocking nightly" echo '```' jq -r "[ ${LIST_FINDINGS} | select(.severity == \"HIGH\") ] | sort_by(-.score) | .[] | \"\\(.id) (score: \\(.score)): \\(.message)\"" trivy-nightly.sarif || true echo '```' } >> "$GITHUB_STEP_SUMMARY" exit 1 fi if [ "$MEDIUM_COUNT" -gt 0 ]; then echo "::warning::Medium vulnerabilities found in nightly build (${MEDIUM_COUNT}). Non-blocking by policy; triage with SLA per .github/security-severity-policy.yml" { echo "" echo "### โš ๏ธ Medium CVEs (non-blocking)" echo '```' jq -r "[ ${LIST_FINDINGS} | select(.severity == \"MEDIUM\") ] | sort_by(-.score) | .[] | \"\\(.id) (score: \\(.score)): \\(.message)\"" trivy-nightly.sarif || true echo '```' } >> "$GITHUB_STEP_SUMMARY" fi echo "โœ… No Critical/High vulnerabilities found"