From 04476c6131e3288555e60999c66dadd3ddf513e3 Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Sun, 1 Nov 2026 22:42:51 +0100 Subject: [PATCH] chore: retire the old path's deploy workflows, which no repository calls Every Project is delivered through the estate now, so nothing on main still calls the deploy-config-schema workflows; the retired repos that do pin them by SHA. --- .github/workflows/ci.yml | 18 +- .github/workflows/deploy-artifact.yml | 184 ------------ .github/workflows/deploy-bundle.yml | 122 -------- .../workflows/deploy-config-render-drift.yml | 72 ----- .github/workflows/deploy-sources-render.yml | 105 ------- .github/workflows/deploy-validate.yml | 92 ------ .github/workflows/gitops-ci.yml | 37 --- .../workflows/platform-config-validate.yml | 82 ------ .github/workflows/release.yml | 38 --- .gitignore | 1 - README.md | 36 +-- actions/deploy-artifact/action.yml | 59 ---- actions/deploy-artifact/install-tooling.sh | 132 --------- actions/deploy-artifact/publish.sh | 139 --------- actions/deploy-artifact/run.sh | 184 ------------ actions/deploy-bundle/action.yml | 68 ----- actions/deploy-bundle/run.sh | 170 ----------- actions/deploy-config-render-drift/action.yml | 39 --- actions/deploy-config-render-drift/run.sh | 202 ------------- actions/deploy-preview/action.yml | 54 ---- actions/deploy-preview/run.sh | 172 ----------- actions/deploy-sources-render/action.yml | 87 ------ actions/deploy-sources-render/run.sh | 183 ------------ actions/platform-config-validate/action.yml | 48 ---- .../fixtures/platform.example.yml | 44 --- .../fixtures/service-intent.example.yml | 51 ---- actions/platform-config-validate/run.sh | 197 ------------- docs/action-pinning.md | 6 +- release-please-config.json | 24 -- .../deploy-artifact/cli-interface.json | 109 ------- tests/test_crac_train_workflow.py | 197 +------------ tests/test_deploy_actions_cli_drift.py | 146 ---------- tests/test_deploy_platform_d.py | 125 +------- tests/test_raw_manifests_unsupported.py | 51 ---- tests/test_workflow_job_timeouts.py | 1 - tools/deploy-check/README.md | 84 ------ tools/deploy-check/bin/deploy-check.js | 192 ------------- tools/deploy-check/lib/environments.js | 32 --- tools/deploy-check/lib/index.js | 6 - tools/deploy-check/lib/preview.js | 158 ----------- tools/deploy-check/lib/resolve-toolkit.js | 64 ----- tools/deploy-check/lib/scorecard.js | 237 ---------------- tools/deploy-check/lib/summary.js | 59 ---- tools/deploy-check/lib/toolkit.js | 118 -------- tools/deploy-check/package-lock.json | 37 --- tools/deploy-check/package.json | 35 --- tools/deploy-check/test/environments.test.js | 39 --- tools/deploy-check/test/invocation.test.js | 249 ---------------- tools/deploy-check/test/scorecard.test.js | 267 ------------------ tools/deploy-check/test/toolkit.test.js | 67 ----- 50 files changed, 12 insertions(+), 4907 deletions(-) delete mode 100644 .github/workflows/deploy-artifact.yml delete mode 100644 .github/workflows/deploy-bundle.yml delete mode 100644 .github/workflows/deploy-config-render-drift.yml delete mode 100644 .github/workflows/deploy-sources-render.yml delete mode 100644 .github/workflows/deploy-validate.yml delete mode 100644 .github/workflows/platform-config-validate.yml delete mode 100644 actions/deploy-artifact/action.yml delete mode 100755 actions/deploy-artifact/install-tooling.sh delete mode 100755 actions/deploy-artifact/publish.sh delete mode 100755 actions/deploy-artifact/run.sh delete mode 100644 actions/deploy-bundle/action.yml delete mode 100755 actions/deploy-bundle/run.sh delete mode 100644 actions/deploy-config-render-drift/action.yml delete mode 100644 actions/deploy-config-render-drift/run.sh delete mode 100644 actions/deploy-preview/action.yml delete mode 100755 actions/deploy-preview/run.sh delete mode 100644 actions/deploy-sources-render/action.yml delete mode 100755 actions/deploy-sources-render/run.sh delete mode 100644 actions/platform-config-validate/action.yml delete mode 100644 actions/platform-config-validate/fixtures/platform.example.yml delete mode 100644 actions/platform-config-validate/fixtures/service-intent.example.yml delete mode 100755 actions/platform-config-validate/run.sh delete mode 100644 tests/fixtures/deploy-artifact/cli-interface.json delete mode 100644 tests/test_deploy_actions_cli_drift.py delete mode 100644 tests/test_raw_manifests_unsupported.py delete mode 100644 tools/deploy-check/README.md delete mode 100755 tools/deploy-check/bin/deploy-check.js delete mode 100644 tools/deploy-check/lib/environments.js delete mode 100644 tools/deploy-check/lib/index.js delete mode 100644 tools/deploy-check/lib/preview.js delete mode 100644 tools/deploy-check/lib/resolve-toolkit.js delete mode 100644 tools/deploy-check/lib/scorecard.js delete mode 100644 tools/deploy-check/lib/summary.js delete mode 100644 tools/deploy-check/lib/toolkit.js delete mode 100644 tools/deploy-check/package-lock.json delete mode 100644 tools/deploy-check/package.json delete mode 100644 tools/deploy-check/test/environments.test.js delete mode 100644 tools/deploy-check/test/invocation.test.js delete mode 100644 tools/deploy-check/test/scorecard.test.js delete mode 100644 tools/deploy-check/test/toolkit.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de679d0..91c7783 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -142,26 +142,10 @@ jobs: # explicitly fails unless every dependency succeeded -- so a skipped or # failed gating job can never be mistaken for a green pipeline. # --------------------------------------------------------------------------- - node-tests: - name: Deploy Check Tests - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: actions/setup-node@v4 - with: - node-version: '20' - - name: Install deploy-check dependencies - working-directory: tools/deploy-check - run: npm ci --no-audit --no-fund - - name: Run deploy-check unit tests - working-directory: tools/deploy-check - run: npm test - pipeline-complete: name: Pipeline Complete if: always() - needs: [actionlint, yaml-validity, python-tests, node-tests] + needs: [actionlint, yaml-validity, python-tests] runs-on: ubuntu-latest timeout-minutes: 15 steps: diff --git a/.github/workflows/deploy-artifact.yml b/.github/workflows/deploy-artifact.yml deleted file mode 100644 index 719a7c8..0000000 --- a/.github/workflows/deploy-artifact.yml +++ /dev/null @@ -1,184 +0,0 @@ -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 - apply-bundle: - description: > - Emit out/apply// with the workload objects lifted out of the - fragments plus a kustomization.yaml, so fleet-infra can consume this - artifact through an OCIRepository. Requires schema-version >= 0.20.0. - required: false - default: false - type: boolean - 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 - timeout-minutes: 30 - 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: v0.42.0 # x-release-please-version - 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@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.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 }} - apply-bundle: ${{ inputs.apply-bundle }} - 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 }} - GITHUB_TOKEN: ${{ github.token }} - GITHUB_ACTOR: ${{ github.actor }} - run: bash .github-workflows/actions/deploy-artifact/publish.sh - - - name: Attest build provenance - if: ${{ steps.publish.outputs.artifact-digest != '' }} - uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 - with: - subject-name: ghcr.io/jorisjonkers-dev/${{ inputs.artifact-name }} - subject-digest: ${{ steps.publish.outputs.artifact-digest }} - push-to-registry: true - - finalize: - name: Finalize - runs-on: ubuntu-latest - timeout-minutes: 30 - 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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - if: always() - with: - name: gate-summary-deploy-artifact - path: gate-summary.json diff --git a/.github/workflows/deploy-bundle.yml b/.github/workflows/deploy-bundle.yml deleted file mode 100644 index 661aff1..0000000 --- a/.github/workflows/deploy-bundle.yml +++ /dev/null @@ -1,122 +0,0 @@ -name: 'Deploy Bundle' - -'on': - 'workflow_call': - 'inputs': - 'deploy-dir': - 'description': 'Path to the caller-owned deploy directory.' - 'required': false - 'type': 'string' - 'default': 'deploy' - 'images': - 'description': 'Newline- or comma-separated image refs to include in the bundle manifest.' - 'required': false - 'type': 'string' - 'default': '' - 'package-version': - 'description': 'Version range for @jorisjonkers-dev/deploy-config-schema.' - 'required': false - 'type': 'string' - 'default': '^0.6.0' - 'version': - 'description': 'Bundle version. Defaults to the ref name or a sha-prefixed development version.' - 'required': false - 'type': 'string' - 'default': '' - 'bundle-name': - 'description': 'Bundle package basename. Defaults to the repository name.' - 'required': false - 'type': 'string' - 'default': '' - 'publish': - 'description': 'Publish the packed deploy bundle to GHCR.' - 'required': false - 'type': 'boolean' - 'default': false - 'secrets': - 'packages-token': - 'description': 'Token used for GitHub Packages reads and GHCR writes.' - 'required': false - 'outputs': - 'bundle-ref': - 'description': 'Published or publishable OCI bundle ref.' - 'value': '${{ jobs.deploy-bundle.outputs.bundle-ref }}' - 'bundle-path': - 'description': 'Packed bundle tarball path.' - 'value': '${{ jobs.deploy-bundle.outputs.bundle-path }}' - -'permissions': - 'contents': 'read' - 'packages': 'write' - -'jobs': - 'deploy-bundle': - 'name': 'Deploy Bundle' - 'runs-on': 'ubuntu-latest' - 'timeout-minutes': 60 - 'outputs': - 'bundle-ref': '${{ steps.bundle-ref.outputs.bundle-ref }}' - 'bundle-path': '${{ steps.bundle.outputs.bundle-path }}' - 'steps': - - 'uses': 'actions/checkout@v7' - - - 'uses': 'actions/checkout@v7' - 'with': - 'repository': 'JorisJonkers-dev/github-workflows' - 'ref': 'v0.42.0' # x-release-please-version - 'path': '.github-workflows' - 'persist-credentials': false - - - 'name': 'Pack deploy bundle' - 'id': 'bundle' - 'uses': './.github-workflows/actions/deploy-bundle' - 'with': - 'deploy-dir': '${{ inputs.deploy-dir }}' - 'images': '${{ inputs.images }}' - 'package-version': '${{ inputs.package-version }}' - 'version': '${{ inputs.version }}' - 'bundle-name': '${{ inputs.bundle-name }}' - 'node-auth-token': '${{ secrets.packages-token || github.token }}' - - - 'name': 'Resolve bundle ref' - 'id': 'bundle-ref' - 'env': - 'BUNDLE_NAME': '${{ steps.bundle.outputs.bundle-name }}' - '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:]')" - name="$(printf '%s' "$BUNDLE_NAME" | tr '[:upper:]' '[:lower:]')" - printf 'bundle-ref=ghcr.io/%s/%s-deploy-bundle:%s\n' "$owner" "$name" "$BUNDLE_VERSION" >> "$GITHUB_OUTPUT" - - - 'name': 'Upload deploy bundle artifact' - 'uses': 'actions/upload-artifact@v7.0.1' - 'with': - 'name': 'deploy-bundle' - 'path': '${{ steps.bundle.outputs.bundle-path }}' - 'if-no-files-found': 'error' - 'retention-days': 7 - - - 'uses': 'oras-project/setup-oras@v2' - 'if': '${{ inputs.publish }}' - - - 'name': 'Log in to GHCR' - 'if': '${{ inputs.publish }}' - 'env': - 'GHCR_TOKEN': '${{ secrets.packages-token || github.token }}' - 'run': | - set -euo pipefail - - oras login ghcr.io --username "$GITHUB_ACTOR" --password-stdin <<< "$GHCR_TOKEN" - - - 'name': 'Publish deploy bundle' - 'if': '${{ inputs.publish }}' - 'env': - 'BUNDLE_PATH': '${{ steps.bundle.outputs.bundle-path }}' - 'BUNDLE_REF': '${{ steps.bundle-ref.outputs.bundle-ref }}' - 'run': | - set -euo pipefail - - oras push --disable-path-validation "$BUNDLE_REF" "$BUNDLE_PATH:application/vnd.jorisjonkers.deployment.bundle.v1+tar" diff --git a/.github/workflows/deploy-config-render-drift.yml b/.github/workflows/deploy-config-render-drift.yml deleted file mode 100644 index cda526b..0000000 --- a/.github/workflows/deploy-config-render-drift.yml +++ /dev/null @@ -1,72 +0,0 @@ -name: Deploy Config Render Drift - -on: - workflow_call: - inputs: - package-version: - description: Version of @jorisjonkers-dev/deploy-config-schema to install. - required: false - type: string - default: ^0.6.0 - deploy-config-command: - description: Shell command that writes deploy-config JSON to DEPLOY_CONFIG_OUT. - required: true - type: string - targets: - description: Newline-separated adapter=relative/committed/path.yaml entries. - required: true - type: string - working-directory: - description: Directory where the deploy-config command runs and target paths are resolved. - required: false - type: string - default: . - node-auth-token: - description: Token with packages:read for installing @jorisjonkers-dev packages from GitHub Packages (pass secrets.GITHUB_TOKEN). - required: false - type: string - default: "" - -permissions: - contents: read - packages: read - -jobs: - render-drift: - name: Deploy Config Render Drift - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - uses: actions/checkout@v7 - - - name: Resolve workflow ref - id: workflow-ref - shell: bash - env: - WORKFLOW_REF: ${{ github.workflow_ref }} - run: | - set -euo pipefail - - ref="${WORKFLOW_REF#*@}" - if [ -z "$ref" ] || [ "$ref" = "$WORKFLOW_REF" ]; then - echo "::error::Unable to resolve reusable workflow ref from $WORKFLOW_REF" - exit 1 - fi - - printf 'ref=%s\n' "$ref" >> "$GITHUB_OUTPUT" - - - uses: actions/checkout@v7 - with: - repository: JorisJonkers-dev/github-workflows - ref: v0.42.0 # x-release-please-version - path: .github-workflows - persist-credentials: false - - - name: Check deploy config render drift - uses: ./.github-workflows/actions/deploy-config-render-drift - with: - package-version: ${{ inputs.package-version }} - deploy-config-command: ${{ inputs.deploy-config-command }} - targets: ${{ inputs.targets }} - working-directory: ${{ inputs.working-directory }} - node-auth-token: ${{ inputs.node-auth-token != '' && inputs.node-auth-token || github.token }} diff --git a/.github/workflows/deploy-sources-render.yml b/.github/workflows/deploy-sources-render.yml deleted file mode 100644 index a43e291..0000000 --- a/.github/workflows/deploy-sources-render.yml +++ /dev/null @@ -1,105 +0,0 @@ -name: 'Deploy Sources Render' - -'on': - 'workflow_call': - 'inputs': - 'package-version': - 'description': 'Version range for @jorisjonkers-dev/deploy-config-schema.' - 'required': false - 'type': 'string' - 'default': '^0.6.0' - 'environment': - 'description': 'Deployment environment to compile.' - 'required': false - 'type': 'string' - 'default': 'production' - 'sources-path': - 'description': 'Path to deployment-sources.yml.' - 'required': false - 'type': 'string' - 'default': 'deployment-sources.yml' - 'lock-path': - 'description': 'Path to deployment.lock.yml.' - 'required': false - 'type': 'string' - 'default': 'deployment.lock.yml' - 'node-contract-path': - 'description': 'Path to the locked node contract.' - 'required': false - 'type': 'string' - 'default': 'inventory/node-contract.lock.yml' - 'reachability-path': - 'description': 'Path to reachability catalog.' - 'required': false - 'type': 'string' - 'default': 'catalog/reachability.yml' - 'output-path': - 'description': 'Rendered Flux output directory.' - 'required': false - 'type': 'string' - 'default': 'cluster/flux' - 'update-lock': - 'description': 'Run lock --update before compile.' - 'required': false - 'type': 'boolean' - 'default': false - 'check': - 'description': 'Require compiled output and derived lock files to be committed.' - 'required': false - 'type': 'boolean' - 'default': true - 'cutover-parity': - 'description': 'Run cutover parity against current-tree.' - 'required': false - 'type': 'boolean' - 'default': false - 'current-tree': - 'description': 'Current live Flux tree used when cutover-parity is true.' - 'required': false - 'type': 'string' - 'default': '' - 'secrets': - 'packages-token': - 'description': 'Token used for GitHub Packages reads.' - 'required': false - 'outputs': - 'image-tags': - 'description': 'Image tags resolved from deployment.lock.yml.' - 'value': '${{ jobs.deploy-sources-render.outputs.image-tags }}' - -'permissions': - 'contents': 'read' - -'jobs': - 'deploy-sources-render': - 'name': 'Deploy Sources Render' - 'runs-on': 'ubuntu-latest' - 'timeout-minutes': 30 - 'outputs': - 'image-tags': '${{ steps.render.outputs.image-tags }}' - 'steps': - - 'uses': 'actions/checkout@v7' - - - 'uses': 'actions/checkout@v7' - 'with': - 'repository': 'JorisJonkers-dev/github-workflows' - 'ref': 'v0.42.0' # x-release-please-version - 'path': '.github-workflows' - 'persist-credentials': false - - - 'name': 'Render deployment sources' - 'id': 'render' - 'uses': './.github-workflows/actions/deploy-sources-render' - 'with': - 'package-version': '${{ inputs.package-version }}' - 'environment': '${{ inputs.environment }}' - 'sources-path': '${{ inputs.sources-path }}' - 'lock-path': '${{ inputs.lock-path }}' - 'node-contract-path': '${{ inputs.node-contract-path }}' - 'reachability-path': '${{ inputs.reachability-path }}' - 'output-path': '${{ inputs.output-path }}' - 'update-lock': '${{ inputs.update-lock }}' - 'check': '${{ inputs.check }}' - 'cutover-parity': '${{ inputs.cutover-parity }}' - 'current-tree': '${{ inputs.current-tree }}' - 'node-auth-token': '${{ secrets.packages-token || github.token }}' diff --git a/.github/workflows/deploy-validate.yml b/.github/workflows/deploy-validate.yml deleted file mode 100644 index 18f5559..0000000 --- a/.github/workflows/deploy-validate.yml +++ /dev/null @@ -1,92 +0,0 @@ -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 - timeout-minutes: 30 - 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 this workflow file's own released version. - # github.job_workflow_sha is empty inside a reusable workflow, which made - # actions/checkout silently fall back to the default branch, so every - # consumer ran main regardless of the tag it pinned. release-please keeps - # the literal below in step with the tag this file ships in. - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - repository: JorisJonkers-dev/github-workflows - ref: v0.42.0 # x-release-please-version - 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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - if: always() - with: - name: gate-summary-deploy-validate - path: gate-summary.json - - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - if: always() - with: - name: deploy-preview-summary - path: deploy-preview-summary.md diff --git a/.github/workflows/gitops-ci.yml b/.github/workflows/gitops-ci.yml index 01fbea0..056c0fe 100644 --- a/.github/workflows/gitops-ci.yml +++ b/.github/workflows/gitops-ci.yml @@ -7,36 +7,14 @@ on: description: Newline- or comma-separated kustomize overlay paths to render and validate. required: true type: string - platform-config-paths: - description: Newline- or comma-separated platform config globs to validate. - required: false - type: string - default: |- - inventory/**/*.yaml - inventory/**/*.yml - deploy-config-command: - description: Optional command that writes deploy-config JSON to DEPLOY_CONFIG_OUT. - required: false - type: string - default: '' - deploy-config-targets: - description: Optional newline-separated adapter=relative/committed/path.yaml render drift targets. - required: false - type: string - default: '' system-tests-command: description: Optional caller-owned system test command. required: false type: string default: '' - secrets: - packages-token: - description: GitHub Packages token used for deploy-config-schema package resolution. - required: false permissions: contents: read - packages: read jobs: gitops: @@ -69,26 +47,11 @@ jobs: path: .github-workflows persist-credentials: false - - name: Validate platform config - uses: ./.github-workflows/actions/platform-config-validate - with: - config-paths: ${{ inputs.platform-config-paths }} - schema-kind: auto - node-auth-token: ${{ secrets.packages-token || github.token }} - - name: Validate Flux render uses: ./.github-workflows/actions/flux-render-validate with: overlay-paths: ${{ inputs.overlay-paths }} - - name: Check deploy config render drift - if: ${{ inputs.deploy-config-command != '' && inputs.deploy-config-targets != '' }} - uses: ./.github-workflows/actions/deploy-config-render-drift - with: - deploy-config-command: ${{ inputs.deploy-config-command }} - targets: ${{ inputs.deploy-config-targets }} - node-auth-token: ${{ secrets.packages-token || github.token }} - - name: Run system tests if: ${{ inputs.system-tests-command != '' }} env: diff --git a/.github/workflows/platform-config-validate.yml b/.github/workflows/platform-config-validate.yml deleted file mode 100644 index 37454be..0000000 --- a/.github/workflows/platform-config-validate.yml +++ /dev/null @@ -1,82 +0,0 @@ -name: Platform Config Validate - -on: - workflow_call: - inputs: - config-paths: - description: Newline- or comma-separated file globs to validate. - required: false - type: string - default: |- - platform/**/*.yaml - platform/**/*.yml - schema-kind: - description: Schema kind to validate. - required: false - type: string - default: auto - package-version: - description: Version of @jorisjonkers-dev/deploy-config-schema to install. - required: false - type: string - default: 0.3.0 - node-auth-token: - description: Token with packages:read for installing @jorisjonkers-dev packages from GitHub Packages (pass secrets.GITHUB_TOKEN). - required: false - type: string - default: "" - drift-check: - description: Run render-tree --check after validation when true. - required: false - type: boolean - default: false - working-directory: - description: Directory where config globs are evaluated. - required: false - type: string - default: . - -permissions: - contents: read - packages: read - -jobs: - validate: - name: Platform Config Validate - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - uses: actions/checkout@v7 - - - name: Resolve workflow ref - id: workflow-ref - shell: bash - env: - WORKFLOW_REF: ${{ github.workflow_ref }} - run: | - set -euo pipefail - - ref="${WORKFLOW_REF#*@}" - if [ -z "$ref" ] || [ "$ref" = "$WORKFLOW_REF" ]; then - echo "::error::Unable to resolve reusable workflow ref from $WORKFLOW_REF" - exit 1 - fi - - printf 'ref=%s\n' "$ref" >> "$GITHUB_OUTPUT" - - - uses: actions/checkout@v7 - with: - repository: JorisJonkers-dev/github-workflows - ref: v0.42.0 # x-release-please-version - path: .github-workflows - persist-credentials: false - - - name: Validate platform config - uses: ./.github-workflows/actions/platform-config-validate - with: - config-paths: ${{ inputs.config-paths }} - schema-kind: ${{ inputs.schema-kind }} - package-version: ${{ inputs.package-version }} - node-auth-token: ${{ inputs.node-auth-token != '' && inputs.node-auth-token || github.token }} - drift-check: ${{ inputs.drift-check && 'true' || 'false' }} - working-directory: ${{ inputs.working-directory }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2470f5f..7c0e1c3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,41 +41,3 @@ jobs: token: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }} config-file: release-please-config.json manifest-file: .release-please-manifest.json - - publish: - needs: release-please - if: ${{ needs.release-please.outputs.release_created == 'true' }} - runs-on: ubuntu-latest - timeout-minutes: 15 - permissions: - contents: read - packages: write - steps: - # The reusable workflows and actions here are consumed by version - # tag/ref. tools/deploy-check is additionally published so developers can - # run the same checks locally without vendoring a copy into every service - # repository. CI never consumes the published package -- it runs the tool - # from the checked-out workflow SHA -- so a publish failure cannot break - # the deployment gate. - - name: Publish tag-ref deliverable - run: echo "Released ${{ needs.release-please.outputs.tag_name }}" - - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - uses: actions/setup-node@v4 - with: - node-version: '20' - registry-url: https://npm.pkg.github.com - scope: '@jorisjonkers-dev' - - - name: Align the package version with the release tag - working-directory: tools/deploy-check - env: - TAG_NAME: ${{ needs.release-please.outputs.tag_name }} - run: npm version "${TAG_NAME#v}" --no-git-tag-version --allow-same-version - - - name: Publish @jorisjonkers-dev/deploy-check - working-directory: tools/deploy-check - env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: npm publish diff --git a/.gitignore b/.gitignore index 8b9252e..a7a40e3 100644 --- a/.gitignore +++ b/.gitignore @@ -32,7 +32,6 @@ scratchpads/ *scratchpad*.md *.scratch.md *agent-council*.md -tools/deploy-check/node_modules/ # Python bytecode. These were committed and then reported as modified on every # test run, because .pyc embeds the source mtime and the interpreter version - diff --git a/README.md b/README.md index 801f36e..fde852e 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ repositories. ## What It Is -`github-workflows` centralizes CI, release, image publishing, deploy bundle, -Project automation, and repository hygiene automation. Consumer repositories -should call released tags instead of branches. +`github-workflows` centralizes CI, release, image publishing, Intent Fragment +publishing, Project automation, and repository hygiene automation. Consumer +repositories should call released tags instead of branches. ## Composite Actions @@ -16,13 +16,9 @@ should call released tags instead of branches. | `actions/setup-java-gradle` | Install Java and configure Gradle package access/cache settings. | | `actions/setup-node` | Install Node, configure npm/pnpm/yarn caching, and install dependencies. | | `actions/prepare-ci-host` | Download Docker image artifacts and prepare optional dev DNS. | -| `actions/platform-config-validate` | Install `@jorisjonkers-dev/deploy-config-schema` and validate platform YAML. | -| `actions/deploy-config-render-drift` | Render deploy-config adapter output and compare committed files. | | `actions/flux-render-validate` | Validate Flux render output for GitOps repositories. | | `actions/compose-system-test-stack` | Run caller-owned Docker Compose system-test stacks. | | `actions/api-client-publish` | Generate and publish TypeScript, Java, and Kotlin API clients. | -| `actions/deploy-bundle` | Validate and pack a first-party `deploy/` directory as an OCI bundle. | -| `actions/deploy-sources-render` | Resolve deployment sources, compile Flux output, and emit image tags. | | `actions/path-buckets` | Decide which path buckets a pull request's diff reaches, after self-testing the caller's `.github/buckets.yml`. | | `actions/resolve-image-lock` | Write a release's images lock (repository, digest, uid, gid and the software version from each image's `org.opencontainers.image.version` label, else its versioned tag) and upload it for `publish-fragment.yml`. Refuses an image with no version. | | `actions/render-diff` | Compose the estate with a pull request's project file and comment the Project's render diff (deploy-kit). | @@ -41,15 +37,11 @@ should call released tags instead of branches. | `docker-image-ci.yml` | Build a Docker image without publishing. | | `container-publish.yml` | Publish a GHCR image with release and sha tags, labelled `org.opencontainers.image.version` with the software inside it: the digest-pinned upstream base's tag, our release for our own software, or the `software-version` input. | | `publish-api-clients.yml` | Generate and publish API clients from an OpenAPI spec. | -| `gitops-ci.yml` | Platform config, Flux render, drift, and optional system tests. | -| `platform-config-validate.yml` | Reusable platform YAML validation job. | -| `deploy-config-render-drift.yml` | Reusable deploy-config render-drift job. | +| `gitops-ci.yml` | Flux render validation and optional system tests. | | `flux-render-validate.yml` | Reusable Flux render validation job. | | `migration-guard.yml` | Block unsafe edits to existing Flyway-style migrations. | | `crac-train.yml` | Build and run CRaC training images with optional sidecars. | | `production-canary.yml` | Run caller-owned production smoke checks. | -| `deploy-bundle.yml` | Validate first-party deploy bundles and optionally publish them to GHCR. | -| `deploy-sources-render.yml` | Render deployment sources and expose image tags for downstream tests. | | `publish-fragment.yml` | Validate, pack, sign and push a project file's Intent Fragment for a release, then start composition (deploy-kit). Runs deploy-kit at the version `tools/deploy-kit` pins; the caller pins none. | | `path-buckets.yml` | The `changes` job of a bucketed pipeline: its `run` output gates each job on the buckets a diff reaches. | | `repository-hygiene-guard.yml` | Block reintroduction of planning and scratch artifacts. | @@ -90,26 +82,6 @@ invocations so Gradle warnings and deprecations fail CI. The default those tasks must pass their own static-only lint command. The default `test-gradle-args` remains `test`. -```yaml -jobs: - deploy-bundle: - uses: JorisJonkers-dev/github-workflows/.github/workflows/deploy-bundle.yml@v0.7.3 - with: - deploy-dir: deploy - version: ${{ needs.release.outputs.version }} - publish: true - secrets: - packages-token: ${{ secrets.GITHUB_TOKEN }} -``` - -```yaml -jobs: - deploy-sources-render: - uses: JorisJonkers-dev/github-workflows/.github/workflows/deploy-sources-render.yml@v0.7.3 - secrets: - packages-token: ${{ secrets.GITHUB_TOKEN }} -``` - ```yaml jobs: crac-train: diff --git a/actions/deploy-artifact/action.yml b/actions/deploy-artifact/action.yml deleted file mode 100644 index b38ae46..0000000 --- a/actions/deploy-artifact/action.yml +++ /dev/null @@ -1,59 +0,0 @@ -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 - apply-bundle: - description: > - Emit out/apply// holding the workload objects lifted out of the - rendered fragments plus a kustomization.yaml, so fleet-infra can track this - artifact with an OCIRepository instead of receiving pushed files. Requires - deploy-config-schema >= 0.20.0; leave false on older pins. - required: false - default: "false" - 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 }} - APPLY_BUNDLE: ${{ inputs.apply-bundle }} - 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 deleted file mode 100755 index 0922fb6..0000000 --- a/actions/deploy-artifact/install-tooling.sh +++ /dev/null @@ -1,132 +0,0 @@ -#!/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="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" - 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="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" - 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="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" - 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="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" - 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="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" - 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="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" - chmod +x /tmp/yq - sudo mv /tmp/yq /usr/local/bin/yq - printf 'Installed yq %s\n' "$version" -} - -main() { - local only="" - while [[ $# -gt 0 ]]; do - case "$1" in - --only) - only="${2:?--only requires a comma-separated tool list}" - shift 2 - ;; - *) - fail "unknown argument: $1 (usage: install-tooling.sh [--only oras,yq,...])" - ;; - esac - done - - local -a tools=(oras cosign syft kubeconform kustomize yq) - if [[ -n "$only" ]]; then - IFS=',' read -ra tools <<< "$only" - fi - - printf '::group::Installing deployment tooling\n' - for tool in "${tools[@]}"; do - case "$tool" in - oras) install_oras ;; - cosign) install_cosign ;; - syft) install_syft ;; - kubeconform) install_kubeconform ;; - kustomize) install_kustomize ;; - yq) install_yq ;; - *) fail "unknown tool: ${tool} (valid: oras,cosign,syft,kubeconform,kustomize,yq)" ;; - esac - done - printf '::endgroup::\n' -} - -main "$@" diff --git a/actions/deploy-artifact/publish.sh b/actions/deploy-artifact/publish.sh deleted file mode 100755 index c43aa1f..0000000 --- a/actions/deploy-artifact/publish.sh +++ /dev/null @@ -1,139 +0,0 @@ -#!/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 </: by - # container-publish.yml; deploy artifacts use ghcr.io//:deploy- - # so they coexist in the same GHCR repository without overwriting each other. - artifact_ref="ghcr.io/jorisjonkers-dev/${artifact_name}:deploy-${artifact_version}" - - # (0) Authenticate to GHCR so oras push and cosign sign can write packages. - # GITHUB_TOKEN is always injected by GitHub Actions (packages: write on the - # deploy-artifact.yml caller grants write access to the service's packages). - if [[ -n "${GITHUB_TOKEN:-}" ]]; then - printf '%s' "$GITHUB_TOKEN" | oras login ghcr.io \ - --username "${GITHUB_ACTOR:-github-actions}" \ - --password-stdin - printf '%s' "$GITHUB_TOKEN" | docker login ghcr.io \ - --username "${GITHUB_ACTOR:-github-actions}" \ - --password-stdin - fi - - # (1) Build deterministic tar (SC-9: sort, mtime=0, uid=0, gid=0, numeric) - tar \ - --sort=name \ - --mtime='UTC 1970-01-01' \ - --owner=0 \ - --group=0 \ - --numeric-owner \ - -cf artifact.tar \ - -C out . - - local artifact_digest - - # (2) Check if the deploy artifact tag already exists (idempotent publish). - # The "deploy-" tag prefix (e.g. "deploy-v1.2.3") ensures this tag can only - # ever point to a deploy artifact — container images use a plain version tag. - if oras manifest fetch "$artifact_ref" > /dev/null 2>&1; then - # Deploy artifact tag exists: verify render-hash; fail on mismatch. - local existing_digest - existing_digest=$(oras resolve "$artifact_ref" 2>/dev/null) - - local existing_hash="" - 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 - - artifact_digest="$existing_digest" - warn "artifact already exists with matching render-hash; skipping push (idempotent)" - else - # (3) Push artifact - # oras 1.x does not support --digest-file; capture digest via oras resolve - # after push, which also serves as the post-push verification (step 4). - oras push "$artifact_ref" \ - --artifact-type "application/vnd.jorisjonkers.deployment.artifact.v1+tar" \ - artifact.tar - - # (4) Resolve digest after push (also detects tag-move races) - local pushed_digest - pushed_digest=$(oras resolve "$artifact_ref" 2>/dev/null) - if [[ -z "$pushed_digest" || "$pushed_digest" != sha256:* ]]; then - emit_gate_summary "deploy-artifact" "Deploy Artifact" "fail" \ - "publish-resolve-failed" "none" - fail "E_PUBLISH_RESOLVE_FAILED: could not resolve digest for ${artifact_ref} after push" - fi - - artifact_digest="$pushed_digest" - fi - - # NOTE: SBOM via syft is intentionally omitted here. This artifact is a - # YAML/tar package (deployment configuration), not a container image; there - # are no software dependencies to enumerate. Container-image SBOM is handled - # separately by the container-publish.yml workflow. - - # (5) Keyless cosign sign (SC-10) - cosign sign --yes \ - "ghcr.io/jorisjonkers-dev/${artifact_name}@${artifact_digest}" - - # (6) 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 deleted file mode 100755 index 31853cd..0000000 --- a/actions/deploy-artifact/run.sh +++ /dev/null @@ -1,184 +0,0 @@ -#!/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. -# -# The render loop, the forbidden-kind guard and the contract emission used to be -# spelled out here in bash, duplicating what actions/deploy-preview did and what -# every service repository carried as platform/render-local.sh. They now come -# from tools/deploy-check, so the artifact published on a tag and the preview -# shown on a pull request are produced by the same code. What remains here is -# what is specific to publishing: provenance verification, the image-lock guard, -# pulling the context, and exporting the render hash. -set -euo pipefail - -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 <&2 ) \ - || fail "E_DEPLOY_CHECK_DEPS_FAILED: npm ci failed in ${tool_dir}" - - # (3) Provenance verification. deploy-check installs the pinned toolkit - # itself, so audit the same version here and record the outcome in the - # contract rather than inferring it. - 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 ) \ - || fail "E_TOOLKIT_INSTALL_FAILED: @jorisjonkers-dev/deploy-config-schema@${schema_version}" - local installed - installed=$(node -e "console.log(require('${install_root}/node_modules/@jorisjonkers-dev/deploy-config-schema/package.json').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 - # NOTE: npm audit signatures only works for packages from npmjs.com. - # Packages installed from GHPR (npm.pkg.github.com) will produce either: - # "found no dependencies to audit that were installed from a supported registry" - # or (when --scope filters to zero matching packages): - # "found no installed dependencies to audit" - # Both are expected skip-conditions, not failures. - local provenance_verified=true - local npm_audit_result - npm_audit_result=$(npm audit signatures \ - --userconfig "$npmrc" \ - --scope @jorisjonkers-dev 2>&1) || { - if echo "$npm_audit_result" | grep -qE "found no dependencies to audit that were installed from a supported registry|found no installed dependencies to audit"; then - warn "npm audit signatures skipped: package is from a private registry not supported by npm audit signatures" - provenance_verified=not_applicable - else - 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}" - fi - } - - # (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 the context package once. Service repos never hold the cluster - # context; it is fetched by digest. - local context_root="context-pkg" - rm -rf "$context_root"; mkdir -p "$context_root" - if ! oras pull "$context_ref" --output "$context_root" 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) Render, guard and emit the contract. deploy-check locates - # cluster-context-public.yml inside the pulled package, validates the - # environment list, refuses a raw-manifests directory it cannot guard, and - # rejects kind: Secret in the rendered output. - local check_exit=0 - node "${tool_dir}/bin/deploy-check.js" preview \ - --deploy-dir "$deploy_dir" \ - --bin "${install_root}/node_modules/.bin/deploy-config-schema" \ - --context-ref "$context_ref" \ - --context-dir "$context_root" \ - --images "$image_lock_path" \ - --environments "$environments" \ - --artifact-name "$artifact_name" \ - --apply-bundle "$apply_bundle" \ - --provenance-verified "$provenance_verified" \ - --out out \ - || check_exit=$? - - if [[ "$check_exit" -ne 0 ]]; then - emit_gate_summary "deploy-artifact" "Deploy Artifact" "fail" "deploy-check-failed" "none" - fail "E_DEPLOY_CHECK_FAILED: deploy-check exited ${check_exit}" - fi - - # (7) Export render-hash to GITHUB_OUTPUT - local render_hash="" - if [[ -f out/render-hash.txt ]]; then - render_hash=$(tr -d '[:space:]' < out/render-hash.txt) - fi - if [[ -z "$render_hash" ]]; then - fail "E_RENDER_HASH_MISSING: deploy-check wrote no render hash to out/render-hash.txt" - fi - printf 'render-hash=%s\n' "$render_hash" >> "${GITHUB_OUTPUT:-/dev/null}" -} - -# Allow sourcing for unit tests of the helpers; execute main only when invoked -# directly. -if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then - main "$@" -fi diff --git a/actions/deploy-bundle/action.yml b/actions/deploy-bundle/action.yml deleted file mode 100644 index 6eebd95..0000000 --- a/actions/deploy-bundle/action.yml +++ /dev/null @@ -1,68 +0,0 @@ -name: 'Deploy Bundle' -description: 'Validate and pack a first-party deploy directory as an OCI deploy bundle.' - -inputs: - deploy-dir: - description: 'Path to the caller-owned deploy directory.' - required: false - default: 'deploy' - images: - description: 'Newline- or comma-separated image refs to include in the bundle manifest.' - required: false - default: '' - package-version: - description: 'Version range for @jorisjonkers-dev/deploy-config-schema.' - required: false - default: '^0.6.0' - version: - description: 'Bundle version. Defaults to the ref name or a sha-prefixed development version.' - required: false - default: '' - bundle-name: - description: 'Bundle package basename. Defaults to the repository name.' - required: false - default: '' - output-directory: - description: 'Directory where the bundle tarball is written.' - required: false - default: '' - working-directory: - description: 'Directory where deploy-dir is resolved.' - required: false - default: '.' - node-auth-token: - description: 'Token with packages:read for installing @jorisjonkers-dev packages from GitHub Packages.' - required: false - default: '' - -outputs: - bundle-path: - description: 'Path to the packed deploy bundle tarball.' - value: '${{ steps.bundle.outputs.bundle-path }}' - bundle-name: - description: 'Resolved deploy bundle name.' - value: '${{ steps.bundle.outputs.bundle-name }}' - bundle-version: - description: 'Resolved deploy bundle version.' - value: '${{ steps.bundle.outputs.bundle-version }}' - -runs: - using: 'composite' - steps: - - uses: 'actions/setup-node@v6' - with: - node-version: '24' - - - name: 'Validate and pack deploy bundle' - id: 'bundle' - shell: 'bash' - env: - DEPLOY_DIR: '${{ inputs.deploy-dir }}' - IMAGES: '${{ inputs.images }}' - PACKAGE_VERSION: '${{ inputs.package-version }}' - VERSION: '${{ inputs.version }}' - BUNDLE_NAME: '${{ inputs.bundle-name }}' - OUTPUT_DIRECTORY: '${{ inputs.output-directory }}' - WORKING_DIRECTORY: '${{ inputs.working-directory }}' - NODE_AUTH_TOKEN: '${{ inputs.node-auth-token }}' - run: 'bash "${{ github.action_path }}/run.sh"' diff --git a/actions/deploy-bundle/run.sh b/actions/deploy-bundle/run.sh deleted file mode 100755 index e8f1121..0000000 --- a/actions/deploy-bundle/run.sh +++ /dev/null @@ -1,170 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -annotation() { - local kind="$1" - local message="$2" - printf '::%s::%s\n' "$kind" "$message" -} - -trim() { - local value="$1" - value="${value#"${value%%[![:space:]]*}"}" - value="${value%"${value##*[![:space:]]}"}" - printf '%s' "$value" -} - -install_schema_cli() { - local install_root="$1" - local version="$2" - 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" - - echo "::group::Install @jorisjonkers-dev/deploy-config-schema@${version}" >&2 - ( - cd "$install_root" - npm init -y >/dev/null - npm install --userconfig "$npmrc" --no-audit --no-fund --save-exact "@jorisjonkers-dev/deploy-config-schema@${version}" >&2 - ) - echo "::endgroup::" >&2 - - ( - cd "$install_root" - node - <<'NODE' -const path = require('path') -const packageRoot = path.resolve('node_modules/@jorisjonkers-dev/deploy-config-schema') -const manifest = require(path.join(packageRoot, 'package.json')) -const bin = manifest.bin - -let relativeBin = null -if (typeof bin === 'string') { - relativeBin = bin -} else if (bin && typeof bin === 'object') { - relativeBin = bin['deploy-config-schema'] || Object.values(bin)[0] -} - -if (!relativeBin) { - console.error('Package @jorisjonkers-dev/deploy-config-schema does not declare a CLI bin.') - process.exit(1) -} - -console.log(path.join(packageRoot, relativeBin)) -NODE - ) -} - -parse_images() { - local raw="$1" - raw="${raw//,/$'\n'}" - - IMAGE_ARGS=() - - local line - while IFS= read -r line || [ -n "$line" ]; do - line="${line//$'\r'/}" - line="$(trim "$line")" - if [ -z "$line" ] || [[ "$line" == \#* ]]; then - continue - fi - IMAGE_ARGS+=( --images "$line" ) - done <<< "$raw" -} - -main() { - local deploy_dir="${DEPLOY_DIR:-deploy}" - local images="${IMAGES:-}" - local package_version="${PACKAGE_VERSION:-^0.6.0}" - local version="${VERSION:-}" - local bundle_name="${BUNDLE_NAME:-}" - local output_directory="${OUTPUT_DIRECTORY:-}" - local working_directory="${WORKING_DIRECTORY:-.}" - - package_version="$(trim "$package_version")" - if [ -z "$package_version" ]; then - annotation error 'package-version is required.' - exit 1 - fi - if [ ! -d "$working_directory" ]; then - annotation error "working-directory does not exist: $working_directory" - exit 1 - fi - - if [ -z "$(trim "$version")" ]; then - version="${GITHUB_REF_NAME:-}" - version="${version#v}" - fi - if [ -z "$(trim "$version")" ]; then - version="0.0.0-sha-${GITHUB_SHA:-unknown}" - fi - - if [ -z "$(trim "$bundle_name")" ]; then - bundle_name="${GITHUB_REPOSITORY##*/}" - fi - if [ -z "$(trim "$bundle_name")" ]; then - annotation error 'bundle-name is required when GITHUB_REPOSITORY is unavailable.' - exit 1 - fi - - if [ -z "$(trim "$output_directory")" ]; then - output_directory="${RUNNER_TEMP:-/tmp}/deploy-bundle" - fi - - local install_root - install_root="${RUNNER_TEMP:-/tmp}/deploy-config-schema-cli" - local cli_bin - cli_bin="$(install_schema_cli "$install_root" "$package_version")" - - cd "$working_directory" - - if [ ! -d "$deploy_dir" ]; then - annotation error "deploy-dir does not exist: $deploy_dir" - exit 1 - fi - - mapfile -t deploy_files < <(find "$deploy_dir" -type f \( -name '*.yml' -o -name '*.yaml' \) | sort) - if [ "${#deploy_files[@]}" -eq 0 ]; then - annotation error "deploy-dir contains no YAML files: $deploy_dir" - exit 1 - fi - - local -a IMAGE_ARGS=() - parse_images "$images" - - mkdir -p "$output_directory" - local bundle_path="${output_directory%/}/${bundle_name}-${version}.tar" - - echo '::group::Validate deploy directory' - "$cli_bin" validate deployment-v2 "${deploy_files[@]}" - echo '::endgroup::' - - echo '::group::Pack deploy bundle' - "$cli_bin" bundle pack \ - --deploy-dir "$deploy_dir" \ - --repo "${GITHUB_REPOSITORY:-$bundle_name}" \ - --git-sha "${GITHUB_SHA:-unknown}" \ - --version "$version" \ - --out "$bundle_path" \ - "${IMAGE_ARGS[@]}" - echo '::endgroup::' - - if [ ! -s "$bundle_path" ]; then - annotation error "Bundle pack did not write a non-empty bundle: $bundle_path" - exit 1 - fi - - { - printf 'bundle-path=%s\n' "$bundle_path" - printf 'bundle-name=%s\n' "$bundle_name" - printf 'bundle-version=%s\n' "$version" - } >> "$GITHUB_OUTPUT" -} - -main "$@" diff --git a/actions/deploy-config-render-drift/action.yml b/actions/deploy-config-render-drift/action.yml deleted file mode 100644 index 1392215..0000000 --- a/actions/deploy-config-render-drift/action.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Deploy Config Render Drift -description: Render deploy-config-schema adapter outputs and compare them with committed files. - -inputs: - package-version: - description: Version of @jorisjonkers-dev/deploy-config-schema to install. - required: false - default: ^0.6.0 - deploy-config-command: - description: Shell command that writes deploy-config JSON to DEPLOY_CONFIG_OUT. - required: true - targets: - description: Newline-separated adapter=relative/committed/path.yaml entries. - required: true - working-directory: - description: Directory where the deploy-config command runs and target paths are resolved. - required: false - default: . - node-auth-token: - description: Token with packages:read for installing @jorisjonkers-dev packages from GitHub Packages (pass secrets.GITHUB_TOKEN). - required: false - default: "" - -runs: - using: composite - steps: - - uses: actions/setup-node@v6 - with: - node-version: '24' - - - name: Check deploy config render drift - shell: bash - env: - PACKAGE_VERSION: ${{ inputs.package-version }} - DEPLOY_CONFIG_COMMAND: ${{ inputs.deploy-config-command }} - TARGETS: ${{ inputs.targets }} - WORKING_DIRECTORY: ${{ inputs.working-directory }} - NODE_AUTH_TOKEN: ${{ inputs.node-auth-token }} - run: bash "${{ github.action_path }}/run.sh" diff --git a/actions/deploy-config-render-drift/run.sh b/actions/deploy-config-render-drift/run.sh deleted file mode 100644 index f8eac7b..0000000 --- a/actions/deploy-config-render-drift/run.sh +++ /dev/null @@ -1,202 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -annotation() { - local kind="$1" - local message="$2" - printf '::%s::%s\n' "${kind}" "${message}" -} - -trim() { - local value="$1" - value="${value#"${value%%[![:space:]]*}"}" - value="${value%"${value##*[![:space:]]}"}" - printf '%s' "${value}" -} - -install_schema_cli() { - local install_root="$1" - local version="$2" - 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}" - - echo "::group::Install @jorisjonkers-dev/deploy-config-schema@${version}" >&2 - ( - cd "${install_root}" - npm init -y >/dev/null - npm install --userconfig "${npmrc}" --no-audit --no-fund --save-exact "@jorisjonkers-dev/deploy-config-schema@${version}" >&2 - ) - echo "::endgroup::" >&2 - - ( - cd "${install_root}" - node - <<'NODE' -const path = require("path"); -const packageRoot = path.resolve("node_modules/@jorisjonkers-dev/deploy-config-schema"); -const manifest = require(path.join(packageRoot, "package.json")); -const bin = manifest.bin; - -let relativeBin = null; -if (typeof bin === "string") { - relativeBin = bin; -} else if (bin && typeof bin === "object") { - relativeBin = bin["deploy-config-schema"] || Object.values(bin)[0]; -} - -if (!relativeBin) { - console.error("Package @jorisjonkers-dev/deploy-config-schema does not declare a CLI bin."); - process.exit(1); -} - -console.log(path.join(packageRoot, relativeBin)); -NODE - ) -} - -parse_targets() { - local raw="$1" - - ADAPTERS=() - TARGET_PATHS=() - - local line - while IFS= read -r line || [ -n "${line}" ]; do - line="${line//$'\r'/}" - line="$(trim "${line}")" - if [ -z "${line}" ] || [[ "${line}" == \#* ]]; then - continue - fi - if [[ "${line}" != *=* ]]; then - annotation error "Invalid target entry: ${line}. Use adapter=relative/path.yaml." - exit 1 - fi - - local adapter="${line%%=*}" - local target_path="${line#*=}" - adapter="$(trim "${adapter}")" - target_path="$(trim "${target_path}")" - - if [ -z "${adapter}" ] || [ -z "${target_path}" ]; then - annotation error "Invalid target entry: ${line}. Adapter and path are required." - exit 1 - fi - if [[ "${target_path}" = /* ]]; then - annotation error "Invalid target path for ${adapter}: ${target_path}. Use a relative path." - exit 1 - fi - - ADAPTERS+=( "${adapter}" ) - TARGET_PATHS+=( "${target_path}" ) - done <<< "${raw}" - - if [ "${#ADAPTERS[@]}" -eq 0 ]; then - annotation error "No render drift targets were provided." - exit 1 - fi -} - -main() { - local package_version="${PACKAGE_VERSION:-^0.6.0}" - local deploy_config_command="${DEPLOY_CONFIG_COMMAND:-}" - local targets="${TARGETS:-}" - local working_directory="${WORKING_DIRECTORY:-.}" - - package_version="$(trim "${package_version}")" - if [ -z "${package_version}" ]; then - annotation error "package-version is required." - exit 1 - fi - if [ -z "$(trim "${deploy_config_command}")" ]; then - annotation error "deploy-config-command is required." - exit 1 - fi - if [ ! -d "${working_directory}" ]; then - annotation error "working-directory does not exist: ${working_directory}" - exit 1 - fi - - local -a ADAPTERS=() - local -a TARGET_PATHS=() - parse_targets "${targets}" - - local install_root - install_root="${RUNNER_TEMP:-/tmp}/deploy-config-render-drift-cli" - local cli_bin - cli_bin="$(install_schema_cli "${install_root}" "${package_version}")" - - local temp_root - temp_root="$(mktemp -d "${RUNNER_TEMP:-/tmp}/deploy-config-render-drift.XXXXXX")" - local render_root="${temp_root}/rendered" - mkdir -p "${render_root}" - - export DEPLOY_CONFIG_SCHEMA_LOCAL="${install_root}/node_modules/@jorisjonkers-dev/deploy-config-schema" - export DEPLOY_CONFIG_OUT="${temp_root}/deploy-config.json" - export PATH="${install_root}/node_modules/.bin:${PATH}" - - printf 'Deploy config render drift package: %s\n' "${package_version}" - printf 'Working directory: %s\n' "${working_directory}" - printf 'Deploy config output: %s\n' "${DEPLOY_CONFIG_OUT}" - printf 'Targets:\n' - local target_index - for target_index in "${!ADAPTERS[@]}"; do - printf ' %s=%s\n' "${ADAPTERS[$target_index]}" "${TARGET_PATHS[$target_index]}" - done - - echo "::group::Generate deploy config" - ( - cd "${working_directory}" - eval "${deploy_config_command}" - ) - echo "::endgroup::" - - if [ ! -s "${DEPLOY_CONFIG_OUT}" ]; then - annotation error "deploy-config-command did not write a non-empty file to DEPLOY_CONFIG_OUT: ${DEPLOY_CONFIG_OUT}" - exit 1 - fi - - local drift_found=0 - for target_index in "${!ADAPTERS[@]}"; do - local adapter="${ADAPTERS[$target_index]}" - local committed_path="${working_directory}/${TARGET_PATHS[$target_index]}" - local rendered_path="${render_root}/${adapter}.yaml" - mkdir -p "$(dirname "${rendered_path}")" - - echo "::group::Render ${adapter}" - "${cli_bin}" render "${adapter}" "${DEPLOY_CONFIG_OUT}" --output "${rendered_path}" - echo "::endgroup::" - - if [ ! -f "${committed_path}" ]; then - printf 'DIFF %s=%s (committed file missing)\n' "${adapter}" "${TARGET_PATHS[$target_index]}" - annotation error "Committed render target is missing: ${committed_path}" - drift_found=1 - continue - fi - - # Functional drift check: compare canonicalised YAML so formatting, key - # order, and comments do not register as drift, but real content changes - # do. This replaces exact byte matching without losing drift detection. - if diff -q \ - <(yq -P 'sort_keys(..)' "${rendered_path}") \ - <(yq -P 'sort_keys(..)' "${committed_path}") >/dev/null 2>&1; then - printf 'MATCH %s=%s\n' "${adapter}" "${TARGET_PATHS[$target_index]}" - else - printf 'DIFF %s=%s\n' "${adapter}" "${TARGET_PATHS[$target_index]}" - annotation error "deploy-config-schema render ${adapter} drifted from ${TARGET_PATHS[$target_index]} (semantic YAML comparison)" - drift_found=1 - fi - done - - if [ "${drift_found}" -ne 0 ]; then - exit 1 - fi -} - -main "$@" diff --git a/actions/deploy-preview/action.yml b/actions/deploy-preview/action.yml deleted file mode 100644 index 4cecdff..0000000 --- a/actions/deploy-preview/action.yml +++ /dev/null @@ -1,54 +0,0 @@ -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: - # The preview pulls the cluster context package by digest; oras and yq are - # required. Reuse the pinned installer from the deploy-artifact action. - - name: Install deployment tooling (oras, yq) - shell: bash - run: bash "${{ github.action_path }}/../deploy-artifact/install-tooling.sh" --only oras,yq - - - 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 deleted file mode 100755 index e1cd828..0000000 --- a/actions/deploy-preview/run.sh +++ /dev/null @@ -1,172 +0,0 @@ -#!/usr/bin/env bash -# actions/deploy-preview/run.sh -# -# Renders a service repository's deployment, scores it, and reports the result -# on the pull request. -# -# The rendering and the scorecard used to live here in bash, and a near-copy of -# both lived in every service repository as platform/render-local.sh. The two -# drifted: this copy read deployment.yml with yq and consulted the artifact -# contract, while the per-repo copy re-derived the same answers by grepping raw -# text, so a local run and a CI run could disagree about the same repository. -# Both are now tools/deploy-check, which is unit tested and published as -# @jorisjonkers-dev/deploy-check for local use. What remains here is the part -# that is genuinely specific to running inside Actions: pulling the context -# package, posting the sticky comment, and emitting the gate summary. -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 || 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}" - local gw_root="${GW_ROOT:?GW_ROOT is required}" - local tool_dir="${gw_root}/tools/deploy-check" - - if [[ "$context_ref" != *"@sha256:"* ]]; then - emit_gate_summary "deploy-validate" "Deploy Validate" "fail" \ - "context-ref-not-pinned" "none" - fail "E_CONTEXT_REF_NOT_PINNED: context-ref must be digest-pinned: ${context_ref}" - fi - - # Install the checker's dependencies from its committed lockfile. CI runs the - # tool from this checkout rather than from the published package, so a publish - # failure cannot break the gate and the code always matches the pinned - # workflow SHA. - ( cd "$tool_dir" && npm ci --no-audit --no-fund >&2 ) \ - || fail "E_DEPLOY_CHECK_DEPS_FAILED: npm ci failed in ${tool_dir}" - - # Pull the context package once. Service repos never hold the cluster - # context; it is fetched by digest. - local context_root="${RUNNER_TEMP:-/tmp}/deploy-preview-context" - rm -rf "$context_root" - mkdir -p "$context_root" - if ! oras pull "$context_ref" --output "$context_root" 2>&1; then - emit_gate_summary "deploy-validate" "Deploy Validate" "fail" \ - "context-pull-failed" "none" - fail "E_CONTEXT_PULL_FAILED: oras pull ${context_ref} failed; rendering impossible" - fi - - local check_exit=0 - node "${tool_dir}/bin/deploy-check.js" preview \ - --deploy-dir "$deploy_dir" \ - --schema-version "$schema_version" \ - --context-ref "$context_ref" \ - --context-dir "$context_root" \ - --images "$image_lock_path" \ - --environments "$environments" \ - --out out \ - --markdown-out deploy-preview-summary.md \ - || check_exit=$? - - # The scorecard is authoritative for the gate. A nonzero exit also covers - # render and contract failures, which the summary already describes. - local overall="fail" - if [[ -f out/scorecard-detail.json ]]; then - overall=$(jq -r '.overall // "fail"' out/scorecard-detail.json 2>/dev/null || echo fail) - fi - emit_gate_summary "deploy-validate" "Deploy Validate" "$overall" "scorecard-evaluated" "none" - - if [[ "$comment" == "true" && -f deploy-preview-summary.md ]]; then - post_sticky_pr_comment deploy-preview-summary.md - fi - - exit "$check_exit" -} - -if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then - main "$@" -fi diff --git a/actions/deploy-sources-render/action.yml b/actions/deploy-sources-render/action.yml deleted file mode 100644 index aa281d1..0000000 --- a/actions/deploy-sources-render/action.yml +++ /dev/null @@ -1,87 +0,0 @@ -name: 'Deploy Sources Render' -description: 'Resolve deployment sources, compile Flux output, and check render drift.' - -inputs: - package-version: - description: 'Version range for @jorisjonkers-dev/deploy-config-schema.' - required: false - default: '^0.6.0' - environment: - description: 'Deployment environment to compile.' - required: false - default: 'production' - sources-path: - description: 'Path to deployment-sources.yml.' - required: false - default: 'deployment-sources.yml' - lock-path: - description: 'Path to deployment.lock.yml.' - required: false - default: 'deployment.lock.yml' - node-contract-path: - description: 'Path to the locked node contract.' - required: false - default: 'inventory/node-contract.lock.yml' - reachability-path: - description: 'Path to reachability catalog.' - required: false - default: 'catalog/reachability.yml' - output-path: - description: 'Rendered Flux output directory.' - required: false - default: 'cluster/flux' - working-directory: - description: 'Directory where paths are resolved.' - required: false - default: '.' - update-lock: - description: 'Run lock --update before compile.' - required: false - default: 'false' - check: - description: 'Require compiled output and derived lock files to be committed.' - required: false - default: 'true' - cutover-parity: - description: 'Run cutover parity against current-tree.' - required: false - default: 'false' - current-tree: - description: 'Current live Flux tree used when cutover-parity is true.' - required: false - default: '' - node-auth-token: - description: 'Token with packages:read for installing @jorisjonkers-dev packages from GitHub Packages.' - required: false - default: '' - -outputs: - image-tags: - description: 'Image tags resolved from deployment.lock.yml.' - value: '${{ steps.render.outputs.image-tags }}' - -runs: - using: 'composite' - steps: - - uses: 'actions/setup-node@v6' - with: - node-version: '24' - - - name: 'Render deployment sources' - id: 'render' - shell: 'bash' - env: - PACKAGE_VERSION: '${{ inputs.package-version }}' - ENVIRONMENT: '${{ inputs.environment }}' - SOURCES_PATH: '${{ inputs.sources-path }}' - LOCK_PATH: '${{ inputs.lock-path }}' - NODE_CONTRACT_PATH: '${{ inputs.node-contract-path }}' - REACHABILITY_PATH: '${{ inputs.reachability-path }}' - OUTPUT_PATH: '${{ inputs.output-path }}' - WORKING_DIRECTORY: '${{ inputs.working-directory }}' - UPDATE_LOCK: '${{ inputs.update-lock }}' - CHECK: '${{ inputs.check }}' - CUTOVER_PARITY: '${{ inputs.cutover-parity }}' - CURRENT_TREE: '${{ inputs.current-tree }}' - NODE_AUTH_TOKEN: '${{ inputs.node-auth-token }}' - run: 'bash "${{ github.action_path }}/run.sh"' diff --git a/actions/deploy-sources-render/run.sh b/actions/deploy-sources-render/run.sh deleted file mode 100755 index 314300d..0000000 --- a/actions/deploy-sources-render/run.sh +++ /dev/null @@ -1,183 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -annotation() { - local kind="$1" - local message="$2" - printf '::%s::%s\n' "$kind" "$message" -} - -trim() { - local value="$1" - value="${value#"${value%%[![:space:]]*}"}" - value="${value%"${value##*[![:space:]]}"}" - printf '%s' "$value" -} - -normalize_bool() { - local name="$1" - local value - value="$(printf '%s' "${2:-false}" | tr '[:upper:]' '[:lower:]')" - case "$value" in - true|1|yes|y|on) - printf 'true' - ;; - false|0|no|n|off|'') - printf 'false' - ;; - *) - annotation error "Unsupported boolean for $name: ${2}. Use true or false." - exit 1 - ;; - esac -} - -install_schema_cli() { - local install_root="$1" - local version="$2" - 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" - - echo "::group::Install @jorisjonkers-dev/deploy-config-schema@${version}" >&2 - ( - cd "$install_root" - npm init -y >/dev/null - npm install --userconfig "$npmrc" --no-audit --no-fund --save-exact "@jorisjonkers-dev/deploy-config-schema@${version}" >&2 - ) - echo "::endgroup::" >&2 - - ( - cd "$install_root" - node - <<'NODE' -const path = require('path') -const packageRoot = path.resolve('node_modules/@jorisjonkers-dev/deploy-config-schema') -const manifest = require(path.join(packageRoot, 'package.json')) -const bin = manifest.bin - -let relativeBin = null -if (typeof bin === 'string') { - relativeBin = bin -} else if (bin && typeof bin === 'object') { - relativeBin = bin['deploy-config-schema'] || Object.values(bin)[0] -} - -if (!relativeBin) { - console.error('Package @jorisjonkers-dev/deploy-config-schema does not declare a CLI bin.') - process.exit(1) -} - -console.log(path.join(packageRoot, relativeBin)) -NODE - ) -} - -require_file() { - local path="$1" - local label="$2" - if [ ! -f "$path" ]; then - annotation error "$label does not exist: $path" - exit 1 - fi -} - -main() { - local package_version="${PACKAGE_VERSION:-^0.6.0}" - local environment="${ENVIRONMENT:-production}" - local sources_path="${SOURCES_PATH:-deployment-sources.yml}" - local lock_path="${LOCK_PATH:-deployment.lock.yml}" - local node_contract_path="${NODE_CONTRACT_PATH:-inventory/node-contract.lock.yml}" - local reachability_path="${REACHABILITY_PATH:-catalog/reachability.yml}" - local output_path="${OUTPUT_PATH:-cluster/flux}" - local working_directory="${WORKING_DIRECTORY:-.}" - local update_lock - local check - local cutover_parity - local current_tree="${CURRENT_TREE:-}" - - package_version="$(trim "$package_version")" - if [ -z "$package_version" ]; then - annotation error 'package-version is required.' - exit 1 - fi - if [ ! -d "$working_directory" ]; then - annotation error "working-directory does not exist: $working_directory" - exit 1 - fi - - update_lock="$(normalize_bool update-lock "${UPDATE_LOCK:-false}")" - check="$(normalize_bool check "${CHECK:-true}")" - cutover_parity="$(normalize_bool cutover-parity "${CUTOVER_PARITY:-false}")" - - local install_root - install_root="${RUNNER_TEMP:-/tmp}/deploy-config-schema-cli" - local cli_bin - cli_bin="$(install_schema_cli "$install_root" "$package_version")" - - cd "$working_directory" - - require_file "$sources_path" 'sources-path' - require_file "$lock_path" 'lock-path' - require_file "$node_contract_path" 'node-contract-path' - require_file "$reachability_path" 'reachability-path' - - echo '::group::Resolve deployment sources' - "$cli_bin" resolve-sources --sources "$sources_path" --lock "$lock_path" --check - echo '::endgroup::' - - if [ "$update_lock" = 'true' ]; then - echo '::group::Update deployment lock' - "$cli_bin" lock --sources "$sources_path" --lock "$lock_path" --update - echo '::endgroup::' - fi - - local -a compile_args=( - compile - --env "$environment" - --sources "$sources_path" - --lock "$lock_path" - --node-contract "$node_contract_path" - --reachability "$reachability_path" - --out "$output_path" - ) - if [ "$check" = 'true' ]; then - compile_args+=( --check ) - fi - - echo '::group::Compile deployment sources' - "$cli_bin" "${compile_args[@]}" - echo '::endgroup::' - - local image_tags - image_tags="$("$cli_bin" lock images --lock "$lock_path" --format image-tags)" - { - printf 'image-tags<> "$GITHUB_OUTPUT" - - if [ "$cutover_parity" = 'true' ]; then - if [ -z "$(trim "$current_tree")" ]; then - annotation error 'current-tree is required when cutover-parity is true.' - exit 1 - fi - echo '::group::Cutover parity' - "$cli_bin" parity --current "$current_tree" --rendered "$output_path" --allow-flux-source-diff true - echo '::endgroup::' - fi - - if [ "$check" = 'true' ] && git rev-parse --is-inside-work-tree >/dev/null 2>&1; then - echo '::group::Render drift' - git diff --exit-code -- "$lock_path" "$node_contract_path" "$reachability_path" "$output_path" - echo '::endgroup::' - fi -} - -main "$@" diff --git a/actions/platform-config-validate/action.yml b/actions/platform-config-validate/action.yml deleted file mode 100644 index 1e5046b..0000000 --- a/actions/platform-config-validate/action.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: Platform Config Validate -description: Validate platform YAML with @jorisjonkers-dev/deploy-config-schema and optionally check rendered-tree drift. - -inputs: - config-paths: - description: Newline- or comma-separated file globs to validate. - required: false - default: |- - platform/**/*.yaml - platform/**/*.yml - schema-kind: - description: Schema kind to validate. Supported values are platform, deploy-config, service-intent, fleet-inventory, vault-dynamic-secrets, or auto. - required: false - default: auto - package-version: - description: Version of @jorisjonkers-dev/deploy-config-schema to install. - required: false - default: 0.3.0 - node-auth-token: - description: Token with packages:read for installing @jorisjonkers-dev packages from GitHub Packages (pass secrets.GITHUB_TOKEN). - required: false - default: "" - drift-check: - description: Run render-tree --check after validation when true. - required: false - default: 'false' - working-directory: - description: Directory where config globs are evaluated. - required: false - default: . - -runs: - using: composite - steps: - - uses: actions/setup-node@v6 - with: - node-version: '24' - - - name: Validate platform config - shell: bash - env: - CONFIG_PATHS: ${{ inputs.config-paths }} - SCHEMA_KIND: ${{ inputs.schema-kind }} - PACKAGE_VERSION: ${{ inputs.package-version }} - NODE_AUTH_TOKEN: ${{ inputs.node-auth-token }} - DRIFT_CHECK: ${{ inputs.drift-check }} - WORKING_DIRECTORY: ${{ inputs.working-directory }} - run: bash "${{ github.action_path }}/run.sh" diff --git a/actions/platform-config-validate/fixtures/platform.example.yml b/actions/platform-config-validate/fixtures/platform.example.yml deleted file mode 100644 index 0f6c21d..0000000 --- a/actions/platform-config-validate/fixtures/platform.example.yml +++ /dev/null @@ -1,44 +0,0 @@ -version: 1 -name: example-platform -domain: example.net - -cluster: - kind: k3s - api: https://203.0.113.10:6443 - bootstrap: edge-1 - -gitops: - root: platform/cluster/flux - environment: production - interval: 2m - -packs: - core: - - cert-manager - - external-dns - - traefik-public - -hosts: - edge-1: - site: primary - system: x86_64-linux - roles: - - base - - k3s-control-plane - - k3s-worker - capabilities: - - public-ingress - ssh: deploy@203.0.113.10:2222 - hostModule: platform/nix/hosts/edge-1/default.nix - -services: - api: - namespace: app-system - image: ghcr.io/example/api:1.0.0 - port: 8080 - route: - host: root - path: / - sso: false - origin: edge_direct - health: /healthz diff --git a/actions/platform-config-validate/fixtures/service-intent.example.yml b/actions/platform-config-validate/fixtures/service-intent.example.yml deleted file mode 100644 index 1209184..0000000 --- a/actions/platform-config-validate/fixtures/service-intent.example.yml +++ /dev/null @@ -1,51 +0,0 @@ -version: 1 - -renderer: - cluster_name: example-cluster - public_domain: example.net - adapters: - - traefik-public - - gatus - -services: - api-service: - description: Renderable API service using route and probe fields. - workload: - kind: deployment - replicas: 2 - restart_policy: Always - image: - repository: registry.example.net/team/api-service - tag: latest - pull_policy: Always - source: first_party - ports: - - name: http - container_port: 8080 - service_port: 80 - exposure: public - networking: - routes: - - name: api-service - host: api.example.net - port: http - paths: - - /api/ - access: direct - origin: public-proxied - gatus: - endpoints: - - name: api-health - type: http - port: http - path: /api/health - expected_status: 200 - strategy: both - group: apps - kubernetes: - namespace_ref: app-system - service_ref: api-service - render_status: candidate - resource_hints: - - Deployment - - Service diff --git a/actions/platform-config-validate/run.sh b/actions/platform-config-validate/run.sh deleted file mode 100755 index 2218bd6..0000000 --- a/actions/platform-config-validate/run.sh +++ /dev/null @@ -1,197 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -annotation() { - local kind="$1" - local message="$2" - printf '::%s::%s\n' "$kind" "$message" -} - -trim() { - local value="$1" - value="${value#"${value%%[![:space:]]*}"}" - value="${value%"${value##*[![:space:]]}"}" - printf '%s' "$value" -} - -normalize_bool() { - local name="$1" - local value - value="$(printf '%s' "${2:-false}" | tr '[:upper:]' '[:lower:]')" - case "$value" in - true|1|yes|y|on) - printf 'true' - ;; - false|0|no|n|off|'') - printf 'false' - ;; - *) - annotation error "Unsupported boolean for $name: ${2}. Use true or false." - exit 1 - ;; - esac -} - -validate_schema_kind() { - case "$1" in - platform|deploy-config|service-intent|fleet-inventory|vault-dynamic-secrets|auto) - ;; - *) - annotation error "Unsupported schema-kind: $1. Use platform, deploy-config, service-intent, fleet-inventory, vault-dynamic-secrets, or auto." - exit 1 - ;; - esac -} - -install_schema_cli() { - local install_root="$1" - local version="$2" - 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" - - echo "::group::Install @jorisjonkers-dev/deploy-config-schema@$version" >&2 - ( - cd "$install_root" - npm init -y >/dev/null - npm install --userconfig "$npmrc" --no-audit --no-fund --save-exact "@jorisjonkers-dev/deploy-config-schema@$version" >&2 - ) - echo "::endgroup::" >&2 - - ( - cd "$install_root" - node - <<'NODE' -const path = require("path"); -const packageRoot = path.resolve("node_modules/@jorisjonkers-dev/deploy-config-schema"); -const manifest = require(path.join(packageRoot, "package.json")); -const bin = manifest.bin; - -let relativeBin = null; -if (typeof bin === "string") { - relativeBin = bin; -} else if (bin && typeof bin === "object") { - relativeBin = bin["deploy-config-schema"] || Object.values(bin)[0]; -} - -if (!relativeBin) { - console.error("Package @jorisjonkers-dev/deploy-config-schema does not declare a CLI bin."); - process.exit(1); -} - -console.log(path.join(packageRoot, relativeBin)); -NODE - ) -} - -expand_config_files() { - local raw="$1" - raw="${raw//,/$'\n'}" - - shopt -s nullglob globstar - - CONFIG_FILES=() - local -A seen=() - local pattern - while IFS= read -r pattern || [ -n "$pattern" ]; do - pattern="${pattern//$'\r'/}" - pattern="$(trim "$pattern")" - if [ -z "$pattern" ] || [[ "$pattern" == \#* ]]; then - continue - fi - - local -a matches=() - # Intentionally unquoted so caller-provided glob patterns expand. - # shellcheck disable=SC2206 - matches=( $pattern ) - if [ "${#matches[@]}" -eq 0 ] && [ -f "$pattern" ]; then - matches=( "$pattern" ) - fi - - local match - for match in "${matches[@]}"; do - if [ -f "$match" ] && [ -z "${seen[$match]+x}" ]; then - CONFIG_FILES+=( "$match" ) - seen["$match"]=1 - fi - done - done <<< "$raw" - - if [ "${#CONFIG_FILES[@]}" -eq 0 ]; then - annotation error "No config files matched config-paths." - exit 1 - fi -} - -main() { - local config_paths="${CONFIG_PATHS:-}" - local schema_kind="${SCHEMA_KIND:-auto}" - local package_version="${PACKAGE_VERSION:-0.3.0}" - local drift_check - local working_directory="${WORKING_DIRECTORY:-.}" - - if [ -z "$(trim "$package_version")" ]; then - annotation error "package-version is required." - exit 1 - fi - - validate_schema_kind "$schema_kind" - drift_check="$(normalize_bool drift-check "${DRIFT_CHECK:-false}")" - - if [ ! -d "$working_directory" ]; then - annotation error "working-directory does not exist: $working_directory" - exit 1 - fi - - local install_root - install_root="${RUNNER_TEMP:-/tmp}/deploy-config-schema-cli" - local cli_bin - cli_bin="$(install_schema_cli "$install_root" "$package_version")" - - cd "$working_directory" - - local -a CONFIG_FILES=() - expand_config_files "$config_paths" - local -a files=( "${CONFIG_FILES[@]}" ) - - if [ "$drift_check" = "true" ] && [ "$schema_kind" != "platform" ] && [ "$schema_kind" != "auto" ]; then - annotation error "drift-check only supports platform configs. Use schema-kind platform or auto." - exit 1 - fi - - printf 'Validating %d platform config file(s) with @jorisjonkers-dev/deploy-config-schema@%s\n' "${#files[@]}" "$package_version" - printf 'Schema kind: %s\n' "$schema_kind" - printf 'Working directory: %s\n' "$working_directory" - printf 'Drift check: %s\n' "$drift_check" - printf 'Config files:\n' - printf ' %s\n' "${files[@]}" - - echo "::group::deploy-config-schema validate" - if ! "$cli_bin" validate "$schema_kind" "${files[@]}"; then - echo "::endgroup::" - annotation error "deploy-config-schema validate failed." - exit 1 - fi - echo "::endgroup::" - - if [ "$drift_check" = "true" ]; then - local drift_file - for drift_file in "${files[@]}"; do - echo "::group::deploy-config-schema render-tree --check $drift_file" - if ! "$cli_bin" render-tree "$drift_file" --check; then - echo "::endgroup::" - annotation error "deploy-config-schema render-tree --check reported drift for $drift_file." - exit 1 - fi - echo "::endgroup::" - done - fi -} - -main "$@" diff --git a/docs/action-pinning.md b/docs/action-pinning.md index d0a93a9..222f0c5 100644 --- a/docs/action-pinning.md +++ b/docs/action-pinning.md @@ -9,10 +9,8 @@ tags (e.g. `@v1.2.3`) and is **not** digest-pinned — the semver tag IS the int 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. +`gitleaks` and `trufflehog` are installed in `actions/leak-scan/action.yml` from +pinned release download URLs, verified with `sha256sum`. ## Adding a new third-party action diff --git a/release-please-config.json b/release-please-config.json index 529752a..9caa656 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -14,26 +14,6 @@ "type": "generic", "path": ".github/workflows/container-publish.yml" }, - { - "type": "generic", - "path": ".github/workflows/deploy-artifact.yml" - }, - { - "type": "generic", - "path": ".github/workflows/deploy-bundle.yml" - }, - { - "type": "generic", - "path": ".github/workflows/deploy-config-render-drift.yml" - }, - { - "type": "generic", - "path": ".github/workflows/deploy-sources-render.yml" - }, - { - "type": "generic", - "path": ".github/workflows/deploy-validate.yml" - }, { "type": "generic", "path": ".github/workflows/flux-render-validate.yml" @@ -70,10 +50,6 @@ "type": "generic", "path": ".github/workflows/node-ci.yml" }, - { - "type": "generic", - "path": ".github/workflows/platform-config-validate.yml" - }, { "type": "generic", "path": ".github/workflows/publish-api-clients.yml" diff --git a/tests/fixtures/deploy-artifact/cli-interface.json b/tests/fixtures/deploy-artifact/cli-interface.json deleted file mode 100644 index e859f32..0000000 --- a/tests/fixtures/deploy-artifact/cli-interface.json +++ /dev/null @@ -1,109 +0,0 @@ -{ - "version": "0.20.0", - "description": "Recorded CLI argument shapes for @jorisjonkers-dev/deploy-config-schema, derived from src/deployment/commands.ts and src/cli.ts in that repository. Any change to the flags used in actions/deploy-artifact/run.sh or actions/deploy-preview/run.sh must be reflected here first; a subcommand invoked by either script with no entry here fails the completeness test. The filename carries no version so that bumping the pinned toolkit does not require renaming this fixture.", - "subcommands": { - "render_fragment": { - "synopsis": "render --env --images (--context-dir | --context --context-path ) [--output ]", - "positionals": [ - "fragment-id", - "deploy-dir" - ], - "required_flags": [ - "--env", - "--images" - ], - "exclusive_groups": [ - [ - "--context-dir" - ], - [ - "--context", - "--context-path" - ] - ], - "optional_flags": [ - "--output" - ], - "notes": [ - "The second positional is the deploy directory (e.g. 'deploy'), NOT the deployment.yml file.", - "The CLI routes to runFragmentRender when the first positional resolves to a fragment adapter.", - "context must be passed either as --context-dir (local dir containing cluster-context-public.yml) or as --context plus --context-path ." - ], - "implemented": true - }, - "artifact_emit_contract": { - "synopsis": "artifact emit-contract --artifact-name --environments --images --context-ref --deployment --context --out [--provenance-verified true|false] [--output-root ]", - "required_flags": [ - "--artifact-name", - "--environments", - "--images", - "--context-ref", - "--deployment", - "--context", - "--out" - ], - "optional_flags": [ - "--provenance-verified", - "--output-root" - ], - "absent_flags": [ - "--schema-version" - ], - "notes": [ - "--schema-version does NOT exist in this subcommand; do not pass it.", - "--deployment is required (path to deployment.yml).", - "--context is required (path to the pulled cluster-context-public.yml file).", - "--context-ref is required (the OCI digest ref string, e.g. ghcr.io/...@sha256:...).", - "--environments takes a comma-separated string (e.g. 'production,staging').", - "The subcommand is 'artifact emit-contract' (two words after deploy-config-schema)." - ], - "implemented": true - }, - "artifact_emit_kustomization_health": { - "synopsis": "artifact emit-kustomization-health --deployment --env --image-digests --out ", - "required_flags": [ - "--deployment", - "--env", - "--image-digests", - "--out" - ], - "optional_flags": [], - "notes": [ - "Takes a single --env per invocation; call once per environment." - ], - "implemented": true - }, - "artifact_emit_apply_bundle": { - "synopsis": "artifact emit-apply-bundle --manifests --out ", - "required_flags": [ - "--manifests", - "--out" - ], - "optional_flags": [], - "since": "0.20.0", - "notes": [ - "Lifts the Kubernetes objects out of kubernetes-workload-fragment.yaml into one document per object plus a kustomization.yaml, so an OCIRepository plus Kustomization pair can apply the artifact.", - "The remaining fragments carry intent that aggregates across services and are reported as skipped, not emitted.", - "Invoked only when the apply-bundle input is true, because it does not exist before 0.20.0." - ], - "implemented": true - }, - "artifact_validate_raw_manifests": { - "synopsis": "artifact validate-raw-manifests --deployment --root --output-root --forbidden-kinds --out ", - "required_flags": [ - "--deployment", - "--root", - "--output-root", - "--forbidden-kinds", - "--out" - ], - "optional_flags": [], - "implemented": false, - "notes": [ - "No published version of the toolkit provides this subcommand: grepping the packaged dist of 0.16.0 and 0.19.0 finds no reference to validate-raw-manifests.", - "The call is reached only when /raw-manifests exists, and no service repository has that directory, so the path has never executed.", - "Recorded as unimplemented rather than removed so the gap is visible: a repository that adds raw-manifests today would fail its publish with an unknown-subcommand error instead of getting the validation the step claims to perform." - ] - } - } -} diff --git a/tests/test_crac_train_workflow.py b/tests/test_crac_train_workflow.py index 906ff4b..2f423e7 100644 --- a/tests/test_crac_train_workflow.py +++ b/tests/test_crac_train_workflow.py @@ -14,15 +14,12 @@ ROOT = Path(__file__).resolve().parents[1] CRAC_WORKFLOW = ROOT / ".github/workflows/crac-train.yml" CI_WORKFLOW = ROOT / ".github/workflows/ci.yml" -PLATFORM_CONFIG_WORKFLOW = ROOT / ".github/workflows/platform-config-validate.yml" NODE_CI_WORKFLOW = ROOT / ".github/workflows/node-ci.yml" PYTHON_CI_WORKFLOW = ROOT / ".github/workflows/python-ci.yml" NIX_CI_WORKFLOW = ROOT / ".github/workflows/nix-ci.yml" DOCKER_IMAGE_CI_WORKFLOW = ROOT / ".github/workflows/docker-image-ci.yml" CONTAINER_PUBLISH_WORKFLOW = ROOT / ".github/workflows/container-publish.yml" GITOPS_CI_WORKFLOW = ROOT / ".github/workflows/gitops-ci.yml" -DEPLOY_BUNDLE_WORKFLOW = ROOT / ".github/workflows/deploy-bundle.yml" -DEPLOY_SOURCES_RENDER_WORKFLOW = ROOT / ".github/workflows/deploy-sources-render.yml" ADD_TO_PROJECT_WORKFLOW = ROOT / ".github/workflows/add-to-project.yml" HYGIENE_GUARD_WORKFLOW = ROOT / ".github/workflows/repository-hygiene-guard.yml" API_CLIENT_PUBLISH_RUNNER = ROOT / "actions/api-client-publish/run.sh" @@ -30,10 +27,6 @@ COMPOSE_RUNNER = ROOT / "actions/compose-system-test-stack/run.sh" COMPOSE_ROUTES_FIXTURE = ROOT / "actions/compose-system-test-stack/fixtures/routes.example.txt" COMPOSE_STACK_FIXTURE = ROOT / "actions/compose-system-test-stack/fixtures/compose.stack.example.yml" -PLATFORM_CONFIG_ACTION = ROOT / "actions/platform-config-validate/action.yml" -PLATFORM_CONFIG_RUNNER = ROOT / "actions/platform-config-validate/run.sh" -PLATFORM_CONFIG_PLATFORM_FIXTURE = ROOT / "actions/platform-config-validate/fixtures/platform.example.yml" -PLATFORM_CONFIG_SERVICE_FIXTURE = ROOT / "actions/platform-config-validate/fixtures/service-intent.example.yml" SETUP_NODE_ACTION = ROOT / "actions/setup-node/action.yml" RENOVATE_CONFIG = ROOT / "renovate.json" RELEASE_CONFIG = ROOT / "release-please-config.json" @@ -334,183 +327,11 @@ def test_compose_fixtures_are_generic_and_parameterized(self) -> None: self.assertIn("${SYSTEM_TEST_APP_IMAGE", compose) -class PlatformConfigValidateSurfaceTest(unittest.TestCase): +class CiWorkflowTest(unittest.TestCase): @classmethod def setUpClass(cls) -> None: - cls.workflow = PLATFORM_CONFIG_WORKFLOW.read_text(encoding="utf-8") - cls.action = PLATFORM_CONFIG_ACTION.read_text(encoding="utf-8") - cls.runner = PLATFORM_CONFIG_RUNNER.read_text(encoding="utf-8") - cls.readme = README.read_text(encoding="utf-8") cls.ci = CI_WORKFLOW.read_text(encoding="utf-8") - def write_executable(self, path: Path, body: str) -> None: - path.write_text(textwrap.dedent(body), encoding="utf-8") - path.chmod(0o755) - - def install_fake_npm(self, bin_dir: Path) -> None: - self.write_executable( - bin_dir / "npm", - """\ - #!/usr/bin/env bash - set -euo pipefail - - printf 'npm' >> "$COMMAND_LOG" - printf ' <%s>' "$@" >> "$COMMAND_LOG" - printf '\\n' >> "$COMMAND_LOG" - - case "${1:-}" in - init) - printf '{}\\n' > package.json - ;; - install) - package_dir="node_modules/@jorisjonkers-dev/deploy-config-schema" - mkdir -p "$package_dir" - cat > "$package_dir/package.json" <<'JSON' - {"bin":{"deploy-config-schema":"cli.js"}} - JSON - cat > "$package_dir/cli.js" <<'JS' - #!/usr/bin/env node - const fs = require("fs"); - const args = process.argv.slice(2); - fs.appendFileSync(process.env.COMMAND_LOG, `cli ${args.map((arg) => `<${arg}>`).join(" ")}\\n`); - if (args[0] === "validate" && process.env.CLI_VALIDATE_EXIT) { - process.exit(Number(process.env.CLI_VALIDATE_EXIT)); - } - if (args[0] === "render-tree" && process.env.CLI_DRIFT_EXIT) { - process.exit(Number(process.env.CLI_DRIFT_EXIT)); - } - JS - chmod +x "$package_dir/cli.js" - ;; - esac - """, - ) - - def run_platform_config_action( - self, - workdir: Path, - env_overrides: dict[str, str], - ) -> subprocess.CompletedProcess[str]: - bin_dir = workdir / "bin" - runner_temp = workdir / "runner-temp" - repo_dir = workdir / "repo" - bin_dir.mkdir(exist_ok=True) - runner_temp.mkdir(exist_ok=True) - repo_dir.mkdir(exist_ok=True) - - command_log = workdir / "commands.log" - self.install_fake_npm(bin_dir) - - env = os.environ.copy() - env.update( - { - "PATH": f"{bin_dir}{os.pathsep}{env['PATH']}", - "COMMAND_LOG": str(command_log), - "RUNNER_TEMP": str(runner_temp), - "CONFIG_PATHS": "platform/**/*.yaml\nplatform/**/*.yml", - "SCHEMA_KIND": "auto", - "PACKAGE_VERSION": "0.3.0", - "DRIFT_CHECK": "false", - "WORKING_DIRECTORY": str(repo_dir), - } - ) - env.update(env_overrides) - - return subprocess.run( - [str(PLATFORM_CONFIG_RUNNER)], - env=env, - check=False, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - - def test_reusable_workflow_exposes_expected_inputs_and_wraps_action(self) -> None: - expected_inputs = [ - "config-paths", - "schema-kind", - "package-version", - "drift-check", - "working-directory", - ] - for expected_input in expected_inputs: - self.assertIn(f" {expected_input}:", self.workflow) - self.assertIn(f" {expected_input}:", self.action) - - self.assertIn("on:\n workflow_call:", self.workflow) - self.assertIn("uses: ./.github-workflows/actions/platform-config-validate", self.workflow) - self.assertIn("repository: JorisJonkers-dev/github-workflows", self.workflow) - self.assertIn("actions/setup-node@v6", self.action) - self.assertIn('run: bash "${{ github.action_path }}/run.sh"', self.action) - - def test_action_supports_schema_kinds_package_pin_and_drift_check(self) -> None: - self.assertIn("default: 0.3.0", self.action) - self.assertIn("platform|deploy-config|service-intent|fleet-inventory|vault-dynamic-secrets|auto", self.runner) - self.assertIn('"$cli_bin" validate "$schema_kind"', self.runner) - self.assertIn('"$cli_bin" validate', self.runner) - self.assertIn('"$cli_bin" render-tree "$drift_file" --check', self.runner) - - def test_runner_expands_globs_installs_package_and_runs_drift_check(self) -> None: - with tempfile.TemporaryDirectory() as temp: - workdir = Path(temp) - repo_dir = workdir / "repo" - repo_dir.mkdir() - (repo_dir / "platform").mkdir() - (repo_dir / "intents").mkdir() - (repo_dir / "platform/app.yml").write_text("kind: platform\n", encoding="utf-8") - (repo_dir / "platform/cluster.yaml").write_text("kind: platform\n", encoding="utf-8") - (repo_dir / "intents/api.yaml").write_text("kind: service-intent\n", encoding="utf-8") - - result = self.run_platform_config_action( - workdir, - { - "WORKING_DIRECTORY": str(repo_dir), - "CONFIG_PATHS": "platform/*.yml\nplatform/*.yaml\nintents/*.yaml", - "SCHEMA_KIND": "platform", - "PACKAGE_VERSION": "9.8.7", - "DRIFT_CHECK": "true", - }, - ) - - self.assertEqual(result.returncode, 0, result.stderr) - log = (workdir / "commands.log").read_text(encoding="utf-8") - self.assertIn("npm <--userconfig>", log) - self.assertIn("<--no-audit> <--no-fund> <--save-exact> <@jorisjonkers-dev/deploy-config-schema@9.8.7>", log) - self.assertIn( - "cli ", - log, - ) - self.assertIn( - "cli <--check>", - log, - ) - - def test_runner_fails_clearly_when_no_configs_match(self) -> None: - with tempfile.TemporaryDirectory() as temp: - workdir = Path(temp) - repo_dir = workdir / "repo" - repo_dir.mkdir() - - result = self.run_platform_config_action( - workdir, - { - "WORKING_DIRECTORY": str(repo_dir), - "CONFIG_PATHS": "platform/**/*.yaml", - }, - ) - - self.assertNotEqual(result.returncode, 0) - self.assertIn("No config files matched config-paths", result.stdout) - - def test_generic_fixtures_and_readme_usage_are_present(self) -> None: - platform_fixture = PLATFORM_CONFIG_PLATFORM_FIXTURE.read_text(encoding="utf-8") - service_fixture = PLATFORM_CONFIG_SERVICE_FIXTURE.read_text(encoding="utf-8") - - self.assertIn("name: example-platform", platform_fixture) - self.assertIn("api-service:", service_fixture) - self.assertIn("platform-config-validate.yml", self.readme) - self.assertIn("@jorisjonkers-dev/deploy-config-schema", self.readme) - def test_ci_uses_official_actionlint_download_script(self) -> None: self.assertIn("download-actionlint.bash", self.ci) self.assertIn("raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash", self.ci) @@ -526,8 +347,6 @@ def setUpClass(cls) -> None: cls.docker_image_ci = DOCKER_IMAGE_CI_WORKFLOW.read_text(encoding="utf-8") cls.container_publish = CONTAINER_PUBLISH_WORKFLOW.read_text(encoding="utf-8") cls.gitops_ci = GITOPS_CI_WORKFLOW.read_text(encoding="utf-8") - cls.deploy_bundle = DEPLOY_BUNDLE_WORKFLOW.read_text(encoding="utf-8") - cls.deploy_sources_render = DEPLOY_SOURCES_RENDER_WORKFLOW.read_text(encoding="utf-8") cls.add_to_project = ADD_TO_PROJECT_WORKFLOW.read_text(encoding="utf-8") cls.hygiene_guard = HYGIENE_GUARD_WORKFLOW.read_text(encoding="utf-8") cls.api_client_publish_runner = API_CLIENT_PUBLISH_RUNNER.read_text(encoding="utf-8") @@ -537,8 +356,6 @@ def setUpClass(cls) -> None: def test_reusable_workflows_are_documented_at_current_tag(self) -> None: for workflow in [ "node-ci.yml", - "deploy-bundle.yml", - "deploy-sources-render.yml", "crac-train.yml", ]: self.assertTrue((ROOT / f".github/workflows/{workflow}").is_file()) @@ -583,12 +400,7 @@ def test_container_publish_tags_version_and_sha_only(self) -> None: self.assertIn('printf \'%s:sha-%s\\n\' "$image_ref" "$GITHUB_SHA"', self.container_publish) self.assertNotIn(":latest", self.container_publish) - def test_deploy_v2_workflows_expose_bundle_render_and_project_surfaces(self) -> None: - self.assertIn("'uses': './.github-workflows/actions/deploy-bundle'", self.deploy_bundle) - self.assertIn("oras push --disable-path-validation", self.deploy_bundle) - self.assertIn("application/vnd.jorisjonkers.deployment.bundle.v1+tar", self.deploy_bundle) - self.assertIn("'uses': './.github-workflows/actions/deploy-sources-render'", self.deploy_sources_render) - self.assertIn("'image-tags':", self.deploy_sources_render) + def test_project_and_hygiene_workflows_expose_their_surfaces(self) -> None: self.assertIn("actions/create-github-app-token@v3", self.add_to_project) self.assertIn("actions/add-to-project@v2.0.0", self.add_to_project) self.assertIn(".specify/**", self.hygiene_guard) @@ -601,11 +413,8 @@ def test_api_client_maven_publish_is_idempotent_on_existing_versions(self) -> No self.assertIn("npm_package_version_exists", self.api_client_publish_runner) self.assertIn("npm package ${ts_package}@${version} already exists", self.api_client_publish_runner) - def test_gitops_ci_runs_required_and_optional_validation_steps(self) -> None: - self.assertIn("uses: ./.github-workflows/actions/platform-config-validate", self.gitops_ci) + def test_gitops_ci_runs_flux_render_and_optional_system_tests(self) -> None: self.assertIn("uses: ./.github-workflows/actions/flux-render-validate", self.gitops_ci) - self.assertIn("uses: ./.github-workflows/actions/deploy-config-render-drift", self.gitops_ci) - self.assertIn("inputs.deploy-config-command != '' && inputs.deploy-config-targets != ''", self.gitops_ci) self.assertIn("inputs.system-tests-command != ''", self.gitops_ci) def test_release_and_renovate_configs_match_migration_contract(self) -> None: diff --git a/tests/test_deploy_actions_cli_drift.py b/tests/test_deploy_actions_cli_drift.py deleted file mode 100644 index cf3166b..0000000 --- a/tests/test_deploy_actions_cli_drift.py +++ /dev/null @@ -1,146 +0,0 @@ -""" -Regression tests for deploy-config-schema 0.16.0 CLI interface drift. - -Test groups: - T-CLI1: Recorded-interface tests — parse run.sh invocations and compare the - flag sets used against the checked-in interface spec - (tests/fixtures/deploy-artifact/cli-interface.json). - T-CLI2: Count-parsing helper — verify the count_lines pattern (grep -c with - || true instead of || echo 0) does not produce a two-line value. - T-CLI3: deploy-preview positional args — second positional must be the - deploy-dir, not the deployment.yml path. - T-CLI4: deploy-artifact positional args — same constraint. - T-CLI5: Silent || true removal — render and emit-contract in deploy-preview - must not use unconditional `|| true`; failures must be captured. - T-CLI6: Context package handling — both actions pull the context by digest - via oras (service repos never hold the context), discover - cluster-context-public.yml in the pulled tree via the - find_cluster_context helper, and pass the digest ref to --context - with the pulled file as --context-path. -""" -from __future__ import annotations - -import json -import re -import subprocess -import sys -import tempfile -import unittest -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] - -DEPLOY_PREVIEW_RUN = ROOT / "actions/deploy-preview/run.sh" -DEPLOY_ARTIFACT_RUN = ROOT / "actions/deploy-artifact/run.sh" -DEPLOY_PREVIEW_ACTION = ROOT / "actions/deploy-preview/action.yml" -CLI_INTERFACE_SPEC = ( - ROOT / "tests/fixtures/deploy-artifact/cli-interface.json" -) - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def read_script(path: Path) -> str: - return path.read_text(encoding="utf-8") - - -def _load_spec() -> dict: - with CLI_INTERFACE_SPEC.open("r", encoding="utf-8") as fh: - return json.load(fh) - - -def _extract_render_fragment_invocations(text: str) -> list[str]: - """ - Return lines (with continuations joined) that contain - 'deploy-config-schema render ' patterns. - - The fragment-id may be a literal like 'kubernetes-workload-fragment' or a - shell variable like '"$fragment"' (loop variable iterating over the list). - """ - # Join line continuations so each logical invocation is one string - joined = text.replace("\\\n", " ") - lines = [] - for line in joined.splitlines(): - stripped = line.strip() - # Matches both literal fragment ids and loop variables - if re.search( - r'deploy-config-schema\s+render\s+(\S+-fragment|"\$\w+"|\'?\$\w+)', - stripped, - ): - lines.append(stripped) - return lines - - -def _extract_emit_contract_invocations(text: str) -> list[str]: - """Return logical lines that contain 'artifact emit-contract'.""" - joined = text.replace("\\\n", " ") - lines = [] - for line in joined.splitlines(): - stripped = line.strip() - if "artifact emit-contract" in stripped and "deploy-config-schema" in stripped: - lines.append(stripped) - return lines - - -def _invoked_subcommands(text: str) -> set[str]: - """ - Return the deploy-config-schema subcommand phrases a script actually runs. - - Comment lines are skipped: run.sh documents a command it deliberately does - not call, and counting that as an invocation would demand a spec entry for - something the script warns against. - """ - joined = text.replace("\\\n", " ") - phrases: set[str] = set() - for line in joined.splitlines(): - stripped = line.strip() - if stripped.startswith("#"): - continue - stripped = re.sub(r"\s+#.*$", "", stripped) - for match in re.finditer(r"deploy-config-schema\s+([a-z][a-z-]*)(?:\s+([a-z][a-z-]*))?", stripped): - head, nested = match.group(1), match.group(2) or "" - phrases.add(f"{head} {nested}".strip() if head == "artifact" else head) - return phrases - - -def _enclosing_condition(lines: list[str], index: int) -> str | None: - """ - Walk upward from a line to the `if` that encloses it, stopping at the `fi` - that would close an earlier block, and return the condition if it tests - something. `if true; then` tests nothing and does not count as a guard. - """ - depth = 0 - for line in reversed(lines[:index]): - stripped = line.strip() - if stripped == "fi" or stripped.startswith("fi "): - depth += 1 - continue - match = re.match(r"if\s+(.*?);\s*then$", stripped) - if not match: - continue - if depth: - depth -= 1 - continue - condition = match.group(1).strip() - if condition in {"true", ":"}: - return None - return condition - return None - - -def _spec_key(phrase: str) -> str: - if phrase == "render": - return "render_fragment" - return phrase.replace("-", "_").replace(" ", "_") - - -def _flags_in_invocation(invocation: str) -> set[str]: - """Extract all --flag tokens from an invocation string.""" - return set(re.findall(r"--[a-z][a-z0-9-]*", invocation)) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_deploy_platform_d.py b/tests/test_deploy_platform_d.py index 02e3165..bc3e891 100644 --- a/tests/test_deploy_platform_d.py +++ b/tests/test_deploy_platform_d.py @@ -1,7 +1,6 @@ """ -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. +Tests for Chunk D: leak-scan.yml, actions/leak-scan, data/leak-patterns.json, +and renovate.json. Test groups: T-D1: Workflow interface and permissions shape @@ -28,16 +27,11 @@ 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" @@ -88,31 +82,7 @@ def run_script( 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"] @@ -123,31 +93,6 @@ def test_leak_scan_output_is_gate_summary_artifact_not_path(self) -> None: "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 = [ @@ -164,19 +109,6 @@ def test_leak_scan_upload_has_if_always(self) -> None: "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) @@ -380,9 +312,7 @@ def test_github_actions_group_is_weekly(self) -> None: 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]: @@ -394,14 +324,6 @@ def _get_upload_artifact_names(self, workflow: dict) -> list[str]: 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( @@ -410,28 +332,10 @@ def test_leak_scan_gate_summary_name(self) -> None: "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", {}) @@ -527,31 +431,6 @@ def test_each_category_has_patterns_list(self) -> None: ) -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 LeakScanInstallAndFailClosedTest(unittest.TestCase): """ T-D7: action.yml install steps and run.sh fail-closed hardening for all-refs mode. diff --git a/tests/test_raw_manifests_unsupported.py b/tests/test_raw_manifests_unsupported.py deleted file mode 100644 index 64a3d1c..0000000 --- a/tests/test_raw_manifests_unsupported.py +++ /dev/null @@ -1,51 +0,0 @@ -"""No action may invoke a deploy-config-schema subcommand that does not exist. - -`artifact` publishes only emit-apply-bundle, emit-contract and -emit-kustomization-health. deploy-artifact called `artifact -validate-raw-manifests`, which exits E_USAGE, and reported the result as a -forbidden-kind violation -- naming a policy breach that had not occurred. - -The refusal itself now lives in tools/deploy-check, keyed on a workload -declaring rawManifests rather than on a directory existing, so what remains -here is the invariant that no action invokes an unpublished subcommand. -""" -import re -import unittest -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -ACTIONS = ROOT / "actions" - -# Subcommands the published CLI exposes under `artifact`, per its own usage line. -PUBLISHED_ARTIFACT_SUBCOMMANDS = { - "emit-apply-bundle", - "emit-contract", - "emit-kustomization-health", -} - - -def shell_sources(): - return sorted(ACTIONS.rglob("*.sh")) - - -class NoUnpublishedSubcommands(unittest.TestCase): - def test_no_action_calls_an_unpublished_artifact_subcommand(self): - offenders = [] - for path in shell_sources(): - for match in re.finditer( - r"deploy-config-schema\s+artifact\s+([a-z][a-z0-9-]*)", path.read_text() - ): - sub = match.group(1) - if sub not in PUBLISHED_ARTIFACT_SUBCOMMANDS: - line = path.read_text()[: match.start()].count("\n") + 1 - offenders.append(f"{path.relative_to(ROOT)}:{line} -> artifact {sub}") - self.assertEqual( - offenders, - [], - "these calls exit E_USAGE because the subcommand is not published: " - + "; ".join(offenders), - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_workflow_job_timeouts.py b/tests/test_workflow_job_timeouts.py index 8768a7c..dbd94ce 100644 --- a/tests/test_workflow_job_timeouts.py +++ b/tests/test_workflow_job_timeouts.py @@ -24,7 +24,6 @@ LONGER_LIMIT_FILES = { "container-publish.yml", "crac-train.yml", - "deploy-bundle.yml", "docker-image-ci.yml", "jvm-ci.yml", } diff --git a/tools/deploy-check/README.md b/tools/deploy-check/README.md deleted file mode 100644 index 4891398..0000000 --- a/tools/deploy-check/README.md +++ /dev/null @@ -1,84 +0,0 @@ -# @jorisjonkers-dev/deploy-check - -Renders a service repository's deployment and scores it against the SC-11 -readiness checks. The `deploy-preview` action runs this exact code, so a local -run and a CI run cannot disagree about the same repository. - -## Why this exists - -The rendering and the scorecard were previously written twice: once in -`actions/deploy-preview/run.sh` and once in a per-repository -`platform/render-local.sh`, roughly 400 lines of bash copied into seven -repositories. The copies drifted. The CI version read `deployment.yml` with -`yq` and consulted the artifact contract; the local version re-derived the same -answers by grepping raw text. Separately, the local copy could not run at all — -it passed a directory where the toolkit expects a file, and called two -`artifact` subcommands that the toolkit does not publish. - -Consolidating removes the copies and makes the logic testable: the scorecard is -a pure function, and the toolkit invocations are asserted against a stub that -records its arguments. - -## Local use - -```bash -npx @jorisjonkers-dev/deploy-check preview \ - --deploy-dir platform \ - --schema-version 0.20.0 \ - --context-ref ghcr.io/jorisjonkers-dev/cluster-deploy-context-public@sha256: \ - --context-dir ./context-package -``` - -Take `--schema-version` and `--context-ref` from the repository's -`deploy-preview.yml`, so a local check matches what CI will do. The toolkit -compares its own version against the context's `spec.schemaVersion` with strict -string equality, so the version is not a preference — an off-by-a-patch install -fails with `E_SCHEMA_VERSION_MISMATCH`. - -Already have the context pulled by digest? Pass `--context-ref` together with -`--context-path ` instead of `--context-dir`. - -`--context-dir` may point at either the directory holding -`cluster-context-public.yml` or the root of a pulled context package; the file -is located by searching, preferring `context/public/`. - -## Publishing options - -The `deploy-artifact` action drives the same command with two extra flags: - -| flag | meaning | -|---|---| -| `--apply-bundle ` | lift the applyable objects into `out/apply/`. Only artifacts consumed through an `OCIRepository` need them. Default `true`. | -| `--provenance-verified ` | record the npm provenance result. `not_applicable` is for a registry that cannot be audited at all — `npm audit signatures` only works for packages from npmjs.com — and reports as `not_applicable` rather than a failed verification, which would block every publish. The artifact contract records a boolean, so `not_applicable` is written there as `false`. | - -`--environments` accepts a comma-separated list. Names must match -`^[a-z0-9][a-z0-9-]*$`; carriage returns and surrounding whitespace are -stripped, duplicates are dropped with a warning, and an empty list is an error -rather than a silent no-op. - -Exit status is 0 when every check passes or is `not_applicable`, and 1 when a -check fails or a render fails. Results are written to `out/`: -`scorecard.json`, `scorecard-detail.json` (with a reason per check), -`scorecard.md`, and `deploy-preview-summary.md`. - -## The three statuses - -| status | meaning | -|---|---| -| `pass` | the check ran and the condition holds | -| `fail` | the check ran and the condition does not hold | -| `not_applicable` | the check does not apply, or nothing was produced to inspect | - -`not_applicable` is deliberately distinct from `pass`. Reporting `pass` for an -absent input is what let a preview that rendered nothing at all report itself -ready to deploy: the secret scan grepped an empty directory, found no `Secret`, -and called that a pass. - -## Library use - -```js -import { computeScorecard, renderPreviewSummary } from '@jorisjonkers-dev/deploy-check' -``` - -`computeScorecard(deployment, contract, evidence)` is pure — all filesystem -reads happen in the caller — so it can be exercised directly against fixtures. diff --git a/tools/deploy-check/bin/deploy-check.js b/tools/deploy-check/bin/deploy-check.js deleted file mode 100755 index 42f11e0..0000000 --- a/tools/deploy-check/bin/deploy-check.js +++ /dev/null @@ -1,192 +0,0 @@ -#!/usr/bin/env node -// deploy-check -- render a service repo's deployment and score its readiness. -// -// Replaces platform/render-local.sh, which every service repo carried its own -// 400-line copy of. The same code path backs the deploy-preview action, so a -// local run and a CI run cannot disagree. -import { existsSync, mkdirSync, writeFileSync } from 'node:fs' -import path from 'node:path' -import { runPreview } from '../lib/preview.js' -import { renderScorecardMarkdown, CHECKS } from '../lib/scorecard.js' -import { renderPreviewSummary } from '../lib/summary.js' -import { resolveToolkit } from '../lib/resolve-toolkit.js' -import { parseEnvironments } from '../lib/environments.js' - -const USAGE = `Usage: - deploy-check preview [options] - -Options: - --deploy-dir directory holding deployment.yml (default: platform) - --schema-version exact deploy-config-schema version (required unless --bin) - --context-ref digest-pinned OCI context ref (required) - --context-dir local context package directory (local development) - --context-path cluster-context-public.yml (with --context-ref, for CI) - --images images.lock.json (default: /images.lock.json) - --artifact-name contract artifact name (default: deployment metadata.name) - --environments comma-separated (default: production) - --apply-bundle lift applyable objects into out/apply (default: true) - --out output directory (default: out) - --bin use an already-installed deploy-config-schema - --provenance-verified - record the npm provenance result; not_applicable when - the registry does not support audit signatures - --json emit the scorecard as JSON on stdout - --markdown-out also write the scorecard markdown to a file - -h, --help - -Exit codes: 0 all checks pass or are not_applicable; 1 a check failed or a -render failed.` - -function parseArgs(argv) { - const opts = { deployDir: 'platform', environments: ['production'], out: 'out' } - let i = 0 - const need = (flag) => { - const v = argv[++i] - if (v === undefined) throw new Error(`${flag} requires a value`) - return v - } - for (; i < argv.length; i++) { - const a = argv[i] - switch (a) { - case '--deploy-dir': opts.deployDir = need(a); break - case '--schema-version': opts.schemaVersion = need(a); break - case '--context-ref': opts.contextRef = need(a); break - case '--context-dir': opts.contextDir = need(a); break - case '--context-path': opts.contextPath = need(a); break - case '--images': opts.images = need(a); break - case '--artifact-name': opts.artifactName = need(a); break - case '--environments': opts.environmentsRaw = need(a); break - case '--out': opts.out = need(a); break - case '--bin': opts.bin = need(a); break - case '--provenance-verified': { - // Accepts a value so a caller can record a negative result; a bare - // flag still means true. - const next = argv[i + 1] - if (next === 'true' || next === 'false') { opts.provenanceVerified = next === 'true'; i++ } - else if (next === 'not_applicable') { opts.provenanceVerified = 'not_applicable'; i++ } - else opts.provenanceVerified = true - break - } - case '--apply-bundle': { - const next = argv[i + 1] - if (next === 'true' || next === 'false') { opts.applyBundle = next === 'true'; i++ } - else opts.applyBundle = true - break - } - case '--json': opts.json = true; break - case '--markdown-out': opts.markdownOut = need(a); break - case '-h': case '--help': opts.help = true; break - default: throw new Error(`unknown option: ${a}`) - } - } - return opts -} - -function fail(msg) { - process.stderr.write(`ERROR: ${msg}\n`) - process.exit(1) -} - -async function main() { - const [command, ...rest] = process.argv.slice(2) - if (!command || command === '-h' || command === '--help') { - process.stdout.write(`${USAGE}\n`) - process.exit(command ? 0 : 1) - } - if (command !== 'preview') fail(`unknown command: ${command}\n\n${USAGE}`) - - let opts - try { opts = parseArgs(rest) } catch (err) { fail(`${err.message}\n\n${USAGE}`) } - if (opts.help) { process.stdout.write(`${USAGE}\n`); process.exit(0) } - - if (!opts.contextRef) fail('--context-ref is required (it is recorded in the artifact contract)') - if (!opts.contextDir && !opts.contextPath) fail('supply --context-dir, or --context-path alongside --context-ref') - if (!opts.images) opts.images = path.join(opts.deployDir, 'images.lock.json') - - try { - const parsed = parseEnvironments(opts.environmentsRaw ?? 'production') - opts.environments = parsed.environments - for (const dup of parsed.duplicates) { - process.stderr.write(`::warning::duplicate environment '${dup}' skipped\n`) - } - } catch (err) { - fail(err.message) - } - - let bin = opts.bin - if (!bin) { - if (!opts.schemaVersion) fail('--schema-version is required unless --bin is given') - try { bin = resolveToolkit({ schemaVersion: opts.schemaVersion }) } catch (err) { fail(err.message) } - } - - if (!opts.artifactName) { - // Default to the deployment's own name rather than a placeholder, so the - // contract is attributable without every caller repeating it. - try { - const YAML = (await import('yaml')).default - const { readFileSync } = await import('node:fs') - opts.artifactName = YAML.parse(readFileSync(path.join(opts.deployDir, 'deployment.yml'), 'utf8'))?.metadata?.name - } catch { /* validated in runPreview */ } - } - if (!opts.artifactName) fail(`--artifact-name is required (deployment.yml declares no metadata.name)`) - - let outcome - try { - outcome = runPreview({ - bin, - deployDir: opts.deployDir, - environments: opts.environments, - images: opts.images, - contextRef: opts.contextRef, - contextDir: opts.contextDir, - contextPath: opts.contextPath, - artifactName: opts.artifactName, - outDir: opts.out, - provenanceVerified: opts.provenanceVerified, - applyBundle: opts.applyBundle, - }) - } catch (err) { - fail(err.stderr ? `${err.message}\n${err.stderr}` : err.message) - } - - const { result, renderFailures, contractError, contract, deployment } = outcome - const markdown = renderScorecardMarkdown(result, opts.artifactName) - const summary = renderPreviewSummary({ - deployment, - contract, - result, - contextRef: opts.contextRef, - environments: opts.environments, - renderedManifests: outcome.renderedManifests ?? [], - renderFailures, - }) - - mkdirSync(opts.out, { recursive: true }) - writeFileSync(path.join(opts.out, 'scorecard.json'), `${JSON.stringify( - Object.fromEntries(CHECKS.map((c) => [c, result.checks[c].status])), null, 2)}\n`) - writeFileSync(path.join(opts.out, 'scorecard-detail.json'), `${JSON.stringify(result, null, 2)}\n`) - writeFileSync(path.join(opts.out, 'scorecard.md'), `${markdown}\n`) - writeFileSync(path.join(opts.out, 'deploy-preview-summary.md'), `${summary}\n`) - if (opts.markdownOut) { - mkdirSync(path.dirname(opts.markdownOut), { recursive: true }) - writeFileSync(opts.markdownOut, `${summary}\n`) - } - - const renderHash = outcome.contract?.spec?.renderHash - if (renderHash) writeFileSync(path.join(opts.out, 'render-hash.txt'), `${renderHash}\n`) - - if (opts.json) process.stdout.write(`${JSON.stringify({ ...result, renderHash }, null, 2)}\n`) - else process.stdout.write(`${markdown}\n`) - - for (const f of renderFailures) { - process.stderr.write(`::error::render ${f.fragment} (${f.env}) failed: ${f.detail}\n`) - } - if (contractError) process.stderr.write(`::error::emit-contract failed: ${contractError.stderr ?? contractError.message}\n`) - - if (renderFailures.length > 0) fail(`E_RENDER_FAILED: ${renderFailures.map((f) => `${f.env}/${f.fragment}`).join(', ')}`) - if (contractError) fail('E_EMIT_CONTRACT_FAILED') - if (result.overall !== 'pass') fail(`scorecard has ${result.failed.length} failing check(s): ${result.failed.join(', ')}`) - process.stderr.write('all scorecard checks passed\n') -} - -main().catch((err) => fail(err.stack ?? String(err))) diff --git a/tools/deploy-check/lib/environments.js b/tools/deploy-check/lib/environments.js deleted file mode 100644 index cef995a..0000000 --- a/tools/deploy-check/lib/environments.js +++ /dev/null @@ -1,32 +0,0 @@ -// Environment list parsing. -// -// The bash callers each did this themselves: strip CR, trim, validate the name, -// drop duplicates. Two implementations of the same parsing is how a caller ends -// up rendering an environment the other would have rejected. -export class EnvironmentError extends Error {} - -const VALID = /^[a-z0-9][a-z0-9-]*$/ - -/** - * @param {string} raw comma-separated list - * @returns {{environments: string[], duplicates: string[]}} - */ -export function parseEnvironments(raw) { - const seen = new Set() - const environments = [] - const duplicates = [] - for (const part of String(raw ?? '').replace(/\r/g, '').split(',')) { - const env = part.trim() - if (env === '') continue - if (!VALID.test(env)) { - throw new EnvironmentError(`E_INVALID_ENV_NAME: '${env}' must match ${VALID}`) - } - if (seen.has(env)) { duplicates.push(env); continue } - seen.add(env) - environments.push(env) - } - if (environments.length === 0) { - throw new EnvironmentError('E_NO_VALID_ENVIRONMENTS: the environment list is empty') - } - return { environments, duplicates } -} diff --git a/tools/deploy-check/lib/index.js b/tools/deploy-check/lib/index.js deleted file mode 100644 index 1833a86..0000000 --- a/tools/deploy-check/lib/index.js +++ /dev/null @@ -1,6 +0,0 @@ -export { CHECKS, computeScorecard, renderScorecardMarkdown, usesLatestTag } from './scorecard.js' -export { FRAGMENTS, PUBLISHED_ARTIFACT_SUBCOMMANDS, ToolkitError, contextArgs } from './toolkit.js' -export { findClusterContext, runPreview } from './preview.js' -export { resolveToolkit } from './resolve-toolkit.js' -export { COMMENT_MARKER, renderPreviewSummary } from './summary.js' -export { EnvironmentError, parseEnvironments } from './environments.js' diff --git a/tools/deploy-check/lib/preview.js b/tools/deploy-check/lib/preview.js deleted file mode 100644 index 279eba1..0000000 --- a/tools/deploy-check/lib/preview.js +++ /dev/null @@ -1,158 +0,0 @@ -// Render a service repo's deployment and score it. One implementation, shared -// by the deploy-preview action and by developers running the check locally, so -// the two cannot report different answers about the same repository. -import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs' -import path from 'node:path' -import YAML from 'yaml' -import { FRAGMENTS, ToolkitError, emitApplyBundle, emitContract, emitKustomizationHealth, renderFragment } from './toolkit.js' -import { computeScorecard } from './scorecard.js' - -/** Locate cluster-context-public.yml in a pulled context package. */ -export function findClusterContext(root) { - const hits = [] - const walk = (dir) => { - let entries - try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return } - for (const e of entries) { - const full = path.join(dir, e.name) - if (e.isDirectory()) walk(full) - else if (e.name === 'cluster-context-public.yml') hits.push(full) - } - } - walk(root) - if (hits.length === 0) return null - hits.sort() - // Prefer the canonical context/public/ location when several copies exist. - return hits.find((h) => h.includes(`${path.sep}context${path.sep}public${path.sep}`)) ?? hits[0] -} - -function readYaml(file) { - return YAML.parse(readFileSync(file, 'utf8')) -} - -function collectRendered(dir) { - if (!existsSync(dir)) return [] - const out = [] - const walk = (d) => { - for (const e of readdirSync(d, { withFileTypes: true })) { - const full = path.join(d, e.name) - if (e.isDirectory()) walk(full) - else if (e.name.endsWith('.yaml') || e.name.endsWith('.yml')) { - out.push({ path: full, text: readFileSync(full, 'utf8') }) - } - } - } - walk(dir) - return out -} - -/** - * @param {object} opts - * @param {string} opts.bin path to the deploy-config-schema executable - * @param {string} opts.deployDir e.g. 'platform' - * @param {string[]} opts.environments - * @param {string} opts.images images.lock.json path - * @param {string} opts.contextRef digest-pinned OCI ref (recorded in the contract) - * @param {string} [opts.contextDir] local context package directory - * @param {string} [opts.contextPath] cluster-context-public.yml, when not using contextDir - * @param {string} opts.artifactName - * @param {string} opts.outDir - * @param {boolean} [opts.provenanceVerified] - * @param {string} [opts.cwd] - */ -export function runPreview(opts) { - const { bin, deployDir, environments, images, contextRef, artifactName, outDir, cwd, provenanceVerified } = opts - const deploymentYml = path.join(deployDir, 'deployment.yml') - const absDeployment = path.isAbsolute(deploymentYml) ? deploymentYml : path.join(cwd ?? process.cwd(), deploymentYml) - if (!existsSync(absDeployment)) { - throw new ToolkitError(`E_DEPLOYMENT_MISSING: ${deploymentYml} not found`) - } - - // --context-dir makes the toolkit look for cluster-context-public.yml - // directly inside that directory; it does not search. A pulled context - // package nests the file under context/public/, so passing the package root - // as --context-dir fails with ENOENT. Resolve to the concrete file and pass - // it as --context-path instead, which works for either layout. - let contextPath = opts.contextPath - if (!contextPath) { - const root = opts.contextDir - ? (path.isAbsolute(opts.contextDir) ? opts.contextDir : path.join(cwd ?? process.cwd(), opts.contextDir)) - : null - if (!root) throw new ToolkitError('supply contextPath, or contextDir to search') - contextPath = findClusterContext(root) - if (!contextPath) { - throw new ToolkitError(`E_CONTEXT_FILE_MISSING: cluster-context-public.yml not found under ${opts.contextDir}`) - } - } - const context = { contextRef, contextPath } - - // A stale tree would let a previous run's manifests satisfy this run's - // checks, which is the failure mode the whole scorecard exists to catch. - rmSync(path.join(cwd ?? process.cwd(), outDir), { recursive: true, force: true }) - - const renderFailures = [] - for (const env of environments) { - for (const fragment of FRAGMENTS) { - const outFile = path.join(outDir, 'manifests', env, `${fragment}.yaml`) - try { - renderFragment(bin, { fragment, deployDir, env, images, outFile: path.join(cwd ?? process.cwd(), outFile), context, cwd }) - } catch (err) { - renderFailures.push({ env, fragment, detail: err.stderr ?? err.message }) - } - } - if (renderFailures.length === 0) { - emitKustomizationHealth(bin, { - deploymentYml, - env, - images, - outFile: path.join(cwd ?? process.cwd(), outDir, 'metadata', env, 'kustomization-health.yml'), - cwd, - }) - // Fragments wrap their payload in a schema document, which kustomize and - // kubeconform both reject. The apply bundle is the applyable form, and it - // ships the kustomization.yaml those tools need. Publishing it is opt-out - // because only artifacts consumed through an OCIRepository need it. - if (opts.applyBundle !== false) { - emitApplyBundle(bin, { - manifestsDir: path.join(outDir, 'manifests', env), - outDir: path.join(outDir, 'apply', env), - cwd, - }) - } - } - } - - let contract = null - let contractError = null - if (renderFailures.length === 0) { - try { - const contractFile = path.join(outDir, 'artifact-contract.yaml') - emitContract(bin, { - artifactName, - environments, - images, - contextRef, - deploymentYml, - contextFile: contextPath, - provenanceVerified, - outFile: path.join(cwd ?? process.cwd(), contractFile), - outputRoot: outDir, - cwd, - }) - contract = readYaml(path.join(cwd ?? process.cwd(), contractFile)) - } catch (err) { - contractError = err - } - } - - const deployment = readYaml(absDeployment) - const guardFile = path.join(cwd ?? process.cwd(), outDir, 'raw-manifests-guard.json') - const evidence = { - renderedManifests: collectRendered(path.join(cwd ?? process.cwd(), outDir, 'manifests')), - rawManifestsGuard: existsSync(guardFile) ? JSON.parse(readFileSync(guardFile, 'utf8')) : null, - provenanceVerified, - } - - const result = computeScorecard(deployment, contract, evidence) - return { result, renderFailures, contractError, contract, deployment, contextPath, renderedManifests: evidence.renderedManifests } -} diff --git a/tools/deploy-check/lib/resolve-toolkit.js b/tools/deploy-check/lib/resolve-toolkit.js deleted file mode 100644 index 77fce22..0000000 --- a/tools/deploy-check/lib/resolve-toolkit.js +++ /dev/null @@ -1,64 +0,0 @@ -// Find or install the pinned deploy-config-schema executable. -// -// The toolkit compares its own package version against the cluster context's -// spec.schemaVersion with strict string equality, so the version is not a -// preference -- an off-by-a-patch install fails with -// E_SCHEMA_VERSION_MISMATCH. It is therefore always taken from the caller and -// never defaulted to "latest". -import { spawnSync } from 'node:child_process' -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import path from 'node:path' -import os from 'node:os' - -const REGISTRY = 'https://npm.pkg.github.com' -const PACKAGE = '@jorisjonkers-dev/deploy-config-schema' - -export class ToolkitResolutionError extends Error {} - -/** - * @param {object} opts - * @param {string} opts.schemaVersion exact version to install - * @param {string} [opts.cacheDir] where to install; defaults under the OS temp dir - * @param {string} [opts.token] GitHub token for npm.pkg.github.com - * @returns {string} path to the deploy-config-schema executable - */ -export function resolveToolkit({ schemaVersion, cacheDir, token }) { - if (!schemaVersion) throw new ToolkitResolutionError('E_SCHEMA_VERSION_REQUIRED: pass the exact toolkit version') - - const root = cacheDir ?? path.join(os.tmpdir(), `deploy-check-toolkit-${schemaVersion}`) - const bin = path.join(root, 'node_modules', '.bin', 'deploy-config-schema') - if (existsSync(bin)) return bin - - mkdirSync(root, { recursive: true }) - const npmrc = path.join(root, '.npmrc') - const lines = [`@jorisjonkers-dev:registry=${REGISTRY}`] - const authToken = token ?? process.env.NODE_AUTH_TOKEN ?? process.env.GITHUB_TOKEN - if (authToken) lines.push(`//npm.pkg.github.com/:_authToken=${authToken}`) - writeFileSync(npmrc, `${lines.join('\n')}\n`) - writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'deploy-check-toolkit-cache', private: true }, null, 2)) - - const res = spawnSync( - 'npm', - ['install', '--userconfig', npmrc, '--no-audit', '--no-fund', '--save-exact', `${PACKAGE}@${schemaVersion}`], - { cwd: root, encoding: 'utf8' }, - ) - if (res.status !== 0) { - throw new ToolkitResolutionError( - `E_TOOLKIT_INSTALL_FAILED: could not install ${PACKAGE}@${schemaVersion}\n${res.stderr ?? ''}`, - ) - } - if (!existsSync(bin)) { - throw new ToolkitResolutionError(`E_TOOLKIT_BIN_MISSING: installed ${PACKAGE}@${schemaVersion} but ${bin} is absent`) - } - return bin -} - -/** Verify the installed toolkit reports the version that was requested. */ -export function assertToolkitVersion(bin, expected) { - const pkg = path.join(bin, '..', '..', '@jorisjonkers-dev', 'deploy-config-schema', 'package.json') - if (!existsSync(pkg)) return - const actual = JSON.parse(readFileSync(pkg, 'utf8')).version - if (actual !== expected) { - throw new ToolkitResolutionError(`E_SCHEMA_VERSION_MISMATCH: installed ${actual}, expected ${expected}`) - } -} diff --git a/tools/deploy-check/lib/scorecard.js b/tools/deploy-check/lib/scorecard.js deleted file mode 100644 index a45db90..0000000 --- a/tools/deploy-check/lib/scorecard.js +++ /dev/null @@ -1,237 +0,0 @@ -// The SC-11 readiness scorecard. -// -// This existed twice in bash -- once in actions/deploy-preview/run.sh and once -// in a per-repo platform/render-local.sh -- and the two drifted. The CI copy -// read deployment.yml with yq and the artifact contract for image and context -// facts; the local copy re-derived the same answers by grepping raw text, so -// the two disagreed about the same repository. Both are replaced by this one -// pure function. -// -// Every check returns exactly one of: -// 'pass' the check ran and the condition holds -// 'fail' the check ran and the condition does not hold -// 'not_applicable' the check does not apply, or nothing was produced to -// inspect. Never report 'pass' for an absent input: an -// empty manifest tree is an absence of evidence, not -// evidence that no secret would have been rendered. -// -// Failures carry a reason so a red check names the workload or file at fault -// instead of leaving the reader to re-derive it. - -export const CHECKS = [ - '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', -] - -const MINIMUM_ROLLBACK_DAYS = 90 - -const pass = () => ({ status: 'pass' }) -const na = (reason) => ({ status: 'not_applicable', reason }) -const fail = (reason) => ({ status: 'fail', reason }) - -function workloadsOf(deployment) { - const w = deployment?.spec?.workloads - return Array.isArray(w) ? w : [] -} - -function routesOf(workload) { - const r = workload?.routes - return Array.isArray(r) ? r : [] -} - -/** A tag is :latest only as a whole tag -- :latest-alpine is a different tag. */ -export function usesLatestTag(ref) { - if (typeof ref !== 'string') return false - const withoutDigest = ref.split('@')[0] - const lastColon = withoutDigest.lastIndexOf(':') - if (lastColon === -1) return false - // A colon inside the registry host:port is not a tag separator. - if (withoutDigest.slice(lastColon).includes('/')) return false - return withoutDigest.slice(lastColon + 1) === 'latest' -} - -const rules = { - schema_pinned(deployment) { - const v = deployment?.spec?.schemaVersion - return typeof v === 'string' && /^\d/.test(v) - ? pass() - : fail(`spec.schemaVersion is ${JSON.stringify(v ?? null)}; expected a version string`) - }, - - context_pinned(_deployment, contract) { - const ref = contract?.spec?.contextRef - if (typeof ref !== 'string' || ref === '') { - return fail('artifact contract has no spec.contextRef') - } - return ref.includes('@sha256:') - ? pass() - : fail(`contextRef is not digest-pinned: ${ref}`) - }, - - no_latest_images(_deployment, contract) { - const digests = contract?.spec?.imageDigests - if (!digests || typeof digests !== 'object') { - return na('artifact contract declares no imageDigests') - } - const offenders = Object.entries(digests) - .filter(([, ref]) => usesLatestTag(ref)) - .map(([name]) => name) - return offenders.length === 0 - ? pass() - : fail(`floating :latest tag on ${offenders.join(', ')}`) - }, - - // Per workload, not any-of. The bash predecessor accepted a single - // acknowledged workload on behalf of all of them for rollback retention; - // health was already per-workload. Verified a no-op against every current - // deployment.yml before tightening. - // A queue consumer has no HTTP surface to probe, so demanding a health path - // from every workload cannot be satisfied. `health: { mandatory: false }` is - // an explicit, reviewable opt-out: the workload still has to say something - // about its health, and the exemption shows up in the reason rather than - // disappearing. Omitting `health` entirely is still a failure -- silence is - // not a decision. - health_declared(deployment) { - const workloads = workloadsOf(deployment) - if (workloads.length === 0) return na('deployment declares no workloads') - const exempt = [] - const missing = [] - for (const w of workloads) { - const name = w?.name ?? '' - const health = w?.health - if (health && health.mandatory === false && !health.path) { - exempt.push(name) - } else if (!health?.path) { - missing.push(name) - } - } - if (missing.length > 0) { - return fail( - `no health.path on ${missing.join(', ')}` + - ' (declare health.mandatory: false to exempt a workload with no HTTP surface)', - ) - } - return exempt.length > 0 - ? { status: 'pass', reason: `exempt by health.mandatory: false: ${exempt.join(', ')}` } - : pass() - }, - - route_owner_authmode_declared(deployment) { - const workloads = workloadsOf(deployment) - const pairs = workloads.flatMap((w) => routesOf(w).map((route) => ({ w, route }))) - if (pairs.length === 0) return na('deployment declares no routes') - const problems = [] - for (const { w, route } of pairs) { - const host = route?.host ?? '' - if (!route?.owner) problems.push(`${host} has no owner`) - const authMode = route?.authMode ?? w?.routeDefaults?.authMode - if (!authMode) problems.push(`${host} has no authMode and the workload sets no routeDefaults.authMode`) - } - return problems.length === 0 ? pass() : fail(problems.join('; ')) - }, - - rollback_retention_acknowledged(deployment) { - const workloads = workloadsOf(deployment) - if (workloads.length === 0) return na('deployment declares no workloads') - const problems = [] - for (const w of workloads) { - const name = w?.name ?? '' - const retention = w?.rollbackTargetRetention - if (!retention) { - problems.push(`${name} declares no rollbackTargetRetention`) - continue - } - if (retention.acknowledged !== true) problems.push(`${name} is not acknowledged`) - const days = Number(retention.minimumDays ?? 0) - if (!Number.isFinite(days) || days < MINIMUM_ROLLBACK_DAYS) { - problems.push(`${name} keeps ${retention.minimumDays ?? 0} days, below ${MINIMUM_ROLLBACK_DAYS}`) - } - } - return problems.length === 0 ? pass() : fail(problems.join('; ')) - }, - - no_raw_secrets(_deployment, _contract, evidence) { - const rendered = evidence?.renderedManifests ?? [] - if (rendered.length === 0) { - return na('nothing was rendered, so no manifest was inspected') - } - const offenders = rendered.filter((m) => /^kind:[ \t]*Secret[ \t]*$/m.test(m.text ?? '')) - return offenders.length === 0 - ? pass() - : fail(`kind: Secret in ${offenders.map((m) => m.path).join(', ')}`) - }, - - stateful_policy_declared(deployment) { - const stateful = workloadsOf(deployment).filter((w) => w?.stateful === true) - if (stateful.length === 0) return na('deployment declares no stateful workloads') - const missing = stateful.filter((w) => !w?.migrationPolicy).map((w) => w?.name ?? '') - return missing.length === 0 ? pass() : fail(`no migrationPolicy on ${missing.join(', ')}`) - }, - - raw_manifests_guarded(deployment, _contract, evidence) { - const enabled = workloadsOf(deployment).filter((w) => w?.rawManifests?.enabled === true) - if (enabled.length === 0) return na('no workload enables rawManifests') - // The toolkit publishes no artifact validate-raw-manifests subcommand, so - // there is no guard to consult. Saying so beats an unexplained fail. - if (!evidence?.rawManifestsGuard) { - return fail( - 'rawManifests is enabled but no guard report exists: the toolkit publishes no ' + - 'artifact validate-raw-manifests subcommand, so forbidden kinds cannot be checked', - ) - } - const violations = evidence.rawManifestsGuard.violations ?? [] - return violations.length === 0 ? pass() : fail(`${violations.length} forbidden-kind violation(s)`) - }, - - // Three states, not two. npm audit signatures only works for packages from - // npmjs.com; a package installed from GitHub Packages reports "found no - // dependencies to audit that were installed from a supported registry", - // which is an expected skip rather than a failed verification. Collapsing - // that into fail would block every publish. The caller decides which case it - // is and says so; a genuine audit failure is fatal before it reaches here. - npm_signatures_verified(_deployment, _contract, evidence) { - const v = evidence?.provenanceVerified - if (v === undefined) return na('provenance is only evaluated when publishing an artifact') - if (v === 'not_applicable') return na('the registry does not support npm audit signatures') - return v ? pass() : fail('npm audit signatures did not verify the package') - }, -} - -/** - * Compute the scorecard. Pure: all filesystem reads happen in the caller and - * arrive as `evidence`. - * - * @param {object} deployment parsed platform/deployment.yml - * @param {object} contract parsed artifact-contract.yaml (may be null) - * @param {object} evidence { renderedManifests: [{path,text}], - * rawManifestsGuard, provenanceVerified } - * @returns {{checks: Record, overall: string}} - */ -export function computeScorecard(deployment, contract, evidence = {}) { - const checks = {} - for (const name of CHECKS) { - checks[name] = rules[name](deployment, contract, evidence) - } - const failed = CHECKS.filter((n) => checks[n].status === 'fail') - return { checks, overall: failed.length === 0 ? 'pass' : 'fail', failed } -} - -/** Markdown table for the sticky PR comment and for terminal output. */ -export function renderScorecardMarkdown(result, title) { - const icon = { pass: '✅', fail: '❌', not_applicable: '➖' } - const lines = [`### SC-11 Readiness Scorecard${title ? ` — ${title}` : ''}`, '', '| | Check | Status | Detail |', '|---|---|---|---|'] - for (const name of CHECKS) { - const { status, reason } = result.checks[name] - lines.push(`| ${icon[status]} | ${name} | ${status} | ${reason ?? ''} |`) - } - lines.push('', '`pass` = ready · `fail` = blocks deployment · `not_applicable` = check does not apply') - return lines.join('\n') -} diff --git a/tools/deploy-check/lib/summary.js b/tools/deploy-check/lib/summary.js deleted file mode 100644 index 1b9ef7f..0000000 --- a/tools/deploy-check/lib/summary.js +++ /dev/null @@ -1,59 +0,0 @@ -// The Deploy Preview comment body. -// -// The counts and image list here were previously re-derived with yq and jq in -// the action, separately from the scorecard that sits below them, so the -// header could describe a deployment the scorecard had not scored. Both now -// come from the same parsed documents. -import { renderScorecardMarkdown } from './scorecard.js' - -export const COMMENT_MARKER = '' - -function countRoutes(deployment) { - const workloads = deployment?.spec?.workloads ?? [] - return workloads.reduce((n, w) => n + (Array.isArray(w?.routes) ? w.routes.length : 0), 0) -} - -function countGatusEndpoints(renderedManifests) { - const fragment = renderedManifests.find((m) => /gatus-endpoint-fragment\.yaml$/.test(m.path)) - if (!fragment) return 0 - // The fragment lists endpoints under `endpoints:`; count list items rather - // than counting the file, which is what the predecessor did (always 0 or 1). - const matches = fragment.text.match(/^\s*-\s+name:/gm) - return matches ? matches.length : 0 -} - -export function renderPreviewSummary({ deployment, contract, result, contextRef, environments, images, renderedManifests = [], renderFailures = [] }) { - const name = deployment?.metadata?.name ?? 'unknown' - const workloads = deployment?.spec?.workloads ?? [] - const lines = [ - COMMENT_MARKER, - `## Deploy Preview — ${name}`, - '', - `**Environments:** ${environments.join(', ')}`, - `**Context ref:** \`${contextRef}\``, - `**Workloads:** ${workloads.length} | **Routes:** ${countRoutes(deployment)} | **Gatus endpoints:** ${countGatusEndpoints(renderedManifests)}`, - '', - ] - - if (renderFailures.length > 0) { - lines.push('### ⚠️ Renders failed', '', 'No manifests were produced, so checks that inspect rendered output report `not_applicable` rather than `pass`.', '') - for (const f of renderFailures) lines.push(`- \`${f.env}/${f.fragment}\`: ${String(f.detail).split('\n')[0]}`) - lines.push('') - } - - const digests = contract?.spec?.imageDigests ?? images ?? {} - if (Object.keys(digests).length > 0) { - lines.push('### Image refs', '') - for (const [key, value] of Object.entries(digests)) lines.push(` - \`${key}\`: \`${value}\``) - lines.push('') - } - - lines.push(renderScorecardMarkdown(result)) - - const notApplicable = Object.entries(result.checks).filter(([, v]) => v.status === 'not_applicable') - if (notApplicable.length > 0) { - lines.push('', `> ➖ ${notApplicable.length} check(s) not applicable: ${notApplicable.map(([k]) => k).join(', ')}`) - } - lines.push('', '---', '_Updated by deploy-check on push to this PR._') - return lines.join('\n') -} diff --git a/tools/deploy-check/lib/toolkit.js b/tools/deploy-check/lib/toolkit.js deleted file mode 100644 index 49984a0..0000000 --- a/tools/deploy-check/lib/toolkit.js +++ /dev/null @@ -1,118 +0,0 @@ -// Thin, typed wrapper over the deploy-config-schema CLI. -// -// Every call here was previously spelled out in bash in two places, and both -// got the argument shapes wrong in ways that only surfaced at runtime: -// - `render --output ` exits EISDIR; --output names a FILE. -// - `artifact leak-scan` and `artifact validate-raw-manifests` are not -// published subcommands; the toolkit exposes only emit-apply-bundle, -// emit-contract and emit-kustomization-health. -// - `artifact emit-contract` requires --deployment and --context, and takes -// no --schema-version flag. -// Keeping the invocations in one place means a wrong flag is a test failure -// here rather than a CI failure in seven repositories. -import { spawnSync } from 'node:child_process' -import { mkdirSync } from 'node:fs' -import path from 'node:path' - -export const FRAGMENTS = [ - 'kubernetes-workload-fragment', - 'traefik-route-fragment', - 'gatus-endpoint-fragment', - 'edge-catalog-fragment', - 'image-metadata-fragment', -] - -/** Subcommands `deploy-config-schema artifact` actually publishes. */ -export const PUBLISHED_ARTIFACT_SUBCOMMANDS = ['emit-apply-bundle', 'emit-contract', 'emit-kustomization-health'] - -export class ToolkitError extends Error { - constructor(message, { code, stderr, args } = {}) { - super(message) - this.name = 'ToolkitError' - this.code = code - this.stderr = stderr - this.args = args - } -} - -function run(bin, args, { cwd } = {}) { - const res = spawnSync(bin, args, { cwd, encoding: 'utf8' }) - if (res.error) throw new ToolkitError(`could not execute ${bin}: ${res.error.message}`, { args }) - return { status: res.status ?? 1, stdout: res.stdout ?? '', stderr: res.stderr ?? '' } -} - -/** - * Context is supplied either as a local directory (local development) or as a - * digest-pinned ref plus the file pulled from it (CI). Both forms are valid; - * mixing them is not. - */ -export function contextArgs({ contextDir, contextRef, contextPath }) { - if (contextDir) return ['--context-dir', contextDir] - if (contextRef && contextPath) return ['--context', contextRef, '--context-path', contextPath] - throw new ToolkitError('supply either contextDir, or both contextRef and contextPath') -} - -export function renderFragment(bin, { fragment, deployDir, env, images, outFile, context, cwd }) { - mkdirSync(path.dirname(outFile), { recursive: true }) - const args = [ - 'render', fragment, deployDir, - '--env', env, - ...contextArgs(context), - '--images', images, - // A directory here exits EISDIR: --output names the file to write. - '--output', outFile, - ] - const { status, stdout, stderr } = run(bin, args, { cwd }) - if (status !== 0) { - throw new ToolkitError(`render ${fragment} (${env}) failed`, { code: status, stderr: stderr || stdout, args }) - } - return outFile -} - -export function emitApplyBundle(bin, { manifestsDir, outDir, cwd }) { - const args = ['artifact', 'emit-apply-bundle', '--manifests', manifestsDir, '--out', outDir] - const { status, stdout, stderr } = run(bin, args, { cwd }) - if (status !== 0) throw new ToolkitError('emit-apply-bundle failed', { code: status, stderr: stderr || stdout, args }) - return outDir -} - -export function emitKustomizationHealth(bin, { deploymentYml, env, images, outFile, cwd }) { - mkdirSync(path.dirname(outFile), { recursive: true }) - const args = [ - 'artifact', 'emit-kustomization-health', - '--deployment', deploymentYml, - '--env', env, - '--image-digests', images, - '--out', outFile, - ] - const { status, stdout, stderr } = run(bin, args, { cwd }) - if (status !== 0) throw new ToolkitError(`emit-kustomization-health (${env}) failed`, { code: status, stderr: stderr || stdout, args }) - return outFile -} - -export function emitContract(bin, { artifactName, environments, images, contextRef, deploymentYml, contextFile, provenanceVerified, outFile, outputRoot, cwd }) { - mkdirSync(path.dirname(outFile), { recursive: true }) - const args = [ - 'artifact', 'emit-contract', - '--artifact-name', artifactName, - '--environments', environments.join(','), - '--images', images, - '--context-ref', contextRef, - // Both required, and both were missing from the per-repo script. - '--deployment', deploymentYml, - '--context', contextFile, - '--out', outFile, - ] - if (provenanceVerified !== undefined) { - // The contract field is a boolean: either provenance was verified or it was - // not. 'not_applicable' distinguishes "could not be evaluated" from "failed - // verification" for the scorecard only, and records as not verified here. - // The toolkit happens to coerce an unknown string the same way; saying it - // explicitly means the recorded value does not depend on that. - args.push('--provenance-verified', provenanceVerified === true ? 'true' : 'false') - } - if (outputRoot) args.push('--output-root', outputRoot) - const { status, stdout, stderr } = run(bin, args, { cwd }) - if (status !== 0) throw new ToolkitError('emit-contract failed', { code: status, stderr: stderr || stdout, args }) - return outFile -} diff --git a/tools/deploy-check/package-lock.json b/tools/deploy-check/package-lock.json deleted file mode 100644 index 64daa22..0000000 --- a/tools/deploy-check/package-lock.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "@jorisjonkers-dev/deploy-check", - "version": "0.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@jorisjonkers-dev/deploy-check", - "version": "0.0.0", - "license": "MIT", - "dependencies": { - "yaml": "^2.5.0" - }, - "bin": { - "deploy-check": "bin/deploy-check.js" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - } - } -} diff --git a/tools/deploy-check/package.json b/tools/deploy-check/package.json deleted file mode 100644 index 7955e7b..0000000 --- a/tools/deploy-check/package.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "@jorisjonkers-dev/deploy-check", - "version": "0.0.0", - "description": "Shared deployment preview and SC-11 readiness scorecard for deploy-platform service repos.", - "type": "module", - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/JorisJonkers-dev/github-workflows.git", - "directory": "tools/deploy-check" - }, - "publishConfig": { - "registry": "https://npm.pkg.github.com" - }, - "bin": { - "deploy-check": "./bin/deploy-check.js" - }, - "exports": { - ".": "./lib/index.js" - }, - "files": [ - "bin/", - "lib/", - "README.md" - ], - "engines": { - "node": ">=20" - }, - "scripts": { - "test": "node --test test/*.test.js" - }, - "dependencies": { - "yaml": "^2.5.0" - } -} diff --git a/tools/deploy-check/test/environments.test.js b/tools/deploy-check/test/environments.test.js deleted file mode 100644 index bae11af..0000000 --- a/tools/deploy-check/test/environments.test.js +++ /dev/null @@ -1,39 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' -import { EnvironmentError, parseEnvironments } from '../lib/environments.js' - -test('a single environment parses', () => { - assert.deepEqual(parseEnvironments('production').environments, ['production']) -}) - -test('whitespace and carriage returns are stripped', () => { - // The bash predecessor ran sed 's/\r$//' because a CRLF list reached it in - // practice; the parser has to survive the same input. - assert.deepEqual(parseEnvironments(' production , staging \r\n').environments, ['production', 'staging']) -}) - -test('duplicates are dropped and reported, not silently kept', () => { - const { environments, duplicates } = parseEnvironments('production,staging,production') - assert.deepEqual(environments, ['production', 'staging']) - assert.deepEqual(duplicates, ['production']) -}) - -test('an invalid name is rejected by name', () => { - for (const bad of ['Production', '-prod', 'prod_1', 'prod!', '_x']) { - assert.throws(() => parseEnvironments(bad), /E_INVALID_ENV_NAME/, bad) - } -}) - -test('a name may contain digits and internal hyphens', () => { - assert.deepEqual(parseEnvironments('prod-1,staging2').environments, ['prod-1', 'staging2']) -}) - -test('an empty or comma-only list is an error, not an empty run', () => { - for (const empty of ['', ' ', ',,', undefined, null]) { - assert.throws(() => parseEnvironments(empty), /E_NO_VALID_ENVIRONMENTS/, JSON.stringify(empty)) - } -}) - -test('the error type is exported so callers can distinguish it', () => { - try { parseEnvironments('') } catch (err) { assert.ok(err instanceof EnvironmentError) } -}) diff --git a/tools/deploy-check/test/invocation.test.js b/tools/deploy-check/test/invocation.test.js deleted file mode 100644 index dbc1459..0000000 --- a/tools/deploy-check/test/invocation.test.js +++ /dev/null @@ -1,249 +0,0 @@ -// Locks in the exact CLI contract by running the pipeline against a stub -// toolkit that records its arguments. Every defect these tests describe was -// shipped at least once, and each was only discoverable at runtime before. -import test from 'node:test' -import assert from 'node:assert/strict' -import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync } from 'node:fs' -import { tmpdir } from 'node:os' -import path from 'node:path' -import { runPreview } from '../lib/preview.js' - -const DEPLOYMENT = `apiVersion: deployment.jorisjonkers.dev/v2 -kind: Deployment -metadata: - name: svc -spec: - schemaVersion: 0.16.0 - namespace: demo - workloads: - - name: api - health: - path: /health - port: 8080 - rollbackTargetRetention: - minimumDays: 90 - acknowledged: true -` - -/** A stub deploy-config-schema that appends its argv to a log and writes the - * file named by --output, so the pipeline sees plausible results. */ -function stubToolkit(dir, { failRender = false } = {}) { - const log = path.join(dir, 'argv.log') - const bin = path.join(dir, 'stub-toolkit.js') - writeFileSync( - bin, - `#!/usr/bin/env node -const fs = require('node:fs'); const path = require('node:path') -const argv = process.argv.slice(2) -fs.appendFileSync(${JSON.stringify(log)}, JSON.stringify(argv) + '\\n') -const failRender = ${failRender ? 'true' : 'false'} -if (argv[0] === 'render' && failRender) { - process.stderr.write(JSON.stringify({ valid: false, diagnostics: [{ code: 'E_COMMAND', message: 'stub failure' }] })) - process.exit(1) -} -const i = argv.indexOf('--output'); const o = argv.indexOf('--out') -const target = i !== -1 ? argv[i + 1] : (o !== -1 ? argv[o + 1] : null) -if (target) { - if (argv[0] === 'artifact' && argv[1] === 'emit-apply-bundle') { - fs.mkdirSync(target, { recursive: true }) - fs.writeFileSync(path.join(target, 'kustomization.yaml'), 'resources: []\\n') - } else if (argv[0] === 'artifact' && argv[1] === 'emit-contract') { - fs.mkdirSync(path.dirname(target), { recursive: true }) - fs.writeFileSync(target, 'spec:\\n contextRef: ghcr.io/x/ctx@sha256:' + 'a'.repeat(64) + '\\n imageDigests:\\n api: ghcr.io/x/api@sha256:' + 'b'.repeat(64) + '\\n') - } else { - fs.mkdirSync(path.dirname(target), { recursive: true }) - fs.writeFileSync(target, 'kind: KubernetesWorkloadFragment\\nmanifests: []\\n') - } -} -`, - ) - chmodSync(bin, 0o755) - return { bin: process.execPath, wrap: bin, log } -} - -function fixture(opts = {}) { - const root = mkdtempSync(path.join(tmpdir(), 'dc-')) - mkdirSync(path.join(root, 'platform'), { recursive: true }) - writeFileSync(path.join(root, 'platform', 'deployment.yml'), DEPLOYMENT) - writeFileSync(path.join(root, 'platform', 'images.lock.json'), JSON.stringify({ api: 'ghcr.io/x/api@sha256:' + 'b'.repeat(64) })) - const ctx = path.join(root, 'ctx', 'context', 'public') - mkdirSync(ctx, { recursive: true }) - writeFileSync(path.join(ctx, 'cluster-context-public.yml'), 'spec:\n schemaVersion: 0.20.0\n') - const stub = stubToolkit(root, opts) - return { root, stub, ctx: path.join(root, 'ctx') } -} - -function invoke(f, over = {}) { - return runPreview({ - bin: f.stub.wrap, - deployDir: 'platform', - environments: ['production'], - images: 'platform/images.lock.json', - contextRef: 'ghcr.io/x/ctx@sha256:' + 'a'.repeat(64), - contextDir: 'ctx', - artifactName: 'svc', - outDir: 'out', - cwd: f.root, - ...over, - }) -} - -const calls = (f) => readFileSync(f.stub.log, 'utf8').trim().split('\n').map((l) => JSON.parse(l)) - -test('render --output names a file, never a directory', () => { - const f = fixture() - invoke(f) - const renders = calls(f).filter((a) => a[0] === 'render') - assert.equal(renders.length, 5) - for (const a of renders) { - const out = a[a.indexOf('--output') + 1] - assert.ok(out.endsWith('.yaml'), `--output ${out} must be a file; a directory exits EISDIR`) - assert.ok(existsSync(out), `${out} should have been written`) - } -}) - -test('each fragment renders to its own file', () => { - const f = fixture() - invoke(f) - const outs = calls(f).filter((a) => a[0] === 'render').map((a) => a[a.indexOf('--output') + 1]) - assert.equal(new Set(outs).size, 5, 'five fragments must not share one output path') -}) - -test('no unpublished artifact subcommand is ever invoked', () => { - const f = fixture() - invoke(f) - const subs = calls(f).filter((a) => a[0] === 'artifact').map((a) => a[1]) - for (const s of subs) { - assert.ok(['emit-apply-bundle', 'emit-contract', 'emit-kustomization-health'].includes(s), `artifact ${s} is not published`) - } - assert.ok(!subs.includes('leak-scan')) - assert.ok(!subs.includes('validate-raw-manifests')) -}) - -test('emit-contract passes --deployment and --context and no --schema-version', () => { - const f = fixture() - invoke(f) - const call = calls(f).find((a) => a[0] === 'artifact' && a[1] === 'emit-contract') - assert.ok(call, 'emit-contract was never called') - assert.ok(call.includes('--deployment'), '--deployment is required') - assert.ok(call.includes('--context'), '--context is required') - assert.ok(!call.includes('--schema-version'), '--schema-version is not a flag on emit-contract') -}) - -test('the apply bundle is produced from the rendered manifests', () => { - const f = fixture() - invoke(f) - const call = calls(f).find((a) => a[0] === 'artifact' && a[1] === 'emit-apply-bundle') - assert.ok(call, 'emit-apply-bundle was never called') - assert.match(call[call.indexOf('--manifests') + 1], /out.manifests.production$/) -}) - -test('a failed render is reported and blocks the contract', () => { - const f = fixture({ failRender: true }) - const { renderFailures, contract, result } = invoke(f) - assert.equal(renderFailures.length, 5) - assert.equal(contract, null) - // Nothing rendered, so the secret check must not claim to have inspected anything. - assert.equal(result.checks.no_raw_secrets.status, 'not_applicable') - assert.ok(!calls(f).some((a) => a[0] === 'artifact' && a[1] === 'emit-contract')) -}) - -test('a stale out/ directory cannot satisfy the next run', () => { - const f = fixture() - const stale = path.join(f.root, 'out', 'manifests', 'production', 'leftover.yaml') - mkdirSync(path.dirname(stale), { recursive: true }) - writeFileSync(stale, 'kind: Secret\n') - const { result } = invoke(f) - assert.equal(result.checks.no_raw_secrets.status, 'pass', 'the stale Secret should have been cleared before rendering') - assert.ok(!existsSync(stale)) -}) - -test('a missing deployment.yml fails with a named error', () => { - const f = fixture() - assert.throws(() => invoke(f, { deployDir: 'nope' }), /E_DEPLOYMENT_MISSING/) -}) - -test('a context directory with no cluster context fails with a named error', () => { - const f = fixture() - mkdirSync(path.join(f.root, 'empty'), { recursive: true }) - assert.throws(() => invoke(f, { contextDir: 'empty' }), /E_CONTEXT_FILE_MISSING/) -}) - -// A pulled context package nests cluster-context-public.yml under -// context/public/. --context-dir makes the toolkit look for the file directly -// inside the directory it is given, without searching, so passing the package -// root fails with ENOENT once the layout is nested -- which is exactly what CI -// pulls. Every render must therefore receive a --context-path that exists. -test('the toolkit is given a context file that exists, not a package root', () => { - const f = fixture() - invoke(f) - for (const a of calls(f).filter((c) => c[0] === 'render')) { - const i = a.indexOf('--context-path') - assert.notEqual(i, -1, 'render must pass --context-path') - const file = a[i + 1] - assert.ok(existsSync(file), `--context-path ${file} does not exist`) - assert.match(file, /cluster-context-public\.yml$/) - assert.ok(!a.includes('--context-dir'), '--context-dir does not search nested layouts') - } -}) - -test('a nested context layout resolves to the nested file', () => { - const f = fixture() - const { contextPath } = invoke(f) - assert.match(contextPath, /ctx.context.public.cluster-context-public\.yml$/) -}) - -test('the apply bundle can be skipped', () => { - const f = fixture() - invoke(f, { applyBundle: false }) - assert.ok(!calls(f).some((a) => a[0] === 'artifact' && a[1] === 'emit-apply-bundle')) -}) - -test('the apply bundle is emitted by default', () => { - const f = fixture() - invoke(f) - assert.ok(calls(f).some((a) => a[0] === 'artifact' && a[1] === 'emit-apply-bundle')) -}) - -test('a recorded provenance result reaches emit-contract', () => { - for (const value of [true, false]) { - const f = fixture() - invoke(f, { provenanceVerified: value }) - const call = calls(f).find((a) => a[0] === 'artifact' && a[1] === 'emit-contract') - const i = call.indexOf('--provenance-verified') - assert.notEqual(i, -1) - assert.equal(call[i + 1], String(value)) - } -}) - -// render takes the deploy DIRECTORY as its second positional. Passing -// deployment.yml there was a real defect in an earlier bash implementation, and -// the toolkit's error for it is unhelpful. -test('render receives the deploy directory as its second positional, not deployment.yml', () => { - const f = fixture() - invoke(f) - for (const a of calls(f).filter((c) => c[0] === 'render')) { - assert.equal(a[2], 'platform', `second positional was ${a[2]}`) - assert.ok(!a.slice(0, 3).some((x) => String(x).endsWith('deployment.yml'))) - } -}) - -// The toolkit resolves the deployment inside the deploy dir itself, so the -// contract emission is the only place that names the file. -test('emit-contract names deployment.yml explicitly', () => { - const f = fixture() - invoke(f) - const call = calls(f).find((a) => a[0] === 'artifact' && a[1] === 'emit-contract') - assert.match(call[call.indexOf('--deployment') + 1], /deployment\.yml$/) -}) - -// The contract field is a boolean. not_applicable is a scorecard distinction, -// and must reach the toolkit as false rather than as an unknown string whose -// coercion happens to be right. -test('not_applicable provenance records as false in the contract', () => { - const f = fixture() - invoke(f, { provenanceVerified: 'not_applicable' }) - const call = calls(f).find((a) => a[0] === 'artifact' && a[1] === 'emit-contract') - assert.equal(call[call.indexOf('--provenance-verified') + 1], 'false') - assert.ok(!call.includes('not_applicable')) -}) diff --git a/tools/deploy-check/test/scorecard.test.js b/tools/deploy-check/test/scorecard.test.js deleted file mode 100644 index 213c43c..0000000 --- a/tools/deploy-check/test/scorecard.test.js +++ /dev/null @@ -1,267 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' -import { CHECKS, computeScorecard, renderScorecardMarkdown, usesLatestTag } from '../lib/scorecard.js' - -const workload = (over = {}) => ({ - name: 'api', - health: { path: '/health', port: 8080 }, - rollbackTargetRetention: { minimumDays: 90, acknowledged: true }, - ...over, -}) -const deployment = (workloads = [workload()], over = {}) => ({ - apiVersion: 'deployment.jorisjonkers.dev/v2', - metadata: { name: 'svc' }, - spec: { schemaVersion: '0.16.0', workloads, ...over }, -}) -const contract = (over = {}) => ({ - spec: { - contextRef: 'ghcr.io/x/ctx@sha256:' + 'a'.repeat(64), - imageDigests: { api: 'ghcr.io/x/api@sha256:' + 'b'.repeat(64) }, - ...over, - }, -}) -const rendered = (text = 'kind: Deployment\n') => [{ path: 'out/manifests/production/w.yaml', text }] - -const statusOf = (res, check) => res.checks[check].status - -test('a clean deployment passes every applicable check', () => { - const res = computeScorecard(deployment(), contract(), { renderedManifests: rendered() }) - assert.equal(res.overall, 'pass') - assert.deepEqual(res.failed, []) - for (const c of CHECKS) assert.notEqual(statusOf(res, c), 'fail', c) -}) - -test('every check reports one of the three known statuses', () => { - const res = computeScorecard(deployment(), contract(), { renderedManifests: rendered() }) - for (const c of CHECKS) { - assert.ok(['pass', 'fail', 'not_applicable'].includes(statusOf(res, c)), `${c} -> ${statusOf(res, c)}`) - } -}) - -// The bug this whole package exists to prevent: with nothing rendered, the -// predecessor grepped an empty directory, found no Secret, and reported pass -- -// so a run that produced no manifests at all looked ready to deploy. -test('no_raw_secrets is not_applicable when nothing was rendered, never pass', () => { - const res = computeScorecard(deployment(), contract(), { renderedManifests: [] }) - assert.equal(statusOf(res, 'no_raw_secrets'), 'not_applicable') - assert.match(res.checks.no_raw_secrets.reason, /nothing was rendered/) -}) - -test('no_raw_secrets passes on real manifests with no Secret', () => { - const res = computeScorecard(deployment(), contract(), { renderedManifests: rendered() }) - assert.equal(statusOf(res, 'no_raw_secrets'), 'pass') -}) - -test('no_raw_secrets fails on a whole-line kind: Secret and names the file', () => { - const res = computeScorecard(deployment(), contract(), { renderedManifests: rendered('apiVersion: v1\nkind: Secret\n') }) - assert.equal(statusOf(res, 'no_raw_secrets'), 'fail') - assert.match(res.checks.no_raw_secrets.reason, /out\/manifests\/production\/w\.yaml/) -}) - -test('no_raw_secrets does not trip on VaultStaticSecret', () => { - const res = computeScorecard(deployment(), contract(), { renderedManifests: rendered('kind: VaultStaticSecret\n') }) - assert.equal(statusOf(res, 'no_raw_secrets'), 'pass') -}) - -test('health_declared names every workload missing a path', () => { - const res = computeScorecard( - deployment([workload(), workload({ name: 'worker', health: undefined })]), - contract(), - { renderedManifests: rendered() }, - ) - assert.equal(statusOf(res, 'health_declared'), 'fail') - assert.match(res.checks.health_declared.reason, /worker/) - assert.doesNotMatch(res.checks.health_declared.reason, /\bapi\b/) -}) - -test('health_declared is not_applicable when there are no workloads', () => { - const res = computeScorecard(deployment([]), contract(), { renderedManifests: rendered() }) - assert.equal(statusOf(res, 'health_declared'), 'not_applicable') -}) - -// The predecessor counted `true` across all workloads and took the maximum -// minimumDays, so one compliant workload vouched for the rest. -test('rollback retention is judged per workload, not any-of', () => { - const res = computeScorecard( - deployment([workload(), workload({ name: 'worker', rollbackTargetRetention: undefined })]), - contract(), - { renderedManifests: rendered() }, - ) - assert.equal(statusOf(res, 'rollback_retention_acknowledged'), 'fail') - assert.match(res.checks.rollback_retention_acknowledged.reason, /worker/) -}) - -test('rollback retention fails below the 90-day floor', () => { - const res = computeScorecard( - deployment([workload({ rollbackTargetRetention: { minimumDays: 89, acknowledged: true } })]), - contract(), { renderedManifests: rendered() }, - ) - assert.equal(statusOf(res, 'rollback_retention_acknowledged'), 'fail') - assert.match(res.checks.rollback_retention_acknowledged.reason, /89 days/) -}) - -test('rollback retention fails when days are met but not acknowledged', () => { - const res = computeScorecard( - deployment([workload({ rollbackTargetRetention: { minimumDays: 120, acknowledged: false } })]), - contract(), { renderedManifests: rendered() }, - ) - assert.equal(statusOf(res, 'rollback_retention_acknowledged'), 'fail') - assert.match(res.checks.rollback_retention_acknowledged.reason, /not acknowledged/) -}) - -test('routes are not_applicable when none are declared', () => { - const res = computeScorecard(deployment(), contract(), { renderedManifests: rendered() }) - assert.equal(statusOf(res, 'route_owner_authmode_declared'), 'not_applicable') -}) - -test('a route inherits authMode from routeDefaults', () => { - const res = computeScorecard( - deployment([workload({ routes: [{ host: 'a.example', owner: 'api' }], routeDefaults: { authMode: 'forward-auth' } })]), - contract(), { renderedManifests: rendered() }, - ) - assert.equal(statusOf(res, 'route_owner_authmode_declared'), 'pass') -}) - -test('a route without owner or any authMode fails and names the host', () => { - const res = computeScorecard( - deployment([workload({ routes: [{ host: 'a.example' }] })]), - contract(), { renderedManifests: rendered() }, - ) - assert.equal(statusOf(res, 'route_owner_authmode_declared'), 'fail') - assert.match(res.checks.route_owner_authmode_declared.reason, /a\.example/) - assert.match(res.checks.route_owner_authmode_declared.reason, /owner/) -}) - -test('context_pinned requires a digest, not a tag', () => { - const tagged = computeScorecard(deployment(), contract({ contextRef: 'ghcr.io/x/ctx:v1' }), { renderedManifests: rendered() }) - assert.equal(statusOf(tagged, 'context_pinned'), 'fail') -}) - -test('context_pinned fails rather than throwing when there is no contract', () => { - const res = computeScorecard(deployment(), null, { renderedManifests: rendered() }) - assert.equal(statusOf(res, 'context_pinned'), 'fail') - assert.equal(res.overall, 'fail') -}) - -test('stateful workloads must declare a migrationPolicy', () => { - const res = computeScorecard( - deployment([workload({ name: 'db', stateful: true })]), - contract(), { renderedManifests: rendered() }, - ) - assert.equal(statusOf(res, 'stateful_policy_declared'), 'fail') - assert.match(res.checks.stateful_policy_declared.reason, /db/) -}) - -test('rawManifests without a guard report explains that no guard exists', () => { - const res = computeScorecard( - deployment([workload({ rawManifests: { enabled: true } })]), - contract(), { renderedManifests: rendered() }, - ) - assert.equal(statusOf(res, 'raw_manifests_guarded'), 'fail') - assert.match(res.checks.raw_manifests_guarded.reason, /validate-raw-manifests/) -}) - -// A package from GitHub Packages cannot be audited by npm audit signatures at -// all. Reporting that as a failed verification would block every publish, so -// "could not be evaluated" is a third state. -test('an unauditable registry is not_applicable, not a failure', () => { - const res = computeScorecard(deployment(), contract(), { renderedManifests: rendered(), provenanceVerified: 'not_applicable' }) - assert.equal(statusOf(res, 'npm_signatures_verified'), 'not_applicable') - assert.equal(res.overall, 'pass') -}) - -test('a genuine verification failure is still a failure', () => { - const res = computeScorecard(deployment(), contract(), { renderedManifests: rendered(), provenanceVerified: false }) - assert.equal(statusOf(res, 'npm_signatures_verified'), 'fail') - assert.equal(res.overall, 'fail') -}) - -test('npm_signatures_verified is not_applicable unless provenance was evaluated', () => { - const preview = computeScorecard(deployment(), contract(), { renderedManifests: rendered() }) - assert.equal(statusOf(preview, 'npm_signatures_verified'), 'not_applicable') - const publishFail = computeScorecard(deployment(), contract(), { renderedManifests: rendered(), provenanceVerified: false }) - assert.equal(statusOf(publishFail, 'npm_signatures_verified'), 'fail') - const publishOk = computeScorecard(deployment(), contract(), { renderedManifests: rendered(), provenanceVerified: true }) - assert.equal(statusOf(publishOk, 'npm_signatures_verified'), 'pass') -}) - -test('schema_pinned fails on a missing or non-numeric version', () => { - for (const v of [undefined, '', 'latest']) { - const d = deployment(); d.spec.schemaVersion = v - assert.equal(statusOf(computeScorecard(d, contract(), { renderedManifests: rendered() }), 'schema_pinned'), 'fail', String(v)) - } -}) - -test('usesLatestTag matches a whole tag only', () => { - assert.equal(usesLatestTag('ghcr.io/x/a:latest'), true) - assert.equal(usesLatestTag('ghcr.io/x/a:latest-alpine'), false) - assert.equal(usesLatestTag('ghcr.io/x/a:v1'), false) - assert.equal(usesLatestTag('ghcr.io/x/a@sha256:' + 'c'.repeat(64)), false) - // a port in the registry host is not a tag - assert.equal(usesLatestTag('registry:5000/x/a'), false) - assert.equal(usesLatestTag(undefined), false) -}) - -test('no_latest_images fails and names the offending image', () => { - const res = computeScorecard(deployment(), contract({ imageDigests: { api: 'ghcr.io/x/api:latest' } }), { renderedManifests: rendered() }) - assert.equal(statusOf(res, 'no_latest_images'), 'fail') - assert.match(res.checks.no_latest_images.reason, /api/) -}) - -test('the markdown table renders one row per check plus a reason column', () => { - const res = computeScorecard(deployment(), contract(), { renderedManifests: rendered() }) - const md = renderScorecardMarkdown(res, 'svc') - for (const c of CHECKS) assert.match(md, new RegExp(c)) - assert.match(md, /\| Detail \|/) -}) - -// A queue consumer has no port to probe. Requiring a path from every workload -// made the check unsatisfiable for knowledge-ingest-worker, which has no ports -// and no probes in its cluster manifest. -test('a workload may be exempted with health.mandatory false', () => { - const res = computeScorecard( - deployment([workload(), workload({ name: 'worker', health: { mandatory: false } })]), - contract(), { renderedManifests: rendered() }, - ) - assert.equal(statusOf(res, 'health_declared'), 'pass') - assert.match(res.checks.health_declared.reason, /worker/) - assert.match(res.checks.health_declared.reason, /mandatory/) -}) - -test('an exemption is reported even when every workload is exempt', () => { - const res = computeScorecard( - deployment([workload({ name: 'w1', health: { mandatory: false } })]), - contract(), { renderedManifests: rendered() }, - ) - assert.equal(statusOf(res, 'health_declared'), 'pass') - assert.match(res.checks.health_declared.reason, /w1/) -}) - -test('omitting health entirely is still a failure, and the message names the escape hatch', () => { - const res = computeScorecard( - deployment([workload({ name: 'worker', health: undefined })]), - contract(), { renderedManifests: rendered() }, - ) - assert.equal(statusOf(res, 'health_declared'), 'fail') - assert.match(res.checks.health_declared.reason, /worker/) - assert.match(res.checks.health_declared.reason, /mandatory: false/) -}) - -test('mandatory false does not excuse a workload that claims a path', () => { - // A declared path is a promise the probe works; mandatory:false alongside it - // is contradictory, so the path wins and the workload is not exempt. - const res = computeScorecard( - deployment([workload({ name: 'w', health: { mandatory: false, path: '/health', port: 8080 } })]), - contract(), { renderedManifests: rendered() }, - ) - assert.equal(statusOf(res, 'health_declared'), 'pass') - assert.equal(res.checks.health_declared.reason, undefined) -}) - -test('a partial health block without a path is not an exemption', () => { - const res = computeScorecard( - deployment([workload({ name: 'w', health: { port: 8080 } })]), - contract(), { renderedManifests: rendered() }, - ) - assert.equal(statusOf(res, 'health_declared'), 'fail') -}) diff --git a/tools/deploy-check/test/toolkit.test.js b/tools/deploy-check/test/toolkit.test.js deleted file mode 100644 index f62e4f5..0000000 --- a/tools/deploy-check/test/toolkit.test.js +++ /dev/null @@ -1,67 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' -import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import path from 'node:path' -import { FRAGMENTS, PUBLISHED_ARTIFACT_SUBCOMMANDS, ToolkitError, contextArgs } from '../lib/toolkit.js' -import { findClusterContext } from '../lib/preview.js' - -test('all five fragments are rendered', () => { - assert.deepEqual(FRAGMENTS, [ - 'kubernetes-workload-fragment', - 'traefik-route-fragment', - 'gatus-endpoint-fragment', - 'edge-catalog-fragment', - 'image-metadata-fragment', - ]) -}) - -// artifact leak-scan and artifact validate-raw-manifests were called by the -// predecessor scripts and exit E_USAGE: they are not published. -test('the published artifact subcommand set is exactly the three that exist', () => { - assert.deepEqual([...PUBLISHED_ARTIFACT_SUBCOMMANDS].sort(), [ - 'emit-apply-bundle', - 'emit-contract', - 'emit-kustomization-health', - ]) - for (const absent of ['leak-scan', 'validate-raw-manifests']) { - assert.ok(!PUBLISHED_ARTIFACT_SUBCOMMANDS.includes(absent), absent) - } -}) - -test('contextArgs uses --context-dir for a local package', () => { - assert.deepEqual(contextArgs({ contextDir: '/ctx' }), ['--context-dir', '/ctx']) -}) - -test('contextArgs uses --context with --context-path for a pinned ref', () => { - assert.deepEqual( - contextArgs({ contextRef: 'ghcr.io/x@sha256:abc', contextPath: '/c/ctx.yml' }), - ['--context', 'ghcr.io/x@sha256:abc', '--context-path', '/c/ctx.yml'], - ) -}) - -test('contextArgs rejects a ref without the file it came from', () => { - assert.throws(() => contextArgs({ contextRef: 'ghcr.io/x@sha256:abc' }), ToolkitError) - assert.throws(() => contextArgs({}), ToolkitError) -}) - -test('findClusterContext prefers the canonical context/public location', () => { - const root = mkdtempSync(path.join(tmpdir(), 'ctx-')) - mkdirSync(path.join(root, 'a'), { recursive: true }) - mkdirSync(path.join(root, 'context', 'public'), { recursive: true }) - writeFileSync(path.join(root, 'a', 'cluster-context-public.yml'), 'x: 1\n') - writeFileSync(path.join(root, 'context', 'public', 'cluster-context-public.yml'), 'x: 2\n') - assert.match(findClusterContext(root), /context.public.cluster-context-public\.yml$/) -}) - -test('findClusterContext falls back to any match', () => { - const root = mkdtempSync(path.join(tmpdir(), 'ctx-')) - mkdirSync(path.join(root, 'deep', 'nested'), { recursive: true }) - writeFileSync(path.join(root, 'deep', 'nested', 'cluster-context-public.yml'), 'x: 1\n') - assert.match(findClusterContext(root), /deep.nested/) -}) - -test('findClusterContext returns null when absent, rather than throwing', () => { - const root = mkdtempSync(path.join(tmpdir(), 'ctx-')) - assert.equal(findClusterContext(root), null) -})