From 6352093be3089f32223d3e9aab1bcf509d5d048e Mon Sep 17 00:00:00 2001 From: "jorisjonkers-dev-agents[bot]" <290081373+jorisjonkers-dev-agents[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 02:18:25 +0000 Subject: [PATCH] feat(platform): adopt deploy-platform v2 - Add platform/ directory with deployment.yml (v2), images.lock.json, production.env, and render-local.sh - Add publish.yml (tag-triggered image build + deploy-artifact publish + register-service PR on homelab-deploy) - Add deploy-preview.yml (PR scorecard validation on platform/** changes) - Replace release.yml: mandatory App token, remove publish-image and publish-deploy-bundle jobs, keep publish-api-clients, add workflow_dispatch - All github-workflows pinned to SHA f6c2969 (v0.12.0) --- .github/workflows/deploy-preview.yml | 27 ++ .github/workflows/publish.yml | 236 ++++++++++++++++ .github/workflows/release.yml | 189 ++++--------- platform/deployment.yml | 24 ++ platform/images.lock.json | 3 + platform/production.env | 3 + platform/render-local.sh | 385 +++++++++++++++++++++++++++ 7 files changed, 728 insertions(+), 139 deletions(-) create mode 100644 .github/workflows/deploy-preview.yml create mode 100644 .github/workflows/publish.yml create mode 100644 platform/deployment.yml create mode 100644 platform/images.lock.json create mode 100644 platform/production.env create mode 100755 platform/render-local.sh diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml new file mode 100644 index 0000000..8676285 --- /dev/null +++ b/.github/workflows/deploy-preview.yml @@ -0,0 +1,27 @@ +# Deploy Preview for deploy-platform service repos. +# +# Any PR touching platform/ gets a full render + validation and a sticky +# "Deploy Preview" comment with the SC-11 readiness scorecard. +name: Deploy Preview + +on: + pull_request: + paths: + - 'platform/**' + +permissions: {} + +jobs: + deploy-preview: + uses: JorisJonkers-dev/github-workflows/.github/workflows/deploy-validate.yml@f6c2969d7f1f4555da3b2cf46ce6a9b364c471b3 # v0.12.0 + with: + deploy-dir: platform + schema-version: 0.16.0 + image-lock-path: platform/images.lock.json + context-ref: ghcr.io/jorisjonkers-dev/cluster-deploy-context-public@sha256:64d00fe03a271dbd03a48005d0a0cc6cc5fe43df23c0e97b649c2f8b3e78b418 + environments: production + comment: true + permissions: + contents: read + packages: read + pull-requests: write diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..942b591 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,236 @@ +# Tag-triggered publish flow for deploy-platform service repos. +# +# push of a v*.*.* tag (authored by release.yml via the App token) drives: +# publish-image -> resolve-image-lock -> publish-deploy-artifact -> register-service +# +# Boundary rule (R1-2): the image lock crosses the reusable-workflow boundary +# as an UPLOADED ARTIFACT (image-lock-artifact), never as a runner-local path. +# deploy-artifact.yml downloads the artifact itself; there is no intermediate +# download job and no image-lock-path input on the deploy-artifact call. +name: Publish + +on: + push: + tags: + - 'v*.*.*' + +permissions: {} + +jobs: + # -- 1. Build and push the container image -------------------------------- + publish-image: + uses: JorisJonkers-dev/github-workflows/.github/workflows/container-publish.yml@f6c2969d7f1f4555da3b2cf46ce6a9b364c471b3 # v0.12.0 + with: + image-name: auth-api/auth-api + version: ${{ github.ref_name }} + permissions: + contents: read + packages: write + + # -- 2. Resolve the image lock (upload artifact, never path) -------------- + resolve-image-lock: + needs: [publish-image] + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + outputs: + images-lock-artifact: images-lock-${{ github.run_id }} + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.ref }} + + - name: resolve-image-digest + id: digest + env: + GH_TOKEN: ${{ github.token }} + IMAGE: ghcr.io/jorisjonkers-dev/auth-api/auth-api + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + printf '%s' "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin + digest="$(docker buildx imagetools inspect "${IMAGE}:${TAG}" --format '{{json .Manifest}}' | jq -r '.digest')" + if [ -z "$digest" ] || [ "$digest" = "null" ]; then + echo "E_FLOATING_IMAGE: could not resolve a digest for ${IMAGE}:${TAG}" >&2 + exit 1 + fi + echo "image-digest=$digest" >> "$GITHUB_OUTPUT" + + - name: lock-image + env: + IMAGE_DIGEST: ${{ steps.digest.outputs.image-digest }} + run: | + set -euo pipefail + if [ -z "$IMAGE_DIGEST" ] || [ "$IMAGE_DIGEST" = "null" ]; then + echo "E_FLOATING_IMAGE: IMAGE_DIGEST is empty" >&2 + exit 1 + fi + case "$IMAGE_DIGEST" in + sha256:*) ;; + *) echo "E_FLOATING_IMAGE: IMAGE_DIGEST is not a digest ref (got: $IMAGE_DIGEST)" >&2; exit 1 ;; + esac + printf '{"auth-api":"ghcr.io/jorisjonkers-dev/auth-api/auth-api@%s"}\n' "$IMAGE_DIGEST" \ + > platform/images.lock.json + echo "Locked image: ghcr.io/jorisjonkers-dev/auth-api/auth-api@${IMAGE_DIGEST}" + + - name: verify-lock + run: | + set -euo pipefail + lock_ref=$(jq -r '.["auth-api"]' platform/images.lock.json) + case "$lock_ref" in + *@sha256:*) echo "Digest pin verified: $lock_ref" ;; + *) echo "E_FLOATING_IMAGE: lock ref is not digest-pinned: $lock_ref" >&2; exit 1 ;; + esac + + - name: upload-image-lock + uses: actions/upload-artifact@v5 + with: + name: images-lock-${{ github.run_id }} + path: platform/images.lock.json + retention-days: 1 + + # -- 3. Render and publish the deploy artifact ---------------------------- + publish-deploy-artifact: + needs: [resolve-image-lock] + uses: JorisJonkers-dev/github-workflows/.github/workflows/deploy-artifact.yml@f6c2969d7f1f4555da3b2cf46ce6a9b364c471b3 # v0.12.0 + with: + ref: ${{ github.ref }} + artifact-name: auth-api + artifact-version: ${{ github.ref_name }} + schema-version: 0.16.0 + context-ref: ghcr.io/jorisjonkers-dev/cluster-deploy-context-public@sha256:64d00fe03a271dbd03a48005d0a0cc6cc5fe43df23c0e97b649c2f8b3e78b418 + deploy-dir: platform + image-lock-artifact: ${{ needs.resolve-image-lock.outputs.images-lock-artifact }} + environments: production + permissions: + contents: read + packages: write + id-token: write + attestations: write + + # -- 4. Open/refresh the homelab-deploy registry PR ------------------------ + register-service: + needs: [publish-deploy-artifact] + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: mint-app-token + id: app-token + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: homelab-deploy + + - name: assert-token-minted + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + if [ -z "$APP_TOKEN" ]; then + echo "E_APP_TOKEN_MISSING: App token was not minted for register-service." >&2 + exit 1 + fi + + - uses: actions/checkout@v7 + with: + ref: ${{ github.ref }} + + - name: build-registry-payload + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + ARTIFACT_REF: ${{ needs.publish-deploy-artifact.outputs.artifact-ref }} + ARTIFACT_DIGEST: ${{ needs.publish-deploy-artifact.outputs.artifact-digest }} + SERVICE_NAME: auth-api + REPO_OWNER: ${{ github.repository_owner }} + SOURCE_REPOSITORY: ${{ github.repository }} + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + branch="registry-update/${SERVICE_NAME}/${TAG}" + registry_path="registry/${SERVICE_NAME}.yaml" + repo="${REPO_OWNER}/homelab-deploy" + + if ! gh api "repos/${repo}/git/refs/heads/${branch}" >/dev/null 2>&1; then + main_sha="$(gh api "repos/${repo}/git/refs/heads/main" --jq '.object.sha')" + gh api --method POST "repos/${repo}/git/refs" \ + -f ref="refs/heads/${branch}" -f sha="$main_sha" >/dev/null + fi + + current_sha="" + if gh api "repos/${repo}/contents/${registry_path}?ref=${branch}" > current.json 2>/dev/null; then + current_sha="$(jq -r '.sha' current.json)" + jq -r '.content' current.json | base64 -d > current.yaml + fi + + python3 - "$SERVICE_NAME" <<'PY' + import os, sys, yaml + + service = sys.argv[1] + artifact = { + "ref": os.environ["ARTIFACT_REF"], + "digest": os.environ["ARTIFACT_DIGEST"], + "contractPath": "artifact-contract.yaml", + } + + if os.path.exists("current.yaml"): + doc = yaml.safe_load(open("current.yaml")) + doc["spec"]["artifact"] = artifact + else: + dep = yaml.safe_load(open("platform/deployment.yml")) + spec = dep.get("spec", {}) + platform = spec.get("platform") or {} + layer = platform.get("layer") + if not layer: + sys.exit("E_UNKNOWN_LAYER: deployment.yml spec.platform.layer is " + "required for first registration") + workloads = spec.get("workloads") or [] + health = (workloads[0].get("health") if workloads else None) or {} + doc = { + "apiVersion": "deployment.jorisjonkers.dev/deploy-unit-registration/v1", + "kind": "DeployUnitRegistration", + "metadata": { + "name": service, + "owner": os.environ["REPO_OWNER"], + }, + "spec": { + "artifact": artifact, + "namespace": spec["namespace"], + "layer": layer, + "sourceRepository": os.environ["SOURCE_REPOSITORY"], + "environments": spec.get("environments") or ["production"], + "healthClass": health.get("class") + or health.get("timeoutClass") + or "stateless", + "prune": {"default": True}, + "allowedClusterScope": [], + }, + } + + yaml.safe_dump(doc, open("registration.yaml", "w"), + sort_keys=False, default_flow_style=False) + PY + + if [ -n "$current_sha" ]; then + gh api --method PUT "repos/${repo}/contents/${registry_path}" \ + -f message="chore(registry): update ${SERVICE_NAME} to ${TAG}" \ + -f content="$(base64 -w0 registration.yaml)" \ + -f sha="$current_sha" -f branch="$branch" >/dev/null + else + gh api --method PUT "repos/${repo}/contents/${registry_path}" \ + -f message="chore(registry): register ${SERVICE_NAME}" \ + -f content="$(base64 -w0 registration.yaml)" \ + -f branch="$branch" >/dev/null + fi + + open_prs="$(gh api "repos/${repo}/pulls?head=${REPO_OWNER}:${branch}&state=open" --jq 'length')" + if [ "$open_prs" -eq 0 ]; then + gh api --method POST "repos/${repo}/pulls" \ + -f title="chore(registry): update ${SERVICE_NAME} to ${TAG}" \ + -f body="Automated registry update from publish.yml. Artifact: ${ARTIFACT_REF}" \ + -f head="$branch" -f base="main" >/dev/null + else + echo "Updated existing registry PR for branch ${branch}" + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 70e27bf..97fc795 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,157 +1,68 @@ -name: 'Release' +name: Release -'on': +on: push: - branches: - - 'main' + branches: [main] + workflow_dispatch: {} -permissions: - contents: 'write' - pull-requests: 'write' - packages: 'write' +permissions: {} jobs: release-please: - name: 'Release Please' - runs-on: 'ubuntu-latest' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write outputs: - release_created: '${{ steps.rp.outputs.release_created }}' - tag_name: '${{ steps.rp.outputs.tag_name }}' + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} steps: - - uses: 'actions/create-github-app-token@v3' - id: 'app-token' - continue-on-error: true + - name: mint-app-token + id: app-token + uses: actions/create-github-app-token@v3 with: - app-id: '${{ secrets.RELEASE_APP_ID }}' - private-key: '${{ secrets.RELEASE_APP_PRIVATE_KEY }}' + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - - uses: 'googleapis/release-please-action@v5' - id: 'rp' - with: - token: '${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }}' - config-file: 'release-please-config.json' - manifest-file: '.release-please-manifest.json' + - name: assert-token-minted + # Fail loudly if the App token is missing — never fall back to GITHUB_TOKEN. + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + if [ -z "$APP_TOKEN" ]; then + echo "E_APP_TOKEN_MISSING: Release App token was not minted." >&2 + echo "Secrets RELEASE_APP_ID and RELEASE_APP_PRIVATE_KEY must be configured." >&2 + echo "GITHUB_TOKEN fallback is intentionally forbidden." >&2 + exit 1 + fi + echo "App token minted successfully (token prefix: ${APP_TOKEN:0:4}...)" - publish-image: - needs: - - 'release-please' - if: '${{ needs.release-please.outputs.release_created == ''true'' }}' - uses: 'JorisJonkers-dev/github-workflows/.github/workflows/container-publish.yml@v0.10.2' - with: - image-name: 'auth-api/auth-api' - version: '${{ needs.release-please.outputs.tag_name }}' - context: '.' - dockerfile: 'Dockerfile' - secrets: - packages-token: '${{ secrets.GITHUB_TOKEN }}' + - uses: googleapis/release-please-action@v5 + id: release + with: + token: ${{ steps.app-token.outputs.token }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json publish-api-clients: - name: 'Publish API Clients' - needs: - - 'release-please' - if: '${{ needs.release-please.outputs.release_created == ''true'' }}' - uses: 'JorisJonkers-dev/github-workflows/.github/workflows/publish-api-clients.yml@v0.10.2' + name: Publish API Clients + needs: [release-please] + if: ${{ needs.release-please.outputs.release_created == 'true' }} + uses: JorisJonkers-dev/github-workflows/.github/workflows/publish-api-clients.yml@f6c2969d7f1f4555da3b2cf46ce6a9b364c471b3 # v0.12.0 permissions: - contents: 'read' - packages: 'write' - id-token: 'write' + contents: read + packages: write + id-token: write with: - spec-path: 'client-spec/openapi/auth-api.json' - api-name: 'auth-api' - version: '${{ needs.release-please.outputs.tag_name }}' + spec-path: client-spec/openapi/auth-api.json + api-name: auth-api + version: ${{ needs.release-please.outputs.tag_name }} ts-package: '@jorisjonkers-dev/auth-api-client' - maven-group: 'dev.jorisjonkers' - java-artifact: 'auth-api-client-java' - kotlin-artifact: 'auth-api-client-kotlin' - openapi-client-gradle-version: '0.3.1' + maven-group: dev.jorisjonkers + java-artifact: auth-api-client-java + kotlin-artifact: auth-api-client-kotlin + openapi-client-gradle-version: 0.3.1 dry-run: false secrets: - packages-token: '${{ secrets.GITHUB_TOKEN }}' - node-auth-token: '${{ secrets.GITHUB_TOKEN }}' - - publish-deploy-bundle: - name: 'Publish Deploy Bundle' - needs: - - 'release-please' - - 'publish-image' - if: '${{ needs.release-please.outputs.release_created == ''true'' }}' - runs-on: 'ubuntu-latest' - permissions: - contents: 'read' - packages: 'write' - steps: - - uses: 'actions/checkout@v6' - - - uses: 'actions/checkout@v6' - with: - repository: 'JorisJonkers-dev/github-workflows' - ref: 'v0.8.0' - path: '.github-workflows' - persist-credentials: false - - - uses: 'docker/setup-buildx-action@v4' - - - uses: 'docker/login-action@v4' - with: - registry: 'ghcr.io' - username: '${{ github.actor }}' - password: '${{ secrets.GITHUB_TOKEN }}' - - - name: 'Resolve released image digest' - id: 'image' - env: - IMAGE_NAME: 'auth-api/auth-api' - VERSION: '${{ needs.release-please.outputs.tag_name }}' - GITHUB_REPOSITORY_OWNER: '${{ github.repository_owner }}' - run: | - set -euo pipefail - - owner="$(printf '%s' "$GITHUB_REPOSITORY_OWNER" | tr '[:upper:]' '[:lower:]')" - image_ref="ghcr.io/${owner}/${IMAGE_NAME}:${VERSION}" - digest="$(docker buildx imagetools inspect "$image_ref" --format '{{json .Manifest.Digest}}' | jq -r .)" - if [ -z "$digest" ] || [ "$digest" = 'null' ]; then - echo "::error::Unable to resolve digest for $image_ref" - exit 1 - fi - printf 'image=%s@%s\n' "$image_ref" "$digest" >> "$GITHUB_OUTPUT" - - - name: 'Write release image lock' - env: - IMAGE_REF: '${{ steps.image.outputs.image }}' - run: | - set -euo pipefail - - jq -n --arg image "$IMAGE_REF" '[$image]' > "$RUNNER_TEMP/images.lock.json" - - - name: 'Pack deploy bundle' - id: 'bundle' - uses: './.github-workflows/actions/deploy-bundle' - with: - deploy-dir: 'deploy' - images: '${{ runner.temp }}/images.lock.json' - package-version: '0.9.0' - version: '${{ needs.release-please.outputs.tag_name }}' - bundle-name: 'auth-api' - node-auth-token: '${{ secrets.GITHUB_TOKEN }}' - - - uses: 'oras-project/setup-oras@v2' - - - name: 'Log in to GHCR' - env: - GHCR_TOKEN: '${{ secrets.GITHUB_TOKEN }}' - run: | - set -euo pipefail - - oras login ghcr.io --username "$GITHUB_ACTOR" --password-stdin <<< "$GHCR_TOKEN" - - - name: 'Publish deploy bundle' - env: - BUNDLE_PATH: '${{ steps.bundle.outputs.bundle-path }}' - BUNDLE_VERSION: '${{ steps.bundle.outputs.bundle-version }}' - GITHUB_REPOSITORY_OWNER: '${{ github.repository_owner }}' - run: | - set -euo pipefail - - owner="$(printf '%s' "$GITHUB_REPOSITORY_OWNER" | tr '[:upper:]' '[:lower:]')" - bundle_ref="ghcr.io/${owner}/auth-api-deploy-bundle:${BUNDLE_VERSION}" - oras push "$bundle_ref" "$BUNDLE_PATH:application/vnd.jorisjonkers.deployment.bundle.v1+tar" + packages-token: ${{ secrets.GITHUB_TOKEN }} + node-auth-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/platform/deployment.yml b/platform/deployment.yml new file mode 100644 index 0000000..1ffb9c8 --- /dev/null +++ b/platform/deployment.yml @@ -0,0 +1,24 @@ +apiVersion: deployment.jorisjonkers.dev/v2 +kind: Deployment +metadata: + name: auth-api +spec: + schemaVersion: 0.16.0 + namespace: auth-system + platform: + layer: apps-core + workloads: + - name: auth-api + image: auth-api + health: + path: /api/actuator/health/readiness + port: 8081 + timeoutClass: stateless + mandatory: true + rollbackTargetRetention: + minimumDays: 90 + acknowledged: true + routes: + - host: auth.jorisjonkers.dev + owner: auth-api + authMode: anonymous diff --git a/platform/images.lock.json b/platform/images.lock.json new file mode 100644 index 0000000..9e9b7fe --- /dev/null +++ b/platform/images.lock.json @@ -0,0 +1,3 @@ +{ + "auth-api": "ghcr.io/jorisjonkers-dev/auth-api/auth-api@sha256:0000000000000000000000000000000000000000000000000000000000000000" +} diff --git a/platform/production.env b/platform/production.env new file mode 100644 index 0000000..9edc945 --- /dev/null +++ b/platform/production.env @@ -0,0 +1,3 @@ +# Non-secret production environment values for auth-api. +# Secrets NEVER live here — they are delivered by the platform secret store. +# Rendered into the workload fragment by deploy-config-schema. diff --git a/platform/render-local.sh b/platform/render-local.sh new file mode 100755 index 0000000..a82aa8c --- /dev/null +++ b/platform/render-local.sh @@ -0,0 +1,385 @@ +#!/usr/bin/env bash +# render-local.sh — local CI-parity render for auth-api. +# +# Mirrors what deploy-validate.yml / deploy-artifact.yml do in CI: +# validate -> render -> kubeconform -> leak-scan -> scorecard +# +# Usage: +# ./platform/render-local.sh [--diff] [--context-dir PATH] [--scorecard-only] +# +# Options: +# --diff compare rendered output against committed fixtures; exit 1 on drift +# --context-dir use a local context directory (bypasses digest requirement for local dev) +# --scorecard-only skip install/pull/render and only evaluate the SC-11 scorecard +# (dry mode: works without the npm package installed) +# +# The scorecard functions are pure shell + jq so they stay testable without +# network access or the @jorisjonkers-dev/deploy-config-schema npm package. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +FLAG_DIFF=false +FLAG_SCORECARD_ONLY=false +CONTEXT_DIR="${CONTEXT_DIR:-}" +CONTEXT_REF="${CONTEXT_REF:-ghcr.io/jorisjonkers-dev/cluster-deploy-context-public@sha256:64d00fe03a271dbd03a48005d0a0cc6cc5fe43df23c0e97b649c2f8b3e78b418}" +OUT_DIR="${OUT_DIR:-$REPO_ROOT/out}" +RESOLVED_VERSION="" +NPM_PROVENANCE_VERIFIED="${NPM_PROVENANCE_VERIFIED:-true}" + +ENVS=(production) +FRAGMENTS=( + kubernetes-workload-fragment + traefik-route-fragment + gatus-endpoint-fragment + edge-catalog-fragment + image-metadata-fragment +) + +log() { echo "[render-local] $*" >&2; } +warn() { echo "[render-local] WARNING: $*" >&2; } +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +usage() { + cat <<'USAGE' +Usage: render-local.sh [--diff] [--context-dir PATH] [--scorecard-only] + +Options: + --diff compare rendered output against committed fixtures; exit 1 on drift + --context-dir use a local context directory (bypasses digest requirement for local dev) + --scorecard-only skip install/pull/render and only evaluate the SC-11 scorecard + -h, --help show this help +USAGE +} + +parse_args() { + while [ $# -gt 0 ]; do + case "$1" in + --diff) + FLAG_DIFF=true + shift + ;; + --context-dir) + [ $# -ge 2 ] || fail "--context-dir requires a PATH argument" + CONTEXT_DIR="$2" + shift 2 + ;; + --scorecard-only) + FLAG_SCORECARD_ONLY=true + shift + ;; + -h | --help) + usage + exit 0 + ;; + *) + usage >&2 + fail "unknown option: $1" + ;; + esac + done +} + +resolve_schema_version() { + if [ -n "${SCHEMA_VERSION:-}" ]; then + RESOLVED_VERSION="$SCHEMA_VERSION" + log "Using SCHEMA_VERSION from env: $RESOLVED_VERSION" + elif [ -f "$REPO_ROOT/.platform/deploy-version" ]; then + RESOLVED_VERSION="$(tr -d '[:space:]' < "$REPO_ROOT/.platform/deploy-version")" + log "Using schema version from .platform/deploy-version: $RESOLVED_VERSION" + else + RESOLVED_VERSION="0.16.0" + log "Using baked-in schema version: $RESOLVED_VERSION" + fi +} + +install_schema() { + log "Installing @jorisjonkers-dev/deploy-config-schema@$RESOLVED_VERSION" + npm install --global "@jorisjonkers-dev/deploy-config-schema@$RESOLVED_VERSION" \ + --registry https://npm.pkg.github.com \ + || fail "failed to install deploy-config-schema@$RESOLVED_VERSION" +} + +npm_audit_and_scorecard_init() { + if npm audit signatures --scope @jorisjonkers-dev >/dev/null 2>&1; then + NPM_PROVENANCE_VERIFIED="true" + else + NPM_PROVENANCE_VERIFIED="false" + warn "npm audit signatures failed; npm_signatures_verified=fail" + fi +} + +require_digest_ref() { + case "$1" in + *@sha256:*) ;; + *) fail "E_CONTEXT_REF_NOT_PINNED: context ref must be digest-pinned (got: $1). Pass --context-dir for local dev." ;; + esac +} + +pull_or_use_local_context() { + if [ -n "$CONTEXT_DIR" ]; then + CONTEXT_PKG_DIR="$CONTEXT_DIR" + warn "--context-dir bypasses OCI digest requirement. Not suitable for CI." + else + require_digest_ref "$CONTEXT_REF" + log "Pulling public cluster context: $CONTEXT_REF" + mkdir -p "$OUT_DIR/context-pkg" + oras pull "$CONTEXT_REF" --output "$OUT_DIR/context-pkg" \ + || fail "failed to pull context package $CONTEXT_REF" + CONTEXT_PKG_DIR="$OUT_DIR/context-pkg" + fi +} + +render_all_fragments() { + local env fragment + for env in "${ENVS[@]}"; do + mkdir -p "$OUT_DIR/manifests/$env" "$OUT_DIR/metadata/$env" + for fragment in "${FRAGMENTS[@]}"; do + log "render $fragment ($env)" + deploy-config-schema render "$fragment" "$SCRIPT_DIR" \ + --env "$env" \ + --context "$CONTEXT_REF" \ + --context-dir "$CONTEXT_PKG_DIR" \ + --images "$SCRIPT_DIR/images.lock.json" \ + --output "$OUT_DIR/manifests/$env" \ + || fail "render failed for $fragment ($env)" + done + deploy-config-schema artifact emit-kustomization-health \ + --deployment "$SCRIPT_DIR/deployment.yml" \ + --env "$env" \ + --image-digests "$SCRIPT_DIR/images.lock.json" \ + --out "$OUT_DIR/metadata/$env/kustomization-health.yml" \ + || fail "emit-kustomization-health failed ($env)" + done +} + +validate_rendered_output() { + local env + log "kubeconform" + kubeconform -schema-location default \ + -strict "$OUT_DIR/manifests/" \ + || fail "kubeconform validation failed" + log "kustomize build dry-run" + for env in "${ENVS[@]}"; do + kustomize build "$OUT_DIR/manifests/$env" >/dev/null \ + || fail "kustomize build failed for $env" + done + if grep -rl 'kind: Secret' "$OUT_DIR/manifests/" 2>/dev/null | grep -q .; then + fail "E_FORBIDDEN_KIND: kind=Secret found in rendered manifests" + fi +} + +validate_raw_manifests_if_present() { + [ -d "$SCRIPT_DIR/raw-manifests" ] || return 0 + deploy-config-schema artifact validate-raw-manifests \ + --deployment "$SCRIPT_DIR/deployment.yml" \ + --root "$SCRIPT_DIR/raw-manifests" \ + --output-root "$OUT_DIR/raw-manifests" \ + --forbidden-kinds Secret,ClusterRole,ClusterRoleBinding,CustomResourceDefinition,Namespace \ + --out "$OUT_DIR/raw-manifests-guard.json" \ + || fail "raw-manifests validation failed" +} + +emit_contract() { + log "leak-scan (deployment-artifact deny-list)" + deploy-config-schema artifact leak-scan \ + --path "$OUT_DIR" \ + --mode deployment-artifact \ + || fail "E_LEAK_DETECTED: deny-list scan flagged rendered output" + + deploy-config-schema artifact emit-contract \ + --artifact-name "auth-api" \ + --schema-version "$RESOLVED_VERSION" \ + --context-ref "$CONTEXT_REF" \ + --environments "production" \ + --images "$SCRIPT_DIR/images.lock.json" \ + --provenance-verified "$NPM_PROVENANCE_VERIFIED" \ + --out "$OUT_DIR/artifact-contract.yaml" \ + || fail "emit-contract failed" +} + +deployment_source() { + sed 's/#.*$//' "$1" +} + +scorecard_images_pinned() { + local lock="$1" ref + [ -f "$lock" ] || { + echo "fail" + return + } + while IFS= read -r ref; do + case "$ref" in + *@sha256:*) ;; + *) + echo "fail" + return + ;; + esac + done < <(jq -r 'if type == "array" then map(.ref) else [.[]] end | .[]' "$lock") + echo "pass" +} + +scorecard_route_owner_authmode() { + local src="$1" + if ! grep -qE '^[[:space:]]*routes:' <<<"$src"; then + echo "not_applicable" + elif grep -qE '^[[:space:]]*owner:' <<<"$src" && grep -qE '^[[:space:]]*authMode:' <<<"$src"; then + echo "pass" + else + echo "fail" + fi +} + +scorecard_stateful_policy() { + local src="$1" + if ! grep -qE '^[[:space:]]*stateful:[[:space:]]*true' <<<"$src"; then + echo "not_applicable" + elif grep -qE '^[[:space:]]*migrationPolicy:' <<<"$src"; then + echo "pass" + else + echo "fail" + fi +} + +scorecard_raw_manifests() { + local src="$1" + if ! grep -qE '^[[:space:]]*rawManifests:' <<<"$src"; then + echo "not_applicable" + elif [ -f "$OUT_DIR/raw-manifests-guard.json" ]; then + echo "pass" + else + echo "fail" + fi +} + +compute_scorecard() { + local deployment="$1" lock="$2" + local src + src="$(deployment_source "$deployment")" + + local schema_pinned=fail context_pinned=fail health_declared=fail + local rollback=fail no_raw_secrets=pass npm_flag=fail + local workload_count health_count + + grep -qE '^[[:space:]]*schemaVersion:[[:space:]]*"?[0-9]' <<<"$src" && schema_pinned=pass + case "$CONTEXT_REF" in *@sha256:*) context_pinned=pass ;; esac + + workload_count="$(grep -cE '^[[:space:]]*-[[:space:]]*name:' <<<"$src" || true)" + health_count="$(grep -cE '^[[:space:]]*health:' <<<"$src" || true)" + if [ "$workload_count" -gt 0 ] && [ "$health_count" -ge "$workload_count" ]; then + health_declared=pass + fi + + if grep -qE '^[[:space:]]*rollbackTargetRetention:' <<<"$src" \ + && grep -qE '^[[:space:]]*acknowledged:[[:space:]]*true' <<<"$src"; then + rollback=pass + fi + + if [ -d "$SCRIPT_DIR/raw-manifests" ] \ + && grep -rl 'kind: Secret' "$SCRIPT_DIR/raw-manifests" 2>/dev/null | grep -q .; then + no_raw_secrets=fail + fi + if [ -d "$OUT_DIR/manifests" ] \ + && grep -rl 'kind: Secret' "$OUT_DIR/manifests" 2>/dev/null | grep -q .; then + no_raw_secrets=fail + fi + + [ "$NPM_PROVENANCE_VERIFIED" = "true" ] && npm_flag=pass + + mkdir -p "$OUT_DIR" + jq -n \ + --arg schema_pinned "$schema_pinned" \ + --arg context_pinned "$context_pinned" \ + --arg no_latest_images "$(scorecard_images_pinned "$lock")" \ + --arg health_declared "$health_declared" \ + --arg route_owner_authmode_declared "$(scorecard_route_owner_authmode "$src")" \ + --arg rollback_retention_acknowledged "$rollback" \ + --arg no_raw_secrets "$no_raw_secrets" \ + --arg stateful_policy_declared "$(scorecard_stateful_policy "$src")" \ + --arg raw_manifests_guarded "$(scorecard_raw_manifests "$src")" \ + --arg npm_signatures_verified "$npm_flag" \ + '{ + schema_pinned: $schema_pinned, + context_pinned: $context_pinned, + no_latest_images: $no_latest_images, + health_declared: $health_declared, + route_owner_authmode_declared: $route_owner_authmode_declared, + rollback_retention_acknowledged: $rollback_retention_acknowledged, + no_raw_secrets: $no_raw_secrets, + stateful_policy_declared: $stateful_policy_declared, + raw_manifests_guarded: $raw_manifests_guarded, + npm_signatures_verified: $npm_signatures_verified + }' > "$OUT_DIR/scorecard.json" +} + +write_scorecard_outputs() { + { + echo "# Deployment readiness scorecard — auth-api" + echo "" + echo "| Check | Status |" + echo "|-------|--------|" + jq -r 'to_entries[] | "| \(.key) | \(.value) |"' "$OUT_DIR/scorecard.json" + echo "" + echo "pass = ready · fail = blocks deployment · not_applicable = check does not apply" + } > "$OUT_DIR/scorecard.md" + cat "$OUT_DIR/scorecard.md" +} + +compare_against_fixtures() { + local drift + drift="$(diff -rq "$OUT_DIR" "$SCRIPT_DIR/fixtures" 2>&1 || true)" + if [ -n "$drift" ]; then + echo "ERROR: Rendered output differs from committed fixtures:" >&2 + echo "$drift" >&2 + echo "Run render-local.sh without --diff to regenerate fixtures, then commit." >&2 + exit 1 + fi + log "No drift detected between rendered output and committed fixtures." +} + +exit_based_on_scorecard() { + local fails + fails="$(jq '[to_entries[] | select(.value == "fail")] | length' "$OUT_DIR/scorecard.json")" + if [ "$fails" -gt 0 ]; then + echo "ERROR: Scorecard has $fails fail(s). See out/scorecard.md for details." >&2 + exit 1 + fi + log "All scorecard checks passed." +} + +main() { + parse_args "$@" + + if [ "$FLAG_SCORECARD_ONLY" = true ]; then + log "scorecard-only dry mode (npm package not required)" + compute_scorecard "$SCRIPT_DIR/deployment.yml" "$SCRIPT_DIR/images.lock.json" + write_scorecard_outputs + exit_based_on_scorecard + return + fi + + resolve_schema_version + install_schema + npm_audit_and_scorecard_init + pull_or_use_local_context + render_all_fragments + validate_rendered_output + validate_raw_manifests_if_present + emit_contract + compute_scorecard "$SCRIPT_DIR/deployment.yml" "$SCRIPT_DIR/images.lock.json" + write_scorecard_outputs + if [ "$FLAG_DIFF" = true ]; then + compare_against_fixtures + fi + exit_based_on_scorecard +} + +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + main "$@" +fi