From 0eb0b456b6483019c7bdc05f7a3bc93ae69433e0 Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Thu, 20 Aug 2026 16:42:27 +0200 Subject: [PATCH 1/2] refactor(platform): call the shared deploy-check instead of a vendored copy platform/render-local.sh was a ~400-line copy of logic that also lived in actions/deploy-preview/run.sh, duplicated into every service repository. The copies drifted, and this one could not run at all: it passed a directory where --output names a file, and called artifact leak-scan and artifact validate-raw-manifests, neither of which the toolkit publishes. The logic now lives in @jorisjonkers-dev/deploy-check, which the deploy-preview action runs as well, so a local result and a CI result cannot disagree. What is left here is a wrapper that pulls the cluster context and invokes the pinned checker. The schema version and context ref are read out of this repository's own workflows rather than restated in the script. The previous copy hardcoded both and both went stale: the schema version by four minor releases, the context digest by two republications. There is now one place to change either. --- platform/render-local.sh | 455 ++++++--------------------------------- 1 file changed, 69 insertions(+), 386 deletions(-) diff --git a/platform/render-local.sh b/platform/render-local.sh index 0b34209..85f933a 100755 --- a/platform/render-local.sh +++ b/platform/render-local.sh @@ -1,401 +1,84 @@ #!/usr/bin/env bash -# render-local.sh — local CI-parity render for agent-runtime. +# render-local.sh -- run the same deployment checks CI runs, locally. # -# Mirrors what deploy-validate.yml / deploy-artifact.yml do in CI: -# validate -> render -> kubeconform -> leak-scan -> scorecard +# This was ~400 lines of bash duplicated into every service repository, and the +# copies drifted from the CI implementation they were meant to mirror. The logic +# now lives in @jorisjonkers-dev/deploy-check, which the deploy-preview action +# runs too, so a local result and a CI result cannot disagree. # -# Usage: -# ./platform/render-local.sh [--diff] [--context-dir PATH] [--scorecard-only] +# The schema version and context ref are read out of this repository's own +# workflow rather than restated here. The previous copy hardcoded both and both +# went stale -- the schema version by four minor releases, the context digest by +# two republications. # -# Options: -# --diff compare rendered output against committed fixtures; exit 1 on drift -# --context-dir use a local context directory (bypasses digest requirement for local dev) -# --scorecard-only skip install/pull/render and only evaluate the SC-11 scorecard -# (dry mode: works without the npm package installed) +# Usage: +# ./platform/render-local.sh [--context-dir DIR] [-- ] # -# The scorecard functions are pure shell + jq so they stay testable without -# network access or the @jorisjonkers-dev/deploy-config-schema npm package. - +# Requires node, and either oras (to pull the context by digest) or +# --context-dir pointing at an already-pulled context package. Installing the +# pinned toolkit reads npm.pkg.github.com, so export GITHUB_TOKEN (or +# NODE_AUTH_TOKEN) first: export GITHUB_TOKEN="$(gh auth token)". set -euo pipefail +DEPLOY_CHECK_VERSION="0.15.0" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$REPO_ROOT" -FLAG_DIFF=false -FLAG_SCORECARD_ONLY=false -CONTEXT_DIR="${CONTEXT_DIR:-}" -CONTEXT_REF="${CONTEXT_REF:-ghcr.io/jorisjonkers-dev/cluster-deploy-context-public@sha256:9479bc22ae11183c0b68f257d2c1a21455be8c3cff602d3a491ea3ff31d01fe3}" -OUT_DIR="${OUT_DIR:-$REPO_ROOT/out}" -RESOLVED_VERSION="" -NPM_PROVENANCE_VERIFIED="${NPM_PROVENANCE_VERIFIED:-true}" - -ENVS=(production) -FRAGMENTS=( - kubernetes-workload-fragment - traefik-route-fragment - gatus-endpoint-fragment - edge-catalog-fragment - image-metadata-fragment -) - -log() { echo "[render-local] $*" >&2; } -warn() { echo "[render-local] WARNING: $*" >&2; } -fail() { - echo "ERROR: $*" >&2 - exit 1 -} - -usage() { - cat <<'USAGE' -Usage: render-local.sh [--diff] [--context-dir PATH] [--scorecard-only] - -Options: - --diff compare rendered output against committed fixtures; exit 1 on drift - --context-dir use a local context directory (bypasses digest requirement for local dev) - --scorecard-only skip install/pull/render and only evaluate the SC-11 scorecard - -h, --help show this help -USAGE -} - -parse_args() { - while [ $# -gt 0 ]; do - case "$1" in - --diff) - FLAG_DIFF=true - shift - ;; - --context-dir) - [ $# -ge 2 ] || fail "--context-dir requires a PATH argument" - CONTEXT_DIR="$2" - shift 2 - ;; - --scorecard-only) - FLAG_SCORECARD_ONLY=true - shift - ;; - -h | --help) - usage - exit 0 - ;; - *) - usage >&2 - fail "unknown option: $1" - ;; - esac - done -} - -resolve_schema_version() { - if [ -n "${SCHEMA_VERSION:-}" ]; then - RESOLVED_VERSION="$SCHEMA_VERSION" - log "Using SCHEMA_VERSION from env: $RESOLVED_VERSION" - elif [ -f "$REPO_ROOT/.platform/deploy-version" ]; then - RESOLVED_VERSION="$(tr -d '[:space:]' < "$REPO_ROOT/.platform/deploy-version")" - log "Using schema version from .platform/deploy-version: $RESOLVED_VERSION" - else - RESOLVED_VERSION="0.20.0" - log "Using baked-in schema version: $RESOLVED_VERSION" - fi -} - -install_schema() { - log "Installing @jorisjonkers-dev/deploy-config-schema@$RESOLVED_VERSION" - npm install --global "@jorisjonkers-dev/deploy-config-schema@$RESOLVED_VERSION" \ - --registry https://npm.pkg.github.com \ - || fail "failed to install deploy-config-schema@$RESOLVED_VERSION" -} - -npm_audit_and_scorecard_init() { - if npm audit signatures --scope @jorisjonkers-dev >/dev/null 2>&1; then - NPM_PROVENANCE_VERIFIED="true" - else - NPM_PROVENANCE_VERIFIED="false" - warn "npm audit signatures failed; npm_signatures_verified=fail" - fi -} +fail() { echo "ERROR: $*" >&2; exit 1; } -require_digest_ref() { +CONTEXT_DIR="" +EXTRA=() +while [ $# -gt 0 ]; do case "$1" in - *@sha256:*) ;; - *) fail "E_CONTEXT_REF_NOT_PINNED: context ref must be digest-pinned (got: $1). Pass --context-dir for local dev." ;; + --context-dir) [ $# -ge 2 ] || fail "--context-dir requires a path"; CONTEXT_DIR="$2"; shift 2 ;; + --) shift; EXTRA=("$@"); break ;; + -h|--help) sed -n '2,20p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) fail "unknown option: $1 (pass extra deploy-check flags after --)" ;; esac -} - -# Locate cluster-context-public.yml inside a context directory, as CI does. -find_cluster_context() { - local match - match=$(find "$1" -type f -name 'cluster-context-public.yml' 2>/dev/null | sort | head -1) - [ -n "$match" ] || fail "E_CONTEXT_FILE_MISSING: cluster-context-public.yml not found under $1" - printf '%s\n' "$match" -} - -pull_or_use_local_context() { - if [ -n "$CONTEXT_DIR" ]; then - CONTEXT_PKG_DIR="$CONTEXT_DIR" - warn "--context-dir bypasses OCI digest requirement. Not suitable for CI." - else - require_digest_ref "$CONTEXT_REF" - log "Pulling public cluster context: $CONTEXT_REF" - mkdir -p "$OUT_DIR/context-pkg" - oras pull "$CONTEXT_REF" --output "$OUT_DIR/context-pkg" \ - || fail "failed to pull context package $CONTEXT_REF" - CONTEXT_PKG_DIR="$OUT_DIR/context-pkg" - fi - CONTEXT_FILE="$(find_cluster_context "$CONTEXT_PKG_DIR")" -} - -render_all_fragments() { - local env fragment - for env in "${ENVS[@]}"; do - mkdir -p "$OUT_DIR/manifests/$env" "$OUT_DIR/metadata/$env" - for fragment in "${FRAGMENTS[@]}"; do - log "render $fragment ($env)" - deploy-config-schema render "$fragment" "$SCRIPT_DIR" \ - --env "$env" \ - --context "$CONTEXT_REF" \ - --context-dir "$CONTEXT_PKG_DIR" \ - --images "$SCRIPT_DIR/images.lock.json" \ - --output "$OUT_DIR/manifests/$env/$fragment.yaml" \ - || fail "render failed for $fragment ($env)" - done - deploy-config-schema artifact emit-kustomization-health \ - --deployment "$SCRIPT_DIR/deployment.yml" \ - --env "$env" \ - --image-digests "$SCRIPT_DIR/images.lock.json" \ - --out "$OUT_DIR/metadata/$env/kustomization-health.yml" \ - || fail "emit-kustomization-health failed ($env)" - done -} - -validate_rendered_output() { - local env - # A fragment is a schema document wrapping its payload, so kubeconform and - # kustomize -- both of which expect apiVersion/kind at the top level -- always - # fail when pointed at out/manifests. CI does not validate that shape at all; - # render correctness comes from the schema CLI. The applyable objects are what - # can be checked, so lift them first and validate those. - for env in "${ENVS[@]}"; do - log "emit-apply-bundle ($env)" - deploy-config-schema artifact emit-apply-bundle \ - --manifests "$OUT_DIR/manifests/$env" \ - --out "$OUT_DIR/apply/$env" \ - || fail "emit-apply-bundle failed for $env" - if command -v kubeconform >/dev/null 2>&1; then - log "kubeconform ($env)" - kubeconform -schema-location default -strict "$OUT_DIR/apply/$env" \ - || fail "kubeconform validation failed for $env" - else - warn "kubeconform not installed; skipping schema validation of $env" - fi - log "kustomize build dry-run ($env)" - kustomize build "$OUT_DIR/apply/$env" >/dev/null \ - || fail "kustomize build failed for $env" - done - if grep -rl 'kind: Secret' "$OUT_DIR/manifests/" 2>/dev/null | grep -q .; then - fail "E_FORBIDDEN_KIND: kind=Secret found in rendered manifests" - fi -} - -# The toolkit exposes only emit-apply-bundle, emit-contract and -# emit-kustomization-health under `artifact`; there is no -# validate-raw-manifests subcommand in any published version, so a -# rawManifests declaration cannot be checked locally. Say so rather than -# scoring an unexplained fail. -reject_unsupported_raw_manifests() { - grep -qE '^[[:space:]]*rawManifests:' <(deployment_source "$SCRIPT_DIR/deployment.yml") || return 0 - fail "E_RAW_MANIFESTS_UNSUPPORTED: deployment.yml declares rawManifests, but deploy-config-schema exposes no artifact validate-raw-manifests subcommand. CI cannot guard it either." -} - -emit_contract() { - deploy-config-schema artifact emit-contract \ - --artifact-name "agent-runtime" \ - --context-ref "$CONTEXT_REF" \ - --environments "production" \ - --images "$SCRIPT_DIR/images.lock.json" \ - --deployment "$SCRIPT_DIR/deployment.yml" \ - --context "$CONTEXT_FILE" \ - --provenance-verified "$NPM_PROVENANCE_VERIFIED" \ - --out "$OUT_DIR/artifact-contract.yaml" \ - || fail "emit-contract failed" -} - -deployment_source() { - sed 's/#.*$//' "$1" -} - -scorecard_images_pinned() { - local lock="$1" ref - [ -f "$lock" ] || { - echo "fail" - return - } - while IFS= read -r ref; do - case "$ref" in - *@sha256:*) ;; - *) - echo "fail" - return - ;; - esac - done < <(jq -r 'if type == "array" then map(.ref) else [.[]] end | .[]' "$lock") - echo "pass" -} - -scorecard_route_owner_authmode() { - local src="$1" - if ! grep -qE '^[[:space:]]*routes:' <<<"$src"; then - echo "not_applicable" - elif grep -qE '^[[:space:]]*owner:' <<<"$src" && grep -qE '^[[:space:]]*authMode:' <<<"$src"; then - echo "pass" - else - echo "fail" - fi -} - -scorecard_stateful_policy() { - local src="$1" - if ! grep -qE '^[[:space:]]*stateful:[[:space:]]*true' <<<"$src"; then - echo "not_applicable" - elif grep -qE '^[[:space:]]*migrationPolicy:' <<<"$src"; then - echo "pass" - else - echo "fail" - fi -} - -scorecard_raw_manifests() { - local src="$1" - if ! grep -qE '^[[:space:]]*rawManifests:' <<<"$src"; then - echo "not_applicable" - elif [ -f "$OUT_DIR/raw-manifests-guard.json" ]; then - echo "pass" - else - echo "fail" - fi -} - -compute_scorecard() { - local deployment="$1" lock="$2" - local src - src="$(deployment_source "$deployment")" - - local schema_pinned=fail context_pinned=fail health_declared=fail - local rollback=fail no_raw_secrets=pass npm_flag=fail - local workload_count health_count - - grep -qE '^[[:space:]]*schemaVersion:[[:space:]]*"?[0-9]' <<<"$src" && schema_pinned=pass - case "$CONTEXT_REF" in *@sha256:*) context_pinned=pass ;; esac - - workload_count="$(grep -cE '^[[:space:]]*-[[:space:]]*name:' <<<"$src" || true)" - health_count="$(grep -cE '^[[:space:]]*health:' <<<"$src" || true)" - if [ "$workload_count" -gt 0 ] && [ "$health_count" -ge "$workload_count" ]; then - health_declared=pass - fi - - if grep -qE '^[[:space:]]*rollbackTargetRetention:' <<<"$src" \ - && grep -qE '^[[:space:]]*acknowledged:[[:space:]]*true' <<<"$src"; then - rollback=pass - fi - - if [ -d "$SCRIPT_DIR/raw-manifests" ] \ - && grep -rl 'kind: Secret' "$SCRIPT_DIR/raw-manifests" 2>/dev/null | grep -q .; then - no_raw_secrets=fail - fi - if [ -d "$OUT_DIR/manifests" ] \ - && grep -rl 'kind: Secret' "$OUT_DIR/manifests" 2>/dev/null | grep -q .; then - no_raw_secrets=fail - fi - - [ "$NPM_PROVENANCE_VERIFIED" = "true" ] && npm_flag=pass - - mkdir -p "$OUT_DIR" - jq -n \ - --arg schema_pinned "$schema_pinned" \ - --arg context_pinned "$context_pinned" \ - --arg no_latest_images "$(scorecard_images_pinned "$lock")" \ - --arg health_declared "$health_declared" \ - --arg route_owner_authmode_declared "$(scorecard_route_owner_authmode "$src")" \ - --arg rollback_retention_acknowledged "$rollback" \ - --arg no_raw_secrets "$no_raw_secrets" \ - --arg stateful_policy_declared "$(scorecard_stateful_policy "$src")" \ - --arg raw_manifests_guarded "$(scorecard_raw_manifests "$src")" \ - --arg npm_signatures_verified "$npm_flag" \ - '{ - schema_pinned: $schema_pinned, - context_pinned: $context_pinned, - no_latest_images: $no_latest_images, - health_declared: $health_declared, - route_owner_authmode_declared: $route_owner_authmode_declared, - rollback_retention_acknowledged: $rollback_retention_acknowledged, - no_raw_secrets: $no_raw_secrets, - stateful_policy_declared: $stateful_policy_declared, - raw_manifests_guarded: $raw_manifests_guarded, - npm_signatures_verified: $npm_signatures_verified - }' > "$OUT_DIR/scorecard.json" -} - -write_scorecard_outputs() { - { - echo "# Deployment readiness scorecard — agent-runtime" - echo "" - echo "| Check | Status |" - echo "|-------|--------|" - jq -r 'to_entries[] | "| \(.key) | \(.value) |"' "$OUT_DIR/scorecard.json" - echo "" - echo "pass = ready · fail = blocks deployment · not_applicable = check does not apply" - } > "$OUT_DIR/scorecard.md" - cat "$OUT_DIR/scorecard.md" -} - -compare_against_fixtures() { - local drift - drift="$(diff -rq "$OUT_DIR" "$SCRIPT_DIR/fixtures" 2>&1 || true)" - if [ -n "$drift" ]; then - echo "ERROR: Rendered output differs from committed fixtures:" >&2 - echo "$drift" >&2 - echo "Run render-local.sh without --diff to regenerate fixtures, then commit." >&2 - exit 1 - fi - log "No drift detected between rendered output and committed fixtures." -} - -exit_based_on_scorecard() { - local fails - fails="$(jq '[to_entries[] | select(.value == "fail")] | length' "$OUT_DIR/scorecard.json")" - if [ "$fails" -gt 0 ]; then - echo "ERROR: Scorecard has $fails fail(s). See out/scorecard.md for details." >&2 - exit 1 - fi - log "All scorecard checks passed." -} - -main() { - parse_args "$@" - - if [ "$FLAG_SCORECARD_ONLY" = true ]; then - log "scorecard-only dry mode (npm package not required)" - compute_scorecard "$SCRIPT_DIR/deployment.yml" "$SCRIPT_DIR/images.lock.json" - write_scorecard_outputs - exit_based_on_scorecard - return - fi - - resolve_schema_version - install_schema - npm_audit_and_scorecard_init - pull_or_use_local_context - render_all_fragments - validate_rendered_output - reject_unsupported_raw_manifests - emit_contract - compute_scorecard "$SCRIPT_DIR/deployment.yml" "$SCRIPT_DIR/images.lock.json" - write_scorecard_outputs - if [ "$FLAG_DIFF" = true ]; then - compare_against_fixtures - fi - exit_based_on_scorecard -} +done + +# Single source of truth: whatever the workflows pin is what a local run uses. +value_from_workflows() { + local key="$1" + grep -rhoE "^[[:space:]]*${key}:[[:space:]]*[^[:space:]]+" .github/workflows/*.yml 2>/dev/null \ + | awk '{print $2}' | sort -u | head -1 +} + +SCHEMA_VERSION="$(value_from_workflows 'schema-version')" +CONTEXT_REF="$(value_from_workflows 'context-ref')" +[ -n "$SCHEMA_VERSION" ] || fail "no schema-version found in .github/workflows/*.yml" +[ -n "$CONTEXT_REF" ] || fail "no context-ref found in .github/workflows/*.yml" + +if [ -z "$CONTEXT_DIR" ]; then + command -v oras >/dev/null 2>&1 \ + || fail "oras is not installed; install it or pass --context-dir " + CONTEXT_DIR="$(mktemp -d)" + echo "[render-local] pulling context $CONTEXT_REF" >&2 + oras pull "$CONTEXT_REF" --output "$CONTEXT_DIR" >&2 \ + || fail "oras pull failed for $CONTEXT_REF" +fi -if [ "${BASH_SOURCE[0]}" = "$0" ]; then - main "$@" +# GitHub Packages requires a token even for public packages, and npx would +# otherwise resolve the scope against registry.npmjs.org and 404. Build a +# throwaway npmrc rather than touching the developer's own. +TOKEN="${GITHUB_TOKEN:-${NODE_AUTH_TOKEN:-}}" +if [ -z "$TOKEN" ] && command -v gh >/dev/null 2>&1; then + TOKEN="$(gh auth token 2>/dev/null || true)" fi +[ -n "$TOKEN" ] || fail "no GitHub token: export GITHUB_TOKEN=\"\$(gh auth token)\" (npm.pkg.github.com requires auth even for public packages)" + +NPMRC="$(mktemp)" +trap 'rm -f "$NPMRC"' EXIT +printf '@jorisjonkers-dev:registry=https://npm.pkg.github.com\n//npm.pkg.github.com/:_authToken=%s\n' "$TOKEN" > "$NPMRC" +export npm_config_userconfig="$NPMRC" +# The toolkit install inside deploy-check reads this too. +export NODE_AUTH_TOKEN="$TOKEN" + +echo "[render-local] deploy-check ${DEPLOY_CHECK_VERSION}, toolkit ${SCHEMA_VERSION}" >&2 +exec npx --yes "@jorisjonkers-dev/deploy-check@${DEPLOY_CHECK_VERSION}" preview \ + --deploy-dir platform \ + --schema-version "$SCHEMA_VERSION" \ + --context-ref "$CONTEXT_REF" \ + --context-dir "$CONTEXT_DIR" \ + "${EXTRA[@]+"${EXTRA[@]}"}" From a92658becf18a0fb7da7d7700d1943d4704d1e8e Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Thu, 20 Aug 2026 17:41:48 +0200 Subject: [PATCH 2/2] refactor(platform): derive the checker version from the workflow pin The wrapper hardcoded deploy-check 0.15.0, which is the same mistake the 400-line copy made with its schema version and context digest -- and 0.15.0 turned out to carry a context-resolution bug fixed in 0.15.1. deploy-check is released from the same repository as the reusable workflows, so the version comment on the deploy-validate (or deploy-artifact) pin is the version to run. The wrapper reads it from there, leaving nothing about the checker restated in this repository. The github-workflows pins move to v0.15.1 accordingly. Both reusable workflow interfaces are unchanged across the versions involved -- same inputs, same required flags -- so no caller change is needed. --- .github/workflows/deploy-preview.yml | 2 +- .github/workflows/publish.yml | 2 +- platform/render-local.sh | 13 ++++++++++++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml index f7e2cee..d921599 100644 --- a/.github/workflows/deploy-preview.yml +++ b/.github/workflows/deploy-preview.yml @@ -13,7 +13,7 @@ permissions: {} jobs: deploy-preview: - uses: JorisJonkers-dev/github-workflows/.github/workflows/deploy-validate.yml@0f169298dcacbc45da3b02a4e87fcd50ebf4be9f # v0.14.2 + uses: JorisJonkers-dev/github-workflows/.github/workflows/deploy-validate.yml@5b5e93dc81846cac4e13118e7280fd81a143685e # v0.15.1 with: deploy-dir: platform schema-version: 0.20.0 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 91a7afa..73a4b95 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -171,7 +171,7 @@ jobs: # -- 3. Render and publish the deploy artifact ---------------------------- publish-deploy-artifact: needs: [resolve-image-lock] - uses: JorisJonkers-dev/github-workflows/.github/workflows/deploy-artifact.yml@ea7a9a1c52870c2ae3db08cd077d54dbef48aa3d # v0.14.0 + uses: JorisJonkers-dev/github-workflows/.github/workflows/deploy-artifact.yml@5b5e93dc81846cac4e13118e7280fd81a143685e # v0.15.1 with: ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }} artifact-name: agent-runtime diff --git a/platform/render-local.sh b/platform/render-local.sh index 85f933a..890fd29 100755 --- a/platform/render-local.sh +++ b/platform/render-local.sh @@ -20,7 +20,6 @@ # NODE_AUTH_TOKEN) first: export GITHUB_TOKEN="$(gh auth token)". set -euo pipefail -DEPLOY_CHECK_VERSION="0.15.0" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" cd "$REPO_ROOT" @@ -45,6 +44,18 @@ value_from_workflows() { | awk '{print $2}' | sort -u | head -1 } +# deploy-check ships from the same repository as the reusable workflows and is +# released with them, so the version comment on the deploy-validate pin is the +# version to run. Hardcoding it here is what let the previous copy rot. +DEPLOY_CHECK_VERSION="$( + grep -rhoE 'github-workflows/\.github/workflows/deploy-(validate|artifact)\.yml@[0-9a-f]{40} # v[0-9]+\.[0-9]+\.[0-9]+' \ + .github/workflows/*.yml 2>/dev/null \ + | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+$' | tr -d v \ + | sort -t. -k1,1n -k2,2n -k3,3n | tail -1 +)" +[ -n "$DEPLOY_CHECK_VERSION" ] \ + || fail "no '# vX.Y.Z' comment on a deploy-validate/deploy-artifact pin in .github/workflows/*.yml" + SCHEMA_VERSION="$(value_from_workflows 'schema-version')" CONTEXT_REF="$(value_from_workflows 'context-ref')" [ -n "$SCHEMA_VERSION" ] || fail "no schema-version found in .github/workflows/*.yml"