413f78c365
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.
370 lines
17 KiB
Bash
Executable File
370 lines
17 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
|
||
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 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|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."
|