fix(ci): gate build and deploy behind a committed .deploy-enabled marker
CD / Detect unsubstituted template (push) Successful in 0s
CD / Lint / Test / Vet (push) Successful in 6s
CD / Build & Import (push) Skipped
CD / Deploy via GitOps (push) Skipped

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZkhi49hfeSqBp5aZJNXsM
This commit is contained in:
2026-10-10 10:53:27 +02:00
co-authored by Claude Sonnet 5.5
parent dd3f607744
commit 54e1db68f3
5 changed files with 239 additions and 2 deletions
+18 -2
View File
@@ -16,6 +16,7 @@ jobs:
runs-on: self-hosted runs-on: self-hosted
outputs: outputs:
is_template: ${{ steps.detect.outputs.is_template }} is_template: ${{ steps.detect.outputs.is_template }}
deploy_enabled: ${{ steps.deploy.outputs.deploy_enabled }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- id: detect - id: detect
@@ -31,6 +32,21 @@ jobs:
# and asserts it still answers correctly. # and asserts it still answers correctly.
run: | run: |
echo "is_template=$(bash scripts/is-template.sh go.mod)" >> "$GITHUB_OUTPUT" 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: check:
name: Lint / Test / Vet name: Lint / Test / Vet
@@ -73,7 +89,7 @@ jobs:
build: build:
name: Build & Import name: Build & Import
needs: [guard, check] 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 runs-on: self-hosted
outputs: outputs:
image-tag: ${{ steps.meta.outputs.sha-tag }} image-tag: ${{ steps.meta.outputs.sha-tag }}
@@ -113,7 +129,7 @@ jobs:
deploy: deploy:
name: Deploy via GitOps name: Deploy via GitOps
needs: [guard, build] 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 runs-on: self-hosted
steps: steps:
- name: Update image tag in infra repo - name: Update image tag in infra repo
+3
View File
@@ -27,6 +27,9 @@ tasks:
# The template guard decides whether CI runs at all in every child repo, # 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. # so its regression test belongs in the same gate as the Go tests.
- bash scripts/tests/is-template_test.sh - 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 # The three Go commands above cannot see cmd/spatial-agent-app -- Go's
# ./... expansion skips directories whose names start with an underscore. # ./... expansion skips directories whose names start with an underscore.
# This gates the substituted result, which is what a child actually runs. # This gates the substituted result, which is what a child actually runs.
+27
View File
@@ -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
+127
View File
@@ -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 <file> <job>: the lines from " <job>:" 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 <file>: 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 <name> <substring check_wiring must report as FAIL> <sed expression>
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 ]
+64
View File
@@ -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 <name> <dir> <want>: stdout must equal <want> 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 ]