generated from mathias/template-go-web
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SZkhi49hfeSqBp5aZJNXsM
This commit is contained in:
+18
-2
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 ]
|
||||||
@@ -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 ]
|
||||||
Reference in New Issue
Block a user