db0bf3b9ab
Adds the attic-closure archetype builder (build closure + attic push, no registry artifact) so caddy/overlay-xonsh/flake-hub/woodpecker-peek share one implementation. Adds non-flake mode to pipeline-doctor so ci-script repos (gitea-mcp, helms) pass the gate. Self-check 9/9; gitea-mcp now passes.
277 lines
12 KiB
Bash
Executable File
277 lines
12 KiB
Bash
Executable File
#!/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
|
||
REPO="."
|
||
for a in "$@"; do
|
||
case "$a" in
|
||
--strict) STRICT=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."
|
||
exit 0
|
||
;;
|
||
-*)
|
||
echo "error: unknown option '$a' (try --help)" >&2
|
||
exit 2
|
||
;;
|
||
*) REPO="$a" ;;
|
||
esac
|
||
done
|
||
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
|
||
for wp in "$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 3–6 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'; then
|
||
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
|
||
# Exclude: redactions, stdin-feeds, 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 — that is the one blessed place a token is emitted).
|
||
if printf '%s' "$token_src" |
|
||
grep -E "$leak_pat" |
|
||
grep -vE "REDACTED|sed |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)"
|
||
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."
|