From 54e1db68f3a04a86d565642c0abbd6026a28c602 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 10 Oct 2026 10:53:27 +0200 Subject: [PATCH] fix(ci): gate build and deploy behind a committed .deploy-enabled marker Every push to main since the first substituted commit was red at "Update image tag in infra repo": sed: can't read k3s/apps/spatial-agent-app/ deployment.yaml. mathias/infra has no such manifest and the repo is a hello-world MVP that should not deploy yet. Port of doodle 32acff8/006c108/3a95cc9: guard outputs deploy_enabled from scripts/deploy-enabled.sh (regular file, not symlink); build and deploy require it to equal 'true', so a missing output fails closed. check and the template guard are unchanged. No marker is committed. Wiring test seen red on the old cd.yml (no gate), green after; removing the clause from build.if or deploy.if makes it fail. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01SZkhi49hfeSqBp5aZJNXsM --- .gitea/workflows/cd.yml | 20 ++++- Taskfile.yml | 3 + scripts/deploy-enabled.sh | 27 ++++++ scripts/tests/cd-wiring_test.sh | 127 +++++++++++++++++++++++++++ scripts/tests/deploy-enabled_test.sh | 64 ++++++++++++++ 5 files changed, 239 insertions(+), 2 deletions(-) create mode 100644 scripts/deploy-enabled.sh create mode 100644 scripts/tests/cd-wiring_test.sh create mode 100644 scripts/tests/deploy-enabled_test.sh diff --git a/.gitea/workflows/cd.yml b/.gitea/workflows/cd.yml index 8fb3bc0..c15650e 100644 --- a/.gitea/workflows/cd.yml +++ b/.gitea/workflows/cd.yml @@ -16,6 +16,7 @@ jobs: runs-on: self-hosted outputs: is_template: ${{ steps.detect.outputs.is_template }} + deploy_enabled: ${{ steps.deploy.outputs.deploy_enabled }} steps: - uses: actions/checkout@v4 - id: detect @@ -31,6 +32,21 @@ jobs: # and asserts it still answers correctly. run: | echo "is_template=$(bash scripts/is-template.sh go.mod)" >> "$GITHUB_OUTPUT" + - id: deploy + # build and deploy run only when .deploy-enabled is committed. Before + # that there is no service: mathias/infra has no + # k3s/apps/spatial-agent-app, so deploy failed on every push to main and + # build pushed images for nothing. To deploy later: create the infra + # manifest first (an infra-lane issue), then commit .deploy-enabled. + # The logic is in scripts/deploy-enabled.sh so a test can run it. + # A missing or misspelt output compares unequal to 'true' below, so + # this fails closed. The script runs on its own line under set -e, not + # inside the echo: a command substitution there swallows its exit 2 and + # a missing directory would look like a deliberate "false". + run: | + set -euo pipefail + enabled="$(bash scripts/deploy-enabled.sh .)" + echo "deploy_enabled=${enabled}" >> "$GITHUB_OUTPUT" check: name: Lint / Test / Vet @@ -73,7 +89,7 @@ jobs: build: name: Build & Import needs: [guard, check] - if: needs.guard.outputs.is_template != 'true' && github.event_name != 'pull_request' + if: needs.guard.outputs.is_template != 'true' && needs.guard.outputs.deploy_enabled == 'true' && github.event_name != 'pull_request' runs-on: self-hosted outputs: image-tag: ${{ steps.meta.outputs.sha-tag }} @@ -113,7 +129,7 @@ jobs: deploy: name: Deploy via GitOps needs: [guard, build] - if: needs.guard.outputs.is_template != 'true' && github.ref == 'refs/heads/main' && github.event_name == 'push' + if: needs.guard.outputs.is_template != 'true' && needs.guard.outputs.deploy_enabled == 'true' && github.ref == 'refs/heads/main' && github.event_name == 'push' runs-on: self-hosted steps: - name: Update image tag in infra repo diff --git a/Taskfile.yml b/Taskfile.yml index b1968bf..89795ab 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -27,6 +27,9 @@ tasks: # The template guard decides whether CI runs at all in every child repo, # so its regression test belongs in the same gate as the Go tests. - bash scripts/tests/is-template_test.sh + # Decide whether build and deploy run at all; same reasoning. + - bash scripts/tests/deploy-enabled_test.sh + - bash scripts/tests/cd-wiring_test.sh # The three Go commands above cannot see cmd/spatial-agent-app -- Go's # ./... expansion skips directories whose names start with an underscore. # This gates the substituted result, which is what a child actually runs. diff --git a/scripts/deploy-enabled.sh b/scripts/deploy-enabled.sh new file mode 100644 index 0000000..56e790f --- /dev/null +++ b/scripts/deploy-enabled.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Has this repo opted in to build and deploy? +# +# Prints "true" or "false". cd.yml's guard job reads it; build and deploy run +# only when it says true (#3). Opt in by committing a regular file named +# .deploy-enabled -- after the infra manifest (k3s/apps/spatial-agent-app) exists, not before. +# +# The marker must be a regular file and NOT a symlink. `[ -f ]` follows +# symlinks, so it alone would say true for a link to any regular file. +# +# A missing directory exits 2 with nothing on stdout. Answering "false" there +# would make a typo in the workflow look like a deliberate opt-out. +set -euo pipefail + +dir="${1:-.}" + +if [ ! -d "$dir" ]; then + echo "deploy-enabled: no such directory: $dir" >&2 + exit 2 +fi + +marker="$dir/.deploy-enabled" +if [ -f "$marker" ] && [ ! -L "$marker" ]; then + echo "true" +else + echo "false" +fi diff --git a/scripts/tests/cd-wiring_test.sh b/scripts/tests/cd-wiring_test.sh new file mode 100644 index 0000000..e912f7d --- /dev/null +++ b/scripts/tests/cd-wiring_test.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# Tests for the deploy_enabled wiring in .gitea/workflows/cd.yml (#3, #15). +# +# scripts/tests/deploy-enabled_test.sh proves the script answers correctly. It +# cannot see the workflow, so deleting the clause that makes build and deploy +# depend on that answer left every test green and brought back the original +# failure (pr-gatekeeper, PR #14, round 1). +# +# Two parts: +# 1. check_wiring: assertions on the TEXT of cd.yml. They prove the clauses +# are present, not that Gitea evaluates them; a run read by job proves that. +# 2. mutation cases: copies of cd.yml with one clause broken, each of which +# check_wiring must reject. A check that has never been seen to fail on a +# broken file is not evidence (#15 lists the breakages an earlier version +# let through). +# +# Plain awk/grep/sed: the CI runner is not known to have a YAML library. +# +# Usage: ./scripts/tests/cd-wiring_test.sh [path/to/cd.yml] + +set -uo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +WF="${1:-$ROOT/.gitea/workflows/cd.yml}" + +pass=0; fail=0 +ok() { printf ' ok %s\n' "$1"; pass=$((pass+1)); } +bad() { printf ' FAIL %s\n %s\n' "$1" "${2:-}"; fail=$((fail+1)); } + +tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT + +# job_block : the lines from " :" up to the next top-level job key. +job_block() { + awk -v job="$2" ' + $0 ~ "^ "job":" { on=1; next } + on && /^ [A-Za-z0-9_-]+:/ { exit } + on { print } + ' "$1" +} + +# check_wiring : prints " ok ..." / " FAIL ..." lines; the caller counts. +check_wiring() { + local f="$1" job + for job in guard check build deploy; do + [ -n "$(job_block "$f" "$job")" ] && printf ' ok job %s is found\n' "$job" \ + || printf ' FAIL job %s is found\n' "$job" + done + + local guard out_line step_id + guard="$(job_block "$f" guard)" + out_line="$(printf '%s\n' "$guard" | grep -E '^ deploy_enabled: ' || true)" + printf '%s\n' "$out_line" | grep -qE '^ deploy_enabled: \$\{\{ steps\.[a-z_]+\.outputs\.deploy_enabled \}\}' \ + && echo " ok guard declares a deploy_enabled output" \ + || echo " FAIL guard declares a deploy_enabled output" + + # The output must point at a step that exists in the guard job. A typo in the + # id leaves the output empty, which fails closed but silently. + step_id="$(printf '%s\n' "$out_line" | sed -nE 's/.*steps\.([a-z_]+)\.outputs\..*/\1/p')" + if [ -n "$step_id" ] && printf '%s\n' "$guard" | grep -qE "^ - id: $step_id\$"; then + echo " ok the output's step id '$step_id' exists in the guard job" + else + echo " FAIL the output's step id '${step_id:-?}' exists in the guard job" + fi + + # The clause is read from the if: line with any trailing comment removed, and + # must be AND-ed in: a clause that lives only in a comment, or behind ||, does + # not gate anything. + local cond + for job in build deploy; do + cond="$(job_block "$f" "$job" | grep -E '^ if:' | sed -E 's/[[:space:]]+#.*$//' || true)" + if printf '%s\n' "$cond" | grep -qF "&& needs.guard.outputs.deploy_enabled == 'true' &&" \ + && ! printf '%s\n' "$cond" | grep -qF '||'; then + echo " ok $job.if requires deploy_enabled == 'true'" + else + echo " FAIL $job.if requires deploy_enabled == 'true'" + fi + done + + printf '%s\n' "$guard" | grep -qE '^ +set -euo pipefail$' \ + && echo " ok the guard step runs under set -euo pipefail" \ + || echo " FAIL the guard step runs under set -euo pipefail" + + job_block "$f" check | grep -q 'deploy_enabled' \ + && echo " FAIL check.if does not mention deploy_enabled" \ + || echo " ok check.if does not mention deploy_enabled" + + if job_block "$f" guard | grep -qE 'echo .*\$\(.*deploy-enabled\.sh'; then + echo " FAIL the guard step does not call deploy-enabled.sh inside echo" + else + echo " ok the guard step does not call deploy-enabled.sh inside echo" + fi +} + +echo "cd.yml wiring tests" +if [ ! -f "$WF" ]; then bad "workflow exists" "$WF missing"; echo; echo "$pass passed, $fail failed"; exit 1; fi + +real="$(check_wiring "$WF")" +printf '%s\n' "$real" +pass=$((pass + $(printf '%s\n' "$real" | grep -c '^ ok'))) +fail=$((fail + $(printf '%s\n' "$real" | grep -c '^ FAIL'))) + +# mutant +mutant() { + local name="$1" want="$2" expr="$3" out + sed -E "$expr" "$WF" > "$tmp/mutant.yml" + if cmp -s "$WF" "$tmp/mutant.yml"; then + bad "mutation: $name" "the sed expression changed nothing; the mutation no longer applies to cd.yml"; return + fi + out="$(check_wiring "$tmp/mutant.yml")" + if printf '%s\n' "$out" | grep -F ' FAIL' | grep -qF "$want"; then ok "rejects: $name" + else bad "rejects: $name" "expected a FAIL naming '$want'"; fi +} + +BUILD_CLAUSE="needs.guard.outputs.deploy_enabled == 'true' && github.event_name != 'pull_request'" +mutant "clause only in a trailing comment on build.if" "build.if" \ + "s/ && $BUILD_CLAUSE\$/ \&\& github.event_name != 'pull_request' # needs.guard.outputs.deploy_enabled == 'true'/" +mutant "|| in place of && before the clause on build.if" "build.if" \ + "s/is_template != 'true' && $BUILD_CLAUSE/is_template != 'true' || $BUILD_CLAUSE/" +mutant "step id mistyped in the guard output" "step id" \ + "s/steps\.deploy\.outputs\.deploy_enabled/steps.deplyo.outputs.deploy_enabled/" +mutant "set -euo pipefail removed from the guard step" "set -euo pipefail" \ + "/^ set -euo pipefail\$/d" +mutant "a job named deploy2 leaks into build's block" "build.if" \ + "s/ && $BUILD_CLAUSE\$/ \&\& github.event_name != 'pull_request'/; s/^ deploy:\$/ deploy2:/" + +echo +echo "$pass passed, $fail failed" +[ "$fail" -eq 0 ] diff --git a/scripts/tests/deploy-enabled_test.sh b/scripts/tests/deploy-enabled_test.sh new file mode 100644 index 0000000..611e37c --- /dev/null +++ b/scripts/tests/deploy-enabled_test.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Tests for scripts/deploy-enabled.sh +# +# cd.yml gates build and deploy on this script's answer (#3). It exists because +# main was red on every push: deploy patches k3s/apps/spatial-agent-app/deployment.yaml in +# mathias/infra, which does not exist, and build kept pushing images for a +# service that is not deployed. +# +# The failure that matters is answering "true" when deployment is not meant to +# be on, so the cases below are about what must NOT enable it: a directory, a +# symlink, an absent marker. +# +# Usage: ./scripts/tests/deploy-enabled_test.sh + +set -uo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SCRIPT="$ROOT/scripts/deploy-enabled.sh" + +pass=0; fail=0 +ok() { printf ' ok %s\n' "$1"; pass=$((pass+1)); } +bad() { printf ' FAIL %s\n %s\n' "$1" "${2:-}"; fail=$((fail+1)); } + +tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT +echo "deploy-enabled tests" + +if [ -f "$SCRIPT" ]; then ok "script exists" +else bad "script exists" "$SCRIPT missing"; echo; echo "$pass passed, $fail failed"; exit 1; fi + +# want_answer : stdout must equal and exit must be 0. +want_answer() { + local name="$1" dir="$2" want="$3" got rc + got="$(bash "$SCRIPT" "$dir" 2>/dev/null)"; rc=$? + if [ "$got" = "$want" ] && [ "$rc" -eq 0 ]; then ok "$name" + else bad "$name" "got '$got' (exit $rc), want '$want' (exit 0)"; fi +} + +mkdir "$tmp/regular"; echo "enabled" > "$tmp/regular/.deploy-enabled" +mkdir "$tmp/absent" +mkdir "$tmp/empty"; : > "$tmp/empty/.deploy-enabled" +mkdir "$tmp/isdir"; mkdir "$tmp/isdir/.deploy-enabled" +mkdir "$tmp/symlink"; echo "enabled" > "$tmp/symlink/target"; ln -s target "$tmp/symlink/.deploy-enabled" +mkdir "$tmp/dangling"; ln -s nowhere "$tmp/dangling/.deploy-enabled" + +want_answer "a regular marker file enables deployment" "$tmp/regular" true +want_answer "no marker means deployment stays off" "$tmp/absent" false +want_answer "an empty marker file still enables (content is ignored)" "$tmp/empty" true +want_answer "a marker that is a directory does not enable" "$tmp/isdir" false +want_answer "a symlink to a regular file does not enable" "$tmp/symlink" false +want_answer "a dangling symlink does not enable" "$tmp/dangling" false + +# A missing directory is not "off": answering false would let a typo in the +# workflow look like a deliberate opt-out. Exit 2 and print nothing on stdout. +got="$(bash "$SCRIPT" "$tmp/nope" 2>/dev/null)"; rc=$? +if [ -z "$got" ] && [ "$rc" -eq 2 ]; then ok "a missing directory exits 2 with empty stdout" +else bad "a missing directory exits 2 with empty stdout" "got '$got' (exit $rc)"; fi + +# Default directory is the current one. +got="$(cd "$tmp/regular" && bash "$SCRIPT" 2>/dev/null)" +[ "$got" = "true" ] && ok "with no argument it inspects the current directory" \ + || bad "with no argument it inspects the current directory" "got '$got'" + +echo +echo "$pass passed, $fail failed" +[ "$fail" -eq 0 ]