Files
parity-lib/ci/pipeline-doctor.sh
Oleks 413f78c365 fix(pipeline-doctor): accept branch-deploy as a valid dev-tag guard (#204)
Branch-deploy repos (event: push + branch:, tagging each push from
CI_COMMIT_*/pipeline-number) are a deliberate continuous-deploy guard, not
the absence of one. cms-plugins/emdash/kotkanagrilli -> 9/9; trio + self-test
unchanged; a tagless default-version publish still FAILs.
2026-06-04 21:29:53 +03:00

370 lines
17 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# pipeline-doctor (cluster #193): assert the parity contract for a repo.
#
# Given a repo path, it statically checks that the repo follows the corrected
# emmett#44 parity standard, and prints the local-equivalent commands a dev can
# run. It is read-only: it never touches the registry and never needs a token.
#
# Usage: pipeline-doctor [--strict] [<repo-path>] (default repo: .)
# --strict also exit non-zero if any WARN was emitted (gate the soft checks).
# Exit: 0 = all required checks pass (and, in --strict, no warnings);
# 1 = one or more required checks failed (or a warning under --strict).
#
# This is a GATE-READY check (cluster #191/#193): a .woodpecker.yaml step or a
# server pre-receive hook can call `pipeline-doctor --strict <repo>` and rely on
# the exit code. It is read-only: it never touches the registry, never needs a
# token, and never prints one.
set -euo pipefail
STRICT=0
SELFTEST=0
REPO="."
for a in "$@"; do
case "$a" in
--strict) STRICT=1 ;;
--self-test) SELFTEST=1 ;;
-h | --help)
echo "Usage: pipeline-doctor [--strict] [<repo-path>]"
echo " Assert the parity contract for <repo-path> (default: .)."
echo " --strict exit non-zero on any WARN as well as any FAIL."
echo " --self-test run the token-leak heuristic fixtures and exit."
exit 0
;;
-*)
echo "error: unknown option '$a' (try --help)" >&2
exit 2
;;
*) REPO="$a" ;;
esac
done
# --self-test (#199): exercise the two corrected heuristics on synthetic ci
# fixtures so a regression in the token-contract / leak-scan logic is caught here
# (and by a CI step) rather than by silently re-breaking 9 real repos. Each case
# builds a throwaway repo, runs THIS script on it, and asserts PASS/FAIL.
if [ "$SELFTEST" -eq 1 ]; then
self="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
st_fail=0
# The fixture token references are ASSEMBLED from $d at runtime so the raw
# bytes of THIS file never contain a literal `echo "$TOKEN"` or a bare
# `set -x` line — otherwise the doctor's own leak/xtrace scan would flag
# pipeline-doctor.sh when run against parity-lib itself (#199). $sx is the
# xtrace directive, likewise assembled to dodge the self-scan.
d='$'
sx="set -""x" # the literal `set -x`, split so THIS file's bytes don't carry it
mk() { # mk <dir> <body-line(s) with $d for a literal dollar>
mkdir -p "$1/ci"
{
printf '%s\n' '#!/usr/bin/env bash' 'set -euo pipefail' \
"PUBLISH=\"${d}{PUBLISH:-0}\"" 'resolve_token() {' \
" if [ -n \"${d}{REGISTRY_TOKEN:-}\" ]; then printf '%s' \"${d}REGISTRY_TOKEN\"; return; fi" \
" pass \"${d}PASS_ENTRY\"" '}' "tok=\"${d}(resolve_token)\""
printf '%b\n' "$2"
} >"$1/ci/local.sh"
}
expect() { # expect <label> <PASS|FAIL> <grep-pat> <repo>
got="$({ bash "$self" "$4" 2>&1 || true; } | grep -E "$3" | { grep -oE 'PASS|FAIL' || true; } | { head -1 || true; })"
if [ "$got" = "$2" ]; then
printf ' ok %s (%s)\n' "$1" "$2"
else
printf ' FAIL %s: expected %s, got %s\n' "$1" "$2" "${got:-<none>}"
st_fail=1
fi
}
td="$(mktemp -d)"
login='docker login "'"$d"'H" -u u --password-stdin >/dev/null'
# token contract: `pass "$VAR"` indirection is a VALID resolution form.
mk "$td/tok" "echo \"${d}tok\" | $login"
expect "token-contract pass-var accepted" PASS 'token = ' "$td/tok"
# leak: password-stdin idiom, flag on a CONTINUATION line, is NOT a leak.
mk "$td/wrap" "echo \"${d}tok\" | docker login \"${d}H\" \\\\\n\t-u u --password-stdin >/dev/null"
expect "password-stdin wrapped is not a leak" PASS 'token leak' "$td/wrap"
# leak: helm registry login --password-stdin wrapped is NOT a leak.
mk "$td/helm" "echo \"${d}REGISTRY_TOKEN\" | helm registry login \"${d}H\" \\\\\n\t--username u --password-stdin >/dev/null"
expect "helm --password-stdin wrapped is not a leak" PASS 'token leak' "$td/helm"
# REAL leak: bare echo of the token to stdout MUST still fail.
mk "$td/bare" "echo \"${d}tok\""
expect "bare echo to stdout is a leak" FAIL 'token leak' "$td/bare"
# REAL leak: echo the token to a FILE MUST still fail.
mk "$td/file" "echo \"${d}REGISTRY_TOKEN\" > /tmp/leaked"
expect "echo to a file is a leak" FAIL 'token leak' "$td/file"
# REAL leak: a standalone `set -x` in a token script MUST still fail.
mk "$td/setx" "$sx\necho \"${d}tok\" | $login"
expect "set -x in token script is a leak" FAIL 'token leak' "$td/setx"
rm -rf "$td"
if [ "$st_fail" -eq 0 ]; then
echo "pipeline-doctor self-test: all heuristic fixtures pass."
exit 0
fi
echo "pipeline-doctor self-test: FAILURES above." >&2
exit 1
fi
REPO="$(cd "$REPO" && pwd)"
FLAKE="$REPO/flake.nix"
# Non-flake parity form (cluster #193, issue #193). A repo with no root flake.nix
# is still a VALID parity repo if it ships the ci-script form: a ci/local.sh (or a
# ci/build.sh + ci/publish.sh pair) that is the single local==CI entrypoint, with
# a thin .woodpecker.yaml that calls those scripts. In that case the "consumes
# parity / single entrypoint" contract is satisfied via the CI-SCRIPT path instead
# of a parity-lib flake input, and the token-leak + #191 no-set-x scans below still
# apply to its ci/*.sh.
CI_SCRIPT_FORM=0
if [ -f "$REPO/ci/local.sh" ] || { [ -f "$REPO/ci/build.sh" ] && [ -f "$REPO/ci/publish.sh" ]; }; then
CI_SCRIPT_FORM=1
fi
fail=0
warn_n=0
pass_n=0
note() { printf ' %s %s\n' "$1" "$2"; }
ok() {
pass_n=$((pass_n + 1))
note "PASS" "$1"
}
bad() {
fail=$((fail + 1))
note "FAIL" "$1"
}
warn() {
warn_n=$((warn_n + 1))
note "WARN" "$1"
}
echo "pipeline-doctor: $REPO"
if [ ! -f "$FLAKE" ]; then
if [ "$CI_SCRIPT_FORM" -eq 1 ]; then
# Valid non-flake parity repo: assert via the ci-script form below.
flake_txt=""
else
echo "FAIL: no flake.nix and no ci/local.sh (or ci/build.sh + ci/publish.sh) at $REPO" >&2
exit 1
fi
else
flake_txt="$(cat "$FLAKE")"
fi
# Documented escape-hatch (cluster #196): a repo that must keep a hand-written
# Dockerfile/OCI pipeline (BuildKit, etc.) instead of a parity-lib builder is
# allowed to opt out PROVIDED it ships a `ci/local.sh` that gives a dev the same
# local==CI entrypoint. When present, the archetype + parity-lib asserts are
# DOWNGRADED to warnings (the escape-hatch is the contract); the token / dev-tag
# / dry-run / set-x asserts below still apply in full to ci/local.sh.
ESCAPE_HATCH=0
if [ -f "$REPO/ci/local.sh" ]; then
ESCAPE_HATCH=1
fi
# Text of the ci/*.sh + .woodpecker.yaml — the archetype marker and the single
# local==CI entrypoint of a non-flake (ci-script form) repo live here, not in a
# flake.nix (cluster #193, issue #193).
ci_txt=""
if [ -d "$REPO/ci" ]; then
ci_txt="$(cat "$REPO"/ci/*.sh 2>/dev/null || true)"
fi
# Woodpecker config may be a single file OR a .woodpecker/ DIRECTORY of per-arch
# workflows (cluster #202) — fold both forms into ci_txt so the dev-tag-guard /
# refs/tags/v* trigger is seen either way.
for wp in "$REPO/.woodpecker.yaml" "$REPO/.woodpecker.yml" \
"$REPO"/.woodpecker/*.yaml "$REPO"/.woodpecker/*.yml; do
[ -f "$wp" ] && ci_txt="$ci_txt
$(cat "$wp")"
done
# 1. archetype declared (a publish/stage app whose name encodes the arch, or an
# explicit archetype marker comment) — in the flake OR, for a non-flake
# ci-script repo, in its ci/*.sh / .woodpecker.yaml.
if printf '%s' "$flake_txt" | grep -Eq 'archetype|stage-[a-z0-9_]+|publish-[a-z0-9_]+'; then
ok "archetype declared (stage-*/publish-* app or archetype marker present)"
elif [ "$CI_SCRIPT_FORM" -eq 1 ] && printf '%s' "$ci_txt" | grep -Eq 'archetype|ci/build\.sh|ci/publish\.sh|ci/local\.sh'; then
ok "archetype declared (non-flake ci-script form: ci/*.sh entrypoints, cluster #193)"
elif [ "$ESCAPE_HATCH" -eq 1 ]; then
warn "no parity-lib archetype, but ci/local.sh escape-hatch present (cluster #196)"
else
bad "no archetype: expected a stage-<arch>/publish-<arch> app or 'archetype' marker (or a ci-script form)"
fi
# 2. consumes parity-lib (the shared module) OR is a valid non-flake ci-script
# repo whose ci/local.sh (or ci/build.sh + ci/publish.sh) is the single
# local==CI entrypoint, OR a documented ci/local.sh escape-hatch.
CONSUMES_PARITY=0
if printf '%s' "$flake_txt" | grep -Eq 'parity-lib|parity\.lib|mk(PyPiWheel|S390xNpm|GenericBinary|Nix2Container|GoBinary|Helm|AtticClosure)Publish'; then
CONSUMES_PARITY=1
fi
if [ "$CONSUMES_PARITY" -eq 1 ]; then
ok "consumes parity-lib (input or one of the mk*Publish builders)"
elif [ "$CI_SCRIPT_FORM" -eq 1 ]; then
ok "single local==CI entrypoint via ci-script form (ci/local.sh or ci/build.sh + ci/publish.sh, cluster #193)"
elif [ "$ESCAPE_HATCH" -eq 1 ]; then
warn "does not consume parity-lib, but ci/local.sh escape-hatch present (cluster #196)"
else
bad "does not consume parity-lib: inline the input and use a mk*Publish builder (or ship a ci-script form)"
fi
# Checks 36 below are GUARANTEED by parity-lib for a consumer: the token
# resolution, dev-tag guard, dry-run gate and meta.description all live inside
# the mk*Publish-generated apps (in the parity-lib store path), not in the
# consumer's flake.nix text. So when the repo consumes parity-lib, those four
# contracts are satisfied BY DELEGATION and we don't re-grep the consumer's
# source for them. They are only grepped for a non-consumer (escape-hatch or
# bespoke) repo, where the consumer's own ci/*.sh must carry them.
# 3. token = $REGISTRY_TOKEN with pass fallback, and never printed.
token_src="$flake_txt"
if [ -d "$REPO/ci" ]; then
token_src="$token_src
$(cat "$REPO"/ci/*.sh 2>/dev/null || true)"
fi
if [ "$CONSUMES_PARITY" -eq 1 ]; then
ok "token = \$REGISTRY_TOKEN with pass fallback (delegated to parity-lib)"
elif printf '%s' "$token_src" | grep -Eq 'REGISTRY_TOKEN' &&
printf '%s' "$token_src" | grep -Eq 'parity_resolve_token|pass (show )?infra/gitea/personal_access_token_packages_rw|pass (show )?["'"'"']?[$]'; then
# Accept the secure INDIRECTION form too: `pass "$PASS_ENTRY"` / `pass "$VAR"`
# (a quoted/unquoted shell var, optionally via `pass show`) is a valid
# token-resolution path, not just a hard-coded `pass <literal/path>` (#199).
ok "token = \$REGISTRY_TOKEN with pass fallback"
else
bad "token contract missing: need \$REGISTRY_TOKEN + pass fallback (parity_resolve_token)"
fi
# token never printed: flag an obvious leak. A redacting pipe (sed s/$tok/.../)
# is fine, so only flag a bare echo/printf of the token NOT piped to a redactor,
# or an enabled `set -x` (which would trace the token). The leak pattern uses a
# literal '$' built via a variable so this lib stays single-quote clean (SC2016).
dollar='[$]'
# Anchor on echo/printf used as a COMMAND (line-leading, after `;`, `|`, `&&`,
# `(` or `then/do/else`) so the doctor doesn't flag the regex literals it
# carries inside string assignments.
leak_pat="(^|[;|&(]|then |do |else )[[:space:]]*(echo|printf)[^|]*${dollar}(REGISTRY_TOKEN|TOKEN|tok|token)([^A-Za-z0-9_]|$)"
leak=0
if printf '%s' "$token_src" | grep -Eq "^[[:space:]]*set -x([[:space:]]|$)"; then leak=1; fi
# Flatten line-continuations (`\`-newline) AND fold the pipe target onto the echo
# line, so the blessed `echo "$TOKEN" | docker login ... --password-stdin` idiom
# is seen as ONE logical command even when the flag sits on a continuation line
# (#199). Without this, the stdin-feed exemption below never matched a wrapped
# `--password-stdin` and the secure idiom was flagged as a leak.
flat_src="$(printf '%s\n' "$token_src" |
awk '{ if (prev ~ /\\$/) { sub(/\\$/, "", prev); prev = prev " " $0; }
else { if (NR > 1) print prev; prev = $0; } }
END { if (NR > 0) print prev; }' |
awk '{ if (NR > 1 && prev ~ /\|[[:space:]]*$/) { prev = prev " " $0; }
else { if (NR > 1) print prev; prev = $0; } }
END { if (NR > 0) print prev; }')"
# Exclude: redactions, stdin-feeds (a token piped to a login via
# `--password-stdin` / `--pass-stdin` goes to STDIN, not the log), the doctor's
# own $token_src var, an escaped '\$' (a token NAME in a message, not its value),
# and the sanctioned `printf '%s' "$TOKEN"` capture idiom (the resolver returns
# the token on stdout for a caller to capture — the one blessed emit).
if printf '%s' "$flat_src" |
grep -E "$leak_pat" |
grep -vE "REDACTED|sed |stdin|--password-stdin|--pass-stdin|token_src" |
grep -vqE 'printf .%s. "[$](REGISTRY_TOKEN|TOKEN|tok|token)"|\\[$]'; then
leak=1
fi
if [ "$leak" -eq 0 ]; then
ok "no obvious token leak (token never bare-echoed; no set -x)"
else
bad "possible token leak: a token var is echo/printf'd un-redacted, or set -x is enabled"
fi
# 3b. (security sweep, cluster #191) no `set -x` in a TOKEN-BEARING ci/*.sh.
# Scoped per-file: a script that references a registry token (REGISTRY_TOKEN /
# CI_REGISTRY_TOKEN / an 'Authorization: token' header) must NOT enable xtrace,
# which would echo the token to the build log. A `set -x` in a token-free helper
# (e.g. a pure version parser) is not flagged here.
xtrace_hits=""
if [ -d "$REPO/ci" ]; then
for s in "$REPO"/ci/*.sh; do
[ -f "$s" ] || continue
if grep -Eq '(REGISTRY_TOKEN|Authorization: token)' "$s" &&
grep -Eq '^[[:space:]]*set -[a-z]*x' "$s"; then
xtrace_hits="$xtrace_hits ${s#"$REPO"/}"
fi
done
fi
if [ -z "$xtrace_hits" ]; then
ok "no set -x in token-bearing ci/*.sh scripts"
else
bad "set -x in token-bearing ci/*.sh:$xtrace_hits (xtrace would echo the token)"
fi
# 4. dev-tag guard present. For a non-flake ci-script repo the equivalent guard
# is the dry-run-DEFAULT publish + the .woodpecker.yaml `refs/tags/v*` gate:
# nothing mutates the registry unless a v* tag run flips PUBLISH/DRY_RUN.
if [ "$CONSUMES_PARITY" -eq 1 ]; then
ok "dev-tag guard present (delegated to parity-lib parity_devtag_guard)"
elif printf '%s' "$token_src" | grep -Eq 'parity_devtag_guard|refusing to publish without an explicit'; then
ok "dev-tag guard present"
elif [ "$CI_SCRIPT_FORM" -eq 1 ] &&
printf '%s' "$ci_txt" | grep -Eq 'DRY_RUN|dry-run|PUBLISH' &&
printf '%s' "$ci_txt" | grep -Eq 'refs/tags/v\*|event:[[:space:]]*tag|tag:'; then
ok "dev-tag guard equivalent (ci-script dry-run default + .woodpecker refs/tags/v* gate, cluster #193)"
elif printf '%s' "$ci_txt" | grep -Eq 'event:[[:space:]]*push' &&
printf '%s' "$ci_txt" | grep -Eq 'branch:' &&
printf '%s' "$ci_txt" | grep -Eq 'CI_COMMIT_BRANCH|CI_COMMIT_SHA|CI_PIPELINE_NUMBER|commit-branch|commit-sha|pipeline-number'; then
# Branch-deploy (cluster #204): a web-app/CMS repo that deploys on push to a
# branch (develop/staging/production) and tags each push from CI_COMMIT_* /
# pipeline-number — a deterministic per-push tag, NOT a clobbering default
# version. This is a deliberate continuous-deploy guard, not the absence of one.
ok "dev-tag guard equivalent (branch-deploy: per-push tags from CI_COMMIT_*/pipeline-number, cluster #204)"
else
bad "no dev-tag guard: a default-version --publish could clobber the registry"
fi
# 5. a --dry-run default exists.
if [ "$CONSUMES_PARITY" -eq 1 ]; then
ok "dry-run default exists (delegated to parity-lib parity_parse_args)"
elif printf '%s' "$token_src" | grep -Eq 'parity_parse_args|DRY-RUN|dry-run|PUBLISH:-0|PUBLISH=\$\{PUBLISH:-0\}'; then
ok "dry-run default exists (publish mutates only on --publish/PUBLISH=1)"
else
bad "no dry-run default: publish must NOT mutate the registry by default"
fi
# 6. apps carry meta.description. N/A for a non-flake ci-script repo (it exposes
# shell-script entrypoints, not flake apps with meta); its self-documenting
# ci/*.sh header comments are the equivalent.
if [ "$CONSUMES_PARITY" -eq 1 ]; then
ok "apps carry meta.description (delegated to parity-lib mkApp)"
elif printf '%s' "$flake_txt" | grep -Eq 'meta\.description|meta = \{'; then
ok "apps carry meta.description"
elif [ "$CI_SCRIPT_FORM" -eq 1 ]; then
ok "meta.description N/A (non-flake ci-script form; ci/*.sh are the documented entrypoints)"
else
bad "apps missing meta.description"
fi
# 7. enumerable publish-* naming. For a non-flake ci-script repo the equivalent
# is the ci/build.sh + ci/publish.sh (or ci/local.sh) entrypoint set.
pubs="$(printf '%s' "$flake_txt" | grep -oE '(stage|publish)(-[a-z0-9_]+)?' | sort -u || true)"
if [ -n "$pubs" ]; then
ok "enumerable publish-* / stage-* app naming:"
printf '%s\n' "$pubs" | sed 's/^/ /'
elif [ "$CI_SCRIPT_FORM" -eq 1 ]; then
ok "enumerable ci-script entrypoints (non-flake parity form, cluster #193):"
for s in ci/local.sh ci/build.sh ci/publish.sh; do
[ -f "$REPO/$s" ] && printf ' %s\n' "$s"
done
else
bad "no enumerable publish-*/stage-* apps found"
fi
echo ""
echo "Local-equivalent commands (what CI runs is identical):"
echo " nix run .#stage-<arch> # BUILD-parity: stage to .parity-stage, no registry"
echo " nix run .#publish-<arch> # stage + push that arch (DRY-RUN; add --publish)"
echo " nix run .#publish-index # build-free multi-arch assembly from pushed digests"
echo " nix run .#publish # all local arches + index; :latest = digest copy of :TAG"
echo " nix run .#push-staged # replay .parity-stage to the registry (cluster-was-down)"
echo " nix run .#verify-digest # OCI only: print local manifest digests (parity assertion)"
echo ""
echo "Summary: $pass_n passed, $fail failed, $warn_n warned."
if [ "$fail" -ne 0 ]; then
echo "RESULT: parity contract VIOLATED ($fail failing check(s))." >&2
exit 1
fi
if [ "$STRICT" -eq 1 ] && [ "$warn_n" -ne 0 ]; then
echo "RESULT: --strict and $warn_n warning(s) present — failing the gate." >&2
exit 1
fi
echo "RESULT: parity contract satisfied."