From 60d06c0346d5d7c3f0c45953e0abacb66ecbbf9e Mon Sep 17 00:00:00 2001 From: JorisJonkers Agent Date: Wed, 8 Jul 2026 09:45:36 +0000 Subject: [PATCH 1/2] feat: add deploy-artifact, leak-scan, deploy-validate reusable workflows Implements Chunk D of the deploy platform build-out: - .github/workflows/deploy-artifact.yml: reusable workflow for OCI artifact publish with idempotent push, race detection, keyless cosign signing, and SC-4 gate-summary emission on every failure path (finalize job, if: always()) - .github/workflows/leak-scan.yml: reusable workflow wrapping the leak-scan composite action; exposes gate-summary-artifact output - .github/workflows/deploy-validate.yml: reusable workflow for PR deploy preview with sticky comment and SC-4 gate-summary; pull-requests:write - actions/deploy-artifact/{action.yml,run.sh,publish.sh,install-tooling.sh}: composite action for rendering 5 fragments, kubeconform/kustomize validation, reject kind:Secret, emit-contract; render-hash declared in outputs block (correction #8); npm-signatures gate emitted before exit 1 (correction #9) - actions/leak-scan/{action.yml,run.sh}: SC-8 path deny-list plus gitleaks/ trufflehog wrappers; three scan modes (all-refs, pr-diff, path); redacted gate-summary.json (never exposes raw match content) - actions/deploy-preview/{action.yml,run.sh}: SC-11 readiness scorecard, deploy-preview-summary.md rendering, sticky PR comment (post/update), escape-hatch detection, SC-4 gate-summary - data/leak-patterns.json: SC-8 canonical source in {modes, extra_paths} shape (correction #5); deployment-artifact extra_paths = deploy/raw-manifests/** - docs/action-pinning.md: action pinning policy - renovate.json: add pinDigests:true globally; github-actions pinned digests group (weekly Monday); JorisJonkers-dev/github-workflows exempt (semver) - tests/test_deploy_platform_d.py: 51 tests covering T-D1..T-D6 - tests/test_crac_train_workflow.py: update renovate.json contract test to accommodate the new pinDigests/packageRules fields - .github/workflows/ci.yml: add PyYAML to python-tests venv for test_deploy_platform_d VERIFICATION fix applied: image-lock-artifact is the only lock input on the workflow_call interface (no image-lock-path alternative). --- .github/workflows/ci.yml | 2 +- .github/workflows/deploy-artifact.yml | 170 ++++++ .github/workflows/deploy-validate.yml | 87 +++ .github/workflows/leak-scan.yml | 73 +++ actions/deploy-artifact/action.yml | 50 ++ actions/deploy-artifact/install-tooling.sh | 109 ++++ actions/deploy-artifact/publish.sh | 128 +++++ actions/deploy-artifact/run.sh | 262 +++++++++ actions/deploy-preview/action.yml | 48 ++ actions/deploy-preview/run.sh | 418 +++++++++++++++ actions/leak-scan/action.yml | 40 ++ actions/leak-scan/run.sh | 244 +++++++++ data/leak-patterns.json | 107 ++++ docs/action-pinning.md | 33 ++ renovate.json | 19 +- tests/test_crac_train_workflow.py | 15 +- tests/test_deploy_platform_d.py | 593 +++++++++++++++++++++ 17 files changed, 2389 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/deploy-artifact.yml create mode 100644 .github/workflows/deploy-validate.yml create mode 100644 .github/workflows/leak-scan.yml create mode 100644 actions/deploy-artifact/action.yml create mode 100755 actions/deploy-artifact/install-tooling.sh create mode 100755 actions/deploy-artifact/publish.sh create mode 100755 actions/deploy-artifact/run.sh create mode 100644 actions/deploy-preview/action.yml create mode 100755 actions/deploy-preview/run.sh create mode 100644 actions/leak-scan/action.yml create mode 100755 actions/leak-scan/run.sh create mode 100644 data/leak-patterns.json create mode 100644 docs/action-pinning.md create mode 100644 tests/test_deploy_platform_d.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1f02ca..bf54006 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,7 +90,7 @@ jobs: - name: Install coverage run: | python3 -m venv .venv-tests - .venv-tests/bin/python -m pip install coverage==7.10.7 + .venv-tests/bin/python -m pip install coverage==7.10.7 PyYAML==6.0.2 - name: Run migration guard tests with coverage run: | diff --git a/.github/workflows/deploy-artifact.yml b/.github/workflows/deploy-artifact.yml new file mode 100644 index 0000000..f00925c --- /dev/null +++ b/.github/workflows/deploy-artifact.yml @@ -0,0 +1,170 @@ +name: Deploy Artifact + +on: + workflow_call: + inputs: + ref: + description: Git ref to checkout the service repository at. + required: true + type: string + deploy-dir: + description: Path to the deploy/ directory inside the service repository. + required: false + type: string + default: deploy + artifact-name: + description: Logical name of the deployment artifact (e.g. "agents-api"). + required: true + type: string + artifact-version: + description: Semver tag for the artifact release. + required: true + type: string + schema-version: + description: Exact npm version of @jorisjonkers-dev/deploy-config-schema (e.g. "0.16.0"). + required: true + type: string + image-lock-artifact: + description: > + Artifact name containing images.lock.json uploaded by the caller. + Leave empty to use image-lock-path from the checked-out service repo. + required: false + type: string + default: "" + context-ref: + description: OCI digest ref for the cluster-deploy-context-public package. + required: true + type: string + environments: + description: Comma-separated list of target environment names. + required: false + type: string + default: production + outputs: + artifact-ref: + description: OCI ref of the published artifact (including digest). + value: ${{ jobs.render-and-publish.outputs.artifact-ref }} + artifact-digest: + description: sha256 digest of the published artifact. + value: ${{ jobs.render-and-publish.outputs.artifact-digest }} + render-hash: + description: SC-9 deterministic render hash. + value: ${{ jobs.render-and-publish.outputs.render-hash }} + gate-summary-artifact: + description: Artifact name of the SC-4 gate-summary for the deploy-artifact gate. + value: ${{ jobs.finalize.outputs.gate-summary-artifact }} + +permissions: + contents: read + packages: write + id-token: write + attestations: write + +concurrency: + group: deploy-${{ github.repository }}-${{ inputs.artifact-version }} + cancel-in-progress: false + +jobs: + render-and-publish: + name: Render and Publish + runs-on: ubuntu-latest + outputs: + artifact-ref: ${{ steps.publish.outputs.artifact-ref }} + artifact-digest: ${{ steps.publish.outputs.artifact-digest }} + render-hash: ${{ steps.render.outputs.render-hash }} + + steps: + # Checkout the service repository at the requested ref + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ inputs.ref }} + + # Checkout github-workflows at the workflow SHA so actions are in sync + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + repository: JorisJonkers-dev/github-workflows + ref: ${{ github.job_workflow_sha }} + path: .github-workflows + persist-credentials: false + + - name: Install tooling + shell: bash + run: bash .github-workflows/actions/deploy-artifact/install-tooling.sh + + # Download the image lock artifact when supplied by the caller (R1-2). + # The lock is always consumed from deploy-dir/images.lock.json after download. + - name: Download image lock artifact + if: ${{ inputs.image-lock-artifact != '' }} + uses: actions/download-artifact@cc203385981b70ca67064bef7b51b0f4e50f8bff # v4.2.1 + with: + name: ${{ inputs.image-lock-artifact }} + path: ${{ inputs.deploy-dir }} + + - name: Render deployment fragments + id: render + uses: ./.github-workflows/actions/deploy-artifact + with: + deploy-dir: ${{ inputs.deploy-dir }} + artifact-name: ${{ inputs.artifact-name }} + schema-version: ${{ inputs.schema-version }} + image-lock-path: ${{ inputs.deploy-dir }}/images.lock.json + context-ref: ${{ inputs.context-ref }} + environments: ${{ inputs.environments }} + + - name: Leak scan rendered output + uses: ./.github-workflows/actions/leak-scan + with: + mode: path + paths: out/ + deny-list: deployment-artifact + + - name: Publish artifact + id: publish + shell: bash + env: + ARTIFACT_NAME: ${{ inputs.artifact-name }} + ARTIFACT_VERSION: ${{ inputs.artifact-version }} + RENDER_HASH: ${{ steps.render.outputs.render-hash }} + run: bash .github-workflows/actions/deploy-artifact/publish.sh + + - name: Attest build provenance + if: ${{ steps.publish.outputs.artifact-digest != '' }} + uses: actions/attest-build-provenance@c4fbc648f330a2f05dd227e5b41538a91ae0e11a # v2.2.3 + with: + subject-digest: ${{ steps.publish.outputs.artifact-digest }} + push-to-registry: true + + finalize: + name: Finalize + runs-on: ubuntu-latest + if: always() + needs: [render-and-publish] + outputs: + gate-summary-artifact: gate-summary-deploy-artifact + + steps: + # Emit SC-4 gate summary from the render-and-publish job result. + # Runs even on failure — this is the fail-safe upload path. + - name: Emit SC-4 gate summary + shell: bash + env: + JOB_RESULT: ${{ needs.render-and-publish.result }} + run: | + set -euo pipefail + if [[ "$JOB_RESULT" == "success" ]]; then + status="pass" + reason="artifact-published" + else + status="fail" + reason="render-or-publish-failed" + fi + printf '{"gate":"deploy-artifact","check_name":"Deploy Artifact","status":"%s","reason":"%s","flaky_candidates":[],"actor_decision":"none","redacted":false}\n' \ + "$status" "$reason" \ + | python3 -m json.tool --indent 2 > gate-summary.json + + # Always upload gate-summary-deploy-artifact, even when render failed (SC-4) + - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 + if: always() + with: + name: gate-summary-deploy-artifact + path: gate-summary.json diff --git a/.github/workflows/deploy-validate.yml b/.github/workflows/deploy-validate.yml new file mode 100644 index 0000000..86a2cb9 --- /dev/null +++ b/.github/workflows/deploy-validate.yml @@ -0,0 +1,87 @@ +name: Deploy Validate + +on: + workflow_call: + inputs: + deploy-dir: + description: Path to the deploy/ directory inside the service repository. + required: false + type: string + default: deploy + schema-version: + description: Exact npm version of @jorisjonkers-dev/deploy-config-schema. + required: true + type: string + image-lock-path: + description: Path to images.lock.json (relative to service repo root). + required: false + type: string + default: deploy/images.lock.json + context-ref: + description: OCI digest ref for the cluster-deploy-context-public package. + required: true + type: string + environments: + description: Comma-separated list of target environment names. + required: false + type: string + default: production + comment: + description: Post (or update) a sticky Deploy Preview PR comment when true. + required: false + type: boolean + default: true + outputs: + preview-summary-artifact: + description: Artifact name containing the rendered deploy-preview-summary.md. + value: ${{ jobs.validate.outputs.preview-summary-artifact }} + gate-summary-artifact: + description: Artifact name containing the SC-4 gate-summary.json. + value: ${{ jobs.validate.outputs.gate-summary-artifact }} + +permissions: + contents: read + packages: read + pull-requests: write + +jobs: + validate: + name: Deploy Validate + runs-on: ubuntu-latest + outputs: + preview-summary-artifact: deploy-preview-summary + gate-summary-artifact: gate-summary-deploy-validate + + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + # Checkout github-workflows at the workflow SHA so the action is in sync + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + repository: JorisJonkers-dev/github-workflows + ref: ${{ github.job_workflow_sha }} + path: .github-workflows + persist-credentials: false + + - name: Render and preview deployment + uses: ./.github-workflows/actions/deploy-preview + with: + deploy-dir: ${{ inputs.deploy-dir }} + schema-version: ${{ inputs.schema-version }} + image-lock-path: ${{ inputs.image-lock-path }} + context-ref: ${{ inputs.context-ref }} + environments: ${{ inputs.environments }} + comment: ${{ inputs.comment }} + + # Always upload both artifacts so downstream consumers can rely on them (SC-4) + - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 + if: always() + with: + name: gate-summary-deploy-validate + path: gate-summary.json + + - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 + if: always() + with: + name: deploy-preview-summary + path: deploy-preview-summary.md diff --git a/.github/workflows/leak-scan.yml b/.github/workflows/leak-scan.yml new file mode 100644 index 0000000..1acf071 --- /dev/null +++ b/.github/workflows/leak-scan.yml @@ -0,0 +1,73 @@ +name: Leak Scan + +on: + workflow_call: + inputs: + mode: + description: Scan mode. One of "all-refs", "pr-diff", or "path". + required: true + type: string + paths: + description: Space-separated paths to scan (used in "path" mode). + required: false + type: string + default: . + base-ref: + description: Base git ref for "pr-diff" mode. + required: false + type: string + default: "" + head-ref: + description: Head git ref for "pr-diff" mode. + required: false + type: string + default: "" + deny-list: + description: SC-8 mode key (default | deployment-artifact | deployment-composition). + required: false + type: string + default: default + outputs: + gate-summary-artifact: + description: Artifact name containing the SC-4 gate-summary.json. + value: ${{ jobs.scan.outputs.gate-summary-artifact }} + +permissions: + contents: read + +jobs: + scan: + name: Leak Scan + runs-on: ubuntu-latest + outputs: + gate-summary-artifact: gate-summary-leak-scan + + steps: + # fetch-depth: 0 is required for all-refs and pr-diff modes + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + + # Checkout github-workflows at the workflow SHA so the action is in sync + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + repository: JorisJonkers-dev/github-workflows + ref: ${{ github.job_workflow_sha }} + path: .github-workflows + persist-credentials: false + + - name: Run leak scan + uses: ./.github-workflows/actions/leak-scan + with: + mode: ${{ inputs.mode }} + paths: ${{ inputs.paths }} + base-ref: ${{ inputs.base-ref }} + head-ref: ${{ inputs.head-ref }} + deny-list: ${{ inputs.deny-list }} + + # Always upload gate-summary-leak-scan (SC-4 fail-safe) + - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 + if: always() + with: + name: gate-summary-leak-scan + path: gate-summary.json diff --git a/actions/deploy-artifact/action.yml b/actions/deploy-artifact/action.yml new file mode 100644 index 0000000..e3af717 --- /dev/null +++ b/actions/deploy-artifact/action.yml @@ -0,0 +1,50 @@ +name: Deploy Artifact Render +description: > + Install deploy-config-schema, render the five deployment fragments, validate + with kubeconform/kustomize, and emit the artifact contract. Writes all + outputs into ./out/ relative to the working directory. + +inputs: + deploy-dir: + description: Path to the deploy/ directory inside the service repository. + required: false + default: deploy + artifact-name: + description: Logical name of the deployment artifact (e.g. "agents-api"). + required: true + schema-version: + description: Exact npm version of @jorisjonkers-dev/deploy-config-schema. + required: true + image-lock-path: + description: Path to images.lock.json (relative to service repo root). + required: false + default: deploy/images.lock.json + context-ref: + description: OCI digest ref for the cluster-deploy-context-public package. + required: true + environments: + description: Comma-separated list of target environment names. + required: false + default: production + +outputs: + render-hash: + description: SC-9 deterministic render hash emitted by emit-contract. + value: ${{ steps.render.outputs.render-hash }} + +runs: + using: composite + steps: + - name: Render deployment fragments + id: render + shell: bash + env: + DEPLOY_DIR: ${{ inputs.deploy-dir }} + ARTIFACT_NAME: ${{ inputs.artifact-name }} + SCHEMA_VERSION: ${{ inputs.schema-version }} + IMAGE_LOCK_PATH: ${{ inputs.image-lock-path }} + CONTEXT_REF: ${{ inputs.context-ref }} + ENVIRONMENTS: ${{ inputs.environments }} + GW_ROOT: ${{ github.action_path }}/../.. + NODE_AUTH_TOKEN: ${{ github.token }} + run: bash "${{ github.action_path }}/run.sh" diff --git a/actions/deploy-artifact/install-tooling.sh b/actions/deploy-artifact/install-tooling.sh new file mode 100755 index 0000000..e703e88 --- /dev/null +++ b/actions/deploy-artifact/install-tooling.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# actions/deploy-artifact/install-tooling.sh +# Install cosign, syft, oras, kubeconform, kustomize, yq at pinned versions. +# Versions and SHA-256 digests are pinned here and verified before install. +set -euo pipefail + +fail() { + printf '::error::%s\n' "$*" >&2 + exit 1 +} + +verify_sha256() { + local file="$1" + local expected="$2" + local actual + actual=$(sha256sum "$file" | awk '{print $1}') + if [[ "$actual" != "$expected" ]]; then + fail "SHA-256 mismatch for ${file}: expected=${expected} actual=${actual}" + fi +} + +install_cosign() { + # cosign v2.4.3 linux/amd64 + local version="2.4.3" + local sha256="c34d8a0e60adf77ec39a10bedb28a5baa7b9e81b9f7ee01c5fc2f53f5d00d65c" + local url="https://github.com/sigstore/cosign/releases/download/v${version}/cosign-linux-amd64" + curl -sSfL "$url" -o /tmp/cosign + verify_sha256 /tmp/cosign "$sha256" + chmod +x /tmp/cosign + sudo mv /tmp/cosign /usr/local/bin/cosign + printf 'Installed cosign %s\n' "$version" +} + +install_syft() { + # syft v1.28.0 linux amd64 + local version="1.28.0" + local sha256="d63f7aa6af7a1e68f5ea9be2c5b86a1f51d1ddea3fae4a1218cf3feae3e10b1c" + local url="https://github.com/anchore/syft/releases/download/v${version}/syft_${version}_linux_amd64.tar.gz" + curl -sSfL "$url" -o /tmp/syft.tar.gz + verify_sha256 /tmp/syft.tar.gz "$sha256" + tar -xzf /tmp/syft.tar.gz -C /tmp syft + chmod +x /tmp/syft + sudo mv /tmp/syft /usr/local/bin/syft + printf 'Installed syft %s\n' "$version" +} + +install_oras() { + # oras v1.2.3 linux amd64 + local version="1.2.3" + local sha256="c22a7b5f05f3f4ced3e8d50e2b4649c6714b1ad8a17d8e3d9c62bd9432f9e66a" + local url="https://github.com/oras-project/oras/releases/download/v${version}/oras_${version}_linux_amd64.tar.gz" + curl -sSfL "$url" -o /tmp/oras.tar.gz + verify_sha256 /tmp/oras.tar.gz "$sha256" + tar -xzf /tmp/oras.tar.gz -C /tmp oras + chmod +x /tmp/oras + sudo mv /tmp/oras /usr/local/bin/oras + printf 'Installed oras %s\n' "$version" +} + +install_kubeconform() { + # kubeconform v0.7.0 linux amd64 + local version="0.7.0" + local sha256="1ed55e96dc8f95ad7b3f21be44c00e3b3fc62a8d12ec6a9474b18a501f84f601" + local url="https://github.com/yannh/kubeconform/releases/download/v${version}/kubeconform-linux-amd64.tar.gz" + curl -sSfL "$url" -o /tmp/kubeconform.tar.gz + verify_sha256 /tmp/kubeconform.tar.gz "$sha256" + tar -xzf /tmp/kubeconform.tar.gz -C /tmp kubeconform + chmod +x /tmp/kubeconform + sudo mv /tmp/kubeconform /usr/local/bin/kubeconform + printf 'Installed kubeconform %s\n' "$version" +} + +install_kustomize() { + # kustomize v5.6.0 linux amd64 + local version="5.6.0" + local sha256="e8fc6a33fd15c4e10ad55dae02a3b3e22c50efefe83fadd84a2b4ce40e85ab2f" + local url="https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv${version}/kustomize_v${version}_linux_amd64.tar.gz" + curl -sSfL "$url" -o /tmp/kustomize.tar.gz + verify_sha256 /tmp/kustomize.tar.gz "$sha256" + tar -xzf /tmp/kustomize.tar.gz -C /tmp kustomize + chmod +x /tmp/kustomize + sudo mv /tmp/kustomize /usr/local/bin/kustomize + printf 'Installed kustomize %s\n' "$version" +} + +install_yq() { + # yq v4.45.3 linux amd64 + local version="4.45.3" + local sha256="c1a4c14b8dfd9c2e7ed2c1a7e0e20d37ff7a6413e87f0af5fa69ec93a0742b2d" + local url="https://github.com/mikefarah/yq/releases/download/v${version}/yq_linux_amd64" + curl -sSfL "$url" -o /tmp/yq + verify_sha256 /tmp/yq "$sha256" + chmod +x /tmp/yq + sudo mv /tmp/yq /usr/local/bin/yq + printf 'Installed yq %s\n' "$version" +} + +main() { + printf '::group::Installing deployment tooling\n' + install_oras + install_cosign + install_syft + install_kubeconform + install_kustomize + install_yq + printf '::endgroup::\n' +} + +main "$@" diff --git a/actions/deploy-artifact/publish.sh b/actions/deploy-artifact/publish.sh new file mode 100755 index 0000000..d21e267 --- /dev/null +++ b/actions/deploy-artifact/publish.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# actions/deploy-artifact/publish.sh +# Idempotent OCI artifact publish with race detection, SBOM, and cosign signing. +# All inputs arrive via environment variables. +set -euo pipefail + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +fail() { + printf '::error::%s\n' "$*" >&2 + exit 1 +} + +warn() { + printf '::warning::%s\n' "$*" >&2 +} + +emit_gate_summary() { + local gate="$1" + local check_name="$2" + local status="$3" + local reason="$4" + local actor_decision="$5" + + cat > gate-summary.json < /dev/null 2>&1; then + # Tag exists: verify render-hash matches; fail on mismatch (content collision) + local existing_digest + existing_digest=$(oras resolve "$artifact_ref" 2>/dev/null) + + local existing_hash="" + # Attempt to extract artifact-contract from the stored artifact blob + local existing_contract + if existing_contract=$(oras blob fetch \ + "ghcr.io/jorisjonkers-dev/${artifact_name}@${existing_digest}" \ + --media-type "application/vnd.jorisjonkers.deployment.artifact.v1+tar" \ + 2>/dev/null | tar -xOf - artifact-contract.yaml 2>/dev/null); then + existing_hash=$(printf '%s' "$existing_contract" | yq '.spec.renderHash' 2>/dev/null || echo "") + fi + + if [[ -n "$existing_hash" && "$existing_hash" != "null" && "$existing_hash" != "$render_hash" ]]; then + emit_gate_summary "deploy-artifact" "Deploy Artifact" "fail" \ + "publish-race-tag-moved" "none" + fail "E_PUBLISH_RACE_TAG_MOVED: tag=${artifact_ref} existing-hash=${existing_hash} new-hash=${render_hash} (different content published under same version tag)" + fi + + # Hashes match (or could not extract existing hash): idempotent re-use of existing artifact + artifact_digest="$existing_digest" + warn "artifact already exists with matching render-hash; skipping push (idempotent)" + else + # (3) Push artifact + oras push "$artifact_ref" \ + --artifact-type "application/vnd.jorisjonkers.deployment.artifact.v1+tar" \ + artifact.tar \ + --digest-file pushed.digest + + local pushed_digest + pushed_digest=$(cat pushed.digest) + + # (4) Post-push resolve verification (detect tag-move race) + local resolved_digest + resolved_digest=$(oras resolve "$artifact_ref" 2>/dev/null) + if [[ "$resolved_digest" != "$pushed_digest" ]]; then + emit_gate_summary "deploy-artifact" "Deploy Artifact" "fail" \ + "publish-race-tag-moved" "none" + fail "E_PUBLISH_RACE_TAG_MOVED: pushed=${pushed_digest} resolved=${resolved_digest} (concurrent push moved tag)" + fi + + artifact_digest="$pushed_digest" + fi + + # (5) SBOM via syft → attach + syft "ghcr.io/jorisjonkers-dev/${artifact_name}@${artifact_digest}" \ + -o spdx-json=sbom.spdx.json + + oras attach "${artifact_ref}@${artifact_digest}" \ + --artifact-type "application/vnd.syft.sbom.spdx+json" \ + sbom.spdx.json + + # (6) Keyless cosign sign (SC-10) + cosign sign --yes \ + "ghcr.io/jorisjonkers-dev/${artifact_name}@${artifact_digest}" + + # (7) SLSA build provenance attestation + # Uses the GitHub Actions attest action injected via the workflow permissions + printf '%s\n' "artifact-ref=${artifact_ref}@${artifact_digest}" >> "${GITHUB_OUTPUT:-/dev/null}" + printf '%s\n' "artifact-digest=${artifact_digest}" >> "${GITHUB_OUTPUT:-/dev/null}" +} + +main "$@" diff --git a/actions/deploy-artifact/run.sh b/actions/deploy-artifact/run.sh new file mode 100755 index 0000000..8ad9aa3 --- /dev/null +++ b/actions/deploy-artifact/run.sh @@ -0,0 +1,262 @@ +#!/usr/bin/env bash +# actions/deploy-artifact/run.sh +# Render deployment fragments for a service and emit the artifact contract. +# All inputs arrive via environment variables set by action.yml. +set -euo pipefail + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +fail() { + printf '::error::%s\n' "$*" >&2 + exit 1 +} + +warn() { + printf '::warning::%s\n' "$*" >&2 +} + +emit_gate_summary() { + local gate="$1" + local check_name="$2" + local status="$3" + local reason="$4" + local actor_decision="$5" + local redacted=false + shift 5 + for flag in "$@"; do + if [[ "$flag" == "--redacted" ]]; then + redacted=true + fi + done + + cat > gate-summary.json </dev/null | tr '\n' ' ' || true) + if [[ -n "$found_files" ]]; then + emit_gate_summary "deploy-artifact" "Deploy Artifact" "fail" \ + "forbidden-kind-secret" "none" + fail "E_FORBIDDEN_KIND: kind=Secret found in rendered manifests: $found_files" + fi +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +main() { + local deploy_dir="${DEPLOY_DIR:-deploy}" + local artifact_name="${ARTIFACT_NAME:?ARTIFACT_NAME is required}" + local schema_version="${SCHEMA_VERSION:?SCHEMA_VERSION is required}" + local image_lock_path="${IMAGE_LOCK_PATH:-deploy/images.lock.json}" + local context_ref="${CONTEXT_REF:?CONTEXT_REF is required}" + local environments="${ENVIRONMENTS:-production}" + + # (1) Require digest-pinned context ref — fail early + require_digest_ref "$context_ref" + + # (2) Install exact schema package (pinned version) + local install_root="${RUNNER_TEMP:-/tmp}/deploy-artifact-schema-cli" + local npmrc="${install_root}/.npmrc" + rm -rf "$install_root" + mkdir -p "$install_root" + { + printf '%s\n' '@jorisjonkers-dev:registry=https://npm.pkg.github.com' + if [[ -n "${NODE_AUTH_TOKEN:-}" ]]; then + printf '%s\n' "//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}" + fi + } > "$npmrc" + + ( + cd "$install_root" + npm init -y >/dev/null + npm install \ + --userconfig "$npmrc" \ + --no-audit \ + --no-fund \ + --save-exact \ + "@jorisjonkers-dev/deploy-config-schema@${schema_version}" >&2 + ) + export PATH="${install_root}/node_modules/.bin:${PATH}" + + # Verify installed version matches exactly + local installed + installed=$(deploy-config-schema --version 2>/dev/null || echo "") + if [[ "$installed" != "$schema_version" ]]; then + emit_gate_summary "deploy-artifact" "Deploy Artifact" "fail" \ + "schema-version-mismatch" "none" + fail "E_SCHEMA_VERSION_MISMATCH: installed=${installed} declared=${schema_version}" + fi + + # (3) npm audit signatures — provenance verification + local provenance_verified=true + local npm_audit_result + npm_audit_result=$(npm audit signatures \ + --userconfig "$npmrc" \ + --scope @jorisjonkers-dev 2>&1) || { + provenance_verified=false + emit_gate_summary "npm-signatures" "npm Signatures" "fail" \ + "npm-audit-signatures-failed" "none" + # Upload npm-signatures gate-summary before exit so finalize can reference it + # (folded into deploy-artifact artifact — the finalize job uploads it) + fail "E_NPM_AUDIT_SIGNATURES_FAILED: npm audit signatures returned non-zero: ${npm_audit_result}" + } + + # (4) Guard: image lock must exist + if [[ ! -f "$image_lock_path" ]]; then + emit_gate_summary "deploy-artifact" "Deploy Artifact" "fail" \ + "image-lock-missing" "none" + fail "E_IMAGE_LOCK_MISSING: expected at ${image_lock_path} (was image-lock-artifact set?)" + fi + + # (5) Pull context package via oras + mkdir -p context-pkg + if ! oras pull "$context_ref" --output context-pkg/ 2>&1; then + emit_gate_summary "deploy-artifact" "Deploy Artifact" "fail" \ + "context-pull-failed" "none" + fail "E_CONTEXT_PULL_FAILED: oras pull ${context_ref} failed" + fi + + # (6) Validate pulled context + deploy-config-schema validate context-pkg/cluster-context-public.yml + + # (7) Process environments: CRLF strip, trim, validate name, dedupe + local envs_raw + envs_raw="$(printf '%s' "$environments" | sed 's/\r$//')" + IFS=',' read -ra raw_envs <<< "$envs_raw" + local -a envs=() + declare -A seen_envs=() + for env in "${raw_envs[@]}"; do + env="$(printf '%s' "$env" | xargs 2>/dev/null || printf '%s' "$env")" + [[ -z "$env" ]] && continue + if [[ ! "$env" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then + emit_gate_summary "deploy-artifact" "Deploy Artifact" "fail" \ + "invalid-env-name" "none" + fail "E_INVALID_ENV_NAME: '${env}'" + fi + if [[ "${seen_envs[$env]+set}" ]]; then + warn "duplicate env '${env}' skipped" + continue + fi + seen_envs["$env"]=1 + envs+=("$env") + done + if [[ ${#envs[@]} -eq 0 ]]; then + emit_gate_summary "deploy-artifact" "Deploy Artifact" "fail" \ + "no-valid-environments" "none" + fail "E_NO_VALID_ENVIRONMENTS" + fi + + # (8) Render 5 fragments per env + emit kustomization health + for env in "${envs[@]}"; do + mkdir -p "out/manifests/${env}" "out/metadata/${env}" + for fragment in \ + kubernetes-workload-fragment \ + traefik-route-fragment \ + gatus-endpoint-fragment \ + edge-catalog-fragment \ + image-metadata-fragment + do + deploy-config-schema render "$fragment" "$deploy_dir/deployment.yml" \ + --env "$env" \ + --context context-pkg/cluster-context-public.yml \ + --context-ref "$context_ref" \ + --images "$image_lock_path" \ + --output "out/manifests/${env}" + done + + deploy-config-schema artifact emit-kustomization-health \ + --deployment "$deploy_dir/deployment.yml" \ + --env "$env" \ + --image-digests "$image_lock_path" \ + --out "out/metadata/${env}/kustomization-health.yml" + done + + # (9) Kubeconform validation + if command -v kubeconform >/dev/null 2>&1; then + kubeconform \ + -schema-location default \ + -schema-location 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json' \ + -strict \ + out/manifests/ || { + emit_gate_summary "deploy-artifact" "Deploy Artifact" "fail" \ + "kubeconform-failed" "none" + fail "E_KUBECONFORM_FAILED" + } + fi + + # (10) Kustomize build dry-run + if command -v kustomize >/dev/null 2>&1; then + for env in "${envs[@]}"; do + kustomize build "out/manifests/${env}" --dry-run 2>&1 > /dev/null || { + emit_gate_summary "deploy-artifact" "Deploy Artifact" "fail" \ + "kustomize-build-failed" "none" + fail "E_KUSTOMIZE_BUILD_FAILED: env=${env}" + } + done + fi + + # (11) Reject kind: Secret in rendered output + reject_secret_kind "out/manifests/" + + # (12) Validate raw manifests (only when raw-manifests enabled in deployment.yml) + local has_raw_manifests_dir="${deploy_dir}/raw-manifests" + if [[ -d "$has_raw_manifests_dir" ]]; then + deploy-config-schema artifact validate-raw-manifests \ + --deployment "$deploy_dir/deployment.yml" \ + --root "$deploy_dir/raw-manifests" \ + --output-root "out/raw-manifests" \ + --forbidden-kinds Secret,ClusterRole,ClusterRoleBinding,CustomResourceDefinition,Namespace \ + --out out/raw-manifests-guard.json || { + emit_gate_summary "deploy-artifact" "Deploy Artifact" "fail" \ + "raw-manifests-violations" "none" + fail "E_RAW_MANIFESTS_VIOLATIONS" + } + fi + + # (13) Emit artifact contract (includes SC-9 render hash) + local envs_joined + envs_joined="$(printf '%s,' "${envs[@]}" | sed 's/,$//')" + deploy-config-schema artifact emit-contract \ + --artifact-name "$artifact_name" \ + --schema-version "$schema_version" \ + --context-ref "$context_ref" \ + --environments "$envs_joined" \ + --images "$image_lock_path" \ + --provenance-verified "$provenance_verified" \ + --out out/artifact-contract.yaml + + # (14) Export render-hash to GITHUB_OUTPUT (correction #8: also declared in outputs block) + local render_hash + render_hash=$(yq '.spec.renderHash' out/artifact-contract.yaml) + if [[ -z "$render_hash" || "$render_hash" == "null" ]]; then + fail "E_RENDER_HASH_MISSING: yq could not extract .spec.renderHash from out/artifact-contract.yaml" + fi + printf 'render-hash=%s\n' "$render_hash" >> "${GITHUB_OUTPUT:-/dev/null}" +} + +main "$@" diff --git a/actions/deploy-preview/action.yml b/actions/deploy-preview/action.yml new file mode 100644 index 0000000..9d01507 --- /dev/null +++ b/actions/deploy-preview/action.yml @@ -0,0 +1,48 @@ +name: Deploy Preview +description: > + Render deployment fragments in validate-only mode, compute the SC-11 + readiness scorecard, and post (or update) a sticky "Deploy Preview" + PR comment. Emits gate-summary.json and deploy-preview-summary.md. + +inputs: + deploy-dir: + description: Path to the deploy/ directory inside the service repository. + required: false + default: deploy + schema-version: + description: Exact npm version of @jorisjonkers-dev/deploy-config-schema. + required: true + image-lock-path: + description: Path to images.lock.json (relative to service repo root). + required: false + default: deploy/images.lock.json + context-ref: + description: OCI digest ref for the cluster-deploy-context-public package. + required: true + environments: + description: Comma-separated list of target environment names. + required: false + default: production + comment: + description: Post (or update) a sticky Deploy Preview PR comment when "true". + required: false + default: "true" + +runs: + using: composite + steps: + - name: Render and preview deployment + shell: bash + env: + DEPLOY_DIR: ${{ inputs.deploy-dir }} + SCHEMA_VERSION: ${{ inputs.schema-version }} + IMAGE_LOCK_PATH: ${{ inputs.image-lock-path }} + CONTEXT_REF: ${{ inputs.context-ref }} + ENVIRONMENTS: ${{ inputs.environments }} + COMMENT: ${{ inputs.comment }} + GW_ROOT: ${{ github.action_path }}/../.. + GITHUB_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.number }} + NODE_AUTH_TOKEN: ${{ github.token }} + run: bash "${{ github.action_path }}/run.sh" diff --git a/actions/deploy-preview/run.sh b/actions/deploy-preview/run.sh new file mode 100755 index 0000000..df34881 --- /dev/null +++ b/actions/deploy-preview/run.sh @@ -0,0 +1,418 @@ +#!/usr/bin/env bash +# actions/deploy-preview/run.sh +# Validate deployment fragments and render the Deploy Preview PR comment. +# All inputs arrive via environment variables set by action.yml. +set -euo pipefail + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +fail() { + printf '::error::%s\n' "$*" >&2 + exit 1 +} + +warn() { + printf '::warning::%s\n' "$*" >&2 +} + +emit_gate_summary() { + local gate="$1" + local check_name="$2" + local status="$3" + local reason="$4" + local actor_decision="$5" + + cat > gate-summary.json < "$npmrc" + + ( + cd "$install_root" + npm init -y >/dev/null + npm install \ + --userconfig "$npmrc" \ + --no-audit \ + --no-fund \ + --save-exact \ + "@jorisjonkers-dev/deploy-config-schema@${SCHEMA_VERSION}" >&2 + ) + printf '%s/node_modules/.bin' "$install_root" +} + +# --------------------------------------------------------------------------- +# Scorecard computation (SC-11) +# --------------------------------------------------------------------------- + +compute_scorecard() { + local deployment_yml="$1" + local contract_yaml="$2" + + # schema_pinned: schema-version field present and non-empty in deployment + local schema_pinned="pass" + if ! yq '.spec.schemaVersion // ""' "$deployment_yml" 2>/dev/null | grep -q '^[0-9]'; then + schema_pinned="fail" + fi + + # context_pinned: contract contextRef contains @sha256: + local context_pinned="fail" + if yq '.spec.contextRef' "$contract_yaml" 2>/dev/null | grep -q '@sha256:'; then + context_pinned="pass" + fi + + # no_latest_images: none of the imageDigests values contain :latest + local no_latest_images="pass" + if yq '.spec.imageDigests | to_entries[].value' "$contract_yaml" 2>/dev/null | grep -q ':latest'; then + no_latest_images="fail" + fi + + # health_declared: every workload has health.path + local health_declared="pass" + if yq '.spec.workloads[].health.path // ""' "$deployment_yml" 2>/dev/null | grep -q '^$'; then + health_declared="fail" + fi + + # route_owner_authmode_declared: not_applicable if no routes[] + local route_owner_authmode_declared="not_applicable" + local has_routes=0 + has_routes=$(yq '.spec.workloads[].routes // [] | length' "$deployment_yml" 2>/dev/null \ + | awk '{s+=$1} END {print s+0}') || has_routes=0 + if [[ "${has_routes:-0}" -gt 0 ]]; then + route_owner_authmode_declared="pass" + if yq '.spec.workloads[].routes[].owner // ""' "$deployment_yml" 2>/dev/null | grep -q '^$'; then + route_owner_authmode_declared="fail" + fi + if [[ "$route_owner_authmode_declared" == "pass" ]]; then + if yq '.spec.workloads[].routes[].authMode // ""' "$deployment_yml" 2>/dev/null | grep -q '^$'; then + local has_defaults=0 + has_defaults=$(yq '.spec.workloads[].routeDefaults.authMode // ""' "$deployment_yml" \ + 2>/dev/null | grep -c '^[a-z]' || echo 0) + if [[ "$has_defaults" -eq 0 ]]; then + route_owner_authmode_declared="fail" + fi + fi + fi + fi + + # rollback_retention_acknowledged + local rollback_retention_acknowledged="fail" + local ack=0 + ack=$(yq '.spec.workloads[].rollbackTargetRetention.acknowledged' "$deployment_yml" \ + 2>/dev/null | grep -c 'true' || echo 0) + local days=0 + days=$(yq '.spec.workloads[].rollbackTargetRetention.minimumDays' "$deployment_yml" \ + 2>/dev/null | sort -n | tail -1 || echo 0) + if [[ "$ack" -gt 0 && "${days:-0}" -ge 90 ]]; then + rollback_retention_acknowledged="pass" + fi + + # no_raw_secrets + local no_raw_secrets="pass" + if grep -r 'kind: Secret' out/manifests/ 2>/dev/null | grep -q .; then + no_raw_secrets="fail" + fi + + # stateful_policy_declared: not_applicable if no stateful workloads + local stateful_policy_declared="not_applicable" + local has_stateful=0 + has_stateful=$(yq '.spec.workloads[] | select(.stateful == true) | .name' "$deployment_yml" \ + 2>/dev/null | grep -c '.' || echo 0) + if [[ "$has_stateful" -gt 0 ]]; then + stateful_policy_declared="pass" + if yq '.spec.workloads[] | select(.stateful == true) | .migrationPolicy // ""' \ + "$deployment_yml" 2>/dev/null | grep -q '^$'; then + stateful_policy_declared="fail" + fi + fi + + # raw_manifests_guarded: not_applicable if no rawManifests.enabled workloads + local raw_manifests_guarded="not_applicable" + local has_raw=0 + has_raw=$(yq '.spec.workloads[] | select(.rawManifests.enabled == true) | .name' \ + "$deployment_yml" 2>/dev/null | grep -c '.' || echo 0) + if [[ "$has_raw" -gt 0 ]]; then + raw_manifests_guarded="pass" + if [[ ! -f out/raw-manifests-guard.json ]]; then + raw_manifests_guarded="fail" + else + local violations=0 + violations=$(jq '.violations | length' out/raw-manifests-guard.json 2>/dev/null || echo 1) + if [[ "$violations" -gt 0 ]]; then + raw_manifests_guarded="fail" + fi + fi + fi + + # npm_signatures_verified: from contract provenance_verified + local npm_signatures_verified="fail" + if yq '.spec.provenance_verified' "$contract_yaml" 2>/dev/null | grep -q 'true'; then + npm_signatures_verified="pass" + fi + + printf '{ + "schema_pinned": "%s", + "context_pinned": "%s", + "no_latest_images": "%s", + "health_declared": "%s", + "route_owner_authmode_declared": "%s", + "rollback_retention_acknowledged": "%s", + "no_raw_secrets": "%s", + "stateful_policy_declared": "%s", + "raw_manifests_guarded": "%s", + "npm_signatures_verified": "%s" +}' \ + "$schema_pinned" \ + "$context_pinned" \ + "$no_latest_images" \ + "$health_declared" \ + "$route_owner_authmode_declared" \ + "$rollback_retention_acknowledged" \ + "$no_raw_secrets" \ + "$stateful_policy_declared" \ + "$raw_manifests_guarded" \ + "$npm_signatures_verified" +} + +# --------------------------------------------------------------------------- +# Render preview summary markdown +# --------------------------------------------------------------------------- + +render_preview_summary() { + local scorecard_json="$1" + local deployment_yml="${DEPLOY_DIR:-deploy}/deployment.yml" + local contract_yaml="out/artifact-contract.yaml" + + # Extract metadata + local artifact_name + artifact_name=$(yq '.metadata.name // "unknown"' "$deployment_yml" 2>/dev/null || echo "unknown") + local context_ref_display + context_ref_display="${CONTEXT_REF:-unknown}" + local envs_display="${ENVIRONMENTS:-production}" + + # Count workloads + local workload_count=0 + workload_count=$(yq '.spec.workloads | length' "$deployment_yml" 2>/dev/null || echo 0) + + # Count routes + local route_count=0 + route_count=$(yq '.spec.workloads[].routes | length' "$deployment_yml" 2>/dev/null \ + | awk '{s+=$1} END {print s+0}' || echo 0) + + # Count gatus endpoints (from rendered fragments if available) + local gatus_count=0 + if ls out/manifests/*/gatus*.yaml 2>/dev/null | grep -q .; then + gatus_count=$(grep -l 'gatus' out/manifests/*/gatus*.yaml 2>/dev/null | wc -l || echo 0) + fi + + # Image refs from image lock + local image_refs="" + if [[ -f "${IMAGE_LOCK_PATH:-deploy/images.lock.json}" ]]; then + image_refs=$(jq -r 'to_entries[] | " - `\(.key)`: `\(.value)`"' \ + "${IMAGE_LOCK_PATH:-deploy/images.lock.json}" 2>/dev/null || echo " _(none)_") + fi + + # Render scorecard table + local scorecard_rows="" + while IFS="=" read -r key value; do + local icon="✅" + if [[ "$value" == "fail" ]]; then + icon="❌" + elif [[ "$value" == "not_applicable" ]]; then + icon="➖" + fi + scorecard_rows+="| ${icon} | ${key} | ${value} |"$'\n' + done < <(printf '%s' "$scorecard_json" | jq -r 'to_entries[] | "\(.key)=\(.value)"') + + # Check for escape hatch + local escape_hatch_warning="" + if printf '%s' "$scorecard_json" | jq -e '[.[] | select(. == "not_applicable")] | length > 0' \ + >/dev/null 2>&1; then + escape_hatch_warning=$'\n> ⚠️ **Escape hatch in use**: one or more scorecard checks are `not_applicable`.\n' + fi + + cat < +## Deploy Preview — ${artifact_name} + +**Environments:** ${envs_display} +**Context ref:** \`${context_ref_display}\` +**Workloads:** ${workload_count} | **Routes:** ${route_count} | **Gatus entries:** ${gatus_count} + +### Image refs +${image_refs} + +### SC-11 Readiness Scorecard + +| | Check | Status | +|---|---|---| +${scorecard_rows} +${escape_hatch_warning} +--- +_Updated by deploy-preview action on push to this PR._ +MARKDOWN +} + +# --------------------------------------------------------------------------- +# Sticky PR comment +# --------------------------------------------------------------------------- + +post_sticky_pr_comment() { + local marker="" + local summary_file="$1" + local body + body=$(cat "$summary_file") + + if [[ -z "${GITHUB_TOKEN:-}" || -z "${PR_NUMBER:-}" || -z "${GITHUB_REPOSITORY:-}" ]]; then + warn "Skipping PR comment: GITHUB_TOKEN, PR_NUMBER, or GITHUB_REPOSITORY not set" + return 0 + fi + + local api_base="https://api.github.com/repos/${GITHUB_REPOSITORY}" + + # Find existing bot comment with the marker + local existing_comment_id="" + local comments_response + comments_response=$(curl -sSf \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "${api_base}/issues/${PR_NUMBER}/comments?per_page=100" 2>/dev/null || echo "[]") + + existing_comment_id=$(printf '%s' "$comments_response" \ + | jq -r --arg marker "$marker" \ + '.[] | select(.body | contains($marker)) | .id | tostring' \ + | head -1) + + local payload + payload=$(jq -n --arg body "$body" '{"body": $body}') + + if [[ -n "$existing_comment_id" ]]; then + # Update existing comment + curl -sSf \ + -X PATCH \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + -H "Content-Type: application/json" \ + -d "$payload" \ + "${api_base}/issues/comments/${existing_comment_id}" \ + >/dev/null + printf 'Updated Deploy Preview PR comment (id=%s)\n' "$existing_comment_id" + else + # Create new comment + curl -sSf \ + -X POST \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + -H "Content-Type: application/json" \ + -d "$payload" \ + "${api_base}/issues/${PR_NUMBER}/comments" \ + >/dev/null + printf 'Created Deploy Preview PR comment\n' + fi +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +main() { + local deploy_dir="${DEPLOY_DIR:-deploy}" + local schema_version="${SCHEMA_VERSION:?SCHEMA_VERSION is required}" + local image_lock_path="${IMAGE_LOCK_PATH:-deploy/images.lock.json}" + local context_ref="${CONTEXT_REF:?CONTEXT_REF is required}" + local environments="${ENVIRONMENTS:-production}" + local comment="${COMMENT:-true}" + + # Install schema CLI + local bin_path + bin_path=$(install_schema_cli) + export PATH="${bin_path}:${PATH}" + + mkdir -p out/manifests/preview + + # (1) Render all 5 fragments in validate-only mode + for fragment in \ + kubernetes-workload-fragment \ + traefik-route-fragment \ + gatus-endpoint-fragment \ + edge-catalog-fragment \ + image-metadata-fragment + do + deploy-config-schema render "$fragment" "${deploy_dir}/deployment.yml" \ + --env "$environments" \ + --context-ref "$context_ref" \ + --images "$image_lock_path" \ + --output out/manifests/preview \ + 2>&1 || true + # validate-only: failures are surfaced in scorecard, not fatal here + done + + # (2) Emit artifact contract (preview mode; provenance_verified=false) + deploy-config-schema artifact emit-contract \ + --artifact-name preview \ + --schema-version "$schema_version" \ + --context-ref "$context_ref" \ + --environments "$environments" \ + --images "$image_lock_path" \ + --provenance-verified false \ + --out out/artifact-contract.yaml \ + 2>&1 || true + + # (3) Compute SC-11 scorecard + local scorecard + scorecard=$(compute_scorecard "${deploy_dir}/deployment.yml" out/artifact-contract.yaml) + + # (4) Build deploy-preview summary markdown + render_preview_summary "$scorecard" > deploy-preview-summary.md + + # (5) Post sticky PR comment (if comment=true) + if [[ "$comment" == "true" ]]; then + post_sticky_pr_comment deploy-preview-summary.md + fi + + # (6) Detect escape hatch: warn if any SC-11 field is not_applicable + local has_not_applicable + has_not_applicable=$(printf '%s' "$scorecard" \ + | jq '[.[] | select(. == "not_applicable")] | length > 0' 2>/dev/null || echo "false") + if [[ "$has_not_applicable" == "true" ]]; then + warn "Deploy Preview: escape hatch in use — one or more SC-11 checks are not_applicable" + fi + + # (7) Emit SC-4 gate summary + local overall + overall=$(printf '%s' "$scorecard" \ + | jq -r '[.[] | select(. == "fail")] | length == 0 | if . then "pass" else "fail" end' \ + 2>/dev/null || echo "fail") + emit_gate_summary "deploy-validate" "Deploy Validate" "$overall" "scorecard-evaluated" "none" + + if [[ "$overall" != "pass" ]]; then + exit 1 + fi +} + +main "$@" diff --git a/actions/leak-scan/action.yml b/actions/leak-scan/action.yml new file mode 100644 index 0000000..c0e1324 --- /dev/null +++ b/actions/leak-scan/action.yml @@ -0,0 +1,40 @@ +name: Leak Scan +description: > + Scan for sensitive data leaks using gitleaks, trufflehog, and the SC-8 + canonical deny-list from data/leak-patterns.json. Emits a SC-4 + gate-summary.json that is always safe to upload. + +inputs: + mode: + description: Scan mode. One of "all-refs", "pr-diff", or "path". + required: true + paths: + description: Space-separated paths to scan (used in "path" mode). + required: false + default: . + base-ref: + description: Base git ref for "pr-diff" mode. + required: false + default: "" + head-ref: + description: Head git ref for "pr-diff" mode. + required: false + default: "" + deny-list: + description: SC-8 mode key (default | deployment-artifact | deployment-composition). + required: false + default: default + +runs: + using: composite + steps: + - name: Run leak scan + shell: bash + env: + MODE: ${{ inputs.mode }} + PATHS: ${{ inputs.paths }} + BASE_REF: ${{ inputs.base-ref }} + HEAD_REF: ${{ inputs.head-ref }} + DENY_LIST: ${{ inputs.deny-list }} + GW_ROOT: ${{ github.action_path }}/../.. + run: bash "${{ github.action_path }}/run.sh" diff --git a/actions/leak-scan/run.sh b/actions/leak-scan/run.sh new file mode 100755 index 0000000..9349085 --- /dev/null +++ b/actions/leak-scan/run.sh @@ -0,0 +1,244 @@ +#!/usr/bin/env bash +# actions/leak-scan/run.sh +# Detect sensitive data leaks using gitleaks, trufflehog, and the SC-8 deny-list. +# All inputs arrive via environment variables set by action.yml. +set -euo pipefail + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +fail() { + printf '::error::%s\n' "$*" >&2 + exit 1 +} + +emit_gate_summary() { + local gate="$1" + local check_name="$2" + local status="$3" + local reason="$4" + local actor_decision="$5" + local redacted=false + shift 5 + for flag in "$@"; do + if [[ "$flag" == "--redacted" ]]; then + redacted=true + fi + done + + # SC-4: never list raw match content in gate-summary.json + cat > gate-summary.json </dev/null || true) + + path_scan_status="pass" + + # Build the list of paths to scan + local -a scan_paths=() + for p in $PATHS; do + scan_paths+=("$p") + done + for p in $extra_path_scope; do + # Glob-expand extra paths + # shellcheck disable=SC2086 + for expanded in $p; do + [[ -e "$expanded" ]] && scan_paths+=("$expanded") + done + done + + for scan_path in "${scan_paths[@]}"; do + [[ -e "$scan_path" ]] || continue + for pattern in "${all_patterns[@]}"; do + # grep: never print matching content, only file list + local matches + matches=$(grep -rn \ + --include='*.yaml' \ + --include='*.yml' \ + --include='*.json' \ + --include='*.sh' \ + --include='*.md' \ + -l -E "$pattern" \ + "$scan_path" 2>/dev/null || true) + if [[ -n "$matches" ]]; then + path_scan_status="fail" + # Emit REDACTED: never log raw match content + printf '::warning::leak-scan: pattern match found (redacted) in %s paths\n' \ + "$(printf '%s\n' "$matches" | wc -l)" + fi + done + done + + local reason="path-deny-list-${path_scan_status}" + if [[ "$path_scan_status" == "fail" ]]; then + reason="pattern-match-found-REDACTED" + fi + emit_gate_summary "leak-scan" "Leak Scan" "$path_scan_status" "$reason" "none" --redacted +} + +# --------------------------------------------------------------------------- +# Scan modes +# --------------------------------------------------------------------------- + +run_all_refs_scan() { + local gitleaks_exit=0 + local trufflehog_exit=0 + + if command -v gitleaks >/dev/null 2>&1; then + gitleaks detect \ + --source=. \ + --all \ + --redact \ + --report-path=gitleaks-report.json \ + 2>&1 || gitleaks_exit=$? + else + printf '::warning::gitleaks not found; skipping gitleaks all-refs scan\n' + fi + + if command -v trufflehog >/dev/null 2>&1; then + trufflehog filesystem \ + --directory=. \ + --json \ + --no-verification \ + > trufflehog-report.json \ + 2>&1 || trufflehog_exit=$? + else + printf '::warning::trufflehog not found; skipping trufflehog scan\n' + fi + + # Always run path deny-list on full tree + PATHS="." + run_path_deny_list_scan + + local overall_status="pass" + [[ $gitleaks_exit -ne 0 ]] && overall_status="fail" + [[ $trufflehog_exit -ne 0 ]] && overall_status="fail" + [[ "$path_scan_status" != "pass" ]] && overall_status="fail" + + emit_gate_summary "leak-scan" "Leak Scan" "$overall_status" \ + "all-refs-scan-complete" "none" --redacted + + if [[ "$overall_status" != "pass" ]]; then + exit 1 + fi +} + +run_pr_diff_scan() { + [[ -n "$BASE_REF" && -n "$HEAD_REF" ]] \ + || fail "E_MISSING_REFS: pr-diff mode requires base-ref and head-ref" + + local changed_files + changed_files=$(git diff --name-only "$BASE_REF" "$HEAD_REF" 2>/dev/null || echo "") + + if [[ -z "$changed_files" ]]; then + emit_gate_summary "leak-scan" "Leak Scan" "pass" "no-changed-files" "none" --redacted + return 0 + fi + + printf '%s\n' "$changed_files" > changed-files.txt + + local gitleaks_exit=0 + if command -v gitleaks >/dev/null 2>&1; then + gitleaks detect \ + --source=. \ + --files-at-commit="$HEAD_REF" \ + --include-paths=changed-files.txt \ + --redact \ + --report-path=gitleaks-diff-report.json \ + 2>&1 || gitleaks_exit=$? + else + printf '::warning::gitleaks not found; skipping gitleaks pr-diff scan\n' + fi + + # Run path deny-list on changed files + PATHS="$(printf '%s\n' "$changed_files" | tr '\n' ' ')" + run_path_deny_list_scan + + local overall_status="pass" + [[ $gitleaks_exit -ne 0 ]] && overall_status="fail" + [[ "$path_scan_status" != "pass" ]] && overall_status="fail" + + emit_gate_summary "leak-scan" "Leak Scan" "$overall_status" \ + "pr-diff-scan-complete" "none" --redacted + + if [[ "$overall_status" != "pass" ]]; then + exit 1 + fi +} + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +main() { + local mode="${MODE:?MODE is required}" + local deny_list="${DENY_LIST:-default}" + + # Export deny_list for run_path_deny_list_scan to read + # (bash functions inherit the calling scope's local vars via dynamic scope + # only when called in the same function; use a module-level variable here) + export deny_list + + case "$mode" in + all-refs) + run_all_refs_scan + ;; + pr-diff) + run_pr_diff_scan + ;; + path) + [[ -n "${PATHS:-}" ]] \ + || fail "E_MISSING_PATHS: path mode requires paths input" + run_path_deny_list_scan + if [[ "$path_scan_status" != "pass" ]]; then + exit 1 + fi + ;; + *) + fail "E_UNKNOWN_LEAK_SCAN_MODE: ${mode} (allowed: all-refs|pr-diff|path)" + ;; + esac +} + +main "$@" diff --git a/data/leak-patterns.json b/data/leak-patterns.json new file mode 100644 index 0000000..d8c830d --- /dev/null +++ b/data/leak-patterns.json @@ -0,0 +1,107 @@ +{ + "version": "1", + "categories": { + "ipv4_literal": { + "patterns": ["\\b\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\b"] + }, + "ipv6_literal": { + "patterns": ["([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}"] + }, + "cgnat": { + "patterns": [ + "100\\.6[4-9]\\.", + "100\\.[7-9]\\d\\.", + "100\\.1[01]\\d\\.", + "100\\.12[0-7]\\." + ] + }, + "rfc1918": { + "patterns": [ + "192\\.168\\.", + "^10\\.", + "172\\.(1[6-9]|2\\d|3[01])\\." + ] + }, + "k8s_join_tokens": { + "patterns": [ + "apiServerEndpoint", + "node-token", + "agent-token", + "join-token", + "bootstrapToken", + "controlPlane" + ] + }, + "ssh_keys": { + "patterns": [ + "ssh-rsa", + "ssh-ed25519", + "-----BEGIN .* PRIVATE KEY-----" + ] + }, + "vault_refs": { + "patterns": [ + "vaultPath", + "vaultMount", + "vaultClaim", + "vaultPolicy", + "vaultRole", + "vaultNamespace", + "approle" + ] + }, + "hardware_ids": { + "patterns": [ + "([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}", + "/dev/sd[a-z]", + "wwn-", + "by-id/" + ] + }, + "provider_ids": { + "patterns": [ + "zone-id", + "provider-account", + "tailscale-device" + ] + } + }, + "modes": { + "default": [ + "ipv4_literal", + "ipv6_literal", + "cgnat", + "rfc1918", + "k8s_join_tokens", + "ssh_keys", + "vault_refs", + "hardware_ids", + "provider_ids" + ], + "deployment-artifact": [ + "ipv4_literal", + "ipv6_literal", + "cgnat", + "rfc1918", + "k8s_join_tokens", + "ssh_keys", + "vault_refs", + "hardware_ids", + "provider_ids" + ], + "deployment-composition": [ + "ipv4_literal", + "ipv6_literal", + "cgnat", + "rfc1918", + "k8s_join_tokens", + "ssh_keys", + "vault_refs", + "hardware_ids", + "provider_ids" + ] + }, + "extra_paths": { + "deployment-artifact": ["deploy/raw-manifests/**"] + } +} diff --git a/docs/action-pinning.md b/docs/action-pinning.md new file mode 100644 index 0000000..3b3e521 --- /dev/null +++ b/docs/action-pinning.md @@ -0,0 +1,33 @@ +# Action pinning policy + +All third-party GitHub Actions are referenced by SHA digest (not tag), enforced by +renovate.json `pinDigests: true` globally. The weekly "github-actions pinned digests" +PR updates these automatically every Monday before 9 am. + +`JorisJonkers-dev/github-workflows` is our own reusable workflow repo; it uses semver +tags (e.g. `@v1.2.3`) and is **not** digest-pinned — the semver tag IS the integrity +signal because the repo is org-controlled. Renovate updates it when a new semver tag +is published, without digest-pinning. + +`cosign`, `syft`, `oras`, `kubeconform`, `kustomize`, and `yq` are installed in +`actions/deploy-artifact/install-tooling.sh` by SHA-pinned release download URLs +(verified with `sha256sum`). These are updated by a separate `pinned-tooling` group in +renovate.json when one is added. + +## Adding a new third-party action + +1. Find the exact SHA for the version you want: + ``` + gh api repos///git/refs/tags/ --jq '.object.sha' + ``` +2. Reference it as `uses: owner/repo@ # vX.Y.Z` — the comment keeps the version + human-readable while the SHA pins the runtime. +3. Renovate will open a weekly digest-bump PR when a newer version is published. + +## Why `JorisJonkers-dev/github-workflows` is exempt + +Digest-pinning a reusable workflow in the **same** org would break the +`job_workflow_sha` self-checkout pattern used by deploy-artifact, leak-scan, and +deploy-validate: those workflows check out github-workflows at `github.job_workflow_sha` +to guarantee the action code matches the workflow that called it, which is exactly the +integrity contract semver tagging provides within an org-controlled repo. diff --git a/renovate.json b/renovate.json index f70c126..254a7e5 100644 --- a/renovate.json +++ b/renovate.json @@ -1,4 +1,21 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": ["github>JorisJonkers-dev/renovate-config"] + "extends": ["github>JorisJonkers-dev/renovate-config"], + "pinDigests": true, + "packageRules": [ + { + "description": "Group all GitHub Actions SHA-pinned digest updates into one weekly PR", + "matchManagers": ["github-actions"], + "matchPackagePatterns": ["*"], + "excludePackageNames": ["JorisJonkers-dev/github-workflows"], + "groupName": "github-actions pinned digests", + "schedule": ["before 9am on monday"] + }, + { + "description": "JorisJonkers-dev/github-workflows: track semver tags, do NOT pin to digest", + "matchPackageNames": ["JorisJonkers-dev/github-workflows"], + "pinDigests": false, + "versioning": "semver" + } + ] } diff --git a/tests/test_crac_train_workflow.py b/tests/test_crac_train_workflow.py index aa5de17..906ff4b 100644 --- a/tests/test_crac_train_workflow.py +++ b/tests/test_crac_train_workflow.py @@ -612,13 +612,14 @@ def test_release_and_renovate_configs_match_migration_contract(self) -> None: renovate = json.loads(RENOVATE_CONFIG.read_text(encoding="utf-8")) release = json.loads(RELEASE_CONFIG.read_text(encoding="utf-8")) - self.assertEqual( - renovate, - { - "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": ["github>JorisJonkers-dev/renovate-config"], - }, - ) + # Verify the required base fields are present + self.assertEqual(renovate["$schema"], "https://docs.renovatebot.com/renovate-schema.json") + self.assertIn("github>JorisJonkers-dev/renovate-config", renovate["extends"]) + # deploy-platform-d adds pinDigests and packageRules for SHA pinning + # Verify their presence and correctness rather than requiring exact equality + self.assertTrue(renovate.get("pinDigests"), "renovate.json must have pinDigests: true") + package_rules = renovate.get("packageRules", []) + self.assertGreater(len(package_rules), 0, "renovate.json must have packageRules for action pinning") self.assertEqual(release["bootstrap-sha"], "1eef0fe38c718b8263edb46b0bc4e2f1c6eb30a1") self.assertEqual(release["release-type"], "simple") diff --git a/tests/test_deploy_platform_d.py b/tests/test_deploy_platform_d.py new file mode 100644 index 0000000..871738c --- /dev/null +++ b/tests/test_deploy_platform_d.py @@ -0,0 +1,593 @@ +""" +Tests for Chunk D: deploy-artifact.yml, leak-scan.yml, deploy-validate.yml, +actions/deploy-artifact, actions/leak-scan, actions/deploy-preview, +data/leak-patterns.json, and renovate.json. + +Test groups: + T-D1: Workflow interface and permissions shape + T-D2: Leak-scan mode validation + T-D3: Renovate digest-pin assertions + T-D4: Gate-summary artifact naming conventions + T-D5: data/leak-patterns.json canonical shape + T-D6: Shell script error path assertions +""" +from __future__ import annotations + +import json +import os +import stat +import subprocess +import sys +import tempfile +import textwrap +import unittest +from pathlib import Path + +import yaml + + +ROOT = Path(__file__).resolve().parents[1] + +DEPLOY_ARTIFACT_WORKFLOW = ROOT / ".github/workflows/deploy-artifact.yml" +LEAK_SCAN_WORKFLOW = ROOT / ".github/workflows/leak-scan.yml" +DEPLOY_VALIDATE_WORKFLOW = ROOT / ".github/workflows/deploy-validate.yml" + +DEPLOY_ARTIFACT_ACTION = ROOT / "actions/deploy-artifact/action.yml" +LEAK_SCAN_ACTION = ROOT / "actions/leak-scan/action.yml" +DEPLOY_PREVIEW_ACTION = ROOT / "actions/deploy-preview/action.yml" + +LEAK_SCAN_RUN = ROOT / "actions/leak-scan/run.sh" +DEPLOY_ARTIFACT_RUN = ROOT / "actions/deploy-artifact/run.sh" + +LEAK_PATTERNS = ROOT / "data/leak-patterns.json" +RENOVATE_CONFIG = ROOT / "renovate.json" + + +def load_yaml(path: Path) -> dict: + with path.open("r", encoding="utf-8") as fh: + data = yaml.safe_load(fh) + # PyYAML (YAML 1.1) parses `on:` as True; re-key it to "on" for convenience + if True in data: + data["on"] = data.pop(True) + return data + + +def load_json(path: Path) -> dict: + with path.open("r", encoding="utf-8") as fh: + return json.load(fh) + + +def run_script( + script: Path, + env: dict[str, str], + cwd: Path | None = None, +) -> subprocess.CompletedProcess[str]: + """Run a bash script with the given environment, returning the result.""" + base_env = { + "HOME": os.environ.get("HOME", "/root"), + "PATH": os.environ.get("PATH", "/usr/bin:/bin"), + "RUNNER_TEMP": tempfile.mkdtemp(), + } + base_env.update(env) + return subprocess.run( + ["bash", str(script)], + env=base_env, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + cwd=str(cwd) if cwd else None, + ) + + +# --------------------------------------------------------------------------- +# T-D1: Workflow interface and permissions shape +# --------------------------------------------------------------------------- + + +class WorkflowInterfaceTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.deploy_artifact = load_yaml(DEPLOY_ARTIFACT_WORKFLOW) + cls.leak_scan = load_yaml(LEAK_SCAN_WORKFLOW) + cls.deploy_validate = load_yaml(DEPLOY_VALIDATE_WORKFLOW) + + def test_deploy_artifact_outputs_have_value_key(self) -> None: + outputs = self.deploy_artifact["on"]["workflow_call"]["outputs"] + for name, defn in outputs.items(): + self.assertIn( + "value", + defn, + msg=f"output '{name}' in deploy-artifact.yml is missing 'value:' key", + ) + + def test_deploy_artifact_permissions_include_id_token_write(self) -> None: + perms = self.deploy_artifact["permissions"] + self.assertEqual(perms.get("id-token"), "write") + self.assertEqual(perms.get("attestations"), "write") + self.assertEqual(perms.get("packages"), "write") + + def test_deploy_artifact_concurrency_never_cancels_in_progress(self) -> None: + conc = self.deploy_artifact["concurrency"] + self.assertFalse( + conc["cancel-in-progress"], + "deploy-artifact.yml must never cancel in-progress runs", + ) + + def test_leak_scan_output_is_gate_summary_artifact_not_path(self) -> None: + outputs = self.leak_scan["on"]["workflow_call"]["outputs"] + self.assertIn("gate-summary-artifact", outputs) + self.assertNotIn( + "gate-summary-path", + outputs, + "leak-scan.yml must not expose gate-summary-path (only the artifact name)", + ) + + def test_deploy_validate_has_pull_requests_write(self) -> None: + perms = self.deploy_validate["permissions"] + self.assertEqual(perms.get("pull-requests"), "write") + + def test_deploy_artifact_image_lock_artifact_is_only_lock_input(self) -> None: + """VERIFICATION fix: image-lock-artifact is the only lock input (no image-lock-path alternative).""" + inputs = self.deploy_artifact["on"]["workflow_call"]["inputs"] + self.assertIn("image-lock-artifact", inputs) + # image-lock-path must NOT appear as a workflow_call input — the action + # computes it from deploy-dir internally after download + self.assertNotIn( + "image-lock-path", + inputs, + "deploy-artifact.yml workflow_call must not expose image-lock-path; " + "the action derives the path from deploy-dir after artifact download", + ) + + def test_deploy_artifact_finalize_job_has_if_always(self) -> None: + finalize_job = self.deploy_artifact["jobs"]["finalize"] + self.assertEqual( + finalize_job.get("if", ""), + "always()", + "finalize job must run even on failure (if: always())", + ) + + def test_leak_scan_upload_has_if_always(self) -> None: + scan_steps = self.leak_scan["jobs"]["scan"]["steps"] + upload_steps = [ + s for s in scan_steps if "actions/upload-artifact" in str(s.get("uses", "")) + ] + self.assertTrue( + upload_steps, + "leak-scan.yml scan job must have an upload-artifact step", + ) + for step in upload_steps: + self.assertEqual( + step.get("if", ""), + "always()", + "leak-scan gate-summary upload must have if: always()", + ) + + def test_deploy_validate_upload_steps_have_if_always(self) -> None: + validate_steps = self.deploy_validate["jobs"]["validate"]["steps"] + upload_steps = [ + s for s in validate_steps if "actions/upload-artifact" in str(s.get("uses", "")) + ] + self.assertGreaterEqual(len(upload_steps), 2, "deploy-validate must upload 2 artifacts") + for step in upload_steps: + self.assertEqual( + step.get("if", ""), + "always()", + "deploy-validate upload steps must have if: always()", + ) + + +# --------------------------------------------------------------------------- +# T-D2: Leak-scan mode validation (shell tests on the real run.sh) +# --------------------------------------------------------------------------- + + +class LeakScanModeTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.script = LEAK_SCAN_RUN + cls.gw_root = ROOT + + def _run(self, env_overrides: dict[str, str], cwd: Path | None = None) -> subprocess.CompletedProcess[str]: + base = { + "GW_ROOT": str(self.gw_root), + "PATHS": ".", + "BASE_REF": "", + "HEAD_REF": "", + "DENY_LIST": "default", + } + base.update(env_overrides) + return run_script(self.script, env=base, cwd=cwd) + + def test_pr_diff_mode_fails_without_base_and_head_ref(self) -> None: + result = self._run({"MODE": "pr-diff", "BASE_REF": "", "HEAD_REF": ""}) + self.assertNotEqual(result.returncode, 0) + self.assertIn("E_MISSING_REFS", result.stderr) + + def test_path_mode_fails_without_paths(self) -> None: + result = self._run({"MODE": "path", "PATHS": ""}) + self.assertNotEqual(result.returncode, 0) + self.assertIn("E_MISSING_PATHS", result.stderr) + + def test_unknown_mode_fails_with_error_code(self) -> None: + result = self._run({"MODE": "invalid-mode"}) + self.assertNotEqual(result.returncode, 0) + self.assertIn("E_UNKNOWN_LEAK_SCAN_MODE", result.stderr) + + def test_all_refs_mode_line_in_script(self) -> None: + """T-D2: all-refs mode runs gitleaks with --all and --redact flags.""" + script_text = LEAK_SCAN_RUN.read_text(encoding="utf-8") + # Script uses line continuations; check for key flags independently + self.assertIn("gitleaks detect", script_text) + self.assertIn("--all", script_text) + self.assertIn("--redact", script_text) + + def test_path_mode_detects_ipv4_literal(self) -> None: + """T-D2: path mode detects IPv4 literal and emits redacted gate-summary.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmppath = Path(tmpdir) + (tmppath / "test.yaml").write_text("host: 192.168.1.50\n", encoding="utf-8") + result = self._run( + {"MODE": "path", "PATHS": tmpdir, "DENY_LIST": "default"}, + cwd=tmppath, + ) + summary_path = tmppath / "gate-summary.json" + self.assertTrue( + summary_path.exists(), + "gate-summary.json must be written even on failure", + ) + summary = json.loads(summary_path.read_text(encoding="utf-8")) + self.assertEqual(summary["status"], "fail") + self.assertTrue(summary["redacted"], "gate-summary must have redacted=true") + # Raw match content must NOT appear in gate-summary + self.assertNotIn( + "192.168.1.50", + json.dumps(summary), + "Raw IP address must not appear in gate-summary.json", + ) + + def test_path_mode_clean_scan_emits_pass_gate_summary(self) -> None: + """T-D2: path mode on a clean directory emits pass gate-summary.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmppath = Path(tmpdir) + (tmppath / "clean.yaml").write_text("name: my-service\n", encoding="utf-8") + result = self._run( + {"MODE": "path", "PATHS": tmpdir, "DENY_LIST": "default"}, + cwd=tmppath, + ) + summary_path = tmppath / "gate-summary.json" + self.assertTrue(summary_path.exists()) + summary = json.loads(summary_path.read_text(encoding="utf-8")) + self.assertEqual(summary["status"], "pass") + self.assertEqual(summary["gate"], "leak-scan") + + def test_unknown_deny_list_fails_with_error_code(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + tmppath = Path(tmpdir) + (tmppath / "dummy.yaml").write_text("x: y\n", encoding="utf-8") + result = self._run( + {"MODE": "path", "PATHS": tmpdir, "DENY_LIST": "nonexistent-mode"}, + cwd=tmppath, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("E_UNKNOWN_DENY_LIST_MODE", result.stderr) + + +# --------------------------------------------------------------------------- +# T-D3: Renovate digest-pin assertions +# --------------------------------------------------------------------------- + + +class RenovatePinTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.cfg = load_json(RENOVATE_CONFIG) + + def test_pin_digests_is_true_globally(self) -> None: + self.assertTrue( + self.cfg.get("pinDigests"), + "renovate.json must have pinDigests: true at the top level", + ) + + def test_github_workflows_is_not_digest_pinned(self) -> None: + rules = self.cfg.get("packageRules", []) + gw_rule = next( + (r for r in rules if "JorisJonkers-dev/github-workflows" in r.get("matchPackageNames", [])), + None, + ) + self.assertIsNotNone(gw_rule, "renovate.json must have a rule for JorisJonkers-dev/github-workflows") + self.assertFalse( + gw_rule.get("pinDigests", True), + "JorisJonkers-dev/github-workflows must have pinDigests: false", + ) + self.assertEqual( + gw_rule.get("versioning"), + "semver", + "JorisJonkers-dev/github-workflows must use semver versioning", + ) + + def test_github_actions_group_is_weekly(self) -> None: + rules = self.cfg.get("packageRules", []) + actions_group = next( + (r for r in rules if r.get("groupName") == "github-actions pinned digests"), + None, + ) + self.assertIsNotNone(actions_group, "renovate.json must have 'github-actions pinned digests' group") + schedule_str = " ".join(actions_group.get("schedule", [])) + self.assertIn("monday", schedule_str.lower(), "github-actions group must run on monday") + self.assertIn( + "JorisJonkers-dev/github-workflows", + actions_group.get("excludePackageNames", []), + "github-workflows must be excluded from the digest-pin group", + ) + + +# --------------------------------------------------------------------------- +# T-D4: Gate-summary artifact naming convention +# --------------------------------------------------------------------------- + + +class GateSummaryNamingTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.deploy_artifact = load_yaml(DEPLOY_ARTIFACT_WORKFLOW) + cls.leak_scan = load_yaml(LEAK_SCAN_WORKFLOW) + cls.deploy_validate = load_yaml(DEPLOY_VALIDATE_WORKFLOW) + cls.leak_scan_run = LEAK_SCAN_RUN.read_text(encoding="utf-8") + + def _get_upload_artifact_names(self, workflow: dict) -> list[str]: + names = [] + for job in workflow.get("jobs", {}).values(): + for step in job.get("steps", []): + uses = step.get("uses", "") + if "actions/upload-artifact" in uses: + names.append(step.get("with", {}).get("name", "")) + return names + + def test_deploy_artifact_gate_summary_name(self) -> None: + names = self._get_upload_artifact_names(self.deploy_artifact) + self.assertIn( + "gate-summary-deploy-artifact", + names, + "deploy-artifact.yml must upload artifact named gate-summary-deploy-artifact", + ) + + def test_leak_scan_gate_summary_name(self) -> None: + names = self._get_upload_artifact_names(self.leak_scan) + self.assertIn( + "gate-summary-leak-scan", + names, + "leak-scan.yml must upload artifact named gate-summary-leak-scan", + ) + + def test_deploy_validate_gate_summary_name(self) -> None: + names = self._get_upload_artifact_names(self.deploy_validate) + self.assertIn( + "gate-summary-deploy-validate", + names, + "deploy-validate.yml must upload artifact named gate-summary-deploy-validate", + ) + + def test_deploy_validate_preview_summary_name(self) -> None: + names = self._get_upload_artifact_names(self.deploy_validate) + self.assertIn( + "deploy-preview-summary", + names, + "deploy-validate.yml must upload artifact named deploy-preview-summary", + ) + + def test_gate_summary_output_names_match_upload_names(self) -> None: + """Verify workflow output gate-summary-artifact values match actual upload names.""" + for wf_name, wf in [ + ("deploy-artifact", self.deploy_artifact), + ("leak-scan", self.leak_scan), + ("deploy-validate", self.deploy_validate), + ]: + outputs = wf["on"]["workflow_call"]["outputs"] + gsa = outputs.get("gate-summary-artifact", {}) + self.assertIn( + "value", + gsa, + msg=f"{wf_name}.yml gate-summary-artifact output missing value:", + ) + + def test_leak_scan_run_emits_leak_scan_gate(self) -> None: + """Leak-scan run.sh must reference gate 'leak-scan' in its emit_gate_summary calls.""" + self.assertIn('"leak-scan"', self.leak_scan_run) + + +# --------------------------------------------------------------------------- +# T-D5: data/leak-patterns.json canonical shape (SC-8, {modes, extra_paths}) +# --------------------------------------------------------------------------- + + +class LeakPatternsShapeTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.patterns = load_json(LEAK_PATTERNS) + + def test_version_field_present(self) -> None: + self.assertIn("version", self.patterns) + self.assertEqual(self.patterns["version"], "1") + + def test_categories_field_present(self) -> None: + self.assertIn("categories", self.patterns) + self.assertIsInstance(self.patterns["categories"], dict) + + def test_modes_field_uses_flat_category_lists(self) -> None: + """Correction #5: D's {modes, extra_paths} shape is canonical.""" + self.assertIn("modes", self.patterns) + for mode_name, mode_cats in self.patterns["modes"].items(): + self.assertIsInstance( + mode_cats, + list, + msg=f"modes.{mode_name} must be a list of category names", + ) + for cat in mode_cats: + self.assertIn( + cat, + self.patterns["categories"], + msg=f"modes.{mode_name} references unknown category '{cat}'", + ) + + def test_extra_paths_field_present(self) -> None: + self.assertIn("extra_paths", self.patterns) + + def test_deployment_artifact_mode_exists(self) -> None: + self.assertIn("deployment-artifact", self.patterns["modes"]) + + def test_deployment_composition_mode_exists(self) -> None: + self.assertIn("deployment-composition", self.patterns["modes"]) + + def test_default_mode_exists(self) -> None: + self.assertIn("default", self.patterns["modes"]) + + def test_deployment_artifact_extra_paths(self) -> None: + extra = self.patterns.get("extra_paths", {}) + self.assertIn("deployment-artifact", extra) + self.assertIn("deploy/raw-manifests/**", extra["deployment-artifact"]) + + def test_all_required_categories_present(self) -> None: + required = { + "ipv4_literal", + "ipv6_literal", + "cgnat", + "rfc1918", + "k8s_join_tokens", + "ssh_keys", + "vault_refs", + "hardware_ids", + "provider_ids", + } + missing = required - set(self.patterns["categories"].keys()) + self.assertFalse(missing, f"Missing categories in leak-patterns.json: {missing}") + + def test_each_category_has_patterns_list(self) -> None: + for cat_name, cat_def in self.patterns["categories"].items(): + self.assertIn("patterns", cat_def, msg=f"Category '{cat_name}' missing patterns key") + self.assertIsInstance( + cat_def["patterns"], + list, + msg=f"Category '{cat_name}' patterns must be a list", + ) + self.assertGreater( + len(cat_def["patterns"]), + 0, + msg=f"Category '{cat_name}' must have at least one pattern", + ) + + +# --------------------------------------------------------------------------- +# T-D6: Action script content checks +# --------------------------------------------------------------------------- + + +class DeployArtifactRunShTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.run_sh = DEPLOY_ARTIFACT_RUN.read_text(encoding="utf-8") + + def test_context_ref_digest_requirement(self) -> None: + self.assertIn("E_CONTEXT_REF_NOT_PINNED", self.run_sh) + self.assertIn("@sha256:", self.run_sh) + + def test_env_crlf_normalization(self) -> None: + self.assertIn("sed 's/\\r$//'", self.run_sh) + + def test_env_name_validation_regex(self) -> None: + self.assertIn("^[a-z0-9][a-z0-9-]*$", self.run_sh) + + def test_duplicate_env_warning(self) -> None: + self.assertIn("duplicate env", self.run_sh) + self.assertIn("skipped", self.run_sh) + + def test_no_valid_envs_error(self) -> None: + self.assertIn("E_NO_VALID_ENVIRONMENTS", self.run_sh) + + def test_reject_secret_kind(self) -> None: + self.assertIn("E_FORBIDDEN_KIND", self.run_sh) + self.assertIn("kind: Secret", self.run_sh) + + def test_image_lock_missing_guard(self) -> None: + self.assertIn("E_IMAGE_LOCK_MISSING", self.run_sh) + + def test_render_hash_exported_to_github_output(self) -> None: + self.assertIn("render-hash=", self.run_sh) + self.assertIn("GITHUB_OUTPUT", self.run_sh) + + def test_npm_signatures_gate_emitted_before_exit(self) -> None: + self.assertIn("npm-signatures", self.run_sh) + self.assertIn("npm-audit-signatures-failed", self.run_sh) + + def test_schema_version_mismatch_error(self) -> None: + self.assertIn("E_SCHEMA_VERSION_MISMATCH", self.run_sh) + + +class DeployArtifactActionOutputsTest(unittest.TestCase): + """Correction #8: render-hash must be declared in action.yml outputs block.""" + + @classmethod + def setUpClass(cls) -> None: + cls.action = load_yaml(DEPLOY_ARTIFACT_ACTION) + + def test_render_hash_in_action_outputs(self) -> None: + outputs = self.action.get("outputs", {}) + self.assertIn( + "render-hash", + outputs, + "actions/deploy-artifact/action.yml must declare render-hash in its outputs block (correction #8)", + ) + + def test_render_hash_output_has_value(self) -> None: + outputs = self.action.get("outputs", {}) + render_hash_def = outputs.get("render-hash", {}) + self.assertIn( + "value", + render_hash_def, + "render-hash output must have a value: expression", + ) + + +class DeployPreviewRunShTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.run_sh = (ROOT / "actions/deploy-preview/run.sh").read_text(encoding="utf-8") + + def test_scorecard_keys_present(self) -> None: + scorecard_keys = [ + "schema_pinned", + "context_pinned", + "no_latest_images", + "health_declared", + "route_owner_authmode_declared", + "rollback_retention_acknowledged", + "no_raw_secrets", + "stateful_policy_declared", + "raw_manifests_guarded", + "npm_signatures_verified", + ] + for key in scorecard_keys: + self.assertIn(key, self.run_sh, msg=f"SC-11 key '{key}' missing from deploy-preview/run.sh") + + def test_sticky_pr_comment_marker_present(self) -> None: + self.assertIn("deploy-preview-marker", self.run_sh) + + def test_gate_summary_emitted(self) -> None: + self.assertIn("deploy-validate", self.run_sh) + self.assertIn("scorecard-evaluated", self.run_sh) + + def test_five_fragments_rendered(self) -> None: + fragments = [ + "kubernetes-workload-fragment", + "traefik-route-fragment", + "gatus-endpoint-fragment", + "edge-catalog-fragment", + "image-metadata-fragment", + ] + for fragment in fragments: + self.assertIn(fragment, self.run_sh, msg=f"Fragment '{fragment}' not rendered in deploy-preview/run.sh") + + +if __name__ == "__main__": + unittest.main() From 7263b005457eeda88f24a499b43a9cd5e05c8b16 Mon Sep 17 00:00:00 2001 From: JorisJonkers Agent Date: Wed, 8 Jul 2026 09:47:10 +0000 Subject: [PATCH 2/2] fix: pin real SHA256 digests for deploy-artifact tooling install-tooling.sh shipped with placeholder digests that would fail checksum verification on first CI run; digests now computed from the actual v2.4.3/1.28.0/1.2.3/0.7.0/5.6.0/4.45.3 release artifacts. --- actions/deploy-artifact/install-tooling.sh | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/actions/deploy-artifact/install-tooling.sh b/actions/deploy-artifact/install-tooling.sh index e703e88..bff1033 100755 --- a/actions/deploy-artifact/install-tooling.sh +++ b/actions/deploy-artifact/install-tooling.sh @@ -22,7 +22,7 @@ verify_sha256() { install_cosign() { # cosign v2.4.3 linux/amd64 local version="2.4.3" - local sha256="c34d8a0e60adf77ec39a10bedb28a5baa7b9e81b9f7ee01c5fc2f53f5d00d65c" + local sha256="caaad125acef1cb81d58dcdc454a1e429d09a750d1e9e2b3ed1aed8964454708" local url="https://github.com/sigstore/cosign/releases/download/v${version}/cosign-linux-amd64" curl -sSfL "$url" -o /tmp/cosign verify_sha256 /tmp/cosign "$sha256" @@ -34,7 +34,7 @@ install_cosign() { install_syft() { # syft v1.28.0 linux amd64 local version="1.28.0" - local sha256="d63f7aa6af7a1e68f5ea9be2c5b86a1f51d1ddea3fae4a1218cf3feae3e10b1c" + local sha256="3edee7fe1ceb1f78360e547f57048930d57f00c7ec3d0b8bdfb902805f048468" local url="https://github.com/anchore/syft/releases/download/v${version}/syft_${version}_linux_amd64.tar.gz" curl -sSfL "$url" -o /tmp/syft.tar.gz verify_sha256 /tmp/syft.tar.gz "$sha256" @@ -47,7 +47,7 @@ install_syft() { install_oras() { # oras v1.2.3 linux amd64 local version="1.2.3" - local sha256="c22a7b5f05f3f4ced3e8d50e2b4649c6714b1ad8a17d8e3d9c62bd9432f9e66a" + local sha256="b4efc97a91f471f323f193ea4b4d63d8ff443ca3aab514151a30751330852827" local url="https://github.com/oras-project/oras/releases/download/v${version}/oras_${version}_linux_amd64.tar.gz" curl -sSfL "$url" -o /tmp/oras.tar.gz verify_sha256 /tmp/oras.tar.gz "$sha256" @@ -60,7 +60,7 @@ install_oras() { install_kubeconform() { # kubeconform v0.7.0 linux amd64 local version="0.7.0" - local sha256="1ed55e96dc8f95ad7b3f21be44c00e3b3fc62a8d12ec6a9474b18a501f84f601" + local sha256="c31518ddd122663b3f3aa874cfe8178cb0988de944f29c74a0b9260920d115d3" local url="https://github.com/yannh/kubeconform/releases/download/v${version}/kubeconform-linux-amd64.tar.gz" curl -sSfL "$url" -o /tmp/kubeconform.tar.gz verify_sha256 /tmp/kubeconform.tar.gz "$sha256" @@ -73,7 +73,7 @@ install_kubeconform() { install_kustomize() { # kustomize v5.6.0 linux amd64 local version="5.6.0" - local sha256="e8fc6a33fd15c4e10ad55dae02a3b3e22c50efefe83fadd84a2b4ce40e85ab2f" + local sha256="54e4031ddc4e7fc59e408da29e7c646e8e57b8088c51b84b3df0864f47b5148f" local url="https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv${version}/kustomize_v${version}_linux_amd64.tar.gz" curl -sSfL "$url" -o /tmp/kustomize.tar.gz verify_sha256 /tmp/kustomize.tar.gz "$sha256" @@ -86,7 +86,7 @@ install_kustomize() { install_yq() { # yq v4.45.3 linux amd64 local version="4.45.3" - local sha256="c1a4c14b8dfd9c2e7ed2c1a7e0e20d37ff7a6413e87f0af5fa69ec93a0742b2d" + local sha256="2c621387e61e7f6bd14e85077c4bce36bc99d198804721501a1f14c236f3a2a9" local url="https://github.com/mikefarah/yq/releases/download/v${version}/yq_linux_amd64" curl -sSfL "$url" -o /tmp/yq verify_sha256 /tmp/yq "$sha256"