diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..e34da68 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: MPL-2.0 +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +# CodeRabbit is advisory in this repository. The estate signing policy forbids +# bot pushes to PR branches; every landed commit must be GitHub-verified. +# Disable CodeRabbit's code-changing finishing touches. This does not reduce +# the GitHub App's permissions or disable a separately enabled Autopilot task. +reviews: + finishing_touches: + docstrings: + enabled: false + unit_tests: + enabled: false + autofix: + enabled: false + fix_ci: + enabled: false + resolve_merge_conflict: + enabled: false diff --git a/.github/scripts/check-workflow-contracts.sh b/.github/scripts/check-workflow-contracts.sh new file mode 100644 index 0000000..be8ca6f --- /dev/null +++ b/.github/scripts/check-workflow-contracts.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Wrapper for the YAML-aware workflow contract checker. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if ! command -v python3 >/dev/null 2>&1; then + echo "ERROR: python3 is required to inspect workflow contracts" >&2 + exit 2 +fi +exec python3 "$SCRIPT_DIR/check_workflow_contracts.py" "$@" diff --git a/.github/scripts/check_workflow_contracts.py b/.github/scripts/check_workflow_contracts.py new file mode 100644 index 0000000..969b7f1 --- /dev/null +++ b/.github/scripts/check_workflow_contracts.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MPL-2.0 +"""Narrow, fail-closed workflow contracts for hyperpolymath/.github. + +This is not a replacement for `gh actions-lock --verify`. It catches the +estate-specific regressions that a syntactically valid lock can still hide: +moved reusable owners, non-immutable reusable pins, missing SPDX headers, +synthetic lock edges for remote reusable workflows, and inline notification +action SHAs that disagree with actions.lock. + +Exit codes: + 0 every inspected contract passed + 1 at least one contract violation + 2 not inspected (missing input, unreadable YAML, missing PyYAML) +""" + +from __future__ import annotations + +import pathlib +import re +import sys + +try: + import yaml +except ImportError: # pragma: no cover - environment failure, not a pass + print("ERROR: PyYAML is required to inspect workflow contracts", file=sys.stderr) + sys.exit(2) + + +class UniqueKeyLoader(yaml.SafeLoader): + """SafeLoader that rejects duplicate mapping keys instead of hiding them.""" + + +def _unique_mapping(loader: UniqueKeyLoader, node: yaml.MappingNode, deep: bool = False) -> dict: + loader.flatten_mapping(node) + result: dict = {} + for key_node, value_node in node.value: + key = loader.construct_object(key_node, deep=deep) + if key in result: + raise ValueError(f"duplicate YAML key: {key!r}") + result[key] = loader.construct_object(value_node, deep=deep) + return result + + +UniqueKeyLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _unique_mapping) + +REMOTE_REUSABLE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/\.github/workflows/[^@]+@(.+)$") +SHA40 = re.compile(r"^[0-9a-f]{40}$") +SMTP_REF = re.compile(r"^hyperpolymath/smtp-notify-action@(v[0-9]+\.[0-9]+\.[0-9]+)$") +LOCAL_SCANNER = "./.github/workflows/secret-scanner-reusable.yml" +VENDORED_SCANNER_PIN = "8d1a2aab4211493cf42f147f2080f024605797dc" +INLINE_SHA = re.compile(r"sha1-([0-9a-f]{40})") + +failures: list[str] = [] + + +def fail(message: str) -> None: + """Report a contract violation and count it for the final exit status.""" + failures.append(message) + + +def load_yaml(path: pathlib.Path): + try: + text = path.read_text(encoding="utf-8") + return yaml.load(text, Loader=UniqueKeyLoader), text + except Exception as exc: + print(f"ERROR: cannot inspect {path}: {exc}", file=sys.stderr) + sys.exit(2) + + +def iter_uses(node, path=()): + """Yield (value, zero-based line, path) for every scalar `uses:` key.""" + if isinstance(node, yaml.MappingNode): + for key_node, value_node in node.value: + if not isinstance(key_node, yaml.ScalarNode): + continue + key = key_node.value + if key == "uses" and isinstance(value_node, yaml.ScalarNode): + yield value_node.value, value_node.start_mark.line, path + yield from iter_uses(value_node, path + (key,)) + elif isinstance(node, yaml.SequenceNode): + for index, item in enumerate(node.value): + yield from iter_uses(item, path + (str(index),)) + + +def main() -> int: + root = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else pathlib.Path.cwd() + wf_dir = root / ".github" / "workflows" + lock_path = wf_dir / "actions.lock" + + if not wf_dir.is_dir(): + print(f"ERROR: no workflow directory: {wf_dir}", file=sys.stderr) + return 2 + if not lock_path.is_file(): + print(f"ERROR: no actions.lock: {lock_path}", file=sys.stderr) + return 2 + + workflows = sorted(list(wf_dir.glob("*.yml")) + list(wf_dir.glob("*.yaml"))) + if not workflows: + print("ERROR: no workflow files", file=sys.stderr) + return 2 + + lock, _ = load_yaml(lock_path) + if not isinstance(lock, dict): + print("ERROR: actions.lock is not a mapping", file=sys.stderr) + return 2 + lock_workflows = lock.get("workflows") + dependencies = lock.get("dependencies") + if not isinstance(lock_workflows, dict) or not isinstance(dependencies, dict): + print("ERROR: actions.lock workflows/dependencies are not mappings", file=sys.stderr) + return 2 + + for wf in workflows: + rel = f".github/workflows/{wf.name}" + try: + first_line = wf.read_text(encoding="utf-8").splitlines()[0] + except Exception as exc: + print(f"ERROR: cannot read {rel}: {exc}", file=sys.stderr) + return 2 + if first_line != "# SPDX-License-Identifier: MPL-2.0": + fail(f"{rel}: SPDX-License-Identifier must be the first line") + + if rel not in lock_workflows: + fail(f"{rel}: missing actions.lock workflows entry") + entry = [] + else: + entry = lock_workflows[rel] + if not isinstance(entry, list): + fail(f"{rel}: actions.lock workflow entry is not a list") + entry = [] + + data, text = load_yaml(wf) + try: + composed = yaml.compose(text, Loader=yaml.SafeLoader) + except Exception as exc: + print(f"ERROR: cannot compose {rel}: {exc}", file=sys.stderr) + return 2 + lines = text.splitlines() + uses = list(iter_uses(composed)) if composed is not None else [] + + has_step_use = False + has_remote_reusable = False + for value, line_no, path in uses: + if not isinstance(value, str): + fail(f"{rel}:{line_no + 1}: uses value is not a string") + continue + if "steps" in path: + has_step_use = True + + if value.startswith("hyperpolymath/standards/"): + fail(f"{rel}:{line_no + 1}: pre-transfer hyperpolymath/standards coordinate: {value}") + + if value.startswith("./.github/workflows/"): + local_target = root / value[2:] + if not local_target.is_file(): + fail(f"{rel}:{line_no + 1}: local reusable workflow does not exist: {value}") + + if "/.github/workflows/" in value and not value.startswith(("./", "$/")): + has_remote_reusable = True + match = REMOTE_REUSABLE.match(value) + if not match: + fail(f"{rel}:{line_no + 1}: remote reusable must name an exact ref: {value}") + elif not SHA40.match(match.group(1)): + fail(f"{rel}:{line_no + 1}: remote reusable must be pinned to a 40-hex commit: {value}") + + # gh-actions-lock v0.4.0 does not follow remote job-level reusable + # workflows. A synthetic through-ref under a reusable-only caller is + # an unused pin and fails --verify. Keep the reviewed closure TSV as + # the trust record, but do not put that edge in the caller lock entry. + if has_remote_reusable and not has_step_use and entry: + fail(f"{rel}: reusable-only workflow must have an empty actions.lock entry") + + for pin in entry: + if not isinstance(pin, str): + fail(f"{rel}: non-string actions.lock pin: {pin!r}") + elif pin not in dependencies: + fail(f"{rel}: actions.lock pin has no dependency record: {pin}") + + if wf.name == "secret-scanner.yml": + scanner_uses = [v for v, _line, path in uses if isinstance(v, str) and "jobs" in path and "steps" not in path] + if scanner_uses != [LOCAL_SCANNER]: + fail(f"{rel}: must call the local vendored scanner {LOCAL_SCANNER} (Actions allowlist blocks the remote standards reusable)") + vendored = wf_dir / "secret-scanner-reusable.yml" + if not vendored.is_file(): + fail(f"{rel}: vendored scanner missing: {vendored}") + else: + vtext = vendored.read_text(encoding="utf-8") + if f"at {VENDORED_SCANNER_PIN}." not in vtext: + fail(f"{rel}: vendored scanner missing provenance pin {VENDORED_SCANNER_PIN}") + if "ref: ${{ job.workflow_sha }}" in vtext: + fail(f"{rel}: vendored scanner must pin standards baseline explicitly, not job.workflow_sha") + + if wf.name == "push-email-notify.yml": + smtp_count = 0 + for value, line_no, _path in uses: + if not isinstance(value, str): + continue + if value.startswith("hyperpolymath/smtp-notify-action@"): + smtp_count += 1 + match = SMTP_REF.match(value) + if not match: + fail(f"{rel}:{line_no + 1}: smtp-notify-action must use an exact semver tag: {value}") + continue + else: + continue + tag = match.group(1) + raw_line = lines[line_no] if line_no < len(lines) else "" + inline = INLINE_SHA.search(raw_line) + if not inline: + fail(f"{rel}:{line_no + 1}: missing inline sha1 pin comment") + continue + sha = inline.group(1) + key = f"hyperpolymath/smtp-notify-action@{tag}" + dep = dependencies.get(key) + if not isinstance(dep, dict): + fail(f"actions.lock: missing dependency record for {key}") + elif dep.get("commit") != f"sha1-{sha}": + fail(f"actions.lock: {key} commit does not match inline sha1-{sha}") + if value not in entry: + fail(f"actions.lock: {rel} does not reference {value}") + if smtp_count == 0: + fail(f"{rel}: missing smtp-notify-action uses line") + + for key, dep in dependencies.items(): + if not isinstance(dep, dict): + fail(f"actions.lock: dependency {key} is not a mapping") + continue + commit = dep.get("commit") + if not isinstance(commit, str) or not re.match(r"^sha1-[0-9a-f]{40}$", commit): + fail(f"actions.lock: dependency {key} has invalid commit: {commit!r}") + ref = dep.get("ref") + key_sha = key.rsplit("@", 1)[-1] + if SHA40.match(key_sha) and isinstance(ref, str) and ref == key_sha and "uses" not in dep: + fail(f"actions.lock: dependency {key} repeats its own SHA in ref") + + if failures: + for message in failures: + print(f"ERROR: {message}", file=sys.stderr) + print(f"{len(failures)} workflow contract failure(s)", file=sys.stderr) + return 1 + print("workflow contracts: OK") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/requirements.txt b/.github/scripts/requirements.txt new file mode 100644 index 0000000..07eacc3 --- /dev/null +++ b/.github/scripts/requirements.txt @@ -0,0 +1,76 @@ +# SPDX-License-Identifier: MPL-2.0 +# Contract-checker dependency. Hashes cover every PyPI artifact for this release. +PyYAML==6.0.3 \ + --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \ + --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \ + --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \ + --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \ + --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \ + --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \ + --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \ + --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \ + --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \ + --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \ + --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \ + --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \ + --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \ + --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \ + --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \ + --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \ + --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \ + --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \ + --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ + --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 \ + --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ + --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ + --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ + --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ + --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ + --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ + --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ + --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ + --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \ + --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ + --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ + --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ + --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ + --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ + --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ + --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ + --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ + --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ + --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \ + --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ + --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ + --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ + --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ + --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \ + --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ + --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ + --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ + --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ + --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ + --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ + --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ + --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ + --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ + --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ + --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ + --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ + --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \ + --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \ + --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \ + --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \ + --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \ + --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \ + --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \ + --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \ + --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \ + --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \ + --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \ + --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ + --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ + --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \ + --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \ + --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \ + --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f diff --git a/.github/scripts/tests/workflow-contracts-test.sh b/.github/scripts/tests/workflow-contracts-test.sh new file mode 100644 index 0000000..3a01baf --- /dev/null +++ b/.github/scripts/tests/workflow-contracts-test.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +set -euo pipefail + +SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/check-workflow-contracts.sh" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +pass=0 +fail=0 + +fixture() { + root="$WORK/$1" + mkdir -p "$root/.github/workflows" + cat > "$root/.github/workflows/actions.lock" <<'LOCK' +version: 'v0.0.3' +workflows: + '.github/workflows/push-email-notify.yml': + - 'hyperpolymath/smtp-notify-action@v0.5.1' + '.github/workflows/secret-scanner.yml': [] + '.github/workflows/secret-scanner-reusable.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' +dependencies: + 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1': + ref: 'v7.0.1' + commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' + owner_id: 44036562 + repo_id: 197814629 + 'hyperpolymath/smtp-notify-action@v0.5.1': + ref: 'v0.5.1' + commit: 'sha1-e384daf6167a7a7567642d6a62060106244d2413' +LOCK + cat > "$root/.github/workflows/push-email-notify.yml" <<'YAML' +# SPDX-License-Identifier: MPL-2.0 +name: Push email notification +jobs: + notify: + steps: + - uses: hyperpolymath/smtp-notify-action@v0.5.1 # NOSONAR — pin authority is actions.lock (sha1-e384daf6167a7a7567642d6a62060106244d2413) +YAML + cat > "$root/.github/workflows/secret-scanner.yml" <<'YAML' +# SPDX-License-Identifier: MPL-2.0 +name: Secret Scanner +jobs: + scan: + uses: ./.github/workflows/secret-scanner-reusable.yml +YAML + cat > "$root/.github/workflows/secret-scanner-reusable.yml" <<'YAML' +# SPDX-License-Identifier: MPL-2.0 +# Vendored from metadatastician/standards/.github/workflows/secret-scanner-reusable.yml +# at 8d1a2aab4211493cf42f147f2080f024605797dc. +name: Secret Scanner Reusable +on: + workflow_call: +jobs: + gitleaks: + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 +YAML +} + +expect() { + name="$1"; want="$2"; root="$3" + set +e + bash "$SCRIPT" "$root" >"$WORK/out" 2>&1 + got=$? + set -e + if [ "$got" -eq "$want" ]; then + printf 'PASS %s\n' "$name"; pass=$((pass + 1)) + else + printf 'FAIL %s: wanted exit %s, got %s\n' "$name" "$want" "$got" >&2 + cat "$WORK/out" >&2 + fail=$((fail + 1)) + fi +} + +fixture valid +expect valid 0 "$WORK/valid" + +fixture stale_sha +sed -i 's/sha1-e384daf6167a7a7567642d6a62060106244d2413/sha1-ede1191ef6ff3ac02c4f4d9efdf837ee517e11d7/' "$WORK/stale_sha/.github/workflows/push-email-notify.yml" +expect stale_inline_sha 1 "$WORK/stale_sha" + +fixture moved_owner +sed -i 's#uses: ./.github/workflows/secret-scanner-reusable.yml#uses: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@8d1a2aab4211493cf42f147f2080f024605797dc#' "$WORK/moved_owner/.github/workflows/secret-scanner.yml" +expect moved_reusable_owner 1 "$WORK/moved_owner" + +fixture floating_pin +sed -i 's#uses: ./.github/workflows/secret-scanner-reusable.yml#uses: metadatastician/standards/.github/workflows/secret-scanner-reusable.yml@main#' "$WORK/floating_pin/.github/workflows/secret-scanner.yml" +expect floating_reusable_pin 1 "$WORK/floating_pin" + +fixture quoted_moved_owner +sed -i 's#uses: ./.github/workflows/secret-scanner-reusable.yml#uses: "hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@8d1a2aab4211493cf42f147f2080f024605797dc"#' "$WORK/quoted_moved_owner/.github/workflows/secret-scanner.yml" +expect quoted_moved_reusable_owner 1 "$WORK/quoted_moved_owner" + +fixture quoted_floating_pin +sed -i "s#uses: ./.github/workflows/secret-scanner-reusable.yml#uses: 'metadatastician/standards/.github/workflows/secret-scanner-reusable.yml@main'#" "$WORK/quoted_floating_pin/.github/workflows/secret-scanner.yml" +expect quoted_floating_reusable_pin 1 "$WORK/quoted_floating_pin" + +fixture remote_scanner_blocked +sed -i 's#uses: ./.github/workflows/secret-scanner-reusable.yml#uses: metadatastician/standards/.github/workflows/secret-scanner-reusable.yml@8d1a2aab4211493cf42f147f2080f024605797dc#' "$WORK/remote_scanner_blocked/.github/workflows/secret-scanner.yml" +expect remote_scanner_allowlist_contract 1 "$WORK/remote_scanner_blocked" + +fixture missing_vendored_scanner +rm "$WORK/missing_vendored_scanner/.github/workflows/secret-scanner-reusable.yml" +expect missing_vendored_scanner 1 "$WORK/missing_vendored_scanner" + +fixture missing_spdx +sed -i '1s/.*/# managed/' "$WORK/missing_spdx/.github/workflows/secret-scanner.yml" +expect missing_spdx_first_line 1 "$WORK/missing_spdx" + +fixture missing_lock_entry +sed -i "/secret-scanner.yml':/d" "$WORK/missing_lock_entry/.github/workflows/actions.lock" +expect missing_lock_workflow_entry 1 "$WORK/missing_lock_entry" + +fixture synthetic_reusable_edge +sed -i 's#uses: ./.github/workflows/secret-scanner-reusable.yml#uses: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@8d1a2aab4211493cf42f147f2080f024605797dc#' "$WORK/synthetic_reusable_edge/.github/workflows/secret-scanner.yml" +sed -i "s#'.github/workflows/secret-scanner.yml': \[\]#'.github/workflows/secret-scanner.yml':\n - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'#" "$WORK/synthetic_reusable_edge/.github/workflows/actions.lock" +expect synthetic_reusable_only_lock_edge 1 "$WORK/synthetic_reusable_edge" + +fixture second_smtp_stale +cat >> "$WORK/second_smtp_stale/.github/workflows/push-email-notify.yml" <<'YAML' + second: + steps: + - uses: hyperpolymath/smtp-notify-action@v0.5.1 # sha1-ede1191ef6ff3ac02c4f4d9efdf837ee517e11d7 +YAML +expect second_smtp_use_stale_sha 1 "$WORK/second_smtp_stale" + +fixture second_smtp_missing_sha +cat >> "$WORK/second_smtp_missing_sha/.github/workflows/push-email-notify.yml" <<'YAML' + second: + steps: + - uses: hyperpolymath/smtp-notify-action@v0.5.1 +YAML +expect second_smtp_use_missing_inline_sha 1 "$WORK/second_smtp_missing_sha" + +fixture second_smtp_floating +cat >> "$WORK/second_smtp_floating/.github/workflows/push-email-notify.yml" <<'YAML' + second: + steps: + - uses: hyperpolymath/smtp-notify-action@main +YAML +expect second_smtp_use_floating_ref 1 "$WORK/second_smtp_floating" + +fixture duplicate_yaml_key +printf '\njobs: {}\n' >> "$WORK/duplicate_yaml_key/.github/workflows/secret-scanner.yml" +expect duplicate_yaml_key_not_inspected 2 "$WORK/duplicate_yaml_key" + +printf '%s passed, %s failed\n' "$pass" "$fail" +[ "$fail" -eq 0 ] diff --git a/.github/settings.yml b/.github/settings.yml index 8441d10..b917055 100644 --- a/.github/settings.yml +++ b/.github/settings.yml @@ -4,8 +4,8 @@ repository: has_wiki: true default_branch: main allow_squash_merge: true - allow_merge_commit: true - allow_rebase_merge: true + allow_merge_commit: false + allow_rebase_merge: false allow_auto_merge: true delete_branch_on_merge: true allow_update_branch: true diff --git a/.github/workflows/actions-lock.closure.tsv b/.github/workflows/actions-lock.closure.tsv new file mode 100644 index 0000000..413ab52 --- /dev/null +++ b/.github/workflows/actions-lock.closure.tsv @@ -0,0 +1,8 @@ +# Reviewed closure of remote reusable workflows (standards#968, #1290). +# Produced by scripts/check-reusable-closure.sh --write-baseline; a change +# here is a change to what this repository trusts, so review the diff. +# workflowreusable-callref reached at startup +# Secret Scanner run 41543261728: metadatastician/standards reusable blocked +# by this repo's Actions allowlist; vendored locally at the standards SHA below. +.github/workflows/secret-scanner.yml ./.github/workflows/secret-scanner-reusable.yml vendored from metadatastician/standards@8d1a2aab4211493cf42f147f2080f024605797dc +.github/workflows/secret-scanner-reusable.yml actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 v7.0.1 diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 7155887..10f7a37 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -8,7 +8,16 @@ workflows: '.github/workflows/push-email-notify.yml': - 'hyperpolymath/smtp-notify-action@v0.5.1' '.github/workflows/secret-scanner.yml': [] + '.github/workflows/secret-scanner-reusable.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + '.github/workflows/workflow-contracts.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' dependencies: + 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1': + ref: 'v7.0.1' + commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' + owner_id: 44036562 + repo_id: 197814629 'hyperpolymath/smtp-notify-action@v0.5.1': ref: 'v0.5.1' commit: 'sha1-e384daf6167a7a7567642d6a62060106244d2413' diff --git a/.github/workflows/label-triage.yml b/.github/workflows/label-triage.yml index 814a192..fc79947 100644 --- a/.github/workflows/label-triage.yml +++ b/.github/workflows/label-triage.yml @@ -1,5 +1,5 @@ -# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. name: Label Triage # Classify newly-filed issues against the estate label taxonomy. diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index 83ab941..af34c6b 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -1,5 +1,5 @@ -# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. name: Labels # Applies the canonical estate label set from .github/labels.json. diff --git a/.github/workflows/push-email-notify.yml b/.github/workflows/push-email-notify.yml index e0f1d22..9e48973 100644 --- a/.github/workflows/push-email-notify.yml +++ b/.github/workflows/push-email-notify.yml @@ -1,4 +1,3 @@ -# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. # Dormant push-email notification. ARMED by setting the repo variable @@ -41,7 +40,7 @@ jobs: timeout-minutes: 5 steps: - name: Send push notification email - uses: hyperpolymath/smtp-notify-action@v0.5.1 # NOSONAR — pin authority is actions.lock (sha1-ede1191ef6ff3ac02c4f4d9efdf837ee517e11d7) + uses: hyperpolymath/smtp-notify-action@v0.5.1 # NOSONAR — pin authority is actions.lock (sha1-e384daf6167a7a7567642d6a62060106244d2413) with: server_address: ${{ secrets.SMTP_HOST }} server_port: ${{ secrets.SMTP_PORT }} diff --git a/.github/workflows/secret-scanner-reusable.yml b/.github/workflows/secret-scanner-reusable.yml new file mode 100644 index 0000000..30bb9ed --- /dev/null +++ b/.github/workflows/secret-scanner-reusable.yml @@ -0,0 +1,830 @@ +# SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. +# Vendored from metadatastician/standards/.github/workflows/secret-scanner-reusable.yml +# at 8d1a2aab4211493cf42f147f2080f024605797dc. +# +# Why this is local: Secret Scanner run 41543261728 failed at startup because +# this repository's Actions allowlist rejects reusable workflows that are not +# from a hyperpolymath-owned repository. The canonical metadatastician/standards +# coordinate is therefore blocked here, and the pre-transfer hyperpolymath/standards +# coordinate is not followed by GitHub Actions. This local copy preserves the +# canonical scanner while remaining inside the allowlist. +# +# Baseline fetches are pinned to the vendored standards SHA below instead of +# job.workflow_sha, because job.workflow_sha would name this repository's commit, +# not a standards commit. Re-vendor deliberately when standards changes; do not +# let this copy drift silently. +# secret-scanner-reusable.yml — Reusable secret-scanner bundle. +# +# Consolidates the per-repo `secret-scanner.yml` workflow (estate-wide: +# 281 deployments, 54 unique blob SHAs, 19% structural drift — top 4 +# SHAs cover 69% of repos, top 6 cover 79%). Sampled top + long-tail +# variants all carry the same 3-job structure (gitleaks + rust-secrets + +# shell-secrets) at 75-81 lines; drift is action-SHA / whitespace churn. +# +# Rationale for gitleaks over trufflehog: +# - Gitleaks has better CVE coverage for common secret patterns +# - Trufflehog was removed as redundant; gitleaks catches what we need +# - Cost reduction: eliminates duplicate scanning +# +# Critical security debt this PR addresses: +# The `shell-secrets` job (added to the canonical 2026-05-21 in +# response to the Cloudflare token leak via `2-protocols/avow/ +# deploy-repos.sh`, which both `trufflehog --only-verified` and +# default gitleaks missed) is currently present in 0 of 16 sampled +# estate repos. The post-incident guardrail intended to catch the +# *next* leak has propagated to nothing. Consolidating behind this +# reusable force-promotes shell-secrets to all 281 repos on the next +# wrapper sweep. +# +# Design: +# - No per-call inputs other than `runs-on`. Each job self-conditions +# internally (rust-secrets exits early on no Cargo.toml; shell-secrets +# no-ops on no .sh/.bash files), so there is nothing for the caller +# to toggle. +# - Caller passes NO `secrets:` line. This reusable declares no +# `workflow_call.secrets` and references no `secrets.*` context: +# gitleaks runs as a pinned local binary (see the PERMISSIONS note), +# and the `GITHUB_TOKEN` the jobs act under is governed by +# `permissions:`, not by caller secret passing. `secrets: inherit` +# would forward every repository and organisation secret the caller +# can see (CWE-250) and buy nothing. An earlier version of this +# header claimed inherit was REQUIRED so `gitleaks/gitleaks-action` +# could see `secrets.GITHUB_TOKEN`; that claim died with #500 but +# kept propagating into generated callers estate-wide. Do not +# re-add it. (standards#1108 §6b) +# - Caller keeps its own `on:` triggers and `concurrency:` group, so +# the read-only cancel-superseded behaviour stays in the wrapper. +# - Trufflehog removed: gitleaks provides sufficient coverage at lower cost. +# +# PERMISSIONS — `contents: read` is the whole requirement. +# +# Every job here runs `actions/checkout` and then a self-contained binary or +# shell scan. There are no artifacts, no cache, no `gh api` calls and no +# workflow-run queries, so nothing needs `actions: read`, and since #500 +# replaced `gitleaks/gitleaks-action` with a pinned checksum-verified binary, +# nothing needs `pull-requests: write` either. +# +# This block previously instructed callers to grant `pull-requests: write` + +# `actions: read`. That guidance was correct for the pre-#500 action-based +# gitleaks job and became wrong when the binary replaced it — but it was left +# in place, and the workflow-level block below kept an unused `actions: read`. +# Because a called workflow cannot exceed its caller's *job-level* grant, a +# correctly least-privileged wrapper granting only `contents: read` was +# rejected at startup — which is exactly what happened to this repo's own +# `secret-scanner.yml`, silently disabling its secret scanning. +# +# Two rules that make that failure mode non-obvious, worth stating plainly: +# 1. A job-level `permissions:` block REPLACES the workflow-level one for +# that job — it does not merge with it. A caller with `actions: read` at +# the top and `contents: read` on the job grants `contents: read` only. +# 2. A rejected run produces NO check run at all — `gh pr checks` shows +# nothing and the annotations endpoint 404s. Diagnose with +# `gh run list --json conclusion`, never by the absence of a red tick. +# +# Every job below therefore declares `permissions: contents: read` explicitly +# rather than inheriting. Inheritance is what let an unused workflow-level +# grant leak into two jobs unnoticed; explicit per-job blocks mean a future +# widening of the workflow-level block cannot silently re-break callers. +# +# Caller example (wrapper) — this is the complete, minimal, correct shape: +# # SPDX-License-Identifier: MPL-2.0 +# name: Secret Scanner +# on: +# pull_request: +# push: +# branches: [main] +# concurrency: +# group: ${{ github.workflow }}-${{ github.ref }} +# cancel-in-progress: true +# permissions: +# contents: read +# jobs: +# scan: +# permissions: +# contents: read +# uses: ./.github/workflows/secret-scanner-reusable.yml +# # Deliberately NO `secrets:` line — the callee declares none and +# # needs none (see the Design note above). + +name: Secret Scanner (reusable) + +on: + workflow_call: + inputs: + runs-on: + description: Runner label for all secret-scanner jobs + type: string + required: false + default: ubuntu-latest + +# `actions: read` was removed here: no job in this file reads workflow runs, +# artifacts or caches, so it was an unused grant — and because a called +# workflow cannot exceed its caller's job-level grant, it broke every wrapper +# that correctly granted `contents: read` alone. See the PERMISSIONS note above. +permissions: + contents: read + +jobs: + gitleaks: + timeout-minutes: 20 + runs-on: ${{ inputs.runs-on }} + # Declared explicitly rather than inherited — see the PERMISSIONS note. + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Full history. One of the two gating passes below scans git history, + # and the default depth-1 clone would silently reduce it to a single + # commit. That pass asserts non-shallowness for itself rather than + # trusting this line to have taken effect. + fetch-depth: 0 + + # Gitleaks runs as a pinned, checksum-verified binary rather than via + # gitleaks/gitleaks-action. The action injects gitleaks into the runner + # tool-cache and prepends it to PATH, which is unreliable on self-hosted + # runners — that unreliability was the reason this step previously carried + # `continue-on-error: true`, i.e. the estate's primary secret scanner + # could not fail a PR. A pinned direct install is deterministic on every + # runner, so the scan gates for real. + - name: Install gitleaks (pinned + checksum-verified) + run: | + set -euo pipefail + GITLEAKS_VERSION=8.18.4 + SHA256=ba6dbb656933921c775ee5a2d1c13a91046e7952e9d919f9bac4cec61d628e7d + cd "$RUNNER_TEMP" + curl -fsSL -o gitleaks.tar.gz \ + "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" + echo "${SHA256} gitleaks.tar.gz" | sha256sum -c - + tar -xzf gitleaks.tar.gz gitleaks + chmod +x gitleaks + ./gitleaks version + + # Stage the ESTATE BASELINE allowlist into the workspace root. + # + # WHY THIS EXISTS. The scan below used to run with no `--config` at all, + # so gitleaks fell back to looking for `.gitleaks.toml` in the CALLER's + # tree. Measured 2026-08-06: none of the eleven repositories then failing + # this gate had one, so all eleven ran on bare default rules with no + # allowlist — and produced 154 findings of which every single one was a + # false positive (ML training corpora, vendored Yarn bundles, published + # conformance vectors, `.example` configs, and fixtures belonging to the + # estate's own secret DETECTORS). + # + # standards/.gitleaks.toml already anticipated precisely this: "turn the + # gate on with no allowlist, get a wall of red that is entirely noise, + # someone reverts, and the estate is back to a scanner that cannot fail." + # The file was written and then never wired to the scan. This wires it. + # + # It is staged at the WORKSPACE ROOT, not $RUNNER_TEMP, because gitleaks + # resolves `[extend] path` relative to the PROCESS CWD — verified, and the + # opposite of the intuitive "relative to the config file". Putting it here + # lets a repo-local `.gitleaks.toml` portably declare + # [extend] + # path = ".gitleaks-estate.toml" + # and inherit the whole baseline while adding only its own exemptions, so + # a repo-specific blind spot stays local to the repo that justified it + # instead of widening the allowlist for all 400+. + # + # THIS STEP RUNS FOR EVERY CONSUMER WITHOUT A `.gitleaks.toml`. It is the + # only thing that creates `.gitleaks-estate.toml` for that consumer — the + # large majority — and that is exactly the consumer whose CONFIG resolves + # to that file in the scan step below. Do not delete it as a duplicate of + # the gated "Estate baseline — is it referenced?" block further down; + # that block fires only for repos that DO have a local config, so + # removing this one leaves CONFIG naming a file nothing creates, across + # most of the estate. + # + # It is skipped when the repo HAS a `.gitleaks.toml`. That repo scans + # with its own config, and the gated block below stages the baseline + # only if that config `[extend]`s it. Staged anyway, the baseline sat + # inside the scanned tree under a config that does not allowlist the + # fixture values its own comments quote, and the gate reported the + # estate file as the caller's leak (echidnabot#185: three + # generic-api-key findings in .gitleaks-estate.toml). + # + # `ref` was `main` until 2026-09-04, and that was worse than a moving + # pin: the file this step fetches has been RENAMED under it. Before + # 86bad549 the shared allowlist WAS `.gitleaks.toml` (148 lines, 16 + # rules) and gen-1 sparse-checked-out that path; after the rename, + # `.gitleaks.toml` is standards' own 18-line dogfooding stub whose + # `[extend] path` points at `config/gitleaks/estate-baseline.toml` — a + # path the consumer does NOT have, because this step stages exactly one + # file. So every consumer pinned before the rename silently began + # staging a stub with a dangling `[extend]`. the vendored standards SHA + # below is the standards commit this copy was taken from, so a consumer + # receives the baseline as it stood at that SHA, and the path each SHA + # references exists at that SHA (verified for all six gen-1 SHAs, + # 2026-09-04). + - name: Fetch estate gitleaks baseline + if: hashFiles('.gitleaks.toml') == '' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: metadatastician/standards + ref: 8d1a2aab4211493cf42f147f2080f024605797dc # vendored standards pin + path: .standards-gitleaks + sparse-checkout: | + config/gitleaks/estate-baseline.toml + sparse-checkout-cone-mode: false + persist-credentials: false + + - name: Stage estate gitleaks baseline + if: hashFiles('.gitleaks.toml') == '' + run: | + set -euo pipefail + # Stage THE estate baseline, not standards' own repo config. + # standards/.gitleaks.toml is a CONSUMER of the baseline -- it carries + # `[extend] path = "config/gitleaks/estate-baseline.toml"`, and + # `[extend] path` resolves against the PROCESS working directory. Copied + # into a consumer, that path does not exist and gitleaks dies with + # FTL failed to load extended config ... no such file + # scanning nothing and failing the gate. The baseline file's own header + # documents this staging as the intended behaviour. + cp .standards-gitleaks/config/gitleaks/estate-baseline.toml .gitleaks-estate.toml + # Delete the tooling checkout BEFORE scanning: otherwise the scan + # walks standards' own tree and reports ITS files as the caller's. + # (The Hypatia baseline job above does the same thing for the same + # reason.) + rm -rf .standards-gitleaks + test -s .gitleaks-estate.toml + + # TWO gating passes run below and they are not redundant. Each sees a + # population the other cannot: + # * working tree (--no-git) -- sees untracked and generated files; + # cannot see a blob deleted in an earlier commit + # * git history -- sees committed-then-deleted secrets; + # cannot see a file that was never committed + # Both gate: a finding in either exits non-zero and fails the check. + # Full history costs a slower scan on every PR. That is the accepted + # price of a deleted secret no longer being permanently invisible. + # ── Estate baseline sharing ────────────────────────────────────────── + # A consumer repo can inherit the estate-wide allowlist instead of + # copying it, by writing in its own .gitleaks.toml: + # + # [extend] + # path = ".gitleaks-estate.toml" + # + # The point of the split is blast radius. An entry in the estate baseline + # blinds all 400+ repositories; an entry in a repo's own config blinds + # one, and sits beside the code it describes. Before this existed the + # only way to share the baseline was to copy it, so the two drifted and + # repo-local suppressions had nowhere to live except the shared file. + # + # Staged ONLY when actually referenced. A repo that does not extend it + # must not gain an unreferenced file inside its own scanned tree. + # + # `[extend] path` resolves against the PROCESS working directory, not the + # config file's directory (verified empirically: the same relative path + # fails when gitleaks is invoked from a subdirectory). The scan below runs + # from the workspace root, so the workspace root is where this lands. + - name: Estate baseline — is it referenced? + id: estate_baseline + run: | + set -euo pipefail + if [ -f .gitleaks.toml ] && \ + grep -qE "^[[:space:]]*path[[:space:]]*=[[:space:]]*[\"']\.gitleaks-estate\.toml[\"']" .gitleaks.toml; then + echo "referenced=true" >> "$GITHUB_OUTPUT" + echo "This repo extends the estate baseline; staging it." + else + echo "referenced=false" >> "$GITHUB_OUTPUT" + echo "No reference to .gitleaks-estate.toml; nothing staged." + fi + + # Pinned by construction: the vendored standards SHA below is the standards + # commit this reusable was copied from, so the baseline always matches + # this vendored workflow. Re-vendor both together; do not move one pin. + - name: Estate baseline — fetch + if: steps.estate_baseline.outputs.referenced == 'true' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: metadatastician/standards + ref: 8d1a2aab4211493cf42f147f2080f024605797dc # vendored standards pin + path: .estate-baseline-checkout + sparse-checkout: config/gitleaks + sparse-checkout-cone-mode: false + persist-credentials: false + + - name: Estate baseline — install + if: steps.estate_baseline.outputs.referenced == 'true' + run: | + set -euo pipefail + src=.estate-baseline-checkout/config/gitleaks/estate-baseline.toml + if [ ! -f "$src" ]; then + echo "::error::Estate baseline missing at $src. The repo's .gitleaks.toml extends .gitleaks-estate.toml, but the baseline could not be fetched — failing rather than scanning with a silently reduced config." + exit 1 + fi + cp "$src" .gitleaks-estate.toml + # Remove the checkout before scanning: otherwise standards' own tree + # is scanned as if it were the caller's, which is how a shared config + # turns into a wall of someone else's findings. + rm -rf .estate-baseline-checkout + echo "Staged .gitleaks-estate.toml ($(wc -l < .gitleaks-estate.toml) lines)." + + # + # --verbose is REQUIRED, not cosmetic. Without it gitleaks prints only + # "leaks found: N" and nothing else — no rule, no file, no line — and the + # run uploads no artifact, so the findings exist solely inside a discarded + # container. That is a gate which blocks a merge while telling the author + # nothing actionable, and it is why 154 false positives sat untriaged + # across eleven repositories. --redact is already set, so values stay + # masked: the log gains the rule, path and line, never the secret. + - name: Gitleaks secret scan (gating) + run: | + set -euo pipefail + + # A repo-local baseline wins outright — it is expected to `[extend]` + # the estate one, so "wins" still means "inherits". This mirrors what + # the AsciiDoc pass below already did, which was inconsistent with + # this step until now. + CONFIG=".gitleaks-estate.toml" + if [ -f .gitleaks.toml ]; then + CONFIG=".gitleaks.toml" + echo "Using repository .gitleaks.toml (extending the estate baseline)." + else + echo "Using estate baseline allowlist." + fi + + "$RUNNER_TEMP/gitleaks" detect \ + --source . \ + --no-git \ + --redact \ + --no-banner \ + --verbose \ + --config "$CONFIG" \ + --exit-code 1 + + # Full-history pass — ADDITIVE, not a replacement. Measured on a fixture + # with this exact pinned binary, the same secret in two placements: + # + # committed then deleted working tree: MISSED history: FOUND + # untracked file working tree: FOUND history: MISSED + # + # git mode scans commit diffs, so it cannot see a file that was never + # committed; --no-git walks the checked-out tree, so it cannot see a blob + # that is no longer in it. Dropping either pass REMOVES coverage. Keeping + # both is why this was added as a new step rather than by editing the one + # above, whose name and log lines existing triage keys on. + # + # KNOWN GAP, stated rather than implied: the AsciiDoc mirror pass below + # scans a filesystem copy with --no-git, so fetch-depth: 0 cannot reach + # it. An AsciiDoc secret that was committed and later deleted stays + # invisible. "The gate is full-history" holds for every file class but + # that one. + - name: Gitleaks secret scan — git history (gating) + run: | + set -euo pipefail + + # fetch-depth: 0 on the checkout is load-bearing HERE. If it ever + # regresses to the default depth-1 clone, detect would walk a single + # commit, find nothing and report a pass — a gate that cannot fail. + # Assert completeness from git itself: gitleaks' own "scanned N + # commits" line under-reports and is not proof of depth. + if [ "$(git rev-parse --is-shallow-repository)" != "false" ]; then + echo "::error::checkout is shallow -- a history scan here would be vacuous; refusing to report a pass" + exit 1 + fi + echo "history available to this scan: $(git rev-list --count HEAD) commits" + + # Same config resolution as the working-tree pass, duplicated rather + # than factored out so that step stays byte-identical. + CONFIG=".gitleaks-estate.toml" + if [ -f .gitleaks.toml ]; then + CONFIG=".gitleaks.toml" + fi + # A staging step that failed silently would leave gitleaks running a + # reduced config over almost nothing and calling the result green. + test -s "$CONFIG" + + "$RUNNER_TEMP/gitleaks" detect \ + --source . \ + --redact \ + --no-banner \ + --verbose \ + --config "$CONFIG" \ + --exit-code 1 + + # AsciiDoc blind-spot pass — MEASURED, not theoretical. + # + # gitleaks' DEFAULT global allowlist excludes paths matching + # `(jpg|gif|doc|docx|zip|xls|pdf|bin|svg|socket)$`. That `doc` alternative + # is an unanchored *substring* match on the filename, so it swallows + # `.adoc` and `.asciidoc` as collateral — AsciiDoc is not a binary format + # and was never the intended target. + # + # This estate writes documentation in AsciiDoc by default, so the blind + # spot covers ~40,000 files estate-wide (1,620 in standards alone). It is + # not hypothetical: a live write-scoped `glpat-` token sat undetected in + # `rhodium-pipeline/docs/QUICKSTART.adoc` while this scanner reported + # "no leaks found". Proof — identical content, four extensions: + # .txt -> DETECTED .md -> DETECTED + # .adoc -> MISSED .asciidoc -> MISSED + # + # The exclusion cannot be switched off from a config that sets + # `[extend] useDefault = true` (verified: neither `[allowlist] paths = []` + # nor adding an explicit custom rule re-enables scanning — the file is + # dropped at path level, before any rule runs). Turning `useDefault` off + # would forfeit all ~150 default rules, so instead this step scans a + # throwaway mirror in which each AsciiDoc file is copied to a `.txt` + # suffix. Same binary, same rules, same repo config — only the filename + # differs, which is the sole thing the allowlist keys on. + # + # Findings are re-mapped back to their real paths before reporting, so + # output never mentions the mirror. Runs as a separate step (not folded + # into the scan above) so the log distinguishes "leak in code" from + # "leak in docs". + - name: Gitleaks secret scan — AsciiDoc mirror (gating) + run: | + set -euo pipefail + + MIRROR="$RUNNER_TEMP/adoc-mirror" + REPORT="$RUNNER_TEMP/adoc-report.json" + rm -rf "$MIRROR"; mkdir -p "$MIRROR" + + # -print0/read -d '' so paths with spaces or newlines survive; the + # estate has directories with spaces in them. + found=0 + while IFS= read -r -d '' f; do + dest="$MIRROR/$(dirname "$f")" + mkdir -p "$dest" + cp "$f" "$dest/$(basename "$f").txt" + found=$((found + 1)) + done < <(find . -path ./.git -prune -o \ + \( -name '*.adoc' -o -name '*.asciidoc' \) -type f -print0) + + if [ "$found" -eq 0 ]; then + echo "No AsciiDoc files present — nothing to scan." + exit 0 + fi + echo "Scanning $found AsciiDoc file(s) via mirror." + + # Honour the caller's own baseline when it has one, so repo-specific + # allowlists still apply to docs exactly as they do to code — and + # otherwise fall back to the estate baseline, so docs and code are + # judged by the SAME rules. Previously this step honoured a repo + # config while the code scan above honoured none, which meant an + # allowlist entry could suppress a finding in a `.adoc` file and not + # in the `.md` file beside it. + # + # Absolute paths: this scan's --source is the MIRROR directory, so a + # relative config path would resolve against the mirror rather than + # the repository. `[extend] path = ".gitleaks-estate.toml"` inside a + # repo config resolves against the process CWD (still the repo root), + # which is why the estate baseline is staged there. + config_args=(--config "$PWD/.gitleaks-estate.toml") + if [ -f .gitleaks.toml ]; then + config_args=(--config "$PWD/.gitleaks.toml") + echo "Using repository .gitleaks.toml." + else + echo "Using estate baseline allowlist." + fi + + # Report first, decide after: --exit-code would abort before the paths + # can be mapped back, leaving an unreadable mirror path in the log. + set +e + "$RUNNER_TEMP/gitleaks" detect \ + --source "$MIRROR" \ + --no-git \ + --redact \ + --no-banner \ + --report-format json \ + --report-path "$REPORT" \ + "${config_args[@]}" + set -e + + # `grep` exits 1 when it matches nothing, which under `set -e` + + # `pipefail` aborts the step with status 1 — i.e. a repo whose docs are + # CLEAN would fail the gate. Caught by red-teaming the clean case; + # pipefail is disabled around the count for exactly this reason. + count=0 + if [ -s "$REPORT" ]; then + set +o pipefail + count=$(grep -o '"RuleID"' "$REPORT" 2>/dev/null | wc -l | tr -d ' ') + set -o pipefail + [ -n "$count" ] || count=0 + fi + + if [ "$count" -eq 0 ]; then + echo "No leaks found in AsciiDoc files." + exit 0 + fi + + # Findings list, always printed, values redacted. Paths are recovered + # from the mirror so the output names the real files. + echo "Findings (values redacted; paths are the REAL files, not the mirror):" + sed -n 's/.*"File": *"\([^"]*\)".*/\1/p' "$REPORT" \ + | sed -e "s|^${MIRROR}/||" -e 's|\.txt$||' \ + | sort -u \ + | sed 's/^/ - /' + echo + echo "These files are invisible to a default gitleaks run because the" + echo "default allowlist drops any path ending in 'doc'. Treat every hit" + echo "above as a real, previously unscanned leak." + echo + + # GRACE WINDOW — same cutoff as the docs and package-policy gates + # (standards#513), for the same reason: this step makes ~40,000 + # previously unscanned estate files visible at once, and every hit is + # pre-existing debt rather than a regression introduced by the PR in + # front of it. Turning it straight to blocking would red a large number + # of repos simultaneously for faults none of their authors introduced. + # + # It never prints a green tick while findings exist, and it never + # suppresses the list above — the warning is loud and the date is real. + # An unparseable date refuses to run rather than silently defaulting to + # the permissive branch, which is how a warn-first gate degrades into a + # fake one. + ENFORCE_ADOC_SCAN_FROM="${ENFORCE_ADOC_SCAN_FROM:-2026-08-21}" + TODAY="${ADOC_TODAY:-$(date -u +%Y-%m-%d)}" + for pair in "ENFORCE_ADOC_SCAN_FROM=$ENFORCE_ADOC_SCAN_FROM" "ADOC_TODAY=$TODAY"; do + case "${pair#*=}" in + [0-9][0-9][0-9][0-9]-[0-1][0-9]-[0-3][0-9]) ;; + *) echo "::error::${pair%%=*} is not YYYY-MM-DD; refusing to run."; exit 1 ;; + esac + done + + if [[ "$TODAY" < "$ENFORCE_ADOC_SCAN_FROM" ]]; then + echo "::warning::${count} secret(s) found in AsciiDoc files. This becomes a" \ + "BLOCKING failure on ${ENFORCE_ADOC_SCAN_FROM} (today is ${TODAY})." + echo "NOT YET ENFORCED: AsciiDoc findings present but inside the grace window." + exit 0 + fi + + echo "::error::${count} secret(s) detected in AsciiDoc files." + exit 1 + + # Rust-specific: hardcoded crypto values. Self-skips when no Cargo.toml + # is present, so this job is safe to run on non-Rust repos. + rust-secrets: + timeout-minutes: 10 + runs-on: ${{ inputs.runs-on }} + # Declared explicitly rather than inherited — see the PERMISSIONS note. + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Check for hardcoded secrets in Rust + env: + # Findings outside ./src (newly visible — see the scan-scope note + # below) warn until this date, then block. Same warn-then-enforce + # idiom and same cutoff as check-package-policy.sh / + # check-docs-presence.sh. + ENFORCE_RUST_WIDE_SCAN_FROM: '2026-08-21' + run: | + TODAY="${RUST_TODAY:-$(date -u +%Y-%m-%d)}" + + # An unparseable cutoff would pick the warn branch forever, silently + # disarming the widened scan. Refuse to run instead. + require_date() { + case "$2" in + [0-9][0-9][0-9][0-9]-[0-1][0-9]-[0-3][0-9]) : ;; + *) echo "::error::rust-secrets: $1='$2' is not YYYY-MM-DD." + echo "Refusing to run: an unparseable cutoff would disarm this gate." + exit 1 ;; + esac + } + require_date ENFORCE_RUST_WIDE_SCAN_FROM "$ENFORCE_RUST_WIDE_SCAN_FROM" + require_date RUST_TODAY "$TODAY" + + if ! find . -name Cargo.toml -not -path './target/*' -print -quit | grep -q .; then + echo 'No Cargo.toml found — skipping Rust secrets check' + exit 0 + fi + + # Scan every .rs file, not just ./src. A Cargo workspace keeps its + # code in crates/*/src, and the previous `grep … src/` matched + # nothing there: grep exits 2 on a missing directory, which the `if` + # read as "clean". Measured 2026-07-21 across the 64 Rust repos in + # the estate re-pin sweep: 8 (12.5%) have no root src/ and were + # therefore never scanned at all — a planted secret in + # crates/core/src/lib.rs passed this job silently. + mapfile -t FILES < <(find . -name '*.rs' \ + -not -path './target/*' -not -path '*/target/*' \ + -not -path './vendor/*' -not -path '*/vendor/*' \ + -not -path './.git/*' -print) + if [ ${#FILES[@]} -eq 0 ]; then + echo 'Cargo.toml present but no .rs files — nothing to scan' + exit 0 + fi + echo "Scanning ${#FILES[@]} Rust file(s)." + + # Each pattern requires an assignment DIRECTLY to a string literal of + # meaningful length. The previous forms matched `= ... "` — a quote + # anywhere after an equals — which flagged code that hardcodes + # nothing at all. Measured across the 64 Rust repos in the estate + # sweep, every one of these was a false positive: + # let api_key = request["api_key"].as_str().unwrap_or("") (lookup) + # let api_key_file = temp_dir.path().join("config.js") (path) + # Query::new("Here's my api_key=sk-12345") (a demo) + # A credential shorter than 8 characters is not a credential either. + # `static` is accepted alongside `const`: the old `const.*KEY` form + # missed `static AUTH_KEY: &str = "…"`, a genuine hardcoded secret + # (caught by canary, not by inspection). + PATTERNS=( + '(const|static)[[:space:]]+(mut[[:space:]]+)?[A-Za-z0-9_]*SECRET[A-Za-z0-9_]*[[:space:]]*(:[^=]*)?=[[:space:]]*b?"[^"]{8,}"' + '(const|static)[[:space:]]+(mut[[:space:]]+)?[A-Za-z0-9_]*KEY[A-Za-z0-9_]*[[:space:]]*(:[^=]*)?=[[:space:]]*b?"[a-zA-Z0-9]{16,}"' + '(const|static)[[:space:]]+(mut[[:space:]]+)?[A-Za-z0-9_]*TOKEN[A-Za-z0-9_]*[[:space:]]*(:[^=]*)?=[[:space:]]*b?"[^"]{8,}"' + '(let|static)[[:space:]]+(mut[[:space:]]+)?[a-z0-9_]*api_key[a-z0-9_]*[[:space:]]*(:[^=]*)?=[[:space:]]*b?"[^"]{8,}"' + 'HMAC.*"[a-fA-F0-9]{32,}"' + '[a-z0-9_]*password[a-z0-9_]*[[:space:]]*(:[^=]*)?=[[:space:]]*b?"[^"]{8,}"' + ) + + # Exemptions, mirroring the four layers already documented on + # shell-secrets. All match on the VALUE or on an explicit pragma, + # never on a file path: a path-based exemption would blind the job + # to a genuine secret in the same file (the failure mode the + # .gitleaks.toml allowlist was rewritten to avoid, standards#512). + # + # 1. env-lookup RHS — `env::var("NAME")` IS the practice this job + # exists to enforce, so flagging it fails the correct code. This + # was the sole rust-secrets failure in the 201-repo sweep: + # cloudguard-server src/main.rs:59, + # `let api_key = env::var("CLOUDGUARD_API_KEY").ok();`. Anchored + # to the assignment and stopped at `;`, so a literal secret with + # an unrelated env::var mention later on the line still trips. + # 2. URL values — an OAuth endpoint is public and not a credential. + # `const MS_TOKEN_URL = "https://login.microsoftonline.com/…"` + # tripped `const.*TOKEN.*=.*"` in four places in one repo. + # 3. comment lines — documentation of a pattern is not a live secret. + # 4. inline pragma `// scanner-allow: rust-secrets`. + COMMENT='^[^:]*:[0-9][0-9]*:[[:space:]]*(//|/\*|\*)' + ENV_RHS='=[^;]*(env::var|env::var_os|std::env|env!\(|option_env!\(|from_env|dotenv)' + URL_RHS='=[[:space:]]*b?"(https?|wss?|ftp)://' + PRAGMA='scanner-allow:[[:space:]]*rust-secrets' + + blocking=0 + advisory=0 + for pattern in "${PATTERNS[@]}"; do + hits="$(grep -nHE "$pattern" "${FILES[@]}" 2>/dev/null \ + | grep -vE "$COMMENT" \ + | grep -vE "$ENV_RHS" \ + | grep -vE "$URL_RHS" \ + | grep -vE "$PRAGMA" || [ $? -eq 1 ])" + [ -n "$hits" ] || continue + + # ./src was the only path the previous version scanned. Hits there + # keep blocking exactly as before — no regression. Hits outside it + # are newly visible, so they warn until the cutoff rather than + # reddening repos that were never actually being scanned. + old_scope="$(printf '%s\n' "$hits" | grep -E '^\./src/' || [ $? -eq 1 ])" + new_scope="$(printf '%s\n' "$hits" | grep -vE '^\./src/' || [ $? -eq 1 ])" + + if [ -n "$old_scope" ]; then + printf '%s\n' "$old_scope" + echo "WARNING: Potential hardcoded secret found matching: $pattern" + blocking=1 + fi + if [ -n "$new_scope" ]; then + printf '%s\n' "$new_scope" + if [[ "$TODAY" < "$ENFORCE_RUST_WIDE_SCAN_FROM" ]]; then + echo "::warning::Potential hardcoded secret outside ./src matching:" \ + "$pattern — becomes BLOCKING on $ENFORCE_RUST_WIDE_SCAN_FROM (today is $TODAY)." + advisory=1 + else + echo "WARNING: Potential hardcoded secret found matching: $pattern" + blocking=1 + fi + fi + done + + if [ "$blocking" -eq 1 ]; then + echo "::error::Potential hardcoded secrets detected. Use environment variables instead." + exit 1 + fi + if [ "$advisory" -eq 1 ]; then + # Never claim a clean pass while findings are outstanding. + echo "NOT YET ENFORCED: findings outside ./src are advisory until $ENFORCE_RUST_WIDE_SCAN_FROM." + exit 0 + fi + echo 'No hardcoded secrets detected in Rust sources.' + + # Shell-specific: catch hardcoded credentials in shell scripts. + # Added to canonical 2026-05-21 after default gitleaks missed a real + # Cloudflare API token leaked via 2-protocols/avow/deploy-repos.sh. + # As of this PR, 0 of 16 sampled estate repos carry this guardrail — + # the reusable closes that gap. + # + # Exemption mechanisms (four layers, applied before reporting a hit): + # 1. Inline pragma (same line or the line immediately above): + # # scanner-allow: shell-secrets + # # hypatia: allow security_errors/secret_detected + # Either form suppresses the hit for that line. + # 2. .shell-secrets-ignore in repo root: gitignore-style line globs; + # a match against the file path suppresses every hit in that file. + # 3. Param-expansion RHS: assignments whose value is ${VAR}, ${VAR:-…}, + # ${VAR:?…}, or $VAR (no literal value) are skipped automatically. + # 4. Comment lines (leading non-whitespace is '#'): documentation of + # patterns cannot be real secrets. + shell-secrets: + timeout-minutes: 10 + runs-on: ${{ inputs.runs-on }} + # Declared explicitly rather than inherited — see the PERMISSIONS note. + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Check for hardcoded secrets in shell scripts + run: | + # Patterns: an `export FOO=` or `FOO=` with a quoted literal of meaningful length. + # Restricted to *_TOKEN / *_KEY / *_SECRET / PASSWORD to keep false-positives low. + PATTERNS=( + '(export[[:space:]]+)?[A-Z_]*TOKEN[A-Z_]*=["'"'"'][A-Za-z0-9_./+=-]{20,}["'"'"']' + '(export[[:space:]]+)?[A-Z_]*API_KEY[A-Z_]*=["'"'"'][A-Za-z0-9_./+=-]{20,}["'"'"']' + '(export[[:space:]]+)?[A-Z_]*SECRET[A-Z_]*=["'"'"'][A-Za-z0-9_./+=-]{16,}["'"'"']' + '(export[[:space:]]+)?PASSWORD=["'"'"'][^"'"'"']{6,}["'"'"']' + ) + + # Inline pragma patterns — suppress a hit when found on the same or + # immediately preceding line. + PRAGMA_RE='(scanner-allow:[[:space:]]*shell-secrets|hypatia:[[:space:]]*allow[[:space:]]+security_errors/secret_detected)' + + # Param-expansion RHS pattern — assignments whose value is a variable + # reference rather than a literal are never real secrets. + # Matches: ="$VAR" ="${VAR}" ="${VAR:-…}" ="${VAR:?…}" ='${VAR}' =$VAR + PARAM_EXPANSION_RE='=['"'"'"'"'"']?\$\{?[A-Za-z_][A-Za-z0-9_]*(:[?-][^}]*)?\}?['"'"'"'"'"']?[[:space:]]*(#.*)?$' + + # Load per-repo ignore globs from .shell-secrets-ignore if present. + IGNORE_GLOBS=() + if [[ -f .shell-secrets-ignore ]]; then + while IFS= read -r line || [[ -n "$line" ]]; do + # Skip blank lines and comments + [[ -z "$line" || "$line" == \#* ]] && continue + IGNORE_GLOBS+=("$line") + done < .shell-secrets-ignore + fi + + # is_ignored — returns 0 (true) if path matches any ignore glob. + is_ignored() { + local path="$1" + for glob in "${IGNORE_GLOBS[@]}"; do + # Use bash pattern matching; strip leading ./ for consistency. + local stripped="${path#./}" + # shellcheck disable=SC2254 + if [[ "$stripped" == $glob ]]; then + return 0 + fi + done + return 1 + } + + found=0 + for pattern in "${PATTERNS[@]}"; do + # Collect raw matches: filepath:lineno:content + while IFS= read -r raw_match; do + [[ -z "$raw_match" ]] && continue + + # Parse filepath and line number from grep -n output (file:line:content). + filepath="${raw_match%%:*}" + rest="${raw_match#*:}" + lineno="${rest%%:*}" + content="${rest#*:}" + + # Exemption 1: .shell-secrets-ignore glob match on the file path. + if is_ignored "$filepath"; then + echo " [skip] $filepath:$lineno — matched .shell-secrets-ignore glob" + continue + fi + + # Exemption 2: param-expansion RHS — value is a variable reference, not a literal. + if echo "$content" | grep -qE "$PARAM_EXPANSION_RE"; then + echo " [skip] $filepath:$lineno — param-expansion (not a literal value)" + continue + fi + + # Exemption 3: comment line — leading non-whitespace char is '#'. + # Comment lines cannot hold real secrets; they only document patterns. + if echo "$content" | grep -qE '^[[:space:]]*#'; then + echo " [skip] $filepath:$lineno — comment line (not executable)" + continue + fi + + # Exemption 5: inline pragma on same line. + if echo "$content" | grep -qE "$PRAGMA_RE"; then + echo " [skip] $filepath:$lineno — inline pragma on same line" + continue + fi + + # Exemption 6: inline pragma on the immediately preceding line. + if [[ "$lineno" -gt 1 ]]; then + prev_line=$(sed -n "$((lineno - 1))p" "$filepath" 2>/dev/null) + if echo "$prev_line" | grep -qE "$PRAGMA_RE"; then + echo " [skip] $filepath:$lineno — pragma on preceding line" + continue + fi + fi + + echo "::warning::$filepath:$lineno: $content" + echo " Potential hardcoded secret matching pattern: $pattern" + found=1 + + done < <(grep -rnE --include='*.sh' --include='*.bash' \ + --exclude-dir='.git' --exclude-dir='node_modules' --exclude-dir='target' \ + "$pattern" . 2>/dev/null || [ $? -eq 1 ]) + done + + if [ $found -eq 1 ]; then + echo "::error::Hardcoded secret detected in a shell script. Read the value from the environment (e.g. \"\${MY_TOKEN:?}\") and supply it via a repo or org secret." + exit 1 + fi diff --git a/.github/workflows/secret-scanner.yml b/.github/workflows/secret-scanner.yml index 3e3dcf2..4b3e5f3 100644 --- a/.github/workflows/secret-scanner.yml +++ b/.github/workflows/secret-scanner.yml @@ -1,11 +1,21 @@ -# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. -# Calls the estate's shared secret scanner (gitleaks + rust-secrets + -# shell-secrets). Added because this repository had NO leak scanning at all. +# Calls the estate secret-scanner bundle (gitleaks + rust-secrets + +# shell-secrets) through a local vendored reusable workflow. +# +# Why local: Secret Scanner run 41543261728 failed at startup because this +# repository's Actions allowlist rejects reusable workflows that are not from +# a hyperpolymath-owned repository. The canonical coordinate +# metadatastician/standards/.github/workflows/secret-scanner-reusable.yml@8d1a2aab... +# is therefore blocked here, and GitHub Actions does not follow the REST +# redirect from the pre-transfer hyperpolymath/standards slug. The local copy +# in secret-scanner-reusable.yml preserves the canonical scanner at that +# standards SHA while remaining inside the allowlist. # -# `secrets: inherit` is REQUIRED — without it the gitleaks action's inner -# secrets.GITHUB_TOKEN is empty and the scan silently degrades. +# The reusable declares no workflow_call secrets and references no secrets.* +# context. Do not add `secrets: inherit`: it would forward every repository +# and organisation secret to a scanner that needs none. + name: "Secret Scanner" on: pull_request: @@ -15,9 +25,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true permissions: - actions: read contents: read jobs: scan: - uses: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@bd0df9ead7faf0cdfe0e13e7966d91e28d0101d4 - secrets: inherit + permissions: + contents: read + uses: ./.github/workflows/secret-scanner-reusable.yml diff --git a/.github/workflows/workflow-contracts.yml b/.github/workflows/workflow-contracts.yml new file mode 100644 index 0000000..f434832 --- /dev/null +++ b/.github/workflows/workflow-contracts.yml @@ -0,0 +1,65 @@ +# SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. +name: Workflow contracts + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + contracts: + name: Workflow and lock contracts + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install contract checker dependency + run: | + python3 -m venv "$RUNNER_TEMP/contract-venv" + "$RUNNER_TEMP/contract-venv/bin/pip" install --disable-pip-version-check --require-hashes --only-binary=:all: -r .github/scripts/requirements.txt + echo "$RUNNER_TEMP/contract-venv/bin" >> "$GITHUB_PATH" + + - name: Regression tests for the contract checker + run: bash .github/scripts/tests/workflow-contracts-test.sh + + - name: Repository workflow contracts + run: bash .github/scripts/check-workflow-contracts.sh + + - name: Install gh-actions-lock (pinned release, digest verified) + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + repo=github/gh-actions-lock + tag=v0.4.0 + asset=linux-amd64 + gh release download "$tag" --repo "$repo" --pattern "$asset" --output "$RUNNER_TEMP/gh-actions-lock" + digest=$(gh api "repos/$repo/releases/tags/$tag" --jq ".assets[] | select(.name == \"$asset\") | .digest") + case "$digest" in + sha256:*) ;; + *) echo "missing sha256 digest for $repo $tag $asset" >&2; exit 1 ;; + esac + echo "${digest#sha256:} $RUNNER_TEMP/gh-actions-lock" | sha256sum -c - + install -m 0755 "$RUNNER_TEMP/gh-actions-lock" "$RUNNER_TEMP/gh-actions-lock-bin" + echo "$RUNNER_TEMP" >> "$GITHUB_PATH" + ln -sf "$RUNNER_TEMP/gh-actions-lock-bin" "$RUNNER_TEMP/gh-actions-lock" + + - name: Verify actions.lock against GitHub + env: + GH_TOKEN: ${{ github.token }} + run: gh-actions-lock --verify diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc new file mode 100644 index 0000000..796f698 --- /dev/null +++ b/CHANGELOG.adoc @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 += Changelog + +== Unreleased +* Vendor the estate secret scanner locally because the repository Actions + allowlist blocks the canonical `metadatastician/standards` reusable; keep + the required `scan / gitleaks` context and pin the vendored baseline fetch. + +=== Dependencies + +* Reconcile `actions.lock` and the inline pin comment for + `hyperpolymath/smtp-notify-action` v0.5.1, merged by Dependabot PR #24. + PR #25 is the follow-up CI repair, not a second dependency bump. + +=== Fixed + +* Point the Secret Scanner caller at `metadatastician/standards`; the + pre-transfer `hyperpolymath/standards` coordinate caused a zero-job startup + failure and left the required `scan / gitleaks` check absent. +* Stop inheriting all secrets into the secret scanner; the pinned reusable + declares no secrets. +* Add YAML-aware workflow contract checks for moved reusable coordinates, + immutable reusable pins, quoted `uses:` values, SPDX headers, lockfile + coverage, synthetic reusable-only lock edges, duplicate YAML keys, and + every stale inline notification-action SHA. +* Keep the reusable-only Secret Scanner lock entry empty so + `gh-actions-lock --verify` does not reject an unused synthetic through-ref; + retain the reviewed closure TSV as an advisory trust record. +* Align `.github/settings.yml` merge methods with the live squash-only, + signed, linear-history rulesets. +* Remove the stale `mise.toml` runtime/build template from this + community-health repository. +* Tighten the D199 handoff: fail-closed squash classification, resumable + per-downstream-PR idempotency, source-head revalidation, explicit + `createCommitOnBranch` authorship limits, and GraphQL/REST signature checks. +* Configure CodeRabbit as advisory-only for code-changing finishing touches + after an unsigned autofix commit reached the PR branch; the estate signing + policy requires GitHub-verified commits on PR branches. diff --git a/docs/audits/2026-11-02-open-work-audit.adoc b/docs/audits/2026-11-02-open-work-audit.adoc new file mode 100644 index 0000000..ecd6d66 --- /dev/null +++ b/docs/audits/2026-11-02-open-work-audit.adoc @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 += Open-work audit: hyperpolymath/.github +:toc: +:toclevels: 3 + +Date: 2026-11-02 (Europe/London). Session branch: `arena/1e24712e-github`. +This record distinguishes observed evidence, source changes, and work that +remains outside this repository or outside the integration's permissions. + +== 1. PR #24 and Secret Scanner run 41457289788 + +=== Observed cause + +PR #24 proposed `hyperpolymath/smtp-notify-action` v0.5.0 -> v0.5.1. Its +Secret Scanner run https://github.com/hyperpolymath/.github/actions/runs/41457289788[41457289788] +completed with `failure`, created zero jobs, and had no log archive. The run +page annotation is the decisive evidence: + +[source,text] +---- +Invalid workflow file: .github/workflows/secret-scanner.yml#L22 +error parsing called workflow +".github/workflows/secret-scanner.yml" +-> "hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@bd0df9ead7faf0cdfe0e13e7966d91e28d0101d4" +: workflow was not found. +---- + +The REST API still redirects `hyperpolymath/standards` to +`metadatastician/standards`, and the pinned file exists at the canonical +repository. GitHub Actions does not follow that redirect for job-level +`uses:`. The required `scan / gitleaks` context was therefore absent, not +merely failing. This matches the canonical handoff in +`metadatastician/standards/docs/GOVERNANCE-SOURCE-HANDOFF.adoc` and +`docs/decisions/2026-10-27-recovered-session-decisions.adoc`. + +The lockfile was not the cause of this particular zero-job run. It was, +however, stale in two ways: `main` still locked smtp-notify-action v0.3.0 while +the workflow used v0.5.0, and PR #24's inline comment still named the v0.2.0 +commit `ede1191ef6ff3ac02c4f4d9efdf837ee517e11d7`. + +PR #24 was merged by the repository owner at 2026-11-02 19:18 UTC as +`f0f7896e4fd8a05e533a59dc4ee88aaa136fc523`. The merge did not repair the +caller: post-merge Secret Scanner run +https://github.com/hyperpolymath/.github/actions/runs/41508702476[41508702476] +also completed with `failure` and zero jobs. `main` therefore remained broken +after the dependency bump landed. + +=== Source repair + +PR #25 is the follow-up repair. It keeps the v0.5.1 dependency bump already +merged by PR #24 and repairs the source: + +* `.github/workflows/secret-scanner.yml` now calls + `metadatastician/standards/.github/workflows/secret-scanner-reusable.yml` + at `8d1a2aab4211493cf42f147f2080f024605797dc`. +* The caller grants only `contents: read` and no longer uses + `secrets: inherit`; the pinned reusable declares no secrets. +* `.github/workflows/actions.lock` records smtp-notify-action v0.5.1 at + `e384daf6167a7a7567642d6a62060106244d2413` and the checkout pin used by + `workflow-contracts.yml`. The reusable-only Secret Scanner caller has an + empty lock entry: `gh-actions-lock` v0.4.0 does not follow remote job-level + reusable workflows, and a synthetic through-ref there fails `--verify`. +* `.github/workflows/actions-lock.closure.tsv` remains a reviewed trust + record for the remote reusable closure. It is advisory while upstream + `gh-actions-lock` cannot represent that edge. +* The inline smtp-notify-action comment now names the v0.5.1 commit. +* `.github/workflows/workflow-contracts.yml`, + `.github/scripts/check-workflow-contracts.sh`, and + `.github/scripts/check_workflow_contracts.py` add YAML-aware regression + checks for moved reusable coordinates, floating reusable pins, quoted + `uses:` values, SPDX placement, lockfile coverage, synthetic reusable-only + lock edges, duplicate YAML keys, and every stale inline notification SHA. + +=== Dependency review + +The v0.5.1 release is a metadata correction. The release notes and commit +diff show no Zig/Idris2 source change; the action downloads the same +SHA-256-pinned musl binaries as v0.5.0. The release asset digests match the +action's pins: + +* x86_64: `2683da8f619dd5f310c9f6884da39e825c807c2c86a6319c19aba39b27f9c5c9` +* aarch64: `a58138308fb9832e1ceb551a12f1b04e591dc38ba5fd336c9acfb3f263161f29` + +The v0.5.1 tag peels to commit `e384daf6167a7a7567642d6a62060106244d2413`. +The commit is GitHub-verified. The annotated tag object itself reports +`unsigned`; this audit did not create or move the tag. + +=== Review/app feedback + +PR #24 had no human reviews and no review threads. CodeRabbit skipped review +because the author was a bot. GitGuardian and Semgrep reported success; +CodeQL reported neutral. Those app checks do not substitute for the missing +required `scan / gitleaks` job. + +PR #25 received actionable automated review findings. The initial head +`3106fca2` was corrected before merge: + +* Codex: remove the synthetic `actions/checkout` edge from the reusable-only + Secret Scanner lock entry; `gh-actions-lock` v0.4.0 cannot follow remote + job-level reusable workflows and `--verify` rejects the unused pin. +* CodeRabbit: parse YAML rather than grepping unquoted `uses:` values, check + every notification-action use, and make the D199 handoff precise about + squash evidence, resumable per-PR idempotency, source-head revalidation, + authorship limits, and GraphQL signature fields. + +CodeRabbit also pushed autofix commit `60e9736` to the PR branch. REST reports +that commit as `unsigned`; the estate signing policy forbids bot pushes to PR +branches. This session did not push it. The follow-up signed commit preserves +the intent of its docstring in the replacement Python checker, and +`.coderabbit.yaml` disables CodeRabbit's code-changing finishing touches +(`docstrings`, `unit_tests`, `autofix`, `fix_ci`, `resolve_merge_conflict`). +That repository setting does not reduce the App's permissions or disable a +separately enabled Autopilot task. The unsigned commit remains in the PR +history and must be considered by the merge gate; no force-push or admin +bypass is used to hide it. + +=== Merge path + +This session may push only `arena/1e24712e-github`. PR #24 was merged by the +owner before the repair landed, so PR #25 is a follow-up repair to `main`, +not a replacement for an open PR. No admin bypass, force-push, or +required-context removal is used by this session. + +The conflict with merged PR #24 was reconciled without rewriting history: +the two conflicting files were temporarily aligned with `main`, GitHub's +`updatePullRequestBranch` MERGE created verified merge commit `5aba1a4`, and +a follow-up signed commit reapplied the repaired lockfile and inline SHA. + +== 2. Issue #13 / D199 + +D199 adopts automated rebase-onto-base recovery after squash merges. The +appropriate estate-wide service is the `cicd-squabbler` GitHub App webhook +leg, not a community-health workflow in this repository. The App rollout is +already owner-ruled in https://github.com/hyperpolymath/cicd-squabbler/issues/122[cicd-squabbler#122] +(D266/D267: all repositories, owned compute). The webhook leg is still absent +on `main`. + +A precise implementation and verification handoff is recorded in +`docs/handoffs/D199-post-squash-stack-recovery.adoc`. No deployment is claimed. +The signing policy rules out the naive implementation: a bot must not +force-push rebased commits, and GitHub rebase-merge replays commits unsigned. +The handoff therefore specifies signed replacement branches/PRs through +GitHub's commit API, conflict escalation, fork handling, concurrency, nested +stacks, and deployment evidence. + +== 3. Issue #23, arena scope probe + +Issue #23 and https://github.com/hyperpolymath/absolute-zero/issues/192[absolute-zero#192] +were created by the owner one second apart with identical title/body. The +owner confirmed in this session that #23 was a one-off Arena access/scope +test: whether Arena could create issues in the selected repositories. Issue +creation succeeded. That is evidence of creation capability only, not a +broader permission or deployment guarantee. + +The integration could not write the closing comment or close the issue: +both REST calls returned HTTP 403 `Resource not accessible by integration`. +The issue therefore remains open until a permitted actor closes it as +completed. No product fix is claimed. + +== 4. Policy, security, quality, and CI/CD audit + +=== Branch policy + +Active repository rulesets require linear history, signed commits, and +`scan / gitleaks` (GitHub Actions integration id 15368). Branch-Floor forbids +deletion/non-fast-forward. The secret-scan ruleset has an administrator +bypass actor; this session does not use it. `.github/settings.yml` previously +advertised merge-commit and rebase-merge as allowed, contrary to the live +squash-only policy; it is corrected here. + +=== K9 / DEED / Hypatia + +This repository currently has no committed `.deed` or `.k9.ncl` payloads and +no fork-governance/Hypatia caller. The canonical K9 and DEED reusable gates +would report zero files unless `require-files: true` is adopted. That is a +coverage gap, not a conformance pass. This audit does not claim K9/DEED +conformance or a Hypatia verdict. The local environment lacked the canonical +Elixir/Hypatia toolchain, and release-asset downloads were blocked by the +sandbox egress policy, so no local Hypatia scan was run. + +=== GitHub alerts + +The integration received HTTP 403 for: + +* `repos/hyperpolymath/.github/dependabot/alerts` +* `repos/hyperpolymath/.github/secret-scanning/alerts` +* `repos/hyperpolymath/.github/code-scanning/alerts` +* branch protection and Actions permissions/variables endpoints + +Therefore alert counts and dispositions are not established here. The +visible PR checks are not a substitute for those alert inventories. + +=== Technical debt addressed + +* Removed stale `mise.toml` runtime/build template from a community-health + repository with no local build. +* Added `CHANGELOG.adoc` for the dependency update and repairs. +* Normalized SPDX placement in managed workflows. +* Added regression tests for the workflow coordinate/lock contracts. + +=== Remaining work + +. Merge PR #25 only after the real required check and the new + workflow-contract check pass on its final head. PR #24 is already merged; + no closure action remains for it. +. Close issue #23 as completed with the confirmed purpose, if permitted. +. Implement and deploy D199 in `hyperpolymath/cicd-squabbler` under its own + session; this repository cannot deploy that service. +. Obtain a token/app permission set that can read security alerts and Actions + settings, then audit alert inventories and live posture. +. Decide whether this community-health repository should adopt repo DEED/K9 + metadata and fork-governance/Hypatia callers with `require-files: true`. +. Verify live SMTP delivery separately if `PUSH_EMAIL_ENABLED` is armed; the + repository variable endpoint was inaccessible, and this audit did not send + email. diff --git a/docs/handoffs/D199-post-squash-stack-recovery.adoc b/docs/handoffs/D199-post-squash-stack-recovery.adoc new file mode 100644 index 0000000..04eae51 --- /dev/null +++ b/docs/handoffs/D199-post-squash-stack-recovery.adoc @@ -0,0 +1,236 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 += D199 handoff: automated post-squash stack recovery +:toc: +:toclevels: 3 + +Status: handoff only. Not implemented or deployed by the `.github` session. +Target repository: https://github.com/hyperpolymath/cicd-squabbler[`hyperpolymath/cicd-squabbler`]. +Target service: `squabble-app` webhook leg, with a new stack-recovery move in +the squabble core/forge layers. Related owner rollout: +https://github.com/hyperpolymath/cicd-squabbler/issues/122[cicd-squabbler#122] +(D266/D267). Policy source: +https://github.com/hyperpolymath/.github/issues/13[.github#13] (D199) and +`metadatastician/standards/docs/SIGNING-POLICY.adoc`. + +== Why this repository/service + +D199 is estate-wide PR branch recovery, not a community-health file. The +owner has already ruled that squabble rolls out as a GitHub App installed on +all repositories of both accounts, hosted on owned compute, with no per-repo +workflow churn. `squabble-app` on `main` currently exposes only loopback +`/health`, `/api/v1/diagnose`, and `/api/v1/fight`; it has no webhook route or +App-auth leg. Implementing D199 here would put estate mutation logic in the +wrong source and would not satisfy the rollout decision. + +Where `gitbot-fleet/robot-repo-automaton` is present and working, respect the +estate precedence directive and avoid duplicate writers. The squabbler App +must either dispatch to the primary or hold a per-repo lease before mutating +branches. It must remain standalone when the fleet is absent. + +== Non-negotiable policy constraints + +* No unsigned bot pushes. +* No force-pushes, including `git push --force-with-lease` to a PR branch. +* No protection bypass, admin merge, required-context removal, or check rename. +* No silent conflict skip (`git rebase --skip` is not an autonomous remedy). +* Every new commit must be GitHub-verified, preferably web-flow GPG through + `createCommitOnBranch`. +* A recovered stack is not "green" until the downstream PR's own required + checks run and pass on the new head. + +Because a normal rebase updates a branch by non-fast-forward, D199 must be +implemented as *replacement-branch recovery*, not in-place force-push. + +== Event and detection + +Subscribe to `pull_request` `closed`. Act only when all of the following are +established from fresh API reads, not from the webhook payload alone: + +. `merged == true`; +. the base is the repository default branch or a known stack base; +. the merge is positively classified as a squash merge; +. the merged head branch has open downstream PRs targeting it. + +Squash classification must be fail-closed. GitHub does not expose a stable +post-merge `mergeMethod` field on `PullRequest`, and `merge_commit_sha` alone +does not identify the method. The implementation must record the exact +checks it uses. The minimum evidence set is: + +* repository merge settings at event time permit squash and forbid merge + commits and rebase merges (`allow_squash_merge == true`, + `allow_merge_commit == false`, `allow_rebase_merge == false`); + These settings must come from persisted event-time webhook or audit + evidence. If that evidence is unavailable, classify the event as + `ambiguous_merge_method` and take no mutating action. +* the PR is merged and has a non-null `merge_commit_sha`; +* the merge commit is reachable from the base branch and has exactly one + parent, ruling out an ordinary two-parent merge commit; +* the PR timeline contains the corresponding `merged` event and no + merge-queue entry; +* the commit message/tree metadata is consistent with GitHub's squash commit + for that PR, not merely a title containing `(#N)`. + +If any evidence is missing, unreadable, or conflicting, record +`ambiguous_merge_method` and take no mutating action. Merge commits, rebase +merges, merge-queue merges, and ambiguous events are negative cases, not +silent skips. + +== Idempotency and resume state + +Record a stack event key: repository id + merged PR number + merge commit SHA. +Also record a separate recovery row for each downstream PR: + +`repository id + downstream PR number + stack event key`. + +Each row must persist a state machine, lease owner, lease expiry, attempt +count, last error, captured downstream head/base SHAs, replay tree hash, +replacement branch name, replacement PR number/URL, and original-PR closure +status. Suggested states: + +* `observed` +* `replayed` +* `branch_created` +* `pr_created` +* `original_closed` +* `completed` +* `escalated` +* `failed_retryable` + +Persist the row before mutation. Webhook redelivery must resume non-terminal +rows rather than suppress the only retry. A stack-level key alone is not +sufficient because siblings can partially succeed. If a replacement PR +already exists for the same row and tree hash, report it instead of creating +another. + +== Recovery algorithm + +For each downstream PR in topological order (base PR before children): + +. Fetch the downstream head, the merged base, and the old stack base into an + isolated temporary repository. Never operate on a user's working tree. +. Compute the post-squash replay: `git rebase --onto + ` in the temporary repository, or an equivalent three-way + cherry-pick sequence. Capture the exact commit range and tree hashes. +. If any conflict occurs, stop that branch. Do not push a partial result. + Post a structured report naming the PR, conflicting paths, old/new base + SHAs, and the manual command a human can run. Label it `stack:conflict` or + the repo's equivalent. Continue independent sibling stacks only if their + bases are unaffected. +. Immediately before creating the replacement branch, re-fetch the original + downstream PR and its base. If the PR is no longer open, or its head/base + repository, ref, or SHA differs from the captured values, discard the stale + replay and recompute or escalate. Compare ref identities as well as SHAs: + a retarget to another base branch at the same SHA is still a different PR + relationship. `expectedHeadOid` protects the new recovery branch; it does + not protect the original downstream head. +. If clean and still current, create a new branch in the same repository, for + example `stack-recovery/pr--`, from the new base. +. Recreate the downstream commits on that branch through GitHub's signed + commit API (`createCommitOnBranch`). This API authors commits as the + credential owner; it cannot preserve the original primary author. Preserve + original author/committer metadata in the recovery report and add + `Co-authored-by` trailers where appropriate, but do not claim primary + authorship is preserved. If exact primary authorship is a hard requirement, + escalate or use a separately approved verified signing path with explicit + author metadata; never push unsigned local commits. +. Verify each returned commit before proceeding. In GraphQL, require + `signature.isValid == true` and `signature.wasSignedByGitHub == true`; + a missing signature is a failure. As a cross-check, REST + `commit.verification.verified` must also be true. If the API cannot preserve + required file modes/binary changes, escalate rather than pushing unsigned + local commits. +. Compare the final replacement-branch tree with the captured replay tree + before opening the replacement PR. Compare every path's type, mode, and + object ID. `createCommitOnBranch` accepts file contents and paths but not + file-mode metadata, so a mode/type/object mismatch is a hard stop. If any + entry differs, do not open the replacement PR or close the original PR; + report the mismatch and escalate. +. Open a replacement PR against the correct new base. Link the original PR, + merge commit, recovery run, and tree-equivalence evidence. Copy relevant + labels/review requests only where the API permits and the semantics are + safe. Do not copy approvals as if they apply to the new head. +. Immediately before closing the original downstream PR, re-fetch it again. + Close it as superseded only if it is still open, its head/base repository, + ref, and SHA still match the recovered values, the tree-equivalence check + passed, and the replacement PR exists with the new head visible. If closing + is not permitted, the PR changed, or the trees differ, leave a comment and + report the partial state. +. Re-target or recover nested children against the replacement branch/PR in + the same topological pass. A child must never be replayed onto a base that + has not itself been recovered or merged. + +== Forks + +* Never push to a fork without explicit permission and a verified signing + path. Default: create an upstream replacement branch from the fork head if + the App has read access and the repository permits it, then open an + upstream replacement PR crediting the fork author. +* If the fork head cannot be read or upstream replacement is not permitted, + do not silently skip. Post a recovery report to the original PR with the + exact conflict-free patch/branch instructions and mark it needing human + action. +* Private forks and deleted source branches are explicit escalation cases. + +== Concurrency and rate limits + +* Queue work per repository and per stack root. Use a lease or equivalent + compare-and-swap on the per-downstream-PR recovery row. +* Use `expectedHeadOid` on every commit API call. On mismatch, re-fetch and + recompute; never overwrite a moving branch. +* Debounce by downstream head SHA. One installation token can cover hundreds + of repositories; pace requests and treat secondary-limit 403 as retryable + infrastructure failure, not a verdict. +* Make redelivery safe: if a replacement PR already exists for the same + recovery row and tree hash, report it instead of creating another. +* Crash recovery must resume `replayed`, `branch_created`, and `pr_created` + rows. A row is terminal only when `completed` or explicitly `escalated`. + +== Required tests and evidence + +Unit/fixture tests: + +* squash merge with one downstream PR, clean replay; +* merge commit, rebase merge, and merge-queue merge: no replacement PR; +* ambiguous or conflicting merge evidence: no mutation, escalation recorded; +* nested stack A -> B -> C, topological recovery; +* sibling stacks, independent failure isolation; +* conflict in a middle PR: no push, no silent skip, precise report; +* duplicate webhook delivery: one replacement PR; +* crash after persistence but before replacement PR creation: resume succeeds; +* concurrent downstream head update: stale replay discarded and recomputed; +* concurrent original-PR closure/merge: no stale closure; +* `expectedHeadOid` refusal on the recovery branch: recompute; +* fork PR: no unauthorized fork push; +* deleted fork/base branch: explicit escalation; +* binary file, executable bit, rename, and delete preservation; +* unsigned API response, missing GraphQL signature, or failed REST verification: + stop before closing old PR; +* rate-limit/secondary-limit response: retry/escalate, never green. + +Integration tests: + +* Mock GitHub GraphQL/REST with recorded fixtures for `createCommitOnBranch`, + PR creation, PR close, labels, and check-rollup reads. +* A disposable canary repository with a real squash merge and a two-level + stack. Evidence must include old/new SHAs, replacement PR URLs, verified + commit badges/API verification, required-check runs on the new heads, and + the final merge state. +* A paired conflict canary proving the service refuses to push and reports + the conflicting paths. + +Deployment evidence: + +* App registration/installation ids and permission set, without secrets. +* Webhook signature verification: bad signature -> 401. +* Ingress/TLS and loopback origin configuration on owned compute. +* Health/readiness and queue/lease observability. +* A redelivered production webhook that is idempotent. +* Explicit statement that the squabble advisory check is not required. + +== Acceptance + +D199 is complete only when a squash merge automatically produces verified, +conflict-free replacement downstream PRs (or explicit conflict/fork +escalations), nested stacks recover in order, duplicate/concurrent deliveries +are safe, and the downstream required checks pass on the recovered heads. +Closing the tracking issue or seeing the App online is not sufficient. diff --git a/mise.toml b/mise.toml deleted file mode 100644 index 6dd983f..0000000 --- a/mise.toml +++ /dev/null @@ -1,57 +0,0 @@ -[tools] -# Language runtimes -node = "latest" -python = "latest" -rust = "latest" -go = "latest" -zig = "latest" -java = "latest" -bun = "latest" -denojs = "latest" - -# Package managers -npm = "latest" -yarn = "latest" -pnpm = "latest" -pip = "latest" -cargo = "latest" -go-task = "latest" - -# Formatting & Linting -gofmt = "latest" -black = "latest" -isort = "latest" -ruff = "latest" -prettier = "latest" -shfmt = "latest" -stylua = "latest" - -# Build tools -cmake = "latest" -make = "latest" -ninja = "latest" - -# Shell tools -git = "latest" -gnu-sed = "latest" -gnu-tar = "latest" -gnu-grep = "latest" - -# Testing -vitest = "latest" -pytest = "latest" -jest = "latest" - -[env] -# Common environment variables -NODE_ENV = "development" -PYTHONDONTWRITEBYTECODE = "1" -PYTHONUNBUFFERED = "1" - -# Task runner alias -[alias] -task = "go-task" -build = "cargo build --release || npm run build || go build" -test = "cargo test || npm test || go test ./..." -lint = "ruff check . || prettier --check . || black --check ." -fmt = "ruff format . || prettier --write . || black ."