From 49452574aa477d7d5dd049cba50112dcb9e621d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 13:39:28 +0000 Subject: [PATCH 1/5] chore(template): sync the mint machinery to rsr-template-repo 2be7b88 estate-rules is red on main. The step "Use-this-template mint end to end" runs tests/e2e/use_template_mint_test.sh, inherited from template c481073. That version re-mints this already-minted tree and fails on an unfilled {{OWNER}} token. The template fixed it across #275, #291, #293 and #294; its current test self-skips in a minted child. Reproduced: the c481073 test fails on main, the current test passes here. How the sync was made (three-way, base c481073, which this repo was minted from): - 43 files this repository still held byte-identical to c481073 take the template's 2be7b88 version: mint machinery, hooks, workflows, checks and tests. - 9 new machinery files are added: chain-mint, replay-mint, check-mint-drift, format-container-kyaml, and their tests. The template's own diagnostic workflow, oracle-segfault-diag.yml, and its lock record are left out. - Diverged files were merged with git merge-file. Kept as this repository's own: governance.yml (the #15 caller), README, mise.toml (yq added for scripts/kyaml-format.sh), the verdict-grid lane and rule-order policy (standards 8e48b1c renders; the template's e064539 does not), the rule-order checker and its test (from #22), and CHANGELOG. - secret-scanner.yml takes the template's inline three-scanner workflow (#295: stable contexts, no secrets: inherit). Its lock record is updated and its contract test wired. - tests/workflows/non_interactive_mint_test.sh and tests/e2e/chain_mint_test.sh carry hyperpolymath/rsr-template-repo#300's guard. In a legacy minted repo (no (mint-answers)) they assert repo-init's refusal to mint again, by name, instead of failing. Checked locally: - check-lock-sync: in sync. - check-workflow-edges, check-required-contexts, check-root-shape: pass. - run-proven-suite: ALL LANES PASSED. - Every single-line estate-rules lane: the same five fail as on unmodified main, all environmental here (asciidoctor, this machine's yq) except governance_policy_test. - governance_policy_test still fails on the #15 governance caller's pull_request branch filter. CI never reached that step before, because the mint test failed first. Left for the owner's decision. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BYbQRtNu216R13jtZLmoqs --- .github/hooks/scan-secrets.sh | 10 +- .github/hooks/validate-deed.sh | 48 +- .github/hooks/validate-k9.sh | 4 + .github/rulesets/README.adoc | 61 +- .github/workflows/README.adoc | 26 +- .github/workflows/actions.lock | 2 +- .github/workflows/dogfood-gate.yml | 24 +- .github/workflows/dogfood-summary.yml | 2 +- .github/workflows/estate-rules.yml | 49 +- .github/workflows/fork-governance.yml | 46 +- .github/workflows/proven-tests.yml | 2 +- .github/workflows/secret-scanner.yml | 364 +++++++- .github/workflows/security-policy.yml | 2 +- .github/workflows/verdict-grid-publish.yml | 2 +- .machine_readable/mint-verbatim.txt | 3 + .machine_readable/required-contexts-allow.txt | 24 + .../testing/adversarial-content.k9.ncl | 4 + CHANGELOG.adoc | 11 + CLAUDE.md | 7 + CODE_OF_CONDUCT.adoc | 2 +- build/just/container.just | 15 +- build/just/provision-lib.sh | 2 +- build/just/repo-init.just | 786 ++++++++++++++++-- ci/.pre-commit-config.yaml | 10 +- docs/AI_INSTALLATION_GUIDE.adoc | 19 +- docs/HYPATIA-RULE-ORDER.adoc | 2 +- docs/SETUP.adoc | 11 +- mise.toml | 1 + scripts/chain-mint.sh | 436 ++++++++++ scripts/check-invisible-characters.sh | 2 +- scripts/check-mint-drift.sh | 224 +++++ scripts/check-mise-language.sh | 2 +- scripts/check-no-a2ml.sh | 21 +- scripts/check-required-contexts.sh | 115 ++- scripts/check-variant-drift.sh | 22 +- scripts/check-workflow-edges.sh | 2 +- scripts/format-container-kyaml.sh | 111 +++ scripts/gen-repo-map.sh | 11 +- scripts/render-mise.sh | 1 + scripts/replay-mint.sh | 119 +++ scripts/resolve-template-pin.sh | 57 +- scripts/run-proven-suite.sh | 2 + scripts/rust-tool.sh | 26 +- tests/e2e/chain_mint_test.sh | 600 +++++++++++++ tests/e2e/use_template_mint_test.sh | 447 +++++++++- tests/ffi_strings_test.sh | 12 + tests/run_proven_suite_test.sh | 5 +- tests/workflows/chain_tree_of_test.sh | 76 ++ tests/workflows/check_no_a2ml_test.sh | 15 +- tests/workflows/container_kyaml_test.sh | 23 + tests/workflows/governance_badge_test.sh | 4 +- tests/workflows/governance_policy_test.sh | 9 +- tests/workflows/julia_library_test.sh | 18 +- tests/workflows/kyaml_ratchet_test.sh | 2 +- tests/workflows/mint_cleanup_test.sh | 18 +- tests/workflows/mint_gates_test.sh | 2 +- tests/workflows/non_interactive_mint_test.sh | 41 + tests/workflows/replay_mint_env_test.sh | 38 + tests/workflows/repo_map_newline_test.sh | 29 + tests/workflows/resolve_template_pin_test.sh | 39 +- .../workflows/secret_scanner_contract_test.sh | 230 +++++ tests/workflows/self_name_pass_test.sh | 70 +- tests/workflows/squabble_gate_test.sh | 92 +- verification/proofs/README.adoc | 22 + 64 files changed, 4215 insertions(+), 267 deletions(-) create mode 100755 scripts/chain-mint.sh create mode 100755 scripts/check-mint-drift.sh create mode 100644 scripts/format-container-kyaml.sh create mode 100755 scripts/replay-mint.sh create mode 100755 tests/e2e/chain_mint_test.sh create mode 100755 tests/workflows/chain_tree_of_test.sh create mode 100755 tests/workflows/replay_mint_env_test.sh create mode 100644 tests/workflows/repo_map_newline_test.sh create mode 100755 tests/workflows/secret_scanner_contract_test.sh diff --git a/.github/hooks/scan-secrets.sh b/.github/hooks/scan-secrets.sh index 3ecb23f0..c3b0ebb1 100755 --- a/.github/hooks/scan-secrets.sh +++ b/.github/hooks/scan-secrets.sh @@ -7,13 +7,15 @@ # ── Why this is NOT duplicated work ───────────────────────────────────────── # The estate runs a TWO-TIER secret defence: # -# CI gitleaks, via the standards `secret-scanner-reusable.yml` -# (gitleaks + rust-secrets + shell-secrets). Catches what reaches the +# CI gitleaks, rust-secrets and shell-secrets, inline in +# `.github/workflows/secret-scanner.yml` (the same three scanner +# contracts the standards reusable publishes, kept inline so the three +# check names are this repository's own). Catches what reaches the # remote, on every pull_request and push to main. # LOCAL TruffleHog — this script — before the push leaves the machine. # -# Different detection engines at different checkpoints. The reusable's header -# retires TruffleHog "as redundant with gitleaks"; that judgement is scoped to +# Different detection engines at different checkpoints. The estate retired +# TruffleHog "as redundant with gitleaks"; that judgement is scoped to # CI, where running both cost twice over one population. It is not a reason to # leave the local tier unguarded. DO NOT delete this script as duplication — # see .github/workflows/README.adoc, "Secret scanning is single-sourced". diff --git a/.github/hooks/validate-deed.sh b/.github/hooks/validate-deed.sh index 770ba695..733fa922 100755 --- a/.github/hooks/validate-deed.sh +++ b/.github/hooks/validate-deed.sh @@ -9,34 +9,60 @@ # live here were a second validator. This runs 1-formats/deed/tools/deed_lint.js # (Bun) from a standards checkout over the tracked .deed files, as the # deed-conformance lane does in CI (.github/workflows/dogfood-gate.yml). The -# lane is the gate; this is the early warning. A tracked .a2ml file is an -# error on sight: the format is retired estate-wide (D99, D312) and its facts -# belong in the repo deed. +# lane is the gate; this is the early warning. +# +# A2ML is retired estate-wide (D99, D312); its facts belong in the repo deed. +# Without a ledger, any tracked A2ML file is an error on sight. A repository +# that still carries legacy A2ML lists each file, by exact path, in the +# shrink-only ledger .machine_readable/a2ml-legacy-allow.txt (owner decision +# 2026-11-04), and the A2ML check is then the canonical gate, +# scripts/check-a2ml-archived.sh from the same standards checkout: a file +# outside the ledger and outside 9-archive/ fails, and so does an entry whose +# file is gone. With A2ML_BASE set to a ref (CI sets the PR base), an entry +# the base ledger did not list also fails, so the ledger can only shrink. # # Environment variables: # INPUT_PATH — Repository to check (default: .) # STANDARDS_DIR — A hyperpolymath/standards checkout # (default: ../standards beside the repository) +# A2ML_BASE — Ref whose ledger this one may not grow beyond (optional) # # Exit codes: -# 0 — Every tracked deed lints, or no linter is available (skipped with a -# notice: CI still runs the lane) -# 1 — A deed failed, or a .a2ml file is tracked +# 0 — Every tracked deed lints and the A2ML check passes, or no linter is +# available (skipped with a notice: CI still runs the lane) +# 1 — A deed failed, or an A2ML file is not accounted for set -euo pipefail REPO="${INPUT_PATH:-.}" STANDARDS_DIR="${STANDARDS_DIR:-$(cd "$REPO/.." && pwd)/standards}" +case "$STANDARDS_DIR" in + /*) ;; + *) STANDARDS_DIR="$PWD/$STANDARDS_DIR" ;; +esac LINTER="$STANDARDS_DIR/1-formats/deed/tools/deed_lint.js" +A2ML_GATE="$STANDARDS_DIR/scripts/check-a2ml-archived.sh" +LEDGER=".machine_readable/a2ml-legacy-allow.txt" cd "$REPO" errors=0 -mapfile -t a2ml < <(git ls-files -- '*.a2ml') -for f in "${a2ml[@]}"; do - echo "::error file=$f::.a2ml is retired; move its facts into the repo deed and delete it" - errors=$((errors + 1)) -done +if [ -f "$LEDGER" ]; then + if [ -f "$A2ML_GATE" ]; then + echo " A2ML: checking against the shrink-only ledger $LEDGER" + if ! bash "$A2ML_GATE" ${A2ML_BASE:+--base "$A2ML_BASE"} .; then + errors=$((errors + 1)) + fi + else + echo "::notice::A2ML: $LEDGER is present but the canonical gate is not ($A2ML_GATE); the deed-conformance lane checks it in CI" + fi +else + mapfile -t a2ml < <(git ls-files | grep -E '\.a2ml(\.in|\.template|\.v[0-9]+\.template)?$' | grep -v '^9-archive/' || true) + for f in "${a2ml[@]}"; do + echo "::error file=$f::.a2ml is retired; move its facts into the repo deed and delete it, or list it in $LEDGER" + errors=$((errors + 1)) + done +fi if [ ! -f "$LINTER" ] || ! command -v bun >/dev/null 2>&1; then echo "::notice::DEED: no deed linter here (needs Bun and a standards checkout at $STANDARDS_DIR; set STANDARDS_DIR); the deed-conformance lane lints in CI" diff --git a/.github/hooks/validate-k9.sh b/.github/hooks/validate-k9.sh index 4fff2533..955c6c8d 100755 --- a/.github/hooks/validate-k9.sh +++ b/.github/hooks/validate-k9.sh @@ -26,6 +26,10 @@ set -euo pipefail REPO="${INPUT_PATH:-.}" STANDARDS_DIR="${STANDARDS_DIR:-$(cd "$REPO/.." && pwd)/standards}" +case "$STANDARDS_DIR" in + /*) ;; + *) STANDARDS_DIR="$PWD/$STANDARDS_DIR" ;; +esac VALIDATOR="$STANDARDS_DIR/1-formats/k9/tools/k9-validate.sh" if [ ! -f "$VALIDATOR" ]; then diff --git a/.github/rulesets/README.adoc b/.github/rulesets/README.adoc index a7794bc8..5b0fdf3d 100644 --- a/.github/rulesets/README.adoc +++ b/.github/rulesets/README.adoc @@ -79,14 +79,36 @@ gh api "/repos/{owner}/{repo}/rulesets" --jq '.[] | "\(.id)\t\(.name)\t\(.enforc ---- |=== -| Live ruleset | Enforcement | Target | Covers +| Live ruleset | Enforcement | Target | Rules reported by the API -| `Base` (`23218684`) | active | branch | deletion, required signatures, code scanning (CodeQL/Hypatia), Copilot review, non-fast-forward -| `Require-Linear-History` (`24258228`) | active | branch | required linear history -| `Secret-Scan-Floor` (`24309812`) | active | branch | required status checks (`scan / gitleaks`) +| `Require-Linear-History` (`24258228`) | active | branch | `required_linear_history` +| `Secret-Scan-Floor` (`24309812`) | active | branch | *none* — `rules: []` | `Immutable-Tags` (`22959911`) | active | tag | creation, deletion, non-fast-forward, update, required signatures |=== +[IMPORTANT] +==== +Measured 2026-11-08. Two corrections against the previous version of this table: + +. `Base` (`23218684`) no longer exists. `GET /rulesets/23218684` returns +*404 Not Found*, and the repository's ruleset list does not include it. Every +claim in the drift notes below about what `Base` enforces — code scanning, +Copilot review, `deletion`, `non_fast_forward` — describes a ruleset that is +gone. `GET /rules/branches/main` now returns exactly one rule +(`required_linear_history`, from `24258228`). +. `Secret-Scan-Floor` (`24309812`) is `active` on `target: branch` with +`conditions.ref_name.include: ["~DEFAULT_BRANCH"]`, but it carries **zero +rules**. It therefore requires *no* status check. `scan / gitleaks` is +*produced* by `.github/workflows/secret-scanner.yml`, not required by anything. + +So as of 2026-11-08 `main` is protected only by `required_linear_history`. +This is a record of the live state, not an endorsement: a status-check floor +that enforces nothing is not a floor. Restoring it is an owner action on the +live ruleset and a deliberate decision about which contexts to require, not +something these files can do on their own — `base.json` is upstream-sourced and +its `required_status_checks` is deliberately empty. +==== + === Known drift between these files and what is live The files below are *not* a transcript of the live rulesets. This is the @@ -95,19 +117,28 @@ divergence as of 2026-10-25, recorded rather than silently reconciled because *Live rulesets that no synced file describes* :: -`code_scanning` (with CodeQL and Hypatia tools) and `copilot_code_review` are -enforced live in `Base` (`23218684`), and `Secret-Scan-Floor` (`24309812`) -enforces `required_status_checks` for `scan / gitleaks` (`integration_id: 15368`), -yet neither appears in `base.json` nor any upstream file synced into this -directory. Upstream's nearest equivalent for code scanning, +CORRECTED 2026-11-08: the previous text here stated that `code_scanning` (CodeQL +and Hypatia) and `copilot_code_review` are "enforced live in `Base` +(`23218684`)", and that `Secret-Scan-Floor` (`24309812`) "enforces +`required_status_checks` for `scan / gitleaks`". Both statements are now false. +`Base` returns 404 and is absent from the ruleset list; `Secret-Scan-Floor` is +active but has `rules: []`. No live branch ruleset carries a `code_scanning` +rule either, so CodeQL and Hypatia currently *report* results without gating +merges. What remains true is the file-vs-live gap: upstream's nearest +equivalent for code scanning, `config/rulesets/Optimus-Extras.json`, declares `code_scanning` with an *empty* -`code_scanning_tools` list, so it does not describe what is live here either. -Conversely, `branch-floor.json` is retained here as the irreducible recovery -payload even though the separate `Branch-Floor` live ruleset is not currently -mounted (`Base` covers both `deletion` and `non_fast_forward`). +`code_scanning_tools` list, so it would not describe a populated live rule +either. Conversely, `branch-floor.json` is retained here as the irreducible +recovery payload even though `Branch-Floor` is not mounted as a live ruleset and +nothing currently supplies its `deletion` / `non_fast_forward` coverage. *Removed from live `Base` on 2026-10-02 (owner decision)* :: +NOTE (2026-11-08): the history below is retained, but `Base` was subsequently +deleted outright — the ruleset returns 404 and is absent from the ruleset list. +Every rule discussed here is now unenforced, including the `deletion` and +`non_fast_forward` coverage noted further down. + `Scorecard` was dropped from `code_scanning_tools`, and the `code_coverage` rule (minimum 80%, max drop 5) was deleted. Neither could ever be satisfied by a pull request. `scorecard.yml` runs only on `schedule` and `workflow_dispatch`, and @@ -123,9 +154,9 @@ so they stay. Do not add either rule back without a producer that runs on `required_linear_history` is in `base.json` but not in live `Base`; it is enforced by the separate `Require-Linear-History` ruleset. `required_status_checks` (an empty list, so a no-op) and the -`pull_request` rule are likewise absent from live `Base`. (`non_fast_forward` is +`pull_request` rule are likewise absent from live `Base`. (`non_fast_forward` was live in `Base` as of 2026-10-02; `branch-floor.json` also specifies it as a -retained recovery payload, though `Branch-Floor` is not mounted as a separate live ruleset.) +retained recovery payload, though `Branch-Floor` is not mounted as a separate live ruleset. With `Base` deleted as of 2026-11-08, no live ruleset supplies `non_fast_forward` or `deletion` on branches.) *`Immutable-Tags.json` lags the live ruleset* :: diff --git a/.github/workflows/README.adoc b/.github/workflows/README.adoc index 50e77417..728bcf33 100644 --- a/.github/workflows/README.adoc +++ b/.github/workflows/README.adoc @@ -11,18 +11,36 @@ not "tidied away" into a real coverage gap. Independent; this is the repo's own Hypatia coverage. * `static-analysis-gate.yml` -> `hypatia-scan` job — runs Hypatia as part of the **gate**, then the `deposit-findings` job hands the results to the - **gitbot-fleet learning** pipeline. This job is also a *required status check*. + **gitbot-fleet learning** pipeline. This job is *documented here as a + required status check*, but as of 2026-11-08 no live rule requires it: + `GET /rules/branches/main` returns only `required_linear_history`. It is a + gate in the sense that it reddens a run; it is not a merge blocker until a + ruleset says so. Same tool, two different consumers (security scan vs fleet learning). Removing either loses real function — keep both. == Secret scanning is single-sourced *in CI* — and two-tier overall -`secret-scanner.yml` calls the standards reusable (gitleaks + a Rust-secrets -check). An inline TruffleHog job was removed: the reusable deliberately retired +`secret-scanner.yml` runs all three estate scanners inline: `scan / gitleaks`, +`scan / rust-secrets` and `scan / shell-secrets`. An inline TruffleHog job was +removed earlier: the standards reusable it was paired with deliberately retired TruffleHog as redundant with gitleaks, so re-adding it was duplicated work, not extra coverage. +The three jobs are inline rather than one `uses:` of the standards +`secret-scanner-reusable.yml`, and that is deliberate. A job-level reusable call +reports ` / `, so the wrapper's three public check names +would depend on job names inside a *remote* workflow: rename one there and three +required contexts move in every generated repository. Inline jobs whose `name:` +is the context make the names this repository's own, and the whole file stays +inside `gh actions-lock`'s model — every `uses:` is a step-level action the +official generator can lock, so regenerating never leaves a dangling edge. Do +not "simplify" this back into a single wrapper call; + +`metadatastician/standards` does it as a wrapper because it *owns* the reusable +and can keep `./`-local, so its own names cannot re-stale. + **That retirement is scoped to CI.** The estate runs a deliberate *two-tier* secret defence, and the second tier is not in this directory: @@ -32,7 +50,7 @@ secret defence, and the second tier is not in this directory: | CI | gitleaks (+ rust-secrets, shell-secrets) -| `secret-scanner.yml` -> standards reusable. Runs on pull_request and on push +| `secret-scanner.yml`, three inline jobs. Runs on pull_request and on push to main — i.e. on what has *already reached* the remote. | Local diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 1b212dfc..f84a996e 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -82,7 +82,7 @@ workflows: '.github/workflows/scorecard.yml': - 'metadatastician/standards@46ee18323305d5b36e2eef936e47dd54c39ea736' '.github/workflows/secret-scanner.yml': - - 'metadatastician/standards@46ee18323305d5b36e2eef936e47dd54c39ea736' + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' '.github/workflows/security-policy.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' '.github/workflows/sonarqube.yml': diff --git a/.github/workflows/dogfood-gate.yml b/.github/workflows/dogfood-gate.yml index 70275ba8..a4bf9b5c 100644 --- a/.github/workflows/dogfood-gate.yml +++ b/.github/workflows/dogfood-gate.yml @@ -41,9 +41,11 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: metadatastician/standards - ref: 8e48b1caade08a552646adeab66558ac43b4360d + ref: e064539116c2dcfdf282a4749dbd58c69961c7e6 path: .standards-deed - sparse-checkout: 1-formats/deed + sparse-checkout: | + 1-formats/deed + scripts sparse-checkout-cone-mode: true persist-credentials: false - name: Setup Bun runtime @@ -55,6 +57,10 @@ jobs: set -euo pipefail rm -rf "$RUNNER_TEMP/standards-deed" mv "$PWD/.standards-deed" "$RUNNER_TEMP/standards-deed" + if [ -f .machine_readable/a2ml-legacy-allow.txt ] && [ ! -f "$RUNNER_TEMP/standards-deed/scripts/check-a2ml-archived.sh" ]; then + echo "::error::A2ML ledger present but scripts/check-a2ml-archived.sh is missing from the standards checkout" + exit 1 + fi STANDARDS_DIR="$RUNNER_TEMP/standards-deed" bash .github/hooks/validate-deed.sh # --------------------------------------------------------------------------- @@ -79,7 +85,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: metadatastician/standards - ref: 8e48b1caade08a552646adeab66558ac43b4360d + ref: e064539116c2dcfdf282a4749dbd58c69961c7e6 path: .standards-k9 sparse-checkout: 1-formats/k9 sparse-checkout-cone-mode: true @@ -103,6 +109,14 @@ jobs: set -euo pipefail rm -rf "$RUNNER_TEMP/standards-k9" mv "$PWD/.standards-k9" "$RUNNER_TEMP/standards-k9" + if [ ! -f "$RUNNER_TEMP/standards-k9/1-formats/k9/tools/k9-validate.sh" ]; then + echo "::error::K9 validator is missing from the standards checkout" + exit 1 + fi + if ! command -v nickel >/dev/null 2>&1; then + echo "::error::nickel is required for K9 layer L2 validation" + exit 1 + fi STANDARDS_DIR="$RUNNER_TEMP/standards-k9" bash .github/hooks/validate-k9.sh # The state -> badge mapping lives in scripts/governance-badge.js and @@ -567,7 +581,7 @@ jobs: else GROOVE_STATUS=":ballot_box_with_check:" fi - if grep -rl 'verisimdb\|VeriSimDB' --include='*.toml' --include='*.yaml' --include='*.yml' --include='*.json' --include='*.rs' --include='*.ex' . 2>/dev/null | head -1 | grep -q .; then + if grep -rl 'verisimdb\|VeriSimDB' --include='*.toml' --include='*.yaml' --include='*.yml' --include='*.kyaml' --include='*.json' --include='*.rs' --include='*.ex' . 2>/dev/null | head -1 | grep -q .; then SCORE=$((SCORE + 1)); VSDB_STATUS=":white_check_mark:" else VSDB_STATUS=":ballot_box_with_check:" @@ -605,7 +619,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: metadatastician/standards - ref: 8e48b1caade08a552646adeab66558ac43b4360d + ref: e064539116c2dcfdf282a4749dbd58c69961c7e6 path: _standards persist-credentials: false - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/dogfood-summary.yml b/.github/workflows/dogfood-summary.yml index 9ff1f478..e0c5cae7 100644 --- a/.github/workflows/dogfood-summary.yml +++ b/.github/workflows/dogfood-summary.yml @@ -64,7 +64,7 @@ jobs: fi # VeriSimDB integration? - if grep -rl 'verisimdb\|VeriSimDB' --include='*.toml' --include='*.yaml' --include='*.yml' --include='*.json' --include='*.rs' --include='*.ex' . 2>/dev/null | head -1 | grep -q .; then + if grep -rl 'verisimdb\|VeriSimDB' --include='*.toml' --include='*.yaml' --include='*.yml' --include='*.kyaml' --include='*.json' --include='*.rs' --include='*.ex' . 2>/dev/null | head -1 | grep -q .; then SCORE=$((SCORE + 1)) VSDB_STATUS=":white_check_mark:" else diff --git a/.github/workflows/estate-rules.yml b/.github/workflows/estate-rules.yml index a25eaf41..82843c67 100644 --- a/.github/workflows/estate-rules.yml +++ b/.github/workflows/estate-rules.yml @@ -105,12 +105,37 @@ jobs: echo "a1bc93654f31669fd964ea3011a5e5e9676b9b6f8adcd762606e5140632ea72d $RUNNER_TEMP/just.tgz" | sha256sum --check --strict tar -xzf "$RUNNER_TEMP/just.tgz" -C "$RUNNER_TEMP/just-bin" just echo "$RUNNER_TEMP/just-bin" >> "$GITHUB_PATH" + # Installed before the mint steps: repo-init and container-init reformat the + # container KYAML they fill (scripts/format-container-kyaml.sh), which needs + # a yq that can write KYAML. The KYAML ratchet below uses the same install. + - name: Install verified yq 4.53.3 and kyaml-format.sh (standards 8e48b1c) + run: | + mkdir -p "$RUNNER_TEMP/yq-bin" + curl --proto '=https' --proto-redir '=https' --fail --silent --show-error --location \ + https://github.com/mikefarah/yq/releases/download/v4.53.3/yq_linux_amd64 \ + --output "$RUNNER_TEMP/yq-bin/yq" + echo "fa52a4e758c63d38299163fbdd1edfb4c4963247918bf9c1c5d31d84789eded4 $RUNNER_TEMP/yq-bin/yq" | sha256sum --check --strict + chmod +x "$RUNNER_TEMP/yq-bin/yq" + echo "$RUNNER_TEMP/yq-bin" >> "$GITHUB_PATH" + curl --proto '=https' --proto-redir '=https' --fail --silent --show-error --location \ + https://raw.githubusercontent.com/metadatastician/standards/e064539116c2dcfdf282a4749dbd58c69961c7e6/scripts/kyaml-format.sh \ + --output "$RUNNER_TEMP/kyaml-format.sh" + echo "5fddd58fc8cbd2b68766ef5b4b5ba39f299da3f27252ae6cdcff0a641bbaef18 $RUNNER_TEMP/kyaml-format.sh" | sha256sum --check --strict - name: Non-interactive mint resolves every prompt from the environment run: bash tests/workflows/non_interactive_mint_test.sh # repo-init recorded the CHILD's own HEAD as the template commit whenever # a repo was created with "Use this template" (metadatastician/lintel). - name: Template pin resolves by tree equality (controls) run: bash tests/workflows/resolve_template_pin_test.sh + # A replay trusts RSR_PACKAGE_UUID; only recorded answers may supply it. + - name: Replay passes a package uuid only from recorded answers + run: bash tests/workflows/replay_mint_env_test.sh + # chain-mint reads checkouts through git: gitlinks kept, no ignored file read. + - name: Chain snapshots keep gitlinks and read no ignored file + run: bash tests/workflows/chain_tree_of_test.sh + # The repository map is line-based: a path holding a newline is refused by name. + - name: Repository map refuses a path holding a newline + run: bash tests/workflows/repo_map_newline_test.sh # The first CI job to run a FULL mint: a Use-this-template child, checked # for provenance, untouched fixtures and ignored files, the map and the # closing advice. A fresh mint of main aborted on 2026-10-20 and nothing @@ -119,6 +144,11 @@ jobs: run: | command -v uuidgen >/dev/null || { sudo apt-get update -q && sudo apt-get install -y -q uuid-runtime; } bash tests/e2e/use_template_mint_test.sh + # A repo made from a MINTED repo: its parent's mint is replayed, the + # template is minted with the new answers, and the parent's own work is + # carried by a three-way merge (Codex review of #251; owner ruling). + - name: Chain mint (a minted repo minted again) end to end + run: bash tests/e2e/chain_mint_test.sh # An archetype overlay once shipped its own actions.lock, which replaced # the spine's at mint and left every Julia mint with Lock Sync red. - name: Archetype lock fragments merge into a valid lock @@ -174,19 +204,6 @@ jobs: # step 6 (standards#1025); files a third-party bot or tool rewrites or # finds by schema (Dependabot, FUNDING, issue forms, settings, CodeRabbit, # Copilot, GitLab CI, pre-commit) are not listed, as in standards. - - name: Install verified yq 4.53.3 and kyaml-format.sh (standards 8e48b1c) - run: | - mkdir -p "$RUNNER_TEMP/yq-bin" - curl --proto '=https' --proto-redir '=https' --fail --silent --show-error --location \ - https://github.com/mikefarah/yq/releases/download/v4.53.3/yq_linux_amd64 \ - --output "$RUNNER_TEMP/yq-bin/yq" - echo "fa52a4e758c63d38299163fbdd1edfb4c4963247918bf9c1c5d31d84789eded4 $RUNNER_TEMP/yq-bin/yq" | sha256sum --check --strict - chmod +x "$RUNNER_TEMP/yq-bin/yq" - echo "$RUNNER_TEMP/yq-bin" >> "$GITHUB_PATH" - curl --proto '=https' --proto-redir '=https' --fail --silent --show-error --location \ - https://raw.githubusercontent.com/metadatastician/standards/8e48b1caade08a552646adeab66558ac43b4360d/scripts/kyaml-format.sh \ - --output "$RUNNER_TEMP/kyaml-format.sh" - echo "5fddd58fc8cbd2b68766ef5b4b5ba39f299da3f27252ae6cdcff0a641bbaef18 $RUNNER_TEMP/kyaml-format.sh" | sha256sum --check --strict # Container narrowing at mint (build/just/container.just) can delete any # of these files. The template (archetypes/ present) must keep all of # them; a minted repository checks the ones it kept and names the rest. @@ -267,3 +284,9 @@ jobs: set -euo pipefail bash tests/workflows/squabble_gate_test.sh bash scripts/check-required-contexts.sh . + # The three scanner contexts, their least privilege and their pinning are + # this repository's own: a job-level reusable wrapper would make them the + # CALLEE's job names, and `secrets: inherit` would forward every secret to + # a scanner that declares none. Every property is mutated in the test. + - name: Secret scanner contract (three stable contexts) + run: bash tests/workflows/secret_scanner_contract_test.sh diff --git a/.github/workflows/fork-governance.yml b/.github/workflows/fork-governance.yml index f00165dc..f243ef16 100644 --- a/.github/workflows/fork-governance.yml +++ b/.github/workflows/fork-governance.yml @@ -4,7 +4,7 @@ # # fork-governance.yml — PR-time entry point for DEED syntax, K9-DEED L0–L2 # conformance, the gitbot-fleet report-backs, and THE Hypatia verdict grid. -# Everything is pinned to metadatastician/standards@8e48b1caade08a552646adeab66558ac43b4360d. +# Everything is pinned to metadatastician/standards@e064539116c2dcfdf282a4749dbd58c69961c7e6. # # ── HOW THIS FILE RELATES TO THE SCANNERS (read before editing) ────────────── # @@ -90,9 +90,11 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: metadatastician/standards - ref: 8e48b1caade08a552646adeab66558ac43b4360d + ref: e064539116c2dcfdf282a4749dbd58c69961c7e6 path: .standards-deed - sparse-checkout: 1-formats/deed + sparse-checkout: | + 1-formats/deed + scripts sparse-checkout-cone-mode: true persist-credentials: false @@ -102,12 +104,30 @@ jobs: bun-version: '1.3.12' - name: Validate DEED manifests and block retired .a2ml files + env: + BASE_REF: ${{ github.base_ref }} run: | set -euo pipefail STANDARDS_ABS="$PWD/.standards-deed" rm -rf "$RUNNER_TEMP/standards-deed" mv "$STANDARDS_ABS" "$RUNNER_TEMP/standards-deed" - STANDARDS_DIR="$RUNNER_TEMP/standards-deed" bash .github/hooks/validate-deed.sh + # The lane is the gate: a ledger with no canonical gate to read it fails. + if [ -f .machine_readable/a2ml-legacy-allow.txt ] && [ ! -f "$RUNNER_TEMP/standards-deed/scripts/check-a2ml-archived.sh" ]; then + echo "::error::A2ML ledger present but scripts/check-a2ml-archived.sh is missing from the standards checkout" + exit 1 + fi + # Shrink-only: the base branch's ledger bounds this one. A base that + # cannot be fetched fails the step: skipping the comparison would let + # a new ledger entry through exactly when it cannot be checked. + A2ML_BASE="" + if [ -n "$BASE_REF" ]; then + if ! git fetch -q --depth=1 origin "$BASE_REF"; then + echo "::error::could not fetch the base branch $BASE_REF to bound the A2ML ledger" + exit 1 + fi + A2ML_BASE=FETCH_HEAD + fi + A2ML_BASE="$A2ML_BASE" STANDARDS_DIR="$RUNNER_TEMP/standards-deed" bash .github/hooks/validate-deed.sh k9: name: K9-DEED conformance @@ -128,7 +148,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: metadatastician/standards - ref: 8e48b1caade08a552646adeab66558ac43b4360d + ref: e064539116c2dcfdf282a4749dbd58c69961c7e6 path: .standards-k9 sparse-checkout: 1-formats/k9 sparse-checkout-cone-mode: true @@ -148,6 +168,14 @@ jobs: set -euo pipefail rm -rf "$RUNNER_TEMP/standards-k9" mv "$PWD/.standards-k9" "$RUNNER_TEMP/standards-k9" + if [ ! -f "$RUNNER_TEMP/standards-k9/1-formats/k9/tools/k9-validate.sh" ]; then + echo "::error::K9 validator is missing from the standards checkout" + exit 1 + fi + if ! command -v nickel >/dev/null 2>&1; then + echo "::error::nickel is required for K9 layer L2 validation" + exit 1 + fi STANDARDS_DIR="$RUNNER_TEMP/standards-k9" bash .github/hooks/validate-k9.sh # Fleet report-backs: the evidence behind each gitbot-fleet row of the verdict @@ -165,12 +193,14 @@ jobs: fleet: name: Fleet report-backs runs-on: ubuntu-24.04 - timeout-minutes: 45 + # Worst case, so a stalled phase never kills the job before the upload: the + # 2400 s build budget + seven adapters at 300 s + checkout, cache and upload. + timeout-minutes: 85 permissions: contents: read env: # hyperpolymath/gitbot-fleet commit whose driver and adapters run. - FLEET_REF: e85b71bf139a61f956c7e0717a2912cd6e347cc5 + FLEET_REF: e44af8144789594fcce891ccc7b77fc9a464a520 # panic-attack commit for panicbot. Deliberately the SAME commit # static-analysis-gate.yml builds: panicbot is not enrolled today (the # grid's own panic-attack row already grades that scanner, so a second @@ -322,7 +352,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: metadatastician/standards - ref: 8e48b1caade08a552646adeab66558ac43b4360d + ref: e064539116c2dcfdf282a4749dbd58c69961c7e6 path: .standards-grid persist-credentials: false sparse-checkout: | diff --git a/.github/workflows/proven-tests.yml b/.github/workflows/proven-tests.yml index 8135e18b..40654bb6 100644 --- a/.github/workflows/proven-tests.yml +++ b/.github/workflows/proven-tests.yml @@ -40,7 +40,7 @@ jobs: git -C "$RUNNER_TEMP/standards" remote add origin https://github.com/metadatastician/standards.git git -C "$RUNNER_TEMP/standards" config core.sparseCheckout true printf '1-formats/deed/\n1-formats/k9/\n' > "$RUNNER_TEMP/standards/.git/info/sparse-checkout" - git -C "$RUNNER_TEMP/standards" fetch -q --depth 1 origin 8e48b1caade08a552646adeab66558ac43b4360d + git -C "$RUNNER_TEMP/standards" fetch -q --depth 1 origin e064539116c2dcfdf282a4749dbd58c69961c7e6 git -C "$RUNNER_TEMP/standards" checkout -q FETCH_HEAD - name: Set up Bun diff --git a/.github/workflows/secret-scanner.yml b/.github/workflows/secret-scanner.yml index f31be135..5419b491 100644 --- a/.github/workflows/secret-scanner.yml +++ b/.github/workflows/secret-scanner.yml @@ -1,29 +1,365 @@ # This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 name: Secret Scanner + on: pull_request: push: branches: [main] + workflow_dispatch: + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true + permissions: - actions: read contents: read -# Single secret scanner. The standards reusable runs gitleaks (+ a Rust-secrets -# check). An inline TruffleHog job previously lived here, but the reusable -# DELIBERATELY retired TruffleHog as redundant (gitleaks gives sufficient -# coverage at lower cost — see the reusable's header). Re-adding it was -# duplicated work, not extra coverage, so it has been removed. See -# .github/workflows/README.adoc. + +# Self-contained execution of the estate secret-scanning contract pinned to +# metadatastician/standards@e064539116c2dcfdf282a4749dbd58c69961c7e6. +# Produces the `scan / gitleaks` status check that the `Secret-Scan-Floor` +# ruleset (24309812) was created to require on `main`. +# +# HONEST STATUS OF THAT RULE (measured 2026-11-08): the ruleset is `active` +# with `conditions.ref_name.include: ["~DEFAULT_BRANCH"]` but carries +# `rules: []` and a repository-role-5 `bypass_actors` entry with +# `bypass_mode: always`. It currently requires NOTHING, so this check is +# PRODUCED and reported, not enforced. `GET /rules/branches/main` returns only +# `required_linear_history`. Do not describe these contexts as required without +# re-reading the live ruleset; see `.github/rulesets/README.adoc`. +# +# ─── WHY THREE JOBS, AND WHY THEY ARE INLINE ───────────────────────────────── +# +# The estate canonical wraps all three scanners in ONE reusable workflow call: +# +# jobs: +# scan: +# uses: metadatastician/standards/.github/workflows/secret-scanner-reusable.yml@ +# +# That shape cannot satisfy this repository's contract, and the reason is +# measured, not theoretical. A job-level reusable call produces exactly one +# check, named ` / ` — so the wrapper above +# yields `scan / gitleaks`, `scan / rust-secrets` and `scan / shell-secrets` +# only while the called workflow happens to define jobs with those names. The +# wrapper's own job is named `scan`, so any rename inside the callee silently +# changes three public check names in every generated repository. Three +# required contexts that move when a remote repository is edited is not a +# contract. +# +# This repository therefore runs the same three scanners as three ordinary +# jobs whose `name:` IS the context: +# +# scan / gitleaks (gitleaks, estate baseline + repo config, +# working tree + full history + AsciiDoc mirror) +# scan / rust-secrets (hardcoded literals in .rs files; self-skips +# without a Cargo.toml) +# scan / shell-secrets (hardcoded literals in shell scripts) +# +# All three declare `permissions: contents: read` and NO `secrets:` line. The +# reusable declares no `workflow_call.secrets` and references no `secrets.*` +# context, so `secrets: inherit` on a caller buys nothing and forwards every +# repository and organisation secret to a scanner that needs none (CWE-250, +# standards#1108 §6b). Do not add it back. +# +# ─── actions.lock AND THIS FILE ────────────────────────────────────────────── +# +# Every `uses:` here is a step-level action that `gh actions-lock` knows how to +# lock, so the whole file is inside the generator's model: `gh actions-lock` +# records each ref and cannot produce a dangling edge for anything in it. That +# is deliberate. A job-level call to a remote reusable is NOT lockable by the +# tool, and a hand-added record for one is worse than no record: an entry that +# the runner cannot resolve fails the run at startup with zero jobs, while an +# absent one is merely unverified. +# +# Regenerate with the official generator only: +# +# gh actions-lock # writes .github/workflows/actions.lock +# gh actions-lock --verify # read-only check +# scripts/check-lock-sync.sh .github/workflows # transitive-closure gate +# +# The scanner's own pin is a full 40-hex SHA in `STANDARDS_SHA` below and in the +# estate-baseline checkout, and it must match the standards pin every other +# workflow in this directory uses. jobs: - scan: - # The reusable installs and executes a pinned gitleaks binary directly. - # Since standards#500 it neither comments on PRs nor reads workflow-run - # metadata, so contents: read is the complete permission contract. Keep - # this job-level block explicit: it replaces the workflow-level grant and - # is therefore the cap GitHub applies to the called workflow. + gitleaks: + name: "scan / gitleaks" + runs-on: ubuntu-24.04 + timeout-minutes: 15 permissions: contents: read - uses: metadatastician/standards/.github/workflows/secret-scanner-reusable.yml@46ee18323305d5b36e2eef936e47dd54c39ea736 + env: + GITLEAKS_VERSION: '8.18.4' + GITLEAKS_SHA256: 'ba6dbb656933921c775ee5a2d1c13a91046e7952e9d919f9bac4cec61d628e7d' + STANDARDS_SHA: 'e064539116c2dcfdf282a4749dbd58c69961c7e6' + steps: + - name: Checkout repository with full history + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Checkout estate gitleaks baseline + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: metadatastician/standards + ref: e064539116c2dcfdf282a4749dbd58c69961c7e6 + path: .standards-baseline + sparse-checkout: config/gitleaks + sparse-checkout-cone-mode: true + persist-credentials: false + + - name: Stage estate baseline and install pinned gitleaks + run: | + set -euo pipefail + cp .standards-baseline/config/gitleaks/estate-baseline.toml .gitleaks-estate.toml + rm -rf .standards-baseline + test -s .gitleaks-estate.toml + + TARBALL="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" + URL="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/${TARBALL}" + curl -sSfL --proto '=https' --proto-redir '=https' --tlsv1.2 \ + --retry 3 --retry-delay 2 \ + -o "$RUNNER_TEMP/$TARBALL" "$URL" + echo "${GITLEAKS_SHA256} ${RUNNER_TEMP}/${TARBALL}" | sha256sum -c - + tar -xzf "$RUNNER_TEMP/$TARBALL" -C "$RUNNER_TEMP" gitleaks + chmod +x "$RUNNER_TEMP/gitleaks" + rm -f "$RUNNER_TEMP/$TARBALL" + + - name: Gitleaks working-tree pass + run: | + set -euo pipefail + # The estate baseline always runs; a repository .gitleaks.toml adds a + # second pass and can never stand in for the baseline. + CONFIGS=(".gitleaks-estate.toml") + if [ -f .gitleaks.toml ]; then + CONFIGS+=(".gitleaks.toml") + fi + for CONFIG in "${CONFIGS[@]}"; do + "$RUNNER_TEMP/gitleaks" detect \ + --source . \ + --no-git \ + --redact \ + --no-banner \ + --verbose \ + --config "$CONFIG" \ + --exit-code 1 + done + + - name: Gitleaks full-history pass + run: | + set -euo pipefail + if [ "$(git rev-parse --is-shallow-repository)" != "false" ]; then + echo "::error::checkout is shallow; refusing vacuous history scan" + exit 1 + fi + # The estate baseline always runs; a repository .gitleaks.toml adds a + # second pass and can never stand in for the baseline. + CONFIGS=(".gitleaks-estate.toml") + if [ -f .gitleaks.toml ]; then + CONFIGS+=(".gitleaks.toml") + fi + for CONFIG in "${CONFIGS[@]}"; do + "$RUNNER_TEMP/gitleaks" detect \ + --source . \ + --redact \ + --no-banner \ + --verbose \ + --config "$CONFIG" \ + --exit-code 1 + done + + - name: Gitleaks AsciiDoc mirror pass + run: | + set -euo pipefail + MIRROR="$RUNNER_TEMP/adoc-mirror" + rm -rf "$MIRROR" + mkdir -p "$MIRROR" + 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." + exit 0 + fi + # The estate baseline always runs; a repository .gitleaks.toml adds a + # second pass and can never stand in for the baseline. + CONFIGS=("$PWD/.gitleaks-estate.toml") + if [ -f .gitleaks.toml ]; then + CONFIGS+=("$PWD/.gitleaks.toml") + fi + for CONFIG in "${CONFIGS[@]}"; do + "$RUNNER_TEMP/gitleaks" detect \ + --source "$MIRROR" \ + --no-git \ + --redact \ + --no-banner \ + --config "$CONFIG" \ + --exit-code 1 + done + + rust-secrets: + name: "scan / rust-secrets" + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + env: + # Findings outside ./src (newly visible — see the scan-scope note in the + # step below) warn until this date, then block. Same warn-then-enforce + # idiom and same cutoff as the estate canonical reusable + # (metadatastician/standards .github/workflows/secret-scanner-reusable.yml + # at the pin named in this file's header), so this job and the reusable + # cannot disagree about when the widened scan starts blocking. + ENFORCE_RUST_WIDE_SCAN_FROM: '2026-08-21' + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Check for hardcoded secrets in Rust + run: | + set -euo pipefail + 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" + + # Self-skips when the tree has no Cargo.toml, so the job is safe on a + # repository that is not (yet) Rust. The check run still appears, which + # is what keeps the context stable across every generated repository. + 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 a `grep … src/` matches nothing there: + # grep exits 2 on a missing directory, which an `if` reads as "clean". + 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, so lookups, paths and demos do not match. A + # credential shorter than 8 characters is not a credential either. + # `static` is accepted alongside `const`: a `static AUTH_KEY: &str = + # "…"` is a genuine hardcoded secret. + 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 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. + # 1. env-lookup RHS — `env::var("NAME")` IS the practice this job + # exists to enforce, so flagging it fails the correct code. + # 2. URL values — an OAuth endpoint is public, not a credential. + # 3. comment lines — documentation of a pattern is not a 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 hits keep blocking. Hits outside it are newly visible, so + # they warn until the cutoff rather than reddening repositories + # whose wider tree was 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-secrets: + name: "scan / shell-secrets" + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Run repository secret pattern hook + run: | + set -euo pipefail + rc=0 + git grep -IlE '(ghp_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{82}|glpat-[A-Za-z0-9_-]{20,}|AKIA[0-9A-Z]{16}|-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----)' > "$RUNNER_TEMP/hits.txt" || rc=$? + # git grep exits 1 when nothing matches; above 1 is an error. + if [ "$rc" -gt 1 ]; then + echo "::error::Secret pattern scan failed with exit ${rc}" + exit 2 + fi + # Exclude intentional scanner rules and adversarial negative test fixtures. + filtered="$(grep -vE '^(\.github/hooks/scan-secrets\.sh|\.github/workflows/secret-scanner\.yml|\.machine_readable/testing/adversarial-content\.k9\.ncl|tests/)' "$RUNNER_TEMP/hits.txt" || :)" + if [ -n "$filtered" ]; then + echo "::error::Hardcoded credential material detected:" + printf '%s\n' "$filtered" + exit 1 + fi + echo "No hardcoded shell/source credentials detected." diff --git a/.github/workflows/security-policy.yml b/.github/workflows/security-policy.yml index 6d9e43f5..a95cf6c0 100644 --- a/.github/workflows/security-policy.yml +++ b/.github/workflows/security-policy.yml @@ -36,7 +36,7 @@ jobs: fi # Block HTTP URLs (except localhost) - HTTP_URLS=$(grep -rE 'http://[^l][^o][^c]' --include="*.py" --include="*.js" --include="*.ts" --include="*.go" --include="*.rs" --include="*.yaml" --include="*.yml" . 2>/dev/null | grep -v 'localhost\|127.0.0.1\|example\|test\|spec' | head -5 || true) + HTTP_URLS=$(grep -rE 'http://[^l][^o][^c]' --include="*.py" --include="*.js" --include="*.ts" --include="*.go" --include="*.rs" --include="*.yaml" --include="*.yml" --include="*.kyaml" . 2>/dev/null | grep -v 'localhost\|127.0.0.1\|example\|test\|spec' | head -5 || true) if [ -n "$HTTP_URLS" ]; then echo "⚠️ HTTP URLs found. Use HTTPS:" echo "$HTTP_URLS" diff --git a/.github/workflows/verdict-grid-publish.yml b/.github/workflows/verdict-grid-publish.yml index 7a67d172..d1be6e1b 100644 --- a/.github/workflows/verdict-grid-publish.yml +++ b/.github/workflows/verdict-grid-publish.yml @@ -71,7 +71,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: metadatastician/standards - ref: 8e48b1caade08a552646adeab66558ac43b4360d + ref: e064539116c2dcfdf282a4749dbd58c69961c7e6 path: .standards-grid persist-credentials: false sparse-checkout: | diff --git a/.machine_readable/mint-verbatim.txt b/.machine_readable/mint-verbatim.txt index c8eb0885..c7030f4a 100644 --- a/.machine_readable/mint-verbatim.txt +++ b/.machine_readable/mint-verbatim.txt @@ -27,6 +27,7 @@ scripts/strip-instruction-blocks.rs # names the marker it strips scripts/resolve-template-pin.sh # documents the template it resolves tests/e2e/template_instantiation_test.sh # token answer list; marker exemptions tests/e2e/use_template_mint_test.sh # mints a template copy: template literals +tests/e2e/chain_mint_test.sh # chain fixtures: parent names and paths tests/workflows/mint_cleanup_test.sh # instruction-block fixture tests/workflows/self_name_pass_test.sh # template-name fixtures tests/workflows/template_conformance_test.sh # template-slug fixtures @@ -35,3 +36,5 @@ tests/workflows/mint_gates_test.sh # names an unanswered token, on purpose tests/e2e/language_profiles_mint_test.sh # minted-tree fixtures; keeps archetype paths tests/workflows/resolve_template_pin_test.sh # template-slug fixtures docs/reports/audit/consistency-audit-2026-11-01.adoc # quotes an unfilled project-name token as prose: the audit reports where it was left +tests/ffi_strings_test.sh # FFI token fixture, filled in a scratch copy +tests/workflows/container_kyaml_test.sh # container token fixtures (template only) diff --git a/.machine_readable/required-contexts-allow.txt b/.machine_readable/required-contexts-allow.txt index 1f429f72..dcab0ef1 100644 --- a/.machine_readable/required-contexts-allow.txt +++ b/.machine_readable/required-contexts-allow.txt @@ -8,4 +8,28 @@ # Verified by `scripts/check-required-contexts.sh` (3-outcome 0/1/2) so a # renamed or path-filtered workflow can never silently strand pull requests # behind an unsatisfiable ruleset check (issue #219 class). +# +# All three contexts come from ONE workflow and are ordinary job names, not the +# ` / ` pairs a job-level reusable call would produce: +# +# * `scan / gitleaks` is the historic headline context for the +# `Secret-Scan-Floor` ruleset (24309812). NOTE: as of 2026-11-08 that +# ruleset reads `rules: []` and enforces nothing on `main`, so this context +# is currently PRODUCED but not REQUIRED. Do not describe it as required +# without re-reading the live ruleset. +# * `scan / rust-secrets` and `scan / shell-secrets` are produced by the same +# workflow and are listed here so that (a) the gate certifies that every +# generated repository really starts all three scanner jobs, and (b) turning +# any of them on in a ruleset needs no mapping edit. Listing a produced +# context is not the same as requiring it: this file is a producer map, and +# the empty `required_status_checks` in `.github/rulesets/base.json` and in +# `.github/settings.yml` is still what keeps the floor where the owner left +# it. +# +# A context here MUST be a job `name:` (or job id) in its workflow. A remote +# reusable-workflow call produces nothing this gate can certify, which is why +# `.github/workflows/secret-scanner.yml` runs its three scanners inline instead +# of wrapping them in a single reusable call. scan / gitleaks => .github/workflows/secret-scanner.yml +scan / rust-secrets => .github/workflows/secret-scanner.yml +scan / shell-secrets => .github/workflows/secret-scanner.yml diff --git a/.machine_readable/testing/adversarial-content.k9.ncl b/.machine_readable/testing/adversarial-content.k9.ncl index 97effdd8..3374d1d9 100644 --- a/.machine_readable/testing/adversarial-content.k9.ncl +++ b/.machine_readable/testing/adversarial-content.k9.ncl @@ -31,6 +31,10 @@ K9! path = "tests/workflows/squabble_gate_test.sh", purpose = "Plants admin-override strings, continue-on-error masks, and phantom ruleset contexts into ephemeral workflow fixtures to prove cicd-squabbler and check-required-contexts.sh fire.", }, + { + path = "tests/workflows/secret_scanner_contract_test.sh", + purpose = "Mutates the shipped secret-scanner workflow — renames a job, deletes one, adds `secrets: inherit`, adds a secrets.* read, widens the permission grant, unpins an action, retargets push away from the default branch, drops pull_request, drops concurrency, and converts a job to a remote reusable call — to prove the three stable scanner contexts stay this repository's own.", + }, { path = "tests/workflows/governance_badge_test.js", purpose = "Feeds malformed lane artifacts, malformed existing badge files, 401/403/429/5xx responses, a dead API and 409 sha conflicts into scripts/governance-badge.js against a mock GitHub API, and runs the workflow-wiring checks against a mutated workflow, to prove the publisher never publishes a false green and never dedupes a changed badge.", diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 889f9d56..54d9bb19 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -10,6 +10,17 @@ https://semver.org/spec/v2.0.0.html[Semantic Versioning]. ==== Fixed +* **Mint machinery synced to rsr-template-repo 2be7b88.** estate-rules was red: + the inherited `tests/e2e/use_template_mint_test.sh` (template c481073) + re-minted this already-minted tree and failed on an unfilled token. The + template fixed that in #275, #291, #293 and #294; files this repository held + byte-identical to c481073 now take the template's current version, diverged + files were three-way merged, and the inline three-scanner secret workflow + (#295) replaces the reusable caller. Kept as this repository's own: the + governance caller (#15), the verdict-grid pin (standards 8e48b1c, which + renders; the template's e064539 does not), the rule-order checker and its + policy, README and `mise.toml` (plus `yq`). + * **The template's gates could not fail, and the toolchain was not minted.** Reported from JuliaPackageSpitter.jl and traced here; see `docs/reports/compliance/RSR-TEMPLATE-DAMAGE-2026-10-30.adoc` for the full diff --git a/CLAUDE.md b/CLAUDE.md index b385e2e2..aac404fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -158,3 +158,10 @@ Third-party code (crates such as Bevy, libraries, forks) is **not** converted or re-proved. State what a proof relies on as an `ASSUMED` boundary specification, and when choosing between dependencies that fit equally well, prefer one that carries its own verification. A preference, not a gate. + +Proof grades are policy §8, *Proof Witness Grades*: `PWG-3` < `PWG-2i` < +`PWG-2ii` < `PWG-1`, where `PWG-1` is strongest. A grade is derived from +receipts, never asserted. A "verified" or "machine-checked" claim needs +current receipts. Report a census with its denominator, never a +percentage. Always write the `PWG-` prefix: CRG, TRG, ARG and FRG letters, +and ECHIDNA's `TrustLevel`, are different schemes. diff --git a/CODE_OF_CONDUCT.adoc b/CODE_OF_CONDUCT.adoc index 954b4f91..4af75b50 100644 --- a/CODE_OF_CONDUCT.adoc +++ b/CODE_OF_CONDUCT.adoc @@ -25,7 +25,7 @@ If you believe someone is violating the code of conduct, we ask that you report - Repeated harassment of others. In general, if someone asks you to stop, then stop. - **When we disagree, try to understand why.** Disagreements, both social and technical, happen all the time and Hyperpolymathy Estates is no exception. It is important that we resolve disagreements and differing views constructively. Remember that we’re different. The strength of Hyperpolymathy Estates comes from its varied community, people from a wide range of backgrounds. Different people have different perspectives on issues. Being unable to understand why someone holds a viewpoint doesn’t mean that they’re wrong. Don’t forget that it is human to err and blaming each other doesn’t get us anywhere. Instead, focus on helping to resolve issues and learning from mistakes. -Original text courtesy of the [Speak Up! project](http://web.archive.org/web/20141109123859/http://speakup.io/coc.html). +Original text courtesy of the [Speak Up! project](https://web.archive.org/web/20141109123859/speakup.io/coc.html). ## Questions? diff --git a/build/just/container.just b/build/just/container.just index 900bcc1c..f56a8561 100644 --- a/build/just/container.just +++ b/build/just/container.just @@ -46,6 +46,10 @@ container-init: exit 1 fi + # The configs are reformatted after their tokens are filled, which needs a + # KYAML-capable yq: check before anything is written. + bash scripts/format-container-kyaml.sh --preflight + echo "=== Container Template Initialisation ===" echo "" @@ -65,8 +69,11 @@ container-init: read -rp "Primary port [8080]: " _PORT PORT="${_PORT:-8080}" - read -rp "Container registry [ghcr.io/${OWNER:-{{OWNER}}}]: " _REGISTRY - REGISTRY="${_REGISTRY:-ghcr.io/${OWNER:-{{OWNER}}}}" + # Image paths are lower case; an owner login may not be. + _OWNER="${OWNER:-{{OWNER}}}"; _OWNER="${_OWNER,,}" + read -rp "Container registry [ghcr.io/${_OWNER}]: " _REGISTRY + REGISTRY="${_REGISTRY:-ghcr.io/${_OWNER}}" + bash scripts/format-container-kyaml.sh --check-values "$SERVICE_NAME" "$PORT" "$REGISTRY" echo "" echo " Service: $SERVICE_NAME" @@ -106,6 +113,10 @@ container-init: fi done + # Filling a token can change how KYAML quotes it; reformat the configs or + # Estate Rules' "Converted YAML stays KYAML" fails the child. + bash scripts/format-container-kyaml.sh + # The k9-svc deploy component ships as a template (deploy.k9.ncl.in): a # file with brace tokens is not a K9 component. Render it once nothing is # left to fill (repo-init fills PROJECT_NAME and VERSION), so the K9 lane diff --git a/build/just/provision-lib.sh b/build/just/provision-lib.sh index 83ac0011..4993506a 100755 --- a/build/just/provision-lib.sh +++ b/build/just/provision-lib.sh @@ -176,7 +176,7 @@ guix_specs() { # mise tools per language (registry-checked 2026-09-30, mise 2026.7.5). idris2 comes # from pack and haskell from ghcup or Guix: neither is a mise tool. The recipes # themselves need just and shellcheck, so those two are always declared. -MISE_BASE="just shellcheck" +MISE_BASE="just shellcheck yq" # yq: scripts/kyaml-format.sh (KYAML configs; the mint formats the container ones) # Tools a repo's own recipes call (e.g. `deps-audit` runs trivy): pinned only where used. RECIPE_TOOLS="trivy" # Print the mise tool names for language $1, if supported. diff --git a/build/just/repo-init.just b/build/just/repo-init.just index 59c5db2e..35f7e61e 100644 --- a/build/just/repo-init.just +++ b/build/just/repo-init.just @@ -44,6 +44,28 @@ repo-init archetype="": echo "Error: repo-init must run inside a git work tree (run 'git init' first)." exit 1 fi + # The mint runs git against other repositories too (replays, a template + # clone, the tests it runs in scratch repositories), and an inherited + # repository location (GIT_DIR, GIT_INDEX_FILE, … from a hook or another git + # caller) would redirect every one of them into this repository: the + # replay's commits, the tests' fixture commits (CodeRabbit review of #275). + # So the location is cleared, once, here, for this recipe and everything it + # starts; one that named a different repository than this work tree's own + # is refused, since clearing it would mint something else. + _git_loc=(GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE GIT_OBJECT_DIRECTORY + GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_PREFIX GIT_NAMESPACE) + _git_set=""; for _v in "${_git_loc[@]}"; do [ -z "${!_v+x}" ] || _git_set="${_git_set} ${_v}"; done + if [ -n "$_git_set" ]; then + _here_gd="$(git rev-parse --absolute-git-dir 2>/dev/null || true)" + _bare_gd="$(cd "$(git rev-parse --show-toplevel)" && env $(printf -- '-u %s ' "${_git_loc[@]}") git rev-parse --absolute-git-dir 2>/dev/null || true)" + if [ -z "$_bare_gd" ] || [ "$_here_gd" != "$_bare_gd" ]; then + echo "Error: mint aborted before writing anything — git location variables are set" + echo " (${_git_set# }) and name a repository other than this work tree's own." + echo " Run repo-init from the repository's work tree without them." + exit 1 + fi + unset "${_git_loc[@]}" + fi # --- The template's own identity --------------------------------------- # repo-init is carried into every minted repo UNCHANGED (it is listed in @@ -56,33 +78,72 @@ repo-init archetype="": # metadatastician/lintel 2026-10-19; first fixed there in lintel#9 (not # merged), ported here, where the generator lives. # - # Derived, never asked: these are facts about the checkout, not choices. - # The root Justfile comes first because its OWNER/REPO lines are also the - # literals written through the tree, so they are exactly what the rewrite - # has to match, and they are still the TEMPLATE's in a repository created - # with "Use this template" (whose git remote is already the child's). The - # remote is second, for a Justfile that no longer declares them. The - # original template is last, so a bare export still mints something valid. + # Derived, never guessed: these are facts about the checkout, not choices. + # The root Justfile's OWNER/REPO lines are the literals written through the + # tree, so they are exactly what the rewrite has to match, and they are + # still the TEMPLATE's in a repository created with "Use this template". + # When the Justfile does not declare them, RSR_TEMPLATE_SLUG=owner/repo + # must say so, or the mint stops. There is deliberately no fallback to the + # git remote: in a template-created child the remote is the CHILD, so that + # fallback named the child as its own template, and the pin resolver then + # recorded the child's HEAD (Codex review of #251). Nor is there a default + # template name: a guess here is the hardcoding this block replaced. TEMPLATE_OWNER="" TEMPLATE_REPO="" if [ -f Justfile ]; then - TEMPLATE_REPO="$(sed -n -E 's/^REPO[[:space:]]*:=[[:space:]]*"([^"]+)".*/\1/p' Justfile | head -1)" - TEMPLATE_OWNER="$(sed -n -E 's/^OWNER[[:space:]]*:=[[:space:]]*"([^"]+)".*/\1/p' Justfile | head -1)" + TEMPLATE_REPO="$(sed -n -E "s/^(export[[:space:]]+)?REPO[[:space:]]*:=[[:space:]]*[\"']([^\"']+)[\"'].*/\2/p" Justfile | head -1)" + TEMPLATE_OWNER="$(sed -n -E "s/^(export[[:space:]]+)?OWNER[[:space:]]*:=[[:space:]]*[\"']([^\"']+)[\"'].*/\2/p" Justfile | head -1)" fi if [ -z "$TEMPLATE_REPO" ] || [ -z "$TEMPLATE_OWNER" ]; then - _origin="$(git config --get remote.origin.url 2>/dev/null || true)" - if [ -n "$_origin" ]; then - _origin="${_origin%.git}" - _origin="${_origin%/}" - [ -n "$TEMPLATE_REPO" ] || TEMPLATE_REPO="${_origin##*[/:]}" - _origin="${_origin%/*}" - [ -n "$TEMPLATE_OWNER" ] || TEMPLATE_OWNER="${_origin##*[/:]}" + if [[ "${RSR_TEMPLATE_SLUG:-}" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]]; then + TEMPLATE_OWNER="${RSR_TEMPLATE_SLUG%%/*}" + TEMPLATE_REPO="${RSR_TEMPLATE_SLUG#*/}" + else + echo "Error: the root Justfile does not declare OWNER := and REPO :=, so the" + echo " template this tree came from is unknown. Set RSR_TEMPLATE_SLUG=owner/repo." + exit 1 fi fi - TEMPLATE_REPO="${TEMPLATE_REPO:-rsr-template-repo}" - TEMPLATE_OWNER="${TEMPLATE_OWNER:-hyperpolymath}" TEMPLATE_SLUG="${TEMPLATE_OWNER}/${TEMPLATE_REPO}" + # --- Only a template can be minted (until chain minting lands) ------------ + # A minted repo has already filled its tokens and deleted build/templates, + # so minting it again renders none of the new answers and skips the + # identity splice: the grandchild silently inherited its parent's author, + # contacts, clade and maturity (Codex review of #251). The owner chose to + # support chains properly (2026-11-04); until a minted repo carries the + # pristine inputs that makes possible, stop here, before anything is + # asked or written. A variant template keeps build/templates and still mints. + # + # Chain minting (owner ruling 2026-11-04): a minted repo that recorded its + # (mint-answers) CAN be minted again. scripts/chain-mint.sh replays its + # mint, mints the same template commit with the new answers, and carries + # its own work across by a three-way merge. One minted before answers were + # recorded cannot be replayed, so it is still refused. + # + # Which it is, the deed's recorded mint state decides first: a template + # (or a variant template, which is maintained, never minted) records no + # (mint-answers). A minted repo that added build/templates/identity.deed.in + # back as its own work is still a minted repo (Codex review of #277). + CHAIN_MINT=0 + _prov_deed="$(bash scripts/deed-field.sh --find . 2>/dev/null || true)" + if [ -n "$_prov_deed" ] && bash scripts/deed-field.sh --has "$_prov_deed" mint-answers \ + && [ -f scripts/chain-mint.sh ]; then + CHAIN_MINT=1 + echo "This tree is a minted repo (${TEMPLATE_SLUG}): it will be minted as a chain," + echo "from its template, carrying its own work since its mint." + echo "" + elif [ ! -f build/templates/identity.deed.in ]; then + echo "Error: this tree has already been minted (build/templates/identity.deed.in is" + echo " absent) and records no (mint-answers), so its mint cannot be replayed" + echo " and it cannot be minted again. Mint from the template it came from." + if [ -n "$_prov_deed" ]; then + _prov_from="$(bash scripts/deed-field.sh "$_prov_deed" provenance template-repo 2>/dev/null || true)" + [ -z "$_prov_from" ] || echo " This tree records its template as ${_prov_from}." + fi + exit 1 + fi + # --- What the mint may edit --------------------------------------------- # mint_files prints, one per line, every regular file that belongs to the # repository: tracked, or untracked and not ignored (what `git add -A` @@ -105,9 +166,16 @@ repo-init archetype="": VERBATIM_LIST=".machine_readable/mint-verbatim.txt" [ -f "$VERBATIM_LIST" ] || { echo "Error: $VERBATIM_LIST is missing; the mint cannot tell machinery from content."; exit 1; } mapfile -t MINT_VERBATIM < <(sed -e 's/#.*//' -e 's/[[:space:]]*$//' "$VERBATIM_LIST" | grep -v '^$') - for _v in "${MINT_VERBATIM[@]}"; do - [ -f "$_v" ] || { echo "Error: $VERBATIM_LIST names $_v, which does not exist (stale entry)."; exit 1; } - done + # These two preflights judge a TEMPLATE tree. A chain mint's tree is a + # minted repo whose own work (a deleted verbatim-listed file, an added file + # with a brace token) is the parent's business; the root template is + # checked when chain-mint.sh replays it, and the merged result passes the + # placeholder gate (Codex review of #277). + if [ "$CHAIN_MINT" != "1" ]; then + for _v in "${MINT_VERBATIM[@]}"; do + [ -f "$_v" ] || { echo "Error: $VERBATIM_LIST names $_v, which does not exist (stale entry)."; exit 1; } + done + fi # ── Can the template be minted at all? ────────────────────────────────── # A brace token the recipe below has no answer for is a file that cannot be @@ -115,7 +183,7 @@ repo-init archetype="": # files in every Julia repo minted before 2026-10-30) or trips the # placeholder gate at the END of a mint that has already written the tree. # Both are cheaper to refuse here, before a single byte is written. - if [ -f scripts/check-mint-tokens.sh ]; then + if [ "$CHAIN_MINT" != "1" ] && [ -f scripts/check-mint-tokens.sh ]; then if ! bash scripts/check-mint-tokens.sh; then echo "ERROR: mint aborted before writing anything — the template ships a" echo " token this recipe cannot fill. See the findings above." @@ -141,7 +209,25 @@ repo-init archetype="": ARCHETYPE='{{archetype}}' ARCH_FILE="" ARCH_PRESET="" - if [ -n "$ARCHETYPE" ]; then + # A chain mint has no archetypes/ (the parent's mint removed it); it + # re-mints with the archetype the parent recorded, from the root template + # (scripts/chain-mint.sh). Naming that same archetype is accepted; any + # other is refused, since the parent's tree was built for its own. + # MINT_ARCH is the archetype the mint will actually apply: on a chain, the + # parent's, whether or not it was named (the slug rule below depends on it: + # a Julia package's .jl slug). + MINT_ARCH="$ARCHETYPE" + if [ "$CHAIN_MINT" = "1" ]; then + _p_arch="$(bash scripts/deed-field.sh "$_prov_deed" mint-answers archetype 2>/dev/null || true)" + if [ -n "$ARCHETYPE" ] && [ "$ARCHETYPE" != "$_p_arch" ]; then + echo "Error: this tree was minted as archetype '${_p_arch:-none}'; a chain mint keeps it." + echo " Run just repo-init${_p_arch:+ ${_p_arch}} (or mint from the template for another)." + exit 1 + fi + [ -z "$_p_arch" ] || { echo "Archetype: ${_p_arch} (the parent's, re-minted from the template)"; echo ""; } + MINT_ARCH="$_p_arch" + ARCHETYPE="" + elif [ -n "$ARCHETYPE" ]; then ARCH_FILE="archetypes/${ARCHETYPE}/${ARCHETYPE}_praxis.deed" if [ ! -f "$ARCH_FILE" ]; then echo "Error: unknown archetype '${ARCHETYPE}'. Available:" @@ -175,11 +261,11 @@ repo-init archetype="": # is the package's own identifier (UpperCamelCase by Julia # convention). The julia-library archetype accepts that form as well as # the estate slug; every other mint takes the estate slug only. - if [[ "$ARCHETYPE" = "julia-library" && "$REPO" =~ ^[A-Z][A-Za-z0-9]*\.jl$ ]]; then + if [[ "$MINT_ARCH" = "julia-library" && "$REPO" =~ ^[A-Z][A-Za-z0-9]*\.jl$ ]]; then : elif [[ ! "$REPO" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then echo "Error: repo slug must be lowercase alphanumeric words separated by single hyphens" - [ "$ARCHETYPE" = "julia-library" ] && echo " (or, for a Julia package, .jl with in UpperCamelCase)" + [ "$MINT_ARCH" = "julia-library" ] && echo " (or, for a Julia package, .jl with in UpperCamelCase)" exit 1 fi @@ -251,21 +337,28 @@ repo-init archetype="": ask "OpenSSF Best Practices project ID (blank if not yet registered) []: " OPENSSF_BP_ID # --- Container values (optional — only relevant if build/container/ exists) --- - # The default service name is the slug in lower case: an OCI image name - # may not contain capitals, and a Julia slug (`.jl`) has them. + # Derive the default for a DNS hostname and bare TOML service key, not + # only an OCI image name: Julia dots and slug underscores need hyphens. + # Explicit service answers still go through the validator unchanged. + DEFAULT_SERVICE_NAME="${REPO,,}" + DEFAULT_SERVICE_NAME="${DEFAULT_SERVICE_NAME//[._]/-}" if [ -d "build/container" ]; then echo "" echo "── Container configuration (optional) ─────────" - ask "Service name [${SERVICE_NAME:-${REPO,,}}]: " _SERVICE_NAME - SERVICE_NAME="${_SERVICE_NAME:-${SERVICE_NAME:-${REPO,,}}}" + ask "Service name [${SERVICE_NAME:-$DEFAULT_SERVICE_NAME}]: " _SERVICE_NAME + SERVICE_NAME="${_SERVICE_NAME:-${SERVICE_NAME:-$DEFAULT_SERVICE_NAME}}" ask "Primary port [${PORT:-8080}]: " _PORT PORT="${_PORT:-${PORT:-8080}}" - ask "Container registry [${REGISTRY:-ghcr.io/${OWNER}}]: " _REGISTRY - REGISTRY="${_REGISTRY:-${REGISTRY:-ghcr.io/${OWNER}}}" + ask "Container registry [${REGISTRY:-ghcr.io/${OWNER,,}}]: " _REGISTRY + REGISTRY="${_REGISTRY:-${REGISTRY:-ghcr.io/${OWNER,,}}}" + # The values fill double-quoted KYAML scalars: refuse a value that + # would break them now, before anything is written. + bash scripts/format-container-kyaml.sh --check-values "$SERVICE_NAME" "$PORT" "$REGISTRY" \ + || { echo "ERROR: mint aborted before writing anything — see above."; exit 1; } else - SERVICE_NAME="${SERVICE_NAME:-${REPO,,}}" + SERVICE_NAME="${SERVICE_NAME:-$DEFAULT_SERVICE_NAME}" PORT="${PORT:-8080}" - REGISTRY="${REGISTRY:-ghcr.io/${OWNER}}" + REGISTRY="${REGISTRY:-ghcr.io/${OWNER,,}}" fi # --- Derived values --- @@ -274,8 +367,26 @@ repo-init archetype="": # hyphens of a slug and the dot of a Julia slug alike. PROJECT_UPPER=$(echo "$REPO" | tr '[:lower:]-.' '[:upper:]__') PROJECT_LOWER=$(echo "$REPO" | tr '[:upper:]-.' '[:lower:]__') - CURRENT_YEAR=$(date +%Y) - CURRENT_DATE=$(date +%Y-%m-%d) + # The mint date is an input like any answer: RSR_MINT_DATE replays a mint + # exactly (scripts/check-mint-drift.sh re-mints a child with the date it + # recorded). Otherwise it is today. The year always follows the date. + CURRENT_DATE="${RSR_MINT_DATE:-$(date +%Y-%m-%d)}" + # Validated in bash, not with `date -d`: that is GNU-only, and macOS's date + # rejected every date, today's included (Codex review of #275). + _dm=0 + if [[ "$CURRENT_DATE" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})$ ]]; then + _yy=$((10#${BASH_REMATCH[1]})); _mm=$((10#${BASH_REMATCH[2]})); _dd=$((10#${BASH_REMATCH[3]})) + case "$_mm" in + 1|3|5|7|8|10|12) _dm=31 ;; + 4|6|9|11) _dm=30 ;; + 2) if (( (_yy % 4 == 0 && _yy % 100 != 0) || _yy % 400 == 0 )); then _dm=29; else _dm=28; fi ;; + esac + fi + if [ "$_dm" -eq 0 ] || [ "${_dd:-0}" -lt 1 ] || [ "${_dd:-0}" -gt "$_dm" ]; then + echo "Error: RSR_MINT_DATE must be a real date, YYYY-MM-DD (got '${CURRENT_DATE}')." + exit 1 + fi + CURRENT_YEAR="${CURRENT_DATE:0:4}" VERSION="0.1.0" # --- Derive the repo's uuid -------------------------------------------- @@ -316,6 +427,65 @@ repo-init archetype="": ask "Proceed? [Y/n] " CONFIRM [[ "${CONFIRM:-Y}" =~ ^[Nn] ]] && echo "Aborted." && exit 0 + # An answer is one line: no sed expression, deed string or prompt can carry + # a newline. Refused HERE, before the first write (the overlay, the prune + # and the docs seed all come next), so a bad answer leaves a clean tree. + for _ans in PROJECT_NAME PROJECT_DESCRIPTION OWNER AUTHOR AUTHOR_EMAIL AUTHOR_ORG \ + AUTHOR_LAST AUTHOR_FIRST FORGE SECURITY_EMAIL CONDUCT_EMAIL PROJECT_TYPE \ + WEBSITE SERVICE_NAME PORT REGISTRY AUTHOR_EMAIL_ALT AUTHOR_ORCID OPENSSF_BP_ID; do + case "${!_ans:-}" in + *$'\n'*) echo "Error: the answer for $_ans contains a newline; answers are one line."; exit 1 ;; + esac + done + + # Answers are free text, and the right-hand side of s|…|…| is not: `&` + # inserts the matched token, `\` escapes, `|` ends the expression. A + # description "Tools & proofs" rendered as "Tools proofs" and + # then failed the placeholder gate; a `|` broke the substitution. Every + # answer goes through sed_rhs. (A newline was refused before the first write.) + # Defined here, ahead of the rules below that use it. + sed_rhs() { printf '%s' "$1" | sed -e 's/[\\&|]/\\&/g'; } + + # The self-name rule, defined once: used by the self-name pass below and + # handed to scripts/chain-mint.sh, so a chain mint renames the parent by + # exactly the same rule (passed as data, never re-read from the recipe). + SELF_PROV='instantiated-from|parent|upstream|chain =|lineage|minted from|created from|Template: |reject-if-contains' + # The template's name and slug are LITERAL text, not patterns: a Julia + # mint's parent may be .jl, and `.` in a sed pattern matches any + # character. Escaped inline so self_name_pass_test.sh can evaluate this + # one assignment exactly as the recipe defines it. + # The name is matched only where it stands as a whole name: not inside + # a longer word or slug (Codex review of #251). Unbounded, a parent + # named `app` turned "application mapping" into "newlication mnewing". + # A neighbouring letter or digit blocks the match; anything else does + # not, so `_` (sonar.projectKey), `_chora.deed` and + # derived names such as `-checkout` or `rm-` still follow. Each pass + # loops (:label … t label) because a match consumes its right-hand + # neighbour, and marks through a sentinel so a new name that contains + # the old one (Geometry -> Geometry.jl) cannot loop forever. + SELF_NAME_SED="/${SELF_PROV}/!{:i;s@(^|[^A-Za-z0-9])${TEMPLATE_REPO//./\\.}#([0-9])@\\1\\x01I\\x01\\2@;ti;:s;s@(^|[^A-Za-z0-9])${TEMPLATE_SLUG//./\\.}([^A-Za-z0-9]|$)@\\1\\x01S\\x01\\2@;ts;:n;s@(^|[^A-Za-z0-9])${TEMPLATE_REPO//./\\.}([^A-Za-z0-9]|$)@\\1\\x01N\\x01\\2@;tn;s@\\x01S\\x01@${OWNER}/${REPO}@g;s@\\x01N\\x01@${REPO}@g;s@\\x01I\\x01@${TEMPLATE_REPO}#@g;}" + # The self-owner rule, defined once likewise (see the self-OWNER pass for + # why it is three exact identity lines and never a blanket rewrite): the + # Justfile OWNER declaration in each form the identity parser accepts, and + # the SonarCloud organization and project key. chain-mint.sh applies it to + # the parent's carried files too (Codex review of #277). + SELF_OWNER_SED="s|^((export[[:space:]]+)?OWNER[[:space:]]*:=[[:space:]]*)([\"'])${TEMPLATE_OWNER//./\\.}([\"'])|\\1\\3$(sed_rhs "${OWNER}")\\4|;s|^sonar\\.organization=${TEMPLATE_OWNER//./\\.}\$|sonar.organization=$(sed_rhs "${OWNER}")|;s|^sonar\\.projectKey=${TEMPLATE_OWNER//./\\.}_|sonar.projectKey=$(sed_rhs "${OWNER}")_|" + + # Chain mint: everything below renders a TEMPLATE tree, which this is not. + # chain-mint.sh renders the template afresh with these answers and carries + # this tree's own work across; it writes nothing unless it succeeds. + if [ "$CHAIN_MINT" = "1" ]; then + export PROJECT_NAME REPO OWNER AUTHOR AUTHOR_EMAIL AUTHOR_ORG AUTHOR_EMAIL_ALT \ + AUTHOR_ORCID PROJECT_DESCRIPTION FORGE SECURITY_EMAIL CONDUCT_EMAIL \ + PROJECT_TYPE WEBSITE OPENSSF_BP_ID SERVICE_NAME PORT REGISTRY \ + TEMPLATE_REPO TEMPLATE_OWNER + export RSR_MINT_DATE="$CURRENT_DATE" SELF_NAME_SED SELF_OWNER_SED + bash scripts/chain-mint.sh + echo "" + echo "Done! Chain mint complete. Review with git status, then commit." + exit 0 + fi + # Template-only paths this recipe deletes, as they stand before it runs. # Recorded in the (variant) clause's (paths :removed …) below, so the # record says what the mint took away instead of leaving the drift gate to @@ -355,6 +525,254 @@ repo-init archetype="": exit 1 fi + # --- What this work tree holds beyond the pinned tree ------------------- + # The mint transforms the work tree as it stands; the drift gate replays + # from the pinned tree alone. Anything already changed here before the + # mint (an edit, an untracked file, a deletion) is this repo's own, not + # the mint's, so it is measured now and declared in (variant (paths)): + # the replay stays exact and the record says what happened (Codex review + # of #275). Measured in a scratch index and object store, so .git is not + # written. (A chain mint carries its parent's tree by its own rules.) + PREMINT_A=() PREMINT_M=() PREMINT_D=() + if [ "${CHAIN_MINT:-0}" != "1" ] && [ "$PARENT_TREE" != "UNASSIGNED" ]; then + # The work tree as `git add -A` would commit it, from the current index + # (so a tracked file an ignore rule matches stays), in a scratch index + # and object store. + _pm="$(mktemp -d)"; mkdir -p "$_pm/objects" + _gd="$(cd "$(git rev-parse --git-common-dir)" && pwd)" + _ix="$(git rev-parse --git-path index)" + [ ! -f "$_ix" ] || cp "$_ix" "$_pm/index" + _wt() { GIT_INDEX_FILE="$_pm/index" GIT_OBJECT_DIRECTORY="$_pm/objects" GIT_ALTERNATE_OBJECT_DIRECTORIES="$_gd/objects" git "$@"; } + _wt add -A . + # A gitlink (submodule) cannot be replayed (checkout-index writes no files + # for one), so the drift gate could never pass: refused by name here, + # whether or not anything else changed (Codex review of #275). + _gl="$(_wt ls-files -s | awk -F '\t' '$1 ~ /^160000 / { print " " $2 }')" + if [ -n "$_gl" ]; then + echo "ERROR: mint aborted before writing anything — this tree carries a submodule" + echo " (gitlink), which a replay of the mint cannot reproduce, so its drift gate" + echo " could never pass. Minting such a tree is not supported:" + printf '%s\n' "$_gl" + rm -rf "$_pm"; exit 1 + fi + if git cat-file -e "${PARENT_TREE}^{tree}" 2>/dev/null; then + while IFS= read -r -d '' _st && IFS= read -r -d '' _p; do + # A newline splits the deed's line-based lists; byte 0x01 is the deed + # reader's record separator (Codex review of #277). + case "$_p" in *$'\n'*|*$'\001'*) echo "Error: a path holding a newline or byte 0x01 cannot be declared: $(printf '%q' "$_p")"; rm -rf "$_pm"; exit 1 ;; esac + case "$_st" in + A) PREMINT_A+=("$_p") ;; + D) PREMINT_D+=("$_p") ;; + *) PREMINT_M+=("$_p") ;; + esac + done < <(_wt diff --cached -z --no-renames --name-status "$PARENT_TREE") + elif [ "$(_wt write-tree)" != "$PARENT_TREE" ]; then + # An asserted pin (RSR_TEMPLATE_COMMIT) whose tree this repository + # does not hold: the difference cannot even be listed, so it cannot + # be declared, and the replay would report it (Codex review of #275). + rm -rf "$_pm" + echo "ERROR: mint aborted before writing anything — this work tree differs from the" + echo " template tree ${PARENT_TREE:0:12}, which this repository does not hold, so" + echo " the difference cannot be declared. Fetch it first" + echo " (git fetch ${PARENT_PIN}) or mint from a matching tree." + exit 1 + fi + # The work tree's own tree: what a dry run of this mint starts from + # (below). Its objects stay in $_pm until the replays are done. + _WT_TREE="$(_wt write-tree)" + # The mint moves a root .well-known/ (the www/ migration, below), so a + # path there could not be declared where it ends up: refused by name. + for _p in "${PREMINT_A[@]}" "${PREMINT_M[@]}" "${PREMINT_D[@]}"; do + case "$_p" in + .well-known/*) + echo "ERROR: mint aborted before writing anything — $_p changed before the mint," + echo " and the mint moves .well-known/ into www/.well-known/. Commit that move" + echo " first (bash scripts/migrate-wellknown-to-www.sh), or revert the change." + rm -rf "$_pm"; exit 1 ;; + esac + done + # The mint writes new files of its own: the archetype overlay (before + # and after its .in rename), the docs seed, the deed. A pre-mint file + # at one of those paths would be overwritten (lost) and then declared + # as this repo's own addition, which the replay contradicts (Codex + # review of #275). Refused by name, before anything is written. + _writes=("${REPO}_chora.deed") + if [ -n "$ARCHETYPE" ] && [ -d "archetypes/${ARCHETYPE}/overlay" ]; then + while IFS= read -r -d '' _o; do + _o="${_o#./}"; _writes+=("$_o") + case "$_o" in + src/PACKAGE.jl.in) _writes+=("src/${PROJECT_NAME}.jl") ;; + *.in) _writes+=("${_o%.in}") ;; + esac + done < <(cd "archetypes/${ARCHETYPE}/overlay" && find . -type f -print0) + fi + if [ -d build/docs-seed ]; then + while IFS= read -r -d '' _o; do _writes+=("docs/${_o#./}"); done \ + < <(cd build/docs-seed && find . -type f ! -path ./README.adoc -print0) + fi + # An edited template file the overlay or seed replaces is lost the same way + # (Codex review of #275: provisioning_praxis.deed under julia-library). + for _p in "${PREMINT_A[@]}" "${PREMINT_M[@]}"; do + for _w in "${_writes[@]}"; do + if [ "$_p" = "$_w" ]; then + echo "ERROR: mint aborted before writing anything — $_p changed before the mint, and" + echo " the mint writes that path itself (the archetype overlay, the docs seed or" + echo " the deed), so your file would be overwritten. Move it aside, mint, then" + echo " bring it back as a change of your own." + rm -rf "$_pm"; exit 1 + fi + done + done + _n=$(( ${#PREMINT_A[@]} + ${#PREMINT_M[@]} + ${#PREMINT_D[@]} )) + if [ "$_n" -gt 0 ]; then + echo "NOTE: ${_n} path(s) here already differ from the template tree ${PARENT_TREE:0:12}" + echo " (${#PREMINT_A[@]} added, ${#PREMINT_M[@]} changed, ${#PREMINT_D[@]} removed before this mint). They are" + echo " this repo's own. What they change in the result is declared in" + echo " (variant (paths)), measured against a replay of this mint:" + for _p in "${PREMINT_A[@]}" "${PREMINT_M[@]}" "${PREMINT_D[@]}"; do echo " $_p"; done + fi + fi + + # --- The replay this mint is measured against, when the tree has changed --- + # A pre-mint change can reach the result in ways no rule over its own path + # predicts: the mint moves, overwrites and renders files, and reads its own + # inputs (the overlay, the docs seed, its scripts) from this tree (Codex and + # CodeRabbit review of #275). So this mint is replayed, here, before anything + # is written, from the pinned tree with the same answers and date, exactly + # as the drift gate will replay it; what this tree then differs by is + # declared below. The pinned tree is in this repository (checked above). + # The inner mint skips pin resolution (RSR_ALLOW_UNPINNED with no template + # source): it writes the pin only into its deed, which is not compared. + # + # And a dry run: the same mint of THIS tree, pre-mint changes and all. A + # change at a path where the dry run and the replay agree has no effect on + # the result: the mint writes that path itself (the roadmap, mise.toml, + # the map, the overlay, the docs seed, the deed) and the change + # would be silently discarded. It is refused by name, before anything is + # written (Codex review of #275), whatever writes it. + # _same A B — identical as git records them: content, executable bit, link target. + _same() { + if [ -L "$1" ] || [ -L "$2" ]; then + [ -L "$1" ] && [ -L "$2" ] && [ "$(readlink "$1")" = "$(readlink "$2")" ] + return + fi + { [ -x "$1" ] && [ -x "$2" ]; } || { [ ! -x "$1" ] && [ ! -x "$2" ]; } || return 1 + cmp -s "$1" "$2" + } + _REPLAY="" + if [ $(( ${#PREMINT_A[@]} + ${#PREMINT_M[@]} + ${#PREMINT_D[@]} )) -gt 0 ]; then + # _rg — git with no user or system configuration and no hooks, and no + # inherited repository location (GIT_DIR, GIT_INDEX_FILE, … from a hook or + # another git caller), which would redirect the replay's commits into + # this repository (CodeRabbit review of #275). _rgi IDX — the same, on + # the scratch index IDX and object store $_pm (the work tree's objects). + _rgclean=(env -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY + -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_PREFIX -u GIT_NAMESPACE GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1) + _rgflags=(-c user.name=replay -c user.email=replay@example.invalid -c commit.gpgsign=false -c core.hooksPath=/dev/null) + _rg() { "${_rgclean[@]}" git "${_rgflags[@]}" "$@"; } + _rgi() { local _i="$1"; shift + "${_rgclean[@]}" GIT_INDEX_FILE="$_i" GIT_OBJECT_DIRECTORY="$_pm/objects" GIT_ALTERNATE_OBJECT_DIRECTORIES="$_gd/objects" \ + git "${_rgflags[@]}" "$@"; } + _rp="$(mktemp -d)"; _REPLAY="$_rp/r" + mkdir -p "$_rp/cfg"; : > "$_rp/gitconfig" + # _mint_tree TREE DIR — TREE written into DIR as a checkout writes it + # (checkout-index, not git archive, whose export-ignore and export-subst + # attributes would change the tree (Codex review of #275)), committed as + # "Use this template" commits it, then minted with this mint's answers. + _mint_tree() { + mkdir -p "$2" + _rgi "$_rp/index" read-tree "$1" + # A gitlink has no files to write, so the copy would lose it: refused + # by name rather than replayed inexactly (Codex review of #277). + _gl="$(_rgi "$_rp/index" ls-files -s | awk -F '\t' '$1 ~ /^160000 / { print " " $2 }')" + if [ -n "$_gl" ]; then + echo "ERROR: the tree carries a submodule (gitlink), which the replay of this mint" + echo " cannot reproduce; minting such a tree is not supported:" + printf '%s\n' "$_gl" + rm -f "$_rp/index"; return 1 + fi + _rgi "$_rp/index" checkout-index -a -f --prefix="$2/" + rm -f "$_rp/index" + _rg -C "$2" init -q -b main; _rg -C "$2" add -A; _rg -C "$2" commit -q -m "Initial commit" + _rg -C "$2" remote add origin "git@${FORGE}:${OWNER}/${REPO}.git" + (cd "$2" && env -i PATH="$PATH" HOME="$HOME" TMPDIR="${TMPDIR:-/tmp}" \ + ${RUSTUP_HOME:+RUSTUP_HOME="$RUSTUP_HOME"} ${CARGO_HOME:+CARGO_HOME="$CARGO_HOME"} \ + ${RSR_MINT_TOOL_CACHE:+RSR_MINT_TOOL_CACHE="$RSR_MINT_TOOL_CACHE"} \ + ${RSR_TEMPLATE_SLUG:+RSR_TEMPLATE_SLUG="$RSR_TEMPLATE_SLUG"} \ + ${STANDARDS_UUID_V8_JS:+STANDARDS_UUID_V8_JS="$STANDARDS_UUID_V8_JS"} \ + XDG_CONFIG_HOME="$_rp/cfg" GIT_CONFIG_GLOBAL="$_rp/gitconfig" GIT_CONFIG_NOSYSTEM=1 \ + RSR_NON_INTERACTIVE=1 RSR_MINT_REPLAY=1 RSR_ALLOW_UNPINNED=1 \ + RSR_TEMPLATE_URL="$_rp/no-template-source" RSR_MINT_DATE="$CURRENT_DATE" \ + PROJECT_NAME="$PROJECT_NAME" REPO="$REPO" OWNER="$OWNER" AUTHOR="$AUTHOR" \ + AUTHOR_EMAIL="$AUTHOR_EMAIL" AUTHOR_ORG="${AUTHOR_ORG:-}" AUTHOR_EMAIL_ALT="${AUTHOR_EMAIL_ALT:-}" \ + AUTHOR_ORCID="${AUTHOR_ORCID:-}" PROJECT_DESCRIPTION="${PROJECT_DESCRIPTION:-}" FORGE="$FORGE" \ + SECURITY_EMAIL="${SECURITY_EMAIL:-}" CONDUCT_EMAIL="${CONDUCT_EMAIL:-}" \ + PROJECT_TYPE="${PROJECT_TYPE:-}" WEBSITE="${WEBSITE:-}" OPENSSF_BP_ID="${OPENSSF_BP_ID:-}" \ + SERVICE_NAME="${SERVICE_NAME:-}" PORT="${PORT:-}" REGISTRY="${REGISTRY:-}" \ + just repo-init ${ARCHETYPE:+"$ARCHETYPE"} "$2.log" 2>&1 \ + || { tail -n 30 "$2.log"; return 1; } + } + echo " replaying this mint from the template tree, to measure the pre-mint changes..." + if ! _mint_tree "$PARENT_TREE" "$_REPLAY"; then + echo "ERROR: mint aborted before writing anything — the replay of this mint, needed to" + echo " measure the pre-mint changes above, failed (its log is above)." + rm -rf "$_pm"; exit 1 + fi + echo " and a dry run of it on this tree, to find a change the mint would discard..." + if ! _mint_tree "$_WT_TREE" "$_rp/dry"; then + echo "ERROR: mint aborted before writing anything — a dry run of this mint on this" + echo " tree failed (its log is above): a pre-mint change breaks the mint." + rm -rf "$_pm"; exit 1 + fi + _lost=() + _here() { [ -e "$1" ] || [ -L "$1" ]; } + # _result_tree DIR — the mint result in DIR, as the tree git would commit. + _result_tree() { _rg -C "$1" add -A >/dev/null && _rg -C "$1" write-tree; } + _DRY_T="" + _n=0 + for _p in "${PREMINT_A[@]}" "${PREMINT_M[@]}" "${PREMINT_D[@]}"; do + if _here "$_rp/dry/$_p" && _here "$_REPLAY/$_p"; then + if _same "$_rp/dry/$_p" "$_REPLAY/$_p"; then _lost+=("$_p"); fi + elif ! _here "$_rp/dry/$_p" && ! _here "$_REPLAY/$_p" \ + && { _wt ls-files --error-unmatch -- ":(literal)$_p" >/dev/null 2>&1; }; then + # Added or changed here, and gone from both results: an input + # the mint reads and deletes (the docs seed, the overlay), or a + # file in a directory it deletes. Its change had an effect only if + # the result differs without it: this mint, minus that one change, + # is minted again and compared whole (Codex review of #275: an + # edit to build/docs-seed/README.adoc, which is never copied, + # was destroyed without a trace). + [ -n "$_DRY_T" ] || _DRY_T="$(_result_tree "$_rp/dry")" + _n=$((_n + 1)) + _rgi "$_rp/index" read-tree "$_WT_TREE" + if _rgi "$_rp/index" cat-file -e "${PARENT_TREE}:$_p" 2>/dev/null; then + read -r _m _ _s _ <<<"$(_rgi "$_rp/index" ls-tree "$PARENT_TREE" -- "$_p")" + _rgi "$_rp/index" update-index --add --cacheinfo "$_m,$_s,$_p" + else + _rgi "$_rp/index" update-index --force-remove -- "$_p" + fi + _rev_t="$(_rgi "$_rp/index" write-tree)"; rm -f "$_rp/index" + echo " checking that the pre-mint change to $_p has an effect..." + if ! _mint_tree "$_rev_t" "$_rp/without.$_n"; then + echo "ERROR: mint aborted before writing anything — a dry run without the change" + echo " to $_p failed (its log is above)." + rm -rf "$_pm"; exit 1 + fi + if [ "$(_result_tree "$_rp/without.$_n")" = "$_DRY_T" ]; then _lost+=("$_p"); fi + fi + done + rm -rf "$_pm" + if [ "${#_lost[@]}" -gt 0 ]; then + echo "ERROR: mint aborted before writing anything — these paths changed before the mint," + echo " and the mint writes or deletes each itself, so the change would be discarded:" + for _p in "${_lost[@]}"; do echo " $_p"; done + echo " Revert them (or move them aside), mint, then make the change again." + exit 1 + fi + elif [ -n "${_pm:-}" ]; then + rm -rf "$_pm" + fi + echo "" echo "Replacing placeholders..." @@ -425,48 +843,55 @@ repo-init archetype="": LB=$(printf '\x7b\x7b') RB=$(printf '\x7d\x7d') + # Answers are free text, and the right-hand side of s|…|…| is not: `&` + # inserts the matched token, `\` escapes, `|` ends the expression. A + # description "Tools & proofs" rendered as "Tools proofs" and + # then failed the placeholder gate; a `|` broke the substitution. Every + # answer goes through sed_rhs. (A newline was refused before the first write.) + sed_rhs() { printf '%s' "$1" | sed -e 's/[\\&|]/\\&/g'; } + # Build the sed expression list # Note: using | as delimiter since URLs contain / SED_ARGS=( - -e "s|${LB}PROJECT_NAME${RB}|${PROJECT_NAME}|g" - -e "s|${LB}PROJECT_DESCRIPTION${RB}|${PROJECT_DESCRIPTION}|g" - -e "s|${LB}PROJECT${RB}|${PROJECT_UPPER}|g" - -e "s|${LB}project${RB}|${PROJECT_LOWER}|g" - -e "s|${LB}REPO${RB}|${REPO}|g" - -e "s|${LB}OWNER${RB}|${OWNER}|g" - -e "s|${LB}AUTHOR${RB}|${AUTHOR}|g" - -e "s|${LB}AUTHOR_EMAIL${RB}|${AUTHOR_EMAIL}|g" - -e "s|${LB}AUTHOR_ORG${RB}|${AUTHOR_ORG}|g" - -e "s|${LB}AUTHOR_LAST${RB}|${AUTHOR_LAST}|g" - -e "s|${LB}AUTHOR_FIRST${RB}|${AUTHOR_FIRST}|g" - -e "s|${LB}AUTHOR_INITIALS${RB}|${FIRST_INITIAL}|g" - -e "s|${LB}FORGE${RB}|${FORGE}|g" - -e "s|${LB}CURRENT_YEAR${RB}|${CURRENT_YEAR}|g" - -e "s|${LB}CURRENT_DATE${RB}|${CURRENT_DATE}|g" - -e "s|${LB}DATE${RB}|${CURRENT_DATE}|g" - -e "s|${LB}SECURITY_EMAIL${RB}|${SECURITY_EMAIL}|g" - -e "s|${LB}CONDUCT_EMAIL${RB}|${CONDUCT_EMAIL}|g" + -e "s|${LB}PROJECT_NAME${RB}|$(sed_rhs "${PROJECT_NAME}")|g" + -e "s|${LB}PROJECT_DESCRIPTION${RB}|$(sed_rhs "${PROJECT_DESCRIPTION}")|g" + -e "s|${LB}PROJECT${RB}|$(sed_rhs "${PROJECT_UPPER}")|g" + -e "s|${LB}project${RB}|$(sed_rhs "${PROJECT_LOWER}")|g" + -e "s|${LB}REPO${RB}|$(sed_rhs "${REPO}")|g" + -e "s|${LB}OWNER${RB}|$(sed_rhs "${OWNER}")|g" + -e "s|${LB}AUTHOR${RB}|$(sed_rhs "${AUTHOR}")|g" + -e "s|${LB}AUTHOR_EMAIL${RB}|$(sed_rhs "${AUTHOR_EMAIL}")|g" + -e "s|${LB}AUTHOR_ORG${RB}|$(sed_rhs "${AUTHOR_ORG}")|g" + -e "s|${LB}AUTHOR_LAST${RB}|$(sed_rhs "${AUTHOR_LAST}")|g" + -e "s|${LB}AUTHOR_FIRST${RB}|$(sed_rhs "${AUTHOR_FIRST}")|g" + -e "s|${LB}AUTHOR_INITIALS${RB}|$(sed_rhs "${FIRST_INITIAL}")|g" + -e "s|${LB}FORGE${RB}|$(sed_rhs "${FORGE}")|g" + -e "s|${LB}CURRENT_YEAR${RB}|$(sed_rhs "${CURRENT_YEAR}")|g" + -e "s|${LB}CURRENT_DATE${RB}|$(sed_rhs "${CURRENT_DATE}")|g" + -e "s|${LB}DATE${RB}|$(sed_rhs "${CURRENT_DATE}")|g" + -e "s|${LB}SECURITY_EMAIL${RB}|$(sed_rhs "${SECURITY_EMAIL}")|g" + -e "s|${LB}CONDUCT_EMAIL${RB}|$(sed_rhs "${CONDUCT_EMAIL}")|g" -e "s|${LB}LICENSE${RB}|MPL-2.0|g" -e "s|${LB}CONDUCT_TEAM${RB}|Code of Conduct Committee|g" -e "s|${LB}RESPONSE_TIME${RB}|48 hours|g" -e "s|${LB}MAIN_BRANCH${RB}|main|g" - -e "s|${LB}PROJECT_PURPOSE${RB}|${PROJECT_DESCRIPTION}|g" - -e "s|${LB}PROJECT_ROLE${RB}|${PROJECT_TYPE}|g" - -e "s|${LB}PROJECT_TYPE${RB}|${PROJECT_TYPE}|g" - -e "s|${LB}WEBSITE${RB}|${WEBSITE}|g" - -e "s|${LB}SERVICE_NAME${RB}|${SERVICE_NAME}|g" - -e "s|${LB}PORT${RB}|${PORT}|g" - -e "s|${LB}REGISTRY${RB}|${REGISTRY}|g" - -e "s|${LB}IMAGE${RB}|${REGISTRY}/${SERVICE_NAME}|g" - -e "s|${LB}VERSION${RB}|${VERSION}|g" - -e "s|${LB}EMAIL${RB}|${AUTHOR_EMAIL}|g" - -e "s|${LB}UUID${RB}|${REPO_UUID}|g" - -e "s|${LB}DESCRIPTION${RB}|${PROJECT_DESCRIPTION}|g" + -e "s|${LB}PROJECT_PURPOSE${RB}|$(sed_rhs "${PROJECT_DESCRIPTION}")|g" + -e "s|${LB}PROJECT_ROLE${RB}|$(sed_rhs "${PROJECT_TYPE}")|g" + -e "s|${LB}PROJECT_TYPE${RB}|$(sed_rhs "${PROJECT_TYPE}")|g" + -e "s|${LB}WEBSITE${RB}|$(sed_rhs "${WEBSITE}")|g" + -e "s|${LB}SERVICE_NAME${RB}|$(sed_rhs "${SERVICE_NAME}")|g" + -e "s|${LB}PORT${RB}|$(sed_rhs "${PORT}")|g" + -e "s|${LB}REGISTRY${RB}|$(sed_rhs "${REGISTRY}")|g" + -e "s|${LB}IMAGE${RB}|$(sed_rhs "${REGISTRY}")/$(sed_rhs "${SERVICE_NAME}")|g" + -e "s|${LB}VERSION${RB}|$(sed_rhs "${VERSION}")|g" + -e "s|${LB}EMAIL${RB}|$(sed_rhs "${AUTHOR_EMAIL}")|g" + -e "s|${LB}UUID${RB}|$(sed_rhs "${REPO_UUID}")|g" + -e "s|${LB}DESCRIPTION${RB}|$(sed_rhs "${PROJECT_DESCRIPTION}")|g" ) - [ -n "$AUTHOR_EMAIL_ALT" ] && SED_ARGS+=(-e "s|${LB}AUTHOR_EMAIL_ALT${RB}|${AUTHOR_EMAIL_ALT}|g") - [ -n "$AUTHOR_ORCID" ] && SED_ARGS+=(-e "s|${LB}AUTHOR_ORCID${RB}|${AUTHOR_ORCID}|g") - [ -n "$OPENSSF_BP_ID" ] && SED_ARGS+=(-e "s|${LB}OPENSSF_BP_ID${RB}|${OPENSSF_BP_ID}|g") - [ -n "$OPENSSF_BP_ID" ] && SED_ARGS+=(-e "s|${LB}OPENSSF_PROJECT_ID${RB}|${OPENSSF_BP_ID}|g") + [ -n "$AUTHOR_EMAIL_ALT" ] && SED_ARGS+=(-e "s|${LB}AUTHOR_EMAIL_ALT${RB}|$(sed_rhs "${AUTHOR_EMAIL_ALT}")|g") + [ -n "$AUTHOR_ORCID" ] && SED_ARGS+=(-e "s|${LB}AUTHOR_ORCID${RB}|$(sed_rhs "${AUTHOR_ORCID}")|g") + [ -n "$OPENSSF_BP_ID" ] && SED_ARGS+=(-e "s|${LB}OPENSSF_BP_ID${RB}|$(sed_rhs "${OPENSSF_BP_ID}")|g") + [ -n "$OPENSSF_BP_ID" ] && SED_ARGS+=(-e "s|${LB}OPENSSF_PROJECT_ID${RB}|$(sed_rhs "${OPENSSF_BP_ID}")|g") # Archetype-determined tokens (ADR-0003): the archetype contract fills # what it can honestly determine. Added BEFORE the UNASSIGNED fallback @@ -517,7 +942,14 @@ repo-init archetype="": # GitHub owner logins are case-insensitive; canonicalize to lowercase # before the preimage so owner casing cannot fork the UUID. OWNER_LC=$(printf '%s' "${OWNER}" | tr '[:upper:]' '[:lower:]') - if command -v bun >/dev/null 2>&1 && [ -f "${STANDARDS_UUID_V8_JS:-}" ]; then + # A replay supplies the uuid the mint recorded ((mint-answers + # :package-uuid)), so the drift gate does not depend on which of bun or + # python3 the checking host has (Codex review of #275). Only a replay: + # an ordinary mint always derives it, so a stray RSR_PACKAGE_UUID in the + # environment cannot write an unrelated identity into Project.toml. + if [ "${RSR_MINT_REPLAY:-0}" = "1" ] && [[ "${RSR_PACKAGE_UUID:-}" =~ ^(UNASSIGNED|[0-9a-f]{8}-[0-9a-f]{4}-8[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$ ]]; then + PACKAGE_UUID="$RSR_PACKAGE_UUID" + elif command -v bun >/dev/null 2>&1 && [ -f "${STANDARDS_UUID_V8_JS:-}" ]; then PACKAGE_UUID=$(bun "${STANDARDS_UUID_V8_JS}" c julia-package-identity "github.com/${OWNER_LC}/${REPO}") elif command -v python3 >/dev/null 2>&1; then PACKAGE_UUID=$(python3 -c ' @@ -600,6 +1032,11 @@ repo-init archetype="": fi done + # The pass above fills the container tokens too (SERVICE_NAME, PORT, + # REGISTRY), which changes how KYAML quotes them; reformat the configs or + # Estate Rules' "Converted YAML stays KYAML" fails the child. + bash scripts/format-container-kyaml.sh + # [YOUR-REPO-NAME] and [YOUR-NAME/ORG] (the family-7 allocation manifest # folded into the deed — standards#837 pilot) are replaced in the deed, # found by its shape rather than by the original template's file name: in @@ -607,7 +1044,7 @@ repo-init archetype="": # two seds that named rsr-template-repo_chora.deed silently no-opped there. DEED_IN="$(bash scripts/deed-field.sh --find .)" \ || { echo "Error: the tree must carry exactly one *_chora.deed at its root."; exit 1; } - sed -i -e "s|\[YOUR-REPO-NAME\]|${PROJECT_NAME}|g" -e "s|\[YOUR-NAME/ORG\]|${OWNER}|g" "$DEED_IN" + sed -i -e "s|\[YOUR-REPO-NAME\]|$(sed_rhs "${PROJECT_NAME}")|g" -e "s|\[YOUR-NAME/ORG\]|$(sed_rhs "${OWNER}")|g" "$DEED_IN" # --- Instruction-block pass -------------------------------------------- # Delete the "TEMPLATE INSTRUCTIONS (delete this block before publishing)" @@ -682,7 +1119,16 @@ repo-init archetype="": # mint's parent may be .jl, and `.` in a sed pattern matches any # character. Escaped inline so self_name_pass_test.sh can evaluate this # one assignment exactly as the recipe defines it. - SELF_NAME_SED="/${SELF_PROV}/!{s|${TEMPLATE_REPO//./\\.}#([0-9])|__TEMPLATE_ISSUE__\\1|g;s|${TEMPLATE_SLUG//./\\.}|${OWNER}/${REPO}|g;s|${TEMPLATE_REPO//./\\.}|${REPO}|g;s|__TEMPLATE_ISSUE__|${TEMPLATE_REPO}#|g;}" + # The name is matched only where it stands as a whole name: not inside + # a longer word or slug (Codex review of #251). Unbounded, a parent + # named `app` turned "application mapping" into "newlication mnewing". + # A neighbouring letter or digit blocks the match; anything else does + # not, so `_` (sonar.projectKey), `_chora.deed` and + # derived names such as `-checkout` or `rm-` still follow. Each pass + # loops (:label … t label) because a match consumes its right-hand + # neighbour, and marks through a sentinel so a new name that contains + # the old one (Geometry -> Geometry.jl) cannot loop forever. + SELF_NAME_SED="/${SELF_PROV}/!{:i;s@(^|[^A-Za-z0-9])${TEMPLATE_REPO//./\\.}#([0-9])@\\1\\x01I\\x01\\2@;ti;:s;s@(^|[^A-Za-z0-9])${TEMPLATE_SLUG//./\\.}([^A-Za-z0-9]|$)@\\1\\x01S\\x01\\2@;ts;:n;s@(^|[^A-Za-z0-9])${TEMPLATE_REPO//./\\.}([^A-Za-z0-9]|$)@\\1\\x01N\\x01\\2@;tn;s@\\x01S\\x01@${OWNER}/${REPO}@g;s@\\x01N\\x01@${REPO}@g;s@\\x01I\\x01@${TEMPLATE_REPO}#@g;}" # Paths that are ABOUT the template keep its name wholesale: its ADRs, # changelog and audits. Template machinery (this recipe, the tests whose # fixtures must stay the template's literal name) is not in mint_files @@ -735,9 +1181,9 @@ repo-init archetype="": # every child genuinely points at, and rewriting those would break the # child's canon, CI and badges. if [ "$OWNER" != "$TEMPLATE_OWNER" ]; then - sed -i -E 's|^(OWNER[[:space:]]*:=[[:space:]]*)"'"${TEMPLATE_OWNER}"'"|\1"'"${OWNER}"'"|' Justfile - sed -i -E 's|^sonar\.organization='"${TEMPLATE_OWNER}"'$|sonar.organization='"${OWNER}"'|' sonar-project.properties - sed -i -E 's|^sonar\.projectKey='"${TEMPLATE_OWNER}"'_|sonar.projectKey='"${OWNER}"'_|' sonar-project.properties + # The same declaration forms the identity parser accepts: optional + # `export`, either quote (Codex review of #274). + sed -i -E "$SELF_OWNER_SED" Justfile sonar-project.properties echo " self-owner: rewrote ${TEMPLATE_OWNER} -> ${OWNER} (estate dependency paths preserved)" echo " NOTE: confirm the SonarCloud ORG is really '${OWNER}' before provisioning —" echo " the GitHub owner and the SonarCloud org are not always the same." @@ -929,12 +1375,11 @@ repo-init archetype="": { echo " ; variant — provenance contract (ADR-0002 shape; ADR-0003 use)." echo " ; Records what this repo was minted from, for re-templating tooling and" - echo " ; the drift gate (scripts/check-variant-drift.sh). The mint renders" - echo " ; tokens and rewrites files, so most spine files differ from the parent" - echo " ; by design, and (normalise) does not yet fold the mint's answers" - echo " ; (ADR-0003): the gate is not armed for a minted repo. Measured on a" - echo " ; fresh mint (2026-10-20): 101 rendered files differ. The template pin" - echo " ; is checked by scripts/check-template-conformance.sh instead." + echo " ; the drift gate. For a minted repo the gate replays the mint from" + echo " ; (provenance) and (mint-answers) and diffs this tree against the" + echo " ; replay (scripts/check-mint-drift.sh), so only edits made after the" + echo " ; mint count. Declare those in (paths) below; the derived files are" + echo " ; declared already, since their own gates keep them fresh." echo " (variant" echo " :project \"${REPO}\"" echo " :schema-version \"0.1.0\"" @@ -948,13 +1393,15 @@ repo-init archetype="": echo " (normalise :rules \"action-pins self-name\")" echo " ; Files a minted repo owns from birth are (paths :diverged …)." echo " (paths" - echo " :added ()" + # The paths this work tree changed before the mint are added to these + # lists once the mint has moved and deleted what it will (see "What + # this work tree held before the mint", below). + _list() { local _s="" _x; printf ' :%s (' "$1"; shift; for _x in "$@"; do printf '%s"%s"' "$_s" "$_x"; _s=" "; done; printf ')'; } _removed=("${MINT_REMOVES[@]}") [ "${DEED_IN#./}" = "${REPO}_chora.deed" ] || _removed+=("${DEED_IN#./}") - printf ' :removed ('; _sep="" - for _p in "${_removed[@]}"; do printf '%s"%s"' "$_sep" "$_p"; _sep=" "; done - printf ')\n' - echo " :diverged (\"${REPO}_chora.deed\" \"docs/status/ROADMAP.adoc\")))" + _list added; echo + _list removed "${_removed[@]}"; echo + _list diverged "${REPO}_chora.deed" docs/status/ROADMAP.adoc CLAUDE.md docs/architecture/REPOSITORY-MAP.adoc; echo "))" } | put_clause variant if [ "$PARENT_PIN" = "UNASSIGNED" ]; then echo " provenance: $DEED_OUT (variant) — parent-pin UNASSIGNED (offline?)" @@ -1005,6 +1452,55 @@ repo-init archetype="": echo " (substitutions :rendered 34 :unassigned ()))" } | put_clause provenance + # ── (mint-answers) — the answers, so the mint can be replayed ─────────── + # Provenance names the template commit; this names what it was rendered + # with. Together they make the mint a function that can be run again: + # scripts/check-mint-drift.sh re-mints the template at the pin with these + # answers and diffs the child against the result, so drift is measured + # exactly instead of through hand-written (normalise) rules (ADR-0003). + # Owner ruling (2026-11-04): record only values the mint already writes + # into the child's own files (names, contacts, description, URLs), so the + # record publishes nothing new. Deed strings escape `\` and `"`. + deed_str() { printf '%s' "$1" | sed -e 's/[\\"]/\\&/g'; } + # The ruling is checked here, not left to a hand-kept list (Codex review of + # #275 found project-type recorded, though no template file renders it): a + # non-empty answer is recorded only if its value appears verbatim in this + # repo's files. An answer left out replays as unanswered; it rendered + # nothing here, so the replay is unchanged. + # Not the template-only trees the mint deletes below (MINT_REMOVES: + # archetypes/, build/docs-seed/): a value found only there is in no file + # the child keeps (CodeRabbit review of #275). + mapfile -t _MINTED < <(mint_files | awk -v r="${MINT_REMOVES[*]:-}" \ + 'BEGIN { n = split(r, p, " ") } { for (i = 1; i <= n; i++) if (index($0, p[i]) == 1) next; print }') + _ANSWERS=() _UNRECORDED="" + for _pair in project-name:PROJECT_NAME repo:REPO owner:OWNER author:AUTHOR \ + author-email:AUTHOR_EMAIL author-org:AUTHOR_ORG \ + author-email-alt:AUTHOR_EMAIL_ALT author-orcid:AUTHOR_ORCID \ + description:PROJECT_DESCRIPTION forge:FORGE \ + security-email:SECURITY_EMAIL conduct-email:CONDUCT_EMAIL \ + project-type:PROJECT_TYPE website:WEBSITE \ + openssf-bp-id:OPENSSF_BP_ID service-name:SERVICE_NAME \ + port:PORT registry:REGISTRY archetype:ARCHETYPE \ + package-uuid:PACKAGE_UUID; do + _var="${_pair#*:}" + _val="${!_var:-}" + if [ -n "$_val" ] && ! grep -qsF -- "$_val" "${_MINTED[@]}" /dev/null; then + _UNRECORDED="${_UNRECORDED} ${_pair%%:*}" + continue + fi + _ANSWERS+=(" :${_pair%%:*} \"$(deed_str "$_val")\"") + done + { + echo " ; mint-answers — what just repo-init was answered on ${CURRENT_DATE}." + echo " ; Read by scripts/check-mint-drift.sh to replay the mint. Every value" + echo " ; here already appears in this repo's rendered files." + echo " (mint-answers" + printf '%s\n' "${_ANSWERS[@]}" + echo " :mint-date \"${CURRENT_DATE}\")" + } | put_clause mint-answers + echo " answers: $DEED_OUT (mint-answers) — replayable with scripts/check-mint-drift.sh" + [ -z "$_UNRECORDED" ] || echo " not recorded, written into no file here:${_UNRECORDED}" + if [ "$CANON_VERSION" = "UNASSIGNED" ]; then echo " provenance: $DEED_OUT (provenance) — canon pin UNASSIGNED" echo " The template deed carried no (canon …) clause, so this repo" @@ -1018,6 +1514,31 @@ repo-init archetype="": # (same reason build/templates is removed above). rm -rf archetypes rm -rf build/docs-seed # template-only; its content now lives in docs/ + # Its SD022 exemption goes with it: a child keeping an exemption for a path + # it does not have fails check-hypatia-ignore.sh (stale exemption). The + # block runs from its section header to the entry, plus the blank after. + if [ -f .hypatia-ignore ]; then + awk '/^# ─── build\/docs-seed\/ describes DOWNSTREAM/ { skip = 1 } + skip && /^structural_drift\/SD022:build\/docs-seed\/$/ { skip = 0; blank = 1; next } + skip { next } + blank && /^$/ { blank = 0; next } + { blank = 0; print }' .hypatia-ignore > .hypatia-ignore.mint + mv .hypatia-ignore.mint .hypatia-ignore + fi + # The Hypatia rule-order policy classifies each module .hypatia-ignore + # exempts, and check-hypatia-rule-order.sh fails on a classification with + # no exemption behind it. With no structural_drift exemption left, drop its + # observed_modules entry. The policy is one line of JCS (RFC 8785), so + # removing one object and its comma keeps it canonical; no JSON tool needed. + POLICY=.machine_readable/policies/hypatia-rule-order.json + if [ -f "$POLICY" ] && ! grep -q '^structural_drift/' .hypatia-ignore 2>/dev/null; then + SD_OBJ='[{]"layer":[0-9]+,"module":"structural_drift","rationale":"([^"\\]|\\.)*"[}]' + sed -E -i -e "s/${SD_OBJ},//" -e "s/,${SD_OBJ}//" "$POLICY" + if grep -q '"module":"structural_drift"' "$POLICY"; then + echo "ERROR: could not drop structural_drift from $POLICY; the mint stops here." >&2 + exit 1 + fi + fi # Overlay .in rule (every archetype): an overlay ships content templates as # *.in so a substitution token never survives in a file whose final name is @@ -1093,6 +1614,78 @@ repo-init archetype="": } fi + # ── What this work tree held before the mint ──────────────────────────── + # Declared here, after every step that moves or deletes files, as exactly + # what this tree now differs by from the replay taken before the first write + # (above): the comparison the drift gate makes, so the declaration cannot + # disagree with it, whatever the mint did to a pre-mint change on the way + # (moved, rendered, overwritten, or reached through an edited input) (Codex + # and CodeRabbit review of #275). The files this mint owns are declared + # already. Written into the (variant (paths)) lists by awk, which takes the + # paths as data and escapes \ and " in deed strings. + if [ -n "$_REPLAY" ]; then + _owned=("${REPO}_chora.deed" docs/status/ROADMAP.adoc CLAUDE.md docs/architecture/REPOSITORY-MAP.adoc) + _is_owned() { local _o; for _o in "${_owned[@]}"; do [ "$1" = "$_o" ] && return 0; done; return 1; } + # _files DIR — DIR's own files, one per line (tracked, or untracked and not + # ignored), as the drift gate lists them. This tree is read under its own + # git configuration, as `git add -A` will commit it (a file only a global + # excludesFile ignores is never committed, so never declared); the + # replay under none (CodeRabbit review of #275). + _files() { + { if [ "$1" = "$_REPLAY" ]; then _rg -C "$1" ls-files -z --cached --others --exclude-standard + else git -C "$1" ls-files -z --cached --others --exclude-standard; fi; } \ + | while IFS= read -r -d '' _f; do + if [ -e "$1/$_f" ] || [ -L "$1/$_f" ]; then printf '%s\n' "$_f"; fi + done | LC_ALL=C sort -u + } + _pml="$(mktemp -d)"; : > "$_pml/a"; : > "$_pml/r"; : > "$_pml/d" + _files . > "$_pml/child" + _files "$_REPLAY" > "$_pml/replay" + while IFS= read -r _p; do + _is_owned "$_p" && continue + if ! grep -qxF -- "$_p" "$_pml/replay"; then printf '%s\n' "$_p" >> "$_pml/a" + elif ! _same "$_p" "$_REPLAY/$_p"; then printf '%s\n' "$_p" >> "$_pml/d" + fi + done < "$_pml/child" + while IFS= read -r _p; do + _is_owned "$_p" && continue + grep -qxF -- "$_p" "$_pml/child" || printf '%s\n' "$_p" >> "$_pml/r" + done < "$_pml/replay" + awk -v fa="$_pml/a" -v fr="$_pml/r" -v fd="$_pml/d" ' + function load(file, s, p) { + s = "" + while ((getline p < file) > 0) { + gsub(/\\/, "\\\\", p); gsub(/"/, "\\\"", p) + s = s " \"" p "\"" + } + close(file); return s + } + # insert EXTRA before the ")" that closes the list opened at "(". + function extend(line, extra, i, c, q, open, out) { + open = index(line, "("); q = 0 + for (i = open + 1; i <= length(line); i++) { + c = substr(line, i, 1) + if (q && c == "\\") { i++; continue } + if (c == "\"") q = !q + else if (!q && c == ")") { + out = substr(line, 1, i - 1) extra substr(line, i) + sub(/\( "/, "(\"", out) + return out + } + } + return line + } + BEGIN { A = load(fa); R = load(fr); D = load(fd) } + /^ :added \(/ { $0 = extend($0, A) } + /^ :removed \(/ { $0 = extend($0, R) } + /^ :diverged \(/ { $0 = extend($0, D) } + { print } + ' "$DEED_OUT" > "$_pml/deed" && cat "$_pml/deed" > "$DEED_OUT" + echo " pre-mint: $(wc -l < "$_pml/a") added, $(wc -l < "$_pml/d") diverged, $(wc -l < "$_pml/r") removed;" + echo " declared in $DEED_OUT (variant (paths)) as this repo's own" + rm -rf "$_pml" "$(dirname "$_REPLAY")" + fi + # ── Regenerate what is DERIVED from the deed and the tree ─────────────── # CLAUDE.md's arrival pack and the repository map are generated files, and # until now the mint left both describing the TEMPLATE. The self-name pass @@ -1122,6 +1715,18 @@ repo-init archetype="": echo " repo map: docs/architecture/REPOSITORY-MAP.adoc regenerated" fi + # Replay (scripts/check-mint-drift.sh): every tree-writing step has run, so + # the reconstruction is complete. What follows only checks the tree: the placeholder + # gate (a partial mint made with RSR_ALLOW_PLACEHOLDERS=1 would fail it on + # replay), the www suite (a past RSR_MINT_DATE fails it: security.txt + # expires at the end of the mint year) and the rest. The child's own CI + # runs those checks on the child; the replay exists only to be compared. + if [ "${RSR_MINT_REPLAY:-0}" = "1" ]; then + echo "" + echo "Replay complete (RSR_MINT_REPLAY=1): post-write checks left to the child's CI." + exit 0 + fi + echo "" echo "── Validation ───────────────────────────────────" @@ -1304,8 +1909,7 @@ repo-init archetype="": echo " must be set to ci/.gitlab-ci.yml, or GitLab CI silently never" echo " runs - no pipeline and no error. See ci/README.adoc." fi - echo " 8. HARNESS — check the recorded template pin (provenance):" - echo " scripts/check-template-conformance.sh [--template ]" - echo " scripts/check-variant-drift.sh is not for a minted repo yet: the mint" - echo " renders most spine files, so they differ from the parent by design" - echo " until (normalise) folds the mint's answers (ADR-0003)." + echo " 8. HARNESS — check the recorded pin, then arm the drift gate, which replays" + echo " this mint from (provenance) and (mint-answers) and diffs the result:" + echo " scripts/check-template-conformance.sh" + echo " scripts/check-variant-drift.sh " diff --git a/ci/.pre-commit-config.yaml b/ci/.pre-commit-config.yaml index a5c2794f..74eb6d88 100644 --- a/ci/.pre-commit-config.yaml +++ b/ci/.pre-commit-config.yaml @@ -36,11 +36,17 @@ repos: pass_filenames: false # --- K9 contract validation --- - - repo: https://github.com/hyperpolymath/k9-pre-commit - rev: 9b82e1f7a6b6c0f99df72c145d7dee8851803c30 # k9-pre-commit @ main 2026-06-23 + # The canonical validator, through the same wrapper CI runs (needs a + # standards checkout beside this repo, or STANDARDS_DIR; skips with a + # notice otherwise). + - repo: local hooks: - id: validate-k9 name: Validate K9 contracts + entry: bash .github/hooks/validate-k9.sh + language: system + pass_filenames: false + files: '\.k9(\.(ncl|lock))?$' # --- Shell linting --- - repo: https://github.com/shellcheck-py/shellcheck-py diff --git a/docs/AI_INSTALLATION_GUIDE.adoc b/docs/AI_INSTALLATION_GUIDE.adoc index 9ce7d63a..886b15d0 100644 --- a/docs/AI_INSTALLATION_GUIDE.adoc +++ b/docs/AI_INSTALLATION_GUIDE.adoc @@ -43,9 +43,15 @@ with no git ancestry; `just repo-init` recovers the template tip via [source,bash] ---- -just repo-init # or: just repo-init +mise trust # allow this repository's mise.toml (no prompt without a TTY) +mise install # the toolchain, including the yq repo-init needs +mise exec -- just repo-init # or: mise exec -- just repo-init ---- +When the mint keeps `build/container/`, `just repo-init` reformats its KYAML +configs after filling them, which needs a yq that writes KYAML (mise.toml +provides it). Without one the mint stops before writing anything. + `just repo-init` renders every `{{TOKEN}}` in the tree, derives the repository identity, writes the provenance files, and removes the template-only directories. @@ -62,7 +68,7 @@ are required; an unset optional answer takes its default. ---- RSR_NON_INTERACTIVE=1 PROJECT_NAME="My Project" REPO=my-project \ OWNER=my-org AUTHOR="My Name" AUTHOR_EMAIL=me@example.org \ - just repo-init + mise exec -- just repo-init ---- Until 2026-10-14 this mode stubbed the `read` builtin for the whole recipe, so @@ -109,12 +115,15 @@ returns 1 and kills the recipe under `set -e`. [source,bash] ---- -just --list # every recipe resolves (imports are silent on failure) -just check-root-shape # root matches the allowlist +mise exec -- just --list # every recipe resolves (imports are silent on failure) +mise exec -- just check-root-shape # root matches the allowlist bash scripts/check-no-placeholders.sh . -just verify +mise exec -- just verify ---- +An agent's shell is rarely mise-activated, and each command may run in a fresh +shell, so every `just` call here goes through `mise exec --`. + A successful instantiation leaves **no** `{{TOKEN}}` anywhere in the tree, and removes the template-only directories from the minted repository. If `scripts/validate-template.sh` or the end-to-end instantiation test are still diff --git a/docs/HYPATIA-RULE-ORDER.adoc b/docs/HYPATIA-RULE-ORDER.adoc index 2d3ecdbe..8e57efec 100644 --- a/docs/HYPATIA-RULE-ORDER.adoc +++ b/docs/HYPATIA-RULE-ORDER.adoc @@ -10,7 +10,7 @@ speak about the same file, *which* clashes exist today, and *what resolves each one*. It is local policy about tools that live elsewhere: Hypatia at `hyperpolymath/hypatia@be98f74ce93df200752f3798efa1305bfa3e0e10` (`lib/hypatia/scanner_suppression.ex`, `lib/rules/`), and the verdict grid at -`metadatastician/standards@8e48b1caade08a552646adeab66558ac43b4360d` +`metadatastician/standards@e064539116c2dcfdf282a4749dbd58c69961c7e6` (`docs/VERDICT-GRID-SPEC.adoc`). Nothing here changes what a scanner finds. It states the order the layers are diff --git a/docs/SETUP.adoc b/docs/SETUP.adoc index a44ac0fd..6811138d 100644 --- a/docs/SETUP.adoc +++ b/docs/SETUP.adoc @@ -64,8 +64,11 @@ Or with OPSM, the odds-and-sods package manager (see <>): `opsm install https://github.com/hyperpolymath/secret-types.git --registry git` TIP: To start a *new* project from this template, use GitHub's *Use this template* -button (or clone as above), then run `just repo-init` before step 3; it fills every -placeholder token. The steps below work the same afterwards. +button (or clone as above), install the toolchain (step 3), then run +`mise exec -- just repo-init` (plain `just repo-init` once mise is activated); +it fills every placeholder token, and needs the toolchain's yq to keep the container +configs KYAML (it stops before writing anything without one). The steps below work +the same afterwards. == 3. Install the toolchain @@ -75,6 +78,10 @@ mise trust # allow this repository's mise.toml mise install # installs everything mise.toml lists, at mise.lock's versions ---- +The `just` commands from here on expect the toolchain on `PATH`: activate mise in +your shell (`eval "$(mise activate bash)"`, or your shell's equivalent), or run each +one as `mise exec -- just …`. + That installs: [cols="1,2,3"] diff --git a/mise.toml b/mise.toml index 9db3ec0f..19f214f7 100644 --- a/mise.toml +++ b/mise.toml @@ -19,5 +19,6 @@ lockfile = true [tools] just = "latest" shellcheck = "latest" # the shell scripts +yq = "latest" # scripts/kyaml-format.sh (KYAML configs) zig = "latest" trivy = "latest" # just deps-audit diff --git a/scripts/chain-mint.sh b/scripts/chain-mint.sh new file mode 100755 index 00000000..e1db1ca1 --- /dev/null +++ b/scripts/chain-mint.sh @@ -0,0 +1,436 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# chain-mint.sh — mint a repository made from a MINTED repository. +# +# A minted repo has already filled its tokens and deleted build/templates, so +# minting it again rendered none of the new answers and skipped the identity +# splice: the grandchild inherited its parent's author, contacts and identity +# (Codex review of #251). The owner chose to support chains properly +# (2026-11-04). This is how, as a three-way merge that git performs: +# +# base = replay of the parent's own mint (template @ pin, parent answers) +# theirs = the parent as it stands (base + the parent's own work) +# ours = the template @ pin minted with THIS repo's answers +# result = merge-tree(base, ours, theirs) +# +# base -> theirs is exactly the parent's own work since its mint, and nothing +# else, so it is replayed onto a fresh mint for the new identity. Everything +# the template rendered is rendered afresh; the parent's own files and edits +# are carried; a conflict between the two stops before anything is written. +# Identity-bearing files (the deed, CLAUDE.md, the map, the roadmap) are never +# carried: they come from the new mint and are regenerated. Text the parent +# wrote about itself is renamed by the same rules repo-init uses: the bounded +# self-name rule and the narrow self-owner rule (repo-init defines each once +# and exports SELF_NAME_SED and SELF_OWNER_SED). What it inherits is declared +# by comparing the result with the fresh mint, as the drift gate does. +# +# Called by `just repo-init` from the root of the new repository, whose tree is +# the parent's ("Use this template" on a minted repo), after the prompts, with +# the new answers exported under the names repo-init reads, plus +# TEMPLATE_REPO/TEMPLATE_OWNER (the parent) and RSR_MINT_DATE. +# RSR_TEMPLATE_URL overrides where the template's history is read from. +# Exit: 0 minted; 1 refused (reason on stderr, tree untouched); 2 usage. +set -euo pipefail +# This script runs git against other repositories (-C does not override +# these), so an inherited location (a hook, another git caller) cannot redirect +# it (CodeRabbit review of #275). +unset GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE GIT_OBJECT_DIRECTORY \ + GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_PREFIX GIT_NAMESPACE + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +READ="$HERE/deed-field.sh" +G="$(pwd)" +die() { echo "chain-mint: $*" >&2; exit 1; } +# g — git with no user or system configuration and no hooks, as the drift +# gate's (Codex review of #275): this machine's settings cannot change the +# merge, the commits or the file sets. +g() { + GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 git -c user.name=chain -c user.email=chain@example.invalid \ + -c commit.gpgsign=false -c core.hooksPath=/dev/null "$@" +} + +for v in REPO OWNER TEMPLATE_REPO TEMPLATE_OWNER RSR_MINT_DATE; do + [ -n "${!v:-}" ] || { echo "chain-mint: $v is not set (run through just repo-init)" >&2; exit 2; } +done +# A sparse checkout lists paths it has not checked out; read as a tree, their +# absence is a deletion of most of the repository (Codex review of #277). A +# chain mint carries and rewrites the whole tree, so it needs all of it. +_sparse=0 +while IFS= read -r -d '' e; do case "$e" in "S "*) _sparse=$((_sparse + 1)) ;; esac; done < <(g -C "$G" ls-files -t -z) +[ "$_sparse" -eq 0 ] || die "this checkout is sparse (${_sparse} tracked paths are not checked out); a chain mint carries the whole tree. Run it from a full checkout (git sparse-checkout disable); nothing was written" +# An unresolved merge: its stages would collapse into one ordinary blob, and +# conflict markers would be carried as the parent's work (Codex review of #277). +[ -z "$(g -C "$G" ls-files -u)" ] || die "this checkout has an unresolved merge (git ls-files -u); resolve it first; nothing was written" +# A tracked path beneath a symlink: reading it would read through the link, +# from wherever it points (Codex review of #277). Refused by name. +while IFS= read -r -d '' f; do + p="$f" + while case "$p" in */*) p="${p%/*}"; true ;; *) false ;; esac; do + [ ! -L "$G/$p" ] || die "the tracked path $f lies beneath the symlink $p; restore the directory or untrack the path; nothing was written" + done +done < <(g -C "$G" ls-files -z) +PDEED="$(bash "$READ" --find "$G")" || die "no single *_chora.deed in this tree" +PIN="$(bash "$READ" "$PDEED" provenance template-commit 2>/dev/null || true)" +TSLUG="$(bash "$READ" "$PDEED" provenance template-repo 2>/dev/null || true)" +[[ "$PIN" =~ ^[0-9a-f]{40}$ ]] || die "the parent's (provenance :template-commit) is '${PIN}', not a commit" +P_ARCH="$(bash "$READ" "$PDEED" mint-answers archetype 2>/dev/null || true)" + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +# The root template's history, holding the parent's pin. +URL="${RSR_TEMPLATE_URL:-https://github.com/${TSLUG}.git}" +FILTER=(--filter=blob:none) +case "$URL" in *://*|*@*:*) ;; *) FILTER=() ;; esac +# --template= (none): a global init.templateDir must not put an info/attributes +# into the clone, which every checkout from it would then honour (Codex review +# of #277). Transport settings (credentials, insteadOf) still apply. +git clone -q --no-checkout --template= "${FILTER[@]}" "$URL" "$WORK/template" 2>"$WORK/clone.err" \ + || { cat "$WORK/clone.err" >&2; die "cannot read ${TSLUG} from $URL (set RSR_TEMPLATE_URL)"; } +git -C "$WORK/template" cat-file -e "${PIN}^{commit}" 2>/dev/null \ + || die "$URL does not contain the parent's template commit $PIN" + +# Both baselines are functions of the pin and the answers alone: they run the +# replay wrapper as the template commit has it, not the parent's carried copy, +# which the parent may have edited (Codex review of #277). +PINNED="$WORK/pinned"; mkdir -p "$PINNED" +for s in replay-mint.sh deed-field.sh; do + git -C "$WORK/template" show "${PIN}:scripts/$s" > "$PINNED/$s" 2>/dev/null \ + || die "the template commit ${PIN:0:12} has no scripts/$s, so its mint cannot be replayed for a chain; nothing was written" +done +echo " chain: replaying the parent's mint (${TSLUG}@${PIN:0:12}, its own answers)" +bash "$PINNED/replay-mint.sh" "$WORK/template" "$PIN" "$WORK/base" --answers-from "$PDEED" \ + || die "the replay of the parent's mint failed (above); nothing was written" +echo " chain: minting ${TSLUG}@${PIN:0:12} with this repo's answers" +ARCHETYPE="$P_ARCH" RSR_TEMPLATE_SLUG="$TSLUG" bash "$PINNED/replay-mint.sh" "$WORK/template" "$PIN" "$WORK/new" \ + || die "the mint with the new answers failed (above); nothing was written" + + +# ── Trees, written by git ─────────────────────────────────────────────────── +# Every tree below is written by git from a work tree, and the result is +# written back into this checkout by git. Git never follows a symlink in a work +# tree, and checks every path it would overwrite before it writes any (Codex +# review of #277: copying with tar followed a parent-controlled symlink, +# recursed into a path that had become a directory, and emptied the checkout +# before learning that an ignored entry was in the way). +M="$WORK/merge" +g init -q -b base "$M" +mkdir -p "$M/.git/objects/info" +# link_objects DIR — DIR's objects become readable from M. +link_objects() { + local od + od="$(g -C "$1" rev-parse --path-format=absolute --git-common-dir)/objects" + grep -qxF "$od" "$M/.git/objects/info/alternates" 2>/dev/null || printf '%s\n' "$od" >> "$M/.git/objects/info/alternates" +} +# tree_of DIR NAME [own] — the tree of DIR's own files, as `git add -A` commits +# them from DIR's index: tracked files (an ignore rule matching or not) and the +# untracked files no rule ignores. DIR's own git does the reading, on a scratch +# index, writing its objects into M; DIR's index and objects are not written. +# With "own", DIR is read under its own configuration, exactly as `git status` +# there sees it: its exclusion files (a linked worktree's are in the common +# directory), core.autocrlf, clean filters (Codex review of #277). Without it, +# under none (g): a replay's tree must not depend on this machine. +tree_of() { + local d="$1" idx="$WORK/index.$2" gx=g od + [ "${3:-}" = own ] && gx=git + link_objects "$d" + od="$(g -C "$d" rev-parse --path-format=absolute --git-common-dir)/objects" + rm -f "$idx" + g -C "$d" ls-files -s -z | GIT_INDEX_FILE="$idx" g --git-dir="$M/.git" update-index -z --index-info + GIT_INDEX_FILE="$idx" GIT_OBJECT_DIRECTORY="$M/.git/objects" GIT_ALTERNATE_OBJECT_DIRECTORIES="$od" \ + "$gx" -C "$d" -c core.fsmonitor=false -c core.hooksPath=/dev/null add -A . + GIT_INDEX_FILE="$idx" g --git-dir="$M/.git" write-tree +} +# with_index NAME CMD… — run git CMD in M against the scratch index NAME. +with_index() { local i="$WORK/index.$1"; shift; GIT_INDEX_FILE="$i" g --literal-pathspecs -C "$M" "$@"; } +P_DEED_NAME="$(basename "$PDEED")" +IDENTITY=("$P_DEED_NAME" CLAUDE.md docs/architecture/REPOSITORY-MAP.adoc docs/status/ROADMAP.adoc) + +B="$(g -C "$M" commit-tree "$(tree_of "$WORK/base" base)" -m base)" +# theirs: the parent as it stands (uncommitted work included), with its +# identity files held at base, so they are never carried: they belong to the +# parent. Held in the index, never through the parent's work tree. +PARENT_TREE="$(tree_of "$G" parent own)" +rm -f "$WORK/index.theirs"; with_index theirs read-tree "$PARENT_TREE" +for f in "${IDENTITY[@]}"; do + with_index theirs rm -q --cached --ignore-unmatch -- "$f" + entry="$(g -C "$M" ls-tree "$B" -- "$f")" + [ -n "$entry" ] || continue + read -r mode _ sha _ <<<"$entry" + with_index theirs update-index --add --cacheinfo "$mode,$sha,$f" 2>/dev/null \ + || die "cannot hold $f at the template's version: the parent replaced a directory above it" +done +THEIRS="$(g -C "$M" commit-tree "$(with_index theirs write-tree)" -p "$B" -m theirs)" + +# ours: the fresh mint, kept as its own commit (the drift gate replays exactly +# it, so the declarations are measured against it), then shaped by the +# parent's deletions and type changes. ours differs from base only by +# rendering, so a file the parent deleted stays deleted (else a modify/delete +# conflict: .machine_readable/ai/PLACEHOLDERS.adoc), and a path whose type the +# parent changed keeps base's version there, so the parent's change wins (else +# a "distinct types" conflict against a mere re-rendering). +NEW_T="$(tree_of "$WORK/new" new)" +g -C "$M" branch -q fresh "$(g -C "$M" commit-tree "$NEW_T" -p "$B" -m fresh)" +rm -f "$WORK/index.ours"; with_index ours read-tree "$NEW_T" +# A path the new answers rename (src/.jl -> src/.jl) is deleted where +# the fresh mint has it. The renames are found by the detection merge-tree +# itself applies, so exactly the pairs it would see as rename/delete (Codex +# review of #277). renamed_to P — P's rename destination in the fresh mint. +declare -A RENAMED=() +while IFS= read -r -d '' st && IFS= read -r -d '' old && IFS= read -r -d '' new; do + RENAMED["$old"]="$new" +done < <(g -C "$M" diff -z --find-renames --name-status --diff-filter=R "$B" "$NEW_T") +while IFS= read -r -d '' f; do + with_index ours rm -q --cached --ignore-unmatch -- "$f" + [ -z "${RENAMED[$f]:-}" ] || with_index ours rm -q --cached --ignore-unmatch -- "${RENAMED[$f]}" +done < <(g -C "$M" diff -z --no-renames --name-only --diff-filter=D "$B" "$THEIRS") +while IFS= read -r -d '' f; do + # The fresh mint has this path elsewhere: the parent's new type cannot be + # put where the new answers put the file. Refused by name, rather than + # carried at the old path beside the fresh one (Codex review of #277). + [ -z "${RENAMED[$f]:-}" ] \ + || die "the parent changed the type of $f, which this mint renames to ${RENAMED[$f]}; a chain mint cannot carry that across the rename. Restore it in the parent (or mint from the template); nothing was written" + with_index ours rm -q --cached --ignore-unmatch -- "$f" + read -r mode _ sha _ <<<"$(g -C "$M" ls-tree "$B" -- "$f")" + with_index ours update-index --add --cacheinfo "$mode,$sha,$f" +done < <(g -C "$M" diff -z --no-renames --name-only --diff-filter=T "$B" "$THEIRS") +OURS="$(g -C "$M" commit-tree "$(with_index ours write-tree)" -p fresh -m ours)" + +if ! g -C "$M" merge-tree --write-tree --name-only --merge-base="$B" "$OURS" "$THEIRS" > "$WORK/merge.out" 2>&1; then + echo "chain-mint: the parent's own changes conflict with this repo's fresh mint in:" >&2 + # --name-only: the merged tree, then the conflicted paths, a blank line, + # then git's messages (CONFLICT …), which say what kind of conflict. + sed -n '2,$p' "$WORK/merge.out" | sed '/^$/d; s/^/ /' | head -n 60 >&2 + die "nothing was written. Resolve those in the parent (or mint from the template), then retry." +fi +RESULT="$(head -n 1 "$WORK/merge.out")" + +# Everything below is built and checked in a scratch copy, F; this checkout +# is written only once every step has succeeded, so a failure at any point (a +# carried placeholder, a failing gate) leaves it untouched (Codex review of #277). +F="$WORK/final" +g -C "$M" branch -q result "$(g -C "$M" commit-tree "$RESULT" -m result)" +g init -q -b main "$F" +g -C "$F" fetch -q "$M" fresh:refs/chain/fresh result:refs/chain/result +# F's index is the merged tree itself, and git writes its files (never +# through a symlink); a tracked file an ignore rule matches stays tracked. +g -C "$F" read-tree refs/chain/result +g -C "$F" checkout-index -a -f + +# What this repo inherits is the merged result's difference from the fresh +# mint: exactly what the drift gate will compare. Measured on the result, not +# on the parent's diff, so a path the merge carried through an answer-dependent +# rename (src/.jl -> src/.jl) is named where it now lives (Codex +# review of #277). The files the new mint owns (the deed, CLAUDE.md, the map, +# the roadmap) are declared already, and are regenerated below. +FRESH="$(g -C "$F" rev-parse refs/chain/fresh)" +OWNED=("${REPO}_chora.deed" CLAUDE.md docs/architecture/REPOSITORY-MAP.adoc docs/status/ROADMAP.adoc) +is_owned() { local o; for o in "${OWNED[@]}"; do [ "$1" = "$o" ] && return 0; done; return 1; } +# inherited — "STATUS\0PATH\0" for every path where F differs from the fresh mint. +inherited() { g -C "$F" add -A; g -C "$F" diff --cached -z --no-renames --name-status "$FRESH"; } + +# Rename the parent where its own work names it, by the rules repo-init +# defined and exported, read as data: the bounded self-name rule +# (SELF_NAME_SED) and the narrow self-owner rule for identity settings +# (SELF_OWNER_SED: a carried sonar.organization or OWNER := line). +for v in SELF_NAME_SED SELF_OWNER_SED; do + [ -n "${!v:-}" ] || { echo "chain-mint: $v is not set (run through just repo-init)" >&2; exit 2; } +done +TEMPLATE_SLUG="${TEMPLATE_OWNER}/${TEMPLATE_REPO}" +VERBATIM="$(sed -e 's/#.*//' -e 's/[[:space:]]*$//' "$F/.machine_readable/mint-verbatim.txt" | grep -v '^$' || true)" +while IFS= read -r -d '' st && IFS= read -r -d '' f; do + [ "$st" != D ] || continue + [ -f "$F/$f" ] && [ ! -L "$F/$f" ] || continue + grep -qxF -- "$f" <<< "$VERBATIM" && continue + if [ "$TEMPLATE_OWNER" != "$OWNER" ] && grep -qIF -- "$TEMPLATE_OWNER" "$F/$f" 2>/dev/null; then + sed -i -E "$SELF_OWNER_SED" "$F/$f" + fi + if grep -qIF -- "$TEMPLATE_REPO" "$F/$f" 2>/dev/null; then + sed -i -E "$SELF_NAME_SED" "$F/$f" + fi +done < <(inherited) + +# Every path this script writes from here on must be a real path inside F: a +# symlink the parent carried at one of them, or at a directory above one, +# would redirect the write (Codex review of #277). Refused by name. +DEED="$F/${REPO}_chora.deed" +# The arrival-pack generator's temporary files included (CLAUDE.md.tmp is +# moved over CLAUDE.md) (Codex review of #277). +for w in "${REPO}_chora.deed" CLAUDE.md docs/architecture/REPOSITORY-MAP.adoc \ + .machine_readable/arrival-pack/claude-md-data.json CLAUDE.md.tmp \ + .machine_readable/arrival-pack/.region.tmp; do + p="$w" + while :; do + [ ! -L "$F/$p" ] || die "the carried tree has a symlink at $p, where the mint writes $w; nothing was written" + case "$p" in */*) p="${p%/*}" ;; *) break ;; esac + done +done +[ -f "$DEED" ] || die "the new mint produced no ${REPO}_chora.deed" +cp "$DEED" "$WORK/deed.fresh" + +# declare — the inherited paths, by kind, into the three lists. The deed's +# (paths) lists are line-based: a path holding a newline is refused by name, +# as is one holding byte 0x01, the deed reader's record separator (Codex +# review of #277). +declare_paths() { + : > "$WORK/added.list"; : > "$WORK/removed.list"; : > "$WORK/diverged.list" + N_A=0 N_D=0 N_M=0 + while IFS= read -r -d '' st && IFS= read -r -d '' f; do + is_owned "$f" && continue + case "$f" in *$'\n'*|*$'\001'*) die "cannot declare a carried path containing a newline or byte 0x01: $(printf '%q' "$f"); nothing was written" ;; esac + case "$st" in + A) printf '%s\n' "$f" >> "$WORK/added.list"; N_A=$((N_A + 1)) ;; + D) printf '%s\n' "$f" >> "$WORK/removed.list"; N_D=$((N_D + 1)) ;; + *) printf '%s\n' "$f" >> "$WORK/diverged.list"; N_M=$((N_M + 1)) ;; + esac + done < <(inherited) + # Each path with its category: a path that moves from removed to diverged + # (a generator recreated it) changes the declaration (Codex review of #277). + { sed 's/^/added /' "$WORK/added.list"; sed 's/^/removed /' "$WORK/removed.list" + sed 's/^/diverged /' "$WORK/diverged.list"; } > "$WORK/declared.now" +} +# write_deed — the fresh deed, plus the lineage and the declared lists. Paths +# are written as deed strings (\ and " escaped) by awk, which takes them as +# data: a path holding &, |, " or \ cannot corrupt the clause or the command. +write_deed() { + awk -v slug="$TEMPLATE_SLUG" -v ptree="$PARENT_TREE" \ + -v fa="$WORK/added.list" -v fr="$WORK/removed.list" -v fd="$WORK/diverged.list" ' + function load(file, s, p) { + s = "" + while ((getline p < file) > 0) { + if (p == "") continue + gsub(/\\/, "\\\\", p); gsub(/"/, "\\\"", p) + s = s " \"" p "\"" + } + close(file); return s + } + # insert EXTRA before the first ")" that closes the list opened at "(", + # skipping parentheses inside strings. + function extend(line, extra, i, c, q, open, out) { + open = index(line, "("); q = 0 + for (i = open + 1; i <= length(line); i++) { + c = substr(line, i, 1) + if (q && c == "\\") { i++; continue } + if (c == "\"") q = !q + else if (!q && c == ")") { + out = substr(line, 1, i - 1) extra substr(line, i) + sub(/\( "/, "(\"", out) + return out + } + } + return line + } + BEGIN { A = load(fa); R = load(fr); D = load(fd) } + /^ :added \(/ { $0 = extend($0, A) } + /^ :removed \(/ { $0 = extend($0, R) } + /^ :diverged \(/ { $0 = extend($0, D) } + { print } + /^ :template-tree "[^"]*"$/ { + print " ; minted from a minted repo: its own work since its mint was carried across" + print " :derived-from \"" slug "\"" + print " :derived-from-tree \"" ptree "\"" + } + ' "$WORK/deed.fresh" > "$WORK/deed.new" + cat "$WORK/deed.new" > "$DEED" + grep -q ':derived-from "' "$DEED" || die "could not record :derived-from in $(basename "$DEED")" +} +# regenerate — the derived files follow the merged tree and the new deed. +# They run the CARRIED generators, so what they write is measured afterwards. +regenerate() { + ( cd "$F" && bash .machine_readable/arrival-pack/generate.sh >/dev/null ) \ + || die "the arrival pack could not be regenerated; nothing was written" + # The generator's intermediate data is a build artefact, never carried: it + # goes before the declaration is measured, so a parent that force-added it + # does not leave it declared but absent (Codex review of #277). + rm -f "$F/.machine_readable/arrival-pack/claude-md-data.json" + ( cd "$F" && bash scripts/gen-repo-map.sh --worktree . >/dev/null ) \ + || die "the repository map could not be regenerated; nothing was written" +} +# Declared, written and regenerated until the declaration is stable: a carried +# generator that writes another file is measured, not missed (Codex review of +# #277). Twice settles any generator that is a function of the tree. +declare_paths; write_deed; regenerate +cp "$WORK/declared.now" "$WORK/declared.before" +declare_paths +if ! cmp -s "$WORK/declared.before" "$WORK/declared.now"; then + write_deed; regenerate + cp "$WORK/declared.now" "$WORK/declared.before" + declare_paths + cmp -s "$WORK/declared.before" "$WORK/declared.now" \ + || die "the carried generators keep changing the tree (the declaration does not settle); nothing was written" +fi + +# The result, fixed here: the gates below run on F, and whatever they write +# there (a report, a cache) is theirs, not the result's (Codex review of #277). +FINAL_T="$(g -C "$F" add -A && g -C "$F" write-tree)" +link_objects "$F" + +# The gates a mint enforces on a child, run on the result before this checkout +# is written: a chain mint must not finish with a tree its own CI rejects +# (Codex review of #277). +gate() { local name="$1"; shift; ( cd "$F" && "$@" ) > "$WORK/gate.log" 2>&1 \ + || { tail -n 40 "$WORK/gate.log" >&2; die "the result fails $name (above); nothing was written"; }; } +gate "the placeholder gate" env -u GITHUB_REPOSITORY bash scripts/check-no-placeholders.sh . +for _gate in check-spdx-headers check-mise-language check-container-pins; do + [ -f "$F/scripts/${_gate}.sh" ] && gate "${_gate}" bash "scripts/${_gate}.sh" --root . +done +[ ! -f "$F/www/tests/run-all.sh" ] || gate "the www/ suite" bash www/tests/run-all.sh +gate "just verify" just verify + +# Every check passed: write the result into this checkout, by git. The scratch +# index starts as the parent snapshot (which is what the work tree holds), and +# a two-tree read-tree moves it to the result. This checkout's own git does the +# writing, under its own configuration, as it did the reading (tree_of … own): +# files are checked out as its checkouts are (core.autocrlf, smudge filters), +# from M's objects; it writes no object. +GIDX="$WORK/index.write" +rm -f "$GIDX" +own() { git -C "$G" -c core.fsmonitor=false -c core.hooksPath=/dev/null "$@"; } +wown() { GIT_INDEX_FILE="$GIDX" GIT_OBJECT_DIRECTORY="$M/.git/objects" own "$@"; } +{ wown read-tree "$PARENT_TREE" && wown update-index -q --refresh; } 2> "$WORK/write.err" \ + || { sed 's/^/ /' "$WORK/write.err" >&2; die "cannot read this checkout's state (above); nothing was written"; } +# Every path the result adds must be free here: nothing at it, and no file +# where it needs a directory, that the parent snapshot does not track, ignored +# or not. Checked by name, on the disk, because git's own check treats an +# ignored entry as expendable wherever it can see the rule that ignores it (a +# .gitignore, or this checkout's own excludes) (Codex review of #277). +occupied() { [ -L "$G/$1" ] || { [ -e "$G/$1" ] && [ ! -d "$G/$1" ]; }; } +COLLIDE="$WORK/collide.list"; : > "$COLLIDE" +while IFS= read -r -d '' p; do + if occupied "$p"; then printf '%s\n' "$p" >> "$COLLIDE" + elif [ -d "$G/$p" ] && [ -n "$(wown ls-files -o -z -- ":(literal)$p/" | head -c 1)" ]; then + printf '%s/\n' "$p" >> "$COLLIDE" + fi + d="$p" + while case "$d" in */*) d="${d%/*}"; true ;; *) false ;; esac; do + if occupied "$d" && [ -z "$(wown ls-files -z -- ":(literal)$d")" ]; then printf '%s\n' "$d" >> "$COLLIDE"; fi + done +done < <(g -C "$M" diff -z --no-renames --name-only --diff-filter=A "$PARENT_TREE" "$FINAL_T") +if [ -s "$COLLIDE" ]; then + LC_ALL=C sort -u "$COLLIDE" | sed 's/^/ /' >&2 + die "this checkout cannot take the result without overwriting what it does not track, ignored or not (above); nothing was written" +fi +# Every file the write changes is first checked out into scratch, by this +# checkout's own git, so a smudge filter that fails (a missing LFS, a +# required custom filter) stops the chain here: read-tree -u is not atomic, +# and would have written the files before it (Codex review of #277). +PRE="$WORK/precheckout"; mkdir -p "$PRE" +{ GIT_INDEX_FILE="$WORK/index.pre" GIT_OBJECT_DIRECTORY="$M/.git/objects" own read-tree "$FINAL_T" \ + && g -C "$M" diff -z --no-renames --name-only --diff-filter=AMT "$PARENT_TREE" "$FINAL_T" \ + | GIT_INDEX_FILE="$WORK/index.pre" GIT_OBJECT_DIRECTORY="$M/.git/objects" own checkout-index -f -z --stdin --prefix="$PRE/"; } \ + 2> "$WORK/write.err" \ + || { sed 's/^/ /' "$WORK/write.err" >&2; die "this checkout cannot check the result out (a filter failed, above); nothing was written"; } +if ! wown read-tree -m -u "$PARENT_TREE" "$FINAL_T" 2> "$WORK/write.err"; then + sed 's/^/ /' "$WORK/write.err" >&2 + # It may have written part of the result. The scratch repository holds + # every object of both trees: kept, so the checkout can be put back. + trap - EXIT + echo "chain-mint: the write into this checkout failed part-way (above). Its state before the chain is" >&2 + echo " tree ${PARENT_TREE}, kept in ${M}/.git; to put it back, try" >&2 + echo " GIT_INDEX_FILE=${GIDX} GIT_OBJECT_DIRECTORY=${M}/.git/objects git read-tree -m -u ${FINAL_T} ${PARENT_TREE}" >&2 + exit 1 +fi +echo " chain: carried the parent's own work: ${N_M} edited, ${N_A} added, ${N_D} removed (declared in (variant (paths)))" +echo " chain: ${REPO} minted from ${TEMPLATE_SLUG} (template ${TSLUG}@${PIN:0:12})" diff --git a/scripts/check-invisible-characters.sh b/scripts/check-invisible-characters.sh index abde5044..e47efaf3 100755 --- a/scripts/check-invisible-characters.sh +++ b/scripts/check-invisible-characters.sh @@ -38,7 +38,7 @@ if ! "$find_bin" "$scan_root" \ -not -path '*/external_corpora/*' -not -path '*/.lake/*' \ -type f \( -name '*.rs' -o -name '*.ex' -o -name '*.exs' -o -name '*.res' \ -o -name '*.js' -o -name '*.ts' -o -name '*.json' -o -name '*.toml' \ - -o -name '*.yml' -o -name '*.yaml' -o -name '*.md' -o -name '*.adoc' \ + -o -name '*.yml' -o -name '*.yaml' -o -name '*.kyaml' -o -name '*.md' -o -name '*.adoc' \ -o -name '*.idr' -o -name '*.zig' -o -name '*.v' -o -name '*.jl' \ -o -name '*.gleam' -o -name '*.hs' -o -name '*.ml' -o -name '*.sh' \ -o -name '*.deed' -o -name '*.k9' -o -name '*.ncl' -o -name '*.txt' -o -name '*.just' \ diff --git a/scripts/check-mint-drift.sh b/scripts/check-mint-drift.sh new file mode 100755 index 00000000..cdb65190 --- /dev/null +++ b/scripts/check-mint-drift.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# check-mint-drift.sh — the exact drift gate for a minted repo. +# +# A minted repo differs from its template by design: the mint renders tokens, +# strips instruction blocks, prunes dependabot, splices the identity, seeds +# docs/, regenerates CLAUDE.md and the map. Diffing it against the template +# reported 101 rendered files on a fresh mint (2026-10-20), and no set of +# (normalise) rules could fold that without re-implementing the mint. So this +# gate does not compare against the template. It replays the mint: +# +# child ≡ repo-init(template @ :template-commit, (mint-answers)) +# +# modulo the paths the child declares in its (variant (paths …)) clause. The +# replay runs the REAL recipe from the pinned template commit, with the answers +# and date the child recorded, so a fresh mint is green by construction and +# every later edit to a file the mint produced shows up, by name. +# +# Usage: check-mint-drift.sh [child-dir] +# a clone of the template that contains :template-commit +# [child-dir] the minted repo (default .) +# Needs what a mint needs: just, rustc, nickel, uuidgen. +# Exit: 0 no undeclared drift; 1 drift (listed); 2 usage or cannot replay. +set -euo pipefail +# This script runs git against other repositories (-C does not override +# these), so an inherited location (a hook, another git caller) cannot redirect +# it (CodeRabbit review of #275). +unset GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE GIT_OBJECT_DIRECTORY \ + GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_PREFIX GIT_NAMESPACE + +TDIR="${1:?usage: check-mint-drift.sh [child-dir]}" +CDIR="${2:-.}" +TDIR="$(cd "$TDIR" && pwd)"; CDIR="$(cd "$CDIR" && pwd)" +READ="$CDIR/scripts/deed-field.sh" +die() { echo "check-mint-drift: $*" >&2; exit 2; } + +DEED="$(bash "$READ" --find "$CDIR")" || die "no single *_chora.deed in $CDIR" +bash "$READ" --has "$DEED" mint-answers \ + || die "$(basename "$DEED") has no (mint-answers) clause: this repo was minted before answers were recorded, so its mint cannot be replayed" +ans() { bash "$READ" "$DEED" mint-answers "$1" 2>/dev/null || true; } +PIN="$(bash "$READ" "$DEED" provenance template-commit 2>/dev/null || true)" +SLUG="$(bash "$READ" "$DEED" provenance template-repo 2>/dev/null || true)" +[[ "$PIN" =~ ^[0-9a-f]{40}$ ]] || die "(provenance :template-commit) is '${PIN}', not a commit" +git -C "$TDIR" cat-file -e "${PIN}^{commit}" 2>/dev/null \ + || die "$TDIR does not contain template commit $PIN (fetch ${SLUG:-the template}'s history)" + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +# g — git with no user or system configuration and no hooks, so the replay's +# synthetic commit and file set cannot be changed by this machine's settings (a +# global core.hooksPath, init.templateDir, excludesFile or autocrlf) (Codex +# review of #275). The child's own file set is its own git's (files … own). +g() { + GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 git -c user.name=replay -c user.email=replay@example.invalid \ + -c commit.gpgsign=false -c core.hooksPath=/dev/null "$@" +} + +# The child as "Use this template" made it: the pinned tree, one root commit. +R="$WORK/replay" +mkdir -p "$R" +# Written as a checkout writes it, by git (checkout-index), never git archive, +# whose export-ignore and export-subst attributes change the tree (Codex review +# of #275). Through g: no global core.attributesFile shapes it (Codex review +# of #277). +GIT_INDEX_FILE="$WORK/replay.index" g -C "$TDIR" read-tree "$PIN" +# A gitlink has no files to write, so the copy would lose it: refused by name +# rather than compared inexactly (Codex review of #277). +GL="$(GIT_INDEX_FILE="$WORK/replay.index" g -C "$TDIR" ls-files -s | awk -F '\t' '$1 ~ /^160000 / { print " " $2 }')" +[ -z "$GL" ] || die "the template commit carries a submodule (gitlink), which a replay cannot reproduce: +$GL" +GIT_INDEX_FILE="$WORK/replay.index" g -C "$TDIR" checkout-index -a -f --prefix="$R/" +g -C "$R" init -q -b main +g -C "$R" add -A +g -C "$R" commit -q -m "Initial commit" +g -C "$R" remote add origin "git@$(ans forge):$(ans owner)/$(ans repo).git" + +ARCH="$(ans archetype)" +: > "$WORK/gitconfig" +mkdir -p "$WORK/cfg" +if ! (cd "$R" && env -i PATH="$PATH" HOME="$HOME" TMPDIR="${TMPDIR:-/tmp}" \ + ${RUSTUP_HOME:+RUSTUP_HOME="$RUSTUP_HOME"} ${CARGO_HOME:+CARGO_HOME="$CARGO_HOME"} \ + ${RSR_MINT_TOOL_CACHE:+RSR_MINT_TOOL_CACHE="$RSR_MINT_TOOL_CACHE"} \ + XDG_CONFIG_HOME="$WORK/cfg" GIT_CONFIG_GLOBAL="$WORK/gitconfig" GIT_CONFIG_NOSYSTEM=1 \ + RSR_NON_INTERACTIVE=1 RSR_MINT_REPLAY=1 \ + RSR_TEMPLATE_URL="$TDIR" RSR_TEMPLATE_COMMIT="$PIN" RSR_MINT_DATE="$(ans mint-date)" \ + ${SLUG:+RSR_TEMPLATE_SLUG="$SLUG"} \ + PROJECT_NAME="$(ans project-name)" REPO="$(ans repo)" OWNER="$(ans owner)" \ + AUTHOR="$(ans author)" AUTHOR_EMAIL="$(ans author-email)" AUTHOR_ORG="$(ans author-org)" \ + AUTHOR_EMAIL_ALT="$(ans author-email-alt)" AUTHOR_ORCID="$(ans author-orcid)" \ + PROJECT_DESCRIPTION="$(ans description)" FORGE="$(ans forge)" \ + SECURITY_EMAIL="$(ans security-email)" CONDUCT_EMAIL="$(ans conduct-email)" \ + PROJECT_TYPE="$(ans project-type)" WEBSITE="$(ans website)" \ + OPENSSF_BP_ID="$(ans openssf-bp-id)" SERVICE_NAME="$(ans service-name)" \ + PORT="$(ans port)" REGISTRY="$(ans registry)" RSR_PACKAGE_UUID="$(ans package-uuid)" \ + just repo-init ${ARCH:+"$ARCH"} "$WORK/replay.log" 2>&1; then + tail -n 30 "$WORK/replay.log" >&2 + die "the replay of the mint failed (above); nothing was compared" +fi + +# Declared divergence, from the CHILD's (variant (paths …)) clause, kept by +# category: each one is a claim the gate checks, not a blanket exemption +# (Codex review of #275). An entry ending in / covers a directory. +# added must exist here, and must NOT be something the mint produces; +# removed must be absent here; +# diverged (and -pending-upstream) may differ in content, but must still +# exist here, and must be something the mint produces; +# operational-state excluded from comparison altogether (ADR-0002). +section() { bash "$READ" --list "$DEED" variant/paths "$1" 2>/dev/null || true; } +ADDED="$(section added)" +REMOVED="$(section removed)" +DIVERGED="$(section diverged; section diverged-pending-upstream)" +OPSTATE="$(section operational-state)" +# match_len P LIST — the length of the longest entry in LIST covering P (0: +# none); an exact path outranks any directory that holds it. +match_len() { + local p="$1" e best=0 n + while IFS= read -r e; do + [ -n "$e" ] || continue + case "$e" in + */) case "$p" in "$e"*) n=${#e} ;; *) continue ;; esac ;; + *) [ "$p" = "$e" ] || continue; n=$(( ${#e} + 1 )) ;; + esac + [ "$n" -gt "$best" ] && best=$n + done <<< "$2" + echo "$best" +} +# declared P — the category of the MOST SPECIFIC declaration covering P, so a +# file added under a directory the mint removes is an addition, not a revived +# removal (Codex review of #277). Ties keep removed > added > diverged > +# operational-state. +declared() { + local r a d o + r=$(match_len "$1" "$REMOVED"); a=$(match_len "$1" "$ADDED") + d=$(match_len "$1" "$DIVERGED"); o=$(match_len "$1" "$OPSTATE") + if [ $((r + a + d + o)) -eq 0 ]; then echo none + elif [ "$r" -ge "$a" ] && [ "$r" -ge "$d" ] && [ "$r" -ge "$o" ]; then echo removed + elif [ "$a" -ge "$d" ] && [ "$a" -ge "$o" ]; then echo added + elif [ "$d" -ge "$o" ]; then echo diverged + else echo opstate + fi +} +# files DIR [own] — the repository's own files: tracked, or untracked and not +# ignored. With "own", DIR is listed under its own git configuration, as +# `git add -A` there would commit it (a file only a global excludesFile ignores +# is not this repo's); the replay is listed under none (CodeRabbit review of +# #275). The lists below are line-based, so a path holding a newline is +# refused, loudly. +files() { + local gx=g; [ "${2:-}" = own ] && gx=git + "$gx" -C "$1" -c core.hooksPath=/dev/null ls-files -z --cached --others --exclude-standard \ + | while IFS= read -r -d '' f; do + case "$f" in *$'\n'*) echo "check-mint-drift: cannot check a path containing a newline: $(printf '%q' "$f")" >&2; exit 2 ;; esac + # if, not &&: an absent last path must not fail the pipeline (CodeRabbit). + if [ -e "$1/$f" ] || [ -L "$1/$f" ]; then printf '%s\n' "$f"; fi + done \ + | LC_ALL=C sort -u +} +# Membership is the repository file set on each side, not bare existence: an +# ignored artifact the replay also generates (arrival-pack output) is not a +# file the mint produced, even if a child force-adds it (Codex review of #275). +CLIST="$WORK/child.files"; RLIST="$WORK/replay.files" +files "$CDIR" own > "$CLIST" +files "$R" > "$RLIST" +in_child() { grep -qxF -- "$1" "$CLIST"; } +in_replay() { grep -qxF -- "$1" "$RLIST"; } +# same A B — identical as git would record them: content, the executable bit, +# and for a symlink its target (cmp alone follows links and ignores modes). +same() { + if [ -L "$1" ] || [ -L "$2" ]; then + [ -L "$1" ] && [ -L "$2" ] && [ "$(readlink "$1")" = "$(readlink "$2")" ] + return + fi + { [ -x "$1" ] && [ -x "$2" ]; } || { [ ! -x "$1" ] && [ ! -x "$2" ]; } || return 1 + cmp -s "$1" "$2" +} + +DRIFT=0 +report() { DRIFT=1; echo "DRIFT: $*"; } +while IFS= read -r f; do + kind="$(declared "$f")" + if [ "$kind" = removed ]; then + report "$f exists here but is declared removed" + elif [ "$kind" = added ]; then + if in_replay "$f"; then report "$f is declared added, but the mint produces it (declare it diverged)"; fi + elif [ "$kind" = diverged ]; then + # A diverged path is one the mint produces and this repo changed; a new + # path declared diverged is an undeclared addition (Codex review of #275). + if ! in_replay "$f"; then report "$f is declared diverged, but the mint did not produce it (declare in paths.added)"; fi + elif [ "$kind" = opstate ]; then + : + elif ! in_replay "$f"; then + report "$f exists here but the mint did not produce it (declare in paths.added)" + elif ! same "$R/$f" "$CDIR/$f"; then + report "$f differs from what the mint produced, in content or mode (declare in paths.diverged, or restore it)" + fi +done < "$CLIST" +while IFS= read -r f; do + in_child "$f" && continue + kind="$(declared "$f")" + [ "$kind" = removed ] || [ "$kind" = opstate ] && continue + if [ "$kind" = diverged ]; then + report "$f is declared diverged but is absent here (declare it removed)" + else + report "$f was produced by the mint but is absent here (declare in paths.removed)" + fi +done < "$RLIST" +# Every declared addition must actually be here. +while IFS= read -r e; do + [ -n "$e" ] || continue + case "$e" in + */) P="$e" awk 'index($0, ENVIRON["P"]) == 1 { f = 1; exit } END { exit !f }' "$CLIST" \ + || report "declared-added directory $e has no files here" ;; + *) in_child "$e" || report "declared-added $e is missing" ;; + esac +done <<< "$ADDED" + +if [ "$DRIFT" -eq 0 ]; then + echo "PASS: identical to a replay of the mint (${SLUG}@${PIN:0:12}, $(ans mint-date)), modulo declared variant paths" +else + echo "FAIL: undeclared drift from a replay of the mint (${SLUG}@${PIN:0:12}) — declare it in the deed's (variant (paths …)) clause, or restore it" + exit 1 +fi diff --git a/scripts/check-mise-language.sh b/scripts/check-mise-language.sh index ac37905e..56a8cf79 100644 --- a/scripts/check-mise-language.sh +++ b/scripts/check-mise-language.sh @@ -120,7 +120,7 @@ if [ -z "$TOOLS" ] && [ "$DETECT" -eq 1 ]; then # declaring `erlang elixir` was failed for not declaring `mix`/`erl`, and a # Rust tree declaring `rust` was failed for declaring a tool the engine # installs. A mirror that disagrees with the engine is a bug in the mirror. - _t="just shellcheck trivy" + _t="just shellcheck yq trivy" for l in $LANGS; do case "$l" in rust) _t="$_t rust" ;; diff --git a/scripts/check-no-a2ml.sh b/scripts/check-no-a2ml.sh index fd9a5148..29076d3d 100755 --- a/scripts/check-no-a2ml.sh +++ b/scripts/check-no-a2ml.sh @@ -6,6 +6,14 @@ # format is retired: a repo's facts are clauses of its deed (_chora.deed, # deed.abnf) and its contracts are .k9 / .k9.ncl files. # +# A repository that still carries legacy A2ML lists each file, by exact path, +# in the shrink-only ledger .machine_readable/a2ml-legacy-allow.txt (owner +# decision 2026-11-04); listed files and anything under 9-archive/ are not +# reported here. The ledger's own integrity (a listed file that is gone, a +# glob entry, an entry the base ledger did not list) is the canonical gate's +# job, standards scripts/check-a2ml-archived.sh, run by the deed lane +# (.github/hooks/validate-deed.sh). +# # Usage: check-no-a2ml.sh [REPO_ROOT] # Inspects tracked files when REPO_ROOT is a git work tree, and every file # under it (minus .git/) otherwise, so an untracked mint is covered too. @@ -21,10 +29,19 @@ else FOUND=$(find "$REPO_ROOT" -path '*/.git' -prune -o -type f -iname '*.a2ml' -printf '%P\n') fi +LEDGER="$REPO_ROOT/.machine_readable/a2ml-legacy-allow.txt" +if [ -n "$FOUND" ]; then + FOUND=$(printf '%s\n' "$FOUND" | grep -v '^9-archive/' || true) +fi +if [ -n "$FOUND" ] && [ -f "$LEDGER" ]; then + LISTED=$(sed -e 's/#.*//' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' "$LEDGER" | grep -v '^$' || true) + FOUND=$(printf '%s\n' "$FOUND" | grep -vxF -f <(printf '%s\n' "$LISTED") || true) +fi + if [ -z "$FOUND" ]; then - echo "PASS: no .a2ml files in the inspected repository" + echo "PASS: no .a2ml files outside the legacy ledger and 9-archive/" exit 0 fi -echo "FAIL: .a2ml is a retired format; move these facts into the repo deed or a .k9 contract:" +echo "FAIL: .a2ml is a retired format; move these facts into the repo deed or a .k9 contract (or, for a file that predates the ledger, list it in .machine_readable/a2ml-legacy-allow.txt):" printf ' %s\n' $FOUND exit 1 diff --git a/scripts/check-required-contexts.sh b/scripts/check-required-contexts.sh index 235f28b9..ebf16ec0 100755 --- a/scripts/check-required-contexts.sh +++ b/scripts/check-required-contexts.sh @@ -7,7 +7,8 @@ # .machine_readable/required-contexts-allow.txt (and any non-empty # required_status_checks context in .github/rulesets/*.json) resolves to # an existing workflow under .github/workflows/ that runs on pull_request -# without path-filtering that could strand a PR (#219 defect class). +# without path-filtering that could strand a PR (#219 defect class), and +# that has a job whose check-run name is exactly that context. # 2. No workflow in .github/workflows/ invokes an admin bypass (`gh pr merge # --admin`) — enforcing cicd-squabbler's core `squabble != bypass` # invariant statically alongside the runtime binary. @@ -57,6 +58,93 @@ function fail(msg) { errors++; } +/** + * Expand a job's name over a literal `strategy.matrix`, the way GitHub names + * the check runs: a name that references `matrix.` is evaluated per + * combination; any other name gets the combination's values appended, + * `name (v1, v2)`. Returns null when that cannot be done statically: + * include/exclude, a computed matrix or value, or another expression in the + * name. + */ +function matrixNames(name, job) { + const matrix = job?.strategy?.matrix; + const exprs = [...name.matchAll(/\$\{\{\s*(.*?)\s*\}\}/g)].map((m) => m[1]); + if (matrix === undefined) return exprs.length === 0 ? [name] : null; + if (matrix === null || typeof matrix !== "object" || Array.isArray(matrix)) return null; + if ("include" in matrix || "exclude" in matrix) return null; + const keys = Object.keys(matrix); + // GitHub reads matrix property names case-insensitively. + const byLower = new Map(keys.map((k) => [k.toLowerCase(), k])); + const literal = (v) => v !== null && typeof v !== "object" && !String(v).includes("${{"); + if (keys.length === 0 || byLower.size !== keys.length) return null; + if (keys.some((k) => !Array.isArray(matrix[k]) || !matrix[k].every(literal))) return null; + // A dot dereference needs a property starting with a letter or underscore. + const prop = /^matrix\.([A-Za-z_][A-Za-z0-9_-]*)$/; + if (exprs.some((e) => !prop.test(e) || !byLower.has(e.slice(7).toLowerCase()))) return null; + let combos = [{}]; + for (const k of keys) combos = combos.flatMap((c) => matrix[k].map((v) => ({ ...c, [k]: String(v) }))); + return combos.map((c) => + exprs.length > 0 + ? name.replace(/\$\{\{\s*matrix\.(.*?)\s*\}\}/g, (_, k) => c[byLower.get(k.toLowerCase())]) + : `${name} (${keys.map((k) => c[k]).join(", ")})`); +} + +/** Return the event names a parsed workflow triggers on. */ +function triggersOf(doc) { + const onBlock = doc?.on ?? doc?.true; + return typeof onBlock === "string" + ? [onBlock] + : Array.isArray(onBlock) + ? onBlock.map(String) + : onBlock && typeof onBlock === "object" + ? Object.keys(onBlock) + : []; +} + +/** + * Return the check-run names a parsed workflow's jobs produce, adding to + * `unresolved` each job whose names cannot be known statically. A called + * file counts only when it declares `on: workflow_call`, as GitHub requires; + * GitHub nests at most ten workflows (the caller and nine reusables), and a + * call back into the chain is a cycle GitHub refuses. A same-repository call + * is spelled `./.github/workflows/…` or `$/.github/workflows/…`; a caller + * with a literal matrix produces one ` / ` per run. + */ +function producedNames(doc, unresolved, chain = []) { + const jobs = doc?.jobs && typeof doc.jobs === "object" ? doc.jobs : {}; + const names = []; + for (const [id, job] of Object.entries(jobs)) { + const name = String(job?.name ?? id); + const uses = typeof job?.uses === "string" ? job.uses : null; + // GitHub runs only files directly in .github/workflows/: no subdirectory, + // no traversal. + const local = uses?.match(/^[.$]\/(\.github\/workflows\/[^/@\\]+\.ya?ml)$/); + const expanded = matrixNames(name, job); + if (expanded === null) { + unresolved.push(name); + } else if (uses === null) { + // An ordinary job needs a runner; without `runs-on` GitHub creates no + // check run for it. + const runsOn = job?.["runs-on"]; + if (runsOn !== undefined && runsOn !== null && runsOn !== "") names.push(...expanded); + } else if (local) { + const called = join(root, local[1]); + if (chain.includes(called) || chain.length >= 10) { + unresolved.push(name); + continue; + } + if (!existsSync(called)) continue; + const calledDoc = Bun.YAML.parse(readFileSync(called, "utf8")); + if (!triggersOf(calledDoc).includes("workflow_call")) continue; + const inners = producedNames(calledDoc, unresolved, [...chain, called]); + for (const caller of expanded) { + for (const inner of inners) names.push(`${caller} / ${inner}`); + } + } + } + return names; +} + // 1. Parse mapping file: " => <.github/workflows/foo.yml>" const mapped = new Map(); const lines = readFileSync(allowPath, "utf8").split(/\r?\n/); @@ -102,15 +190,7 @@ for (const [ctx, relWf] of mapped.entries()) { } const doc = Bun.YAML.parse(readFileSync(fullWf, "utf8")); const onBlock = doc?.on ?? doc?.true; - const triggers = - typeof onBlock === "string" - ? [onBlock] - : Array.isArray(onBlock) - ? onBlock.map(String) - : onBlock && typeof onBlock === "object" - ? Object.keys(onBlock) - : []; - if (!triggers.includes("pull_request")) { + if (!triggersOf(doc).includes("pull_request")) { fail(`required context '${ctx}' producer '${relWf}' does not trigger on pull_request`); continue; } @@ -123,6 +203,21 @@ for (const [ctx, relWf] of mapped.entries()) { fail(`required context '${ctx}' producer '${relWf}' filters pull_request by paths — strands PRs that do not touch those paths`); } } + // A job's check-run name is its `name:` (or its id when unnamed), expanded + // over a literal matrix. A job that calls a reusable workflow produces + // ` / ` instead, one per called job; a remote + // reusable cannot be read from here, so it produces nothing this gate can + // certify. Without a job of exactly the context's name the context is never + // produced, however the workflow is triggered; a job whose names cannot be + // known statically is named, never assumed to match. + const unresolved = []; + const checkNames = producedNames(doc, unresolved, [fullWf]); + if (!checkNames.includes(ctx)) { + const why = unresolved.length > 0 + ? `; not resolvable statically (matrix include/exclude, computed values, or other expressions in the name): ${unresolved.join(", ")}` + : ""; + fail(`required context '${ctx}' producer '${relWf}' has no job named '${ctx}' (jobs: ${checkNames.join(", ") || "none"})${why}`); + } } // 4. Enforce squabble != bypass across all workflows: no workflow may call diff --git a/scripts/check-variant-drift.sh b/scripts/check-variant-drift.sh index 0b28991e..6ae9e51a 100755 --- a/scripts/check-variant-drift.sh +++ b/scripts/check-variant-drift.sh @@ -36,17 +36,21 @@ PARENT_NAME="${PARENT_SLUG##*/}" PIN=$(bash "$READ" "$DEED" variant parent-pin || true) [[ "$PIN" =~ ^[0-9a-f]{40}$ ]] || PIN="" -# A minted repo is not yet a target for this gate: the mint renders tokens and -# rewrites files, so most spine files differ from the parent by design, and the -# (normalise) rules do not fold the mint's answers (ADR-0003). repo-init no -# longer recommends arming it there. Say so before the findings, rather than -# leave a reader to "fix" ~100 expected lines by declaring them diverged; the -# verdict itself is unchanged (a gate that turns itself green is no gate). +# The template comparison below cannot judge a minted repo: the mint renders +# tokens and rewrites files, so most spine files differ by design. ROLE=$(bash "$READ" "$DEED" variant role 2>/dev/null || true) +# A minted repo that recorded its answers is checked EXACTLY: the mint is +# replayed from the pinned template and the child is diffed against that +# (scripts/check-mint-drift.sh). The approximate comparison below remains for +# variant templates, and for children minted before (mint-answers) existed. +if [ "$ROLE" = "minted-repo" ] && bash "$READ" --has "$DEED" mint-answers; then + exec bash "$(dirname "$READ")/check-mint-drift.sh" "$PARENT_DIR" "$SELF_DIR" +fi if [ "$ROLE" = "minted-repo" ]; then - echo "NOTE: $DEED records :role \"minted-repo\". Rendered files are expected to differ" - echo " from the parent until (normalise) folds the mint's answers; check the" - echo " template pin with scripts/check-template-conformance.sh instead." + echo "NOTE: $DEED records :role \"minted-repo\" but no (mint-answers): it was minted" + echo " before answers were recorded, so the mint cannot be replayed and rendered" + echo " files are expected to differ below. Check the template pin with" + echo " scripts/check-template-conformance.sh." fi # section_paths SECTION — the path list :SECTION of the (variant (paths …)) diff --git a/scripts/check-workflow-edges.sh b/scripts/check-workflow-edges.sh index edd6596f..07a8f19b 100755 --- a/scripts/check-workflow-edges.sh +++ b/scripts/check-workflow-edges.sh @@ -147,7 +147,7 @@ reusable_source() { echo "check-workflow-edges: reusable '${call}' is not in EDGES_REUSABLE_DIR" >&2 return 2 fi - elif ! curl -fsSL --retry 3 --max-time 30 "https://raw.githubusercontent.com/$owner/$repo/$ref/$path" -o "$out"; then + elif ! curl --proto =https --proto-redir =https -fsSL --retry 3 --max-time 30 "https://raw.githubusercontent.com/$owner/$repo/$ref/$path" -o "$out"; then echo "check-workflow-edges: could not read reusable '${call}' at its pinned commit; the edge through it cannot be verified" >&2 return 2 fi diff --git a/scripts/format-container-kyaml.sh b/scripts/format-container-kyaml.sh new file mode 100644 index 00000000..e85d2cf5 --- /dev/null +++ b/scripts/format-container-kyaml.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# format-container-kyaml.sh — reformat the container KYAML configs after +# their tokens are filled. +# +# The template quotes every token, keys included, because a brace token is +# not a plain YAML scalar. Once `repo-init` or `container-init` fills one, +# the quoting is no longer what canonical KYAML writes (a filled service-name +# key needs none), and Estate Rules' "Converted YAML stays KYAML" fails the +# child. Both recipes call this after their substitution pass. +# +# The formatter needs a yq that writes KYAML (mikefarah yq; mise.toml provides +# it). Both recipes run `--preflight` BEFORE they write anything, so a missing +# yq stops them with the tree untouched instead of half-minted. +# +# The values fill double-quoted KYAML scalars, so a quote or backslash in one +# would leave a file the formatter refuses after the tree is written. Both +# recipes run `--check-values` on the answers before they write anything. +# +# Usage: format-container-kyaml.sh [--preflight] [REPO_ROOT] +# format-container-kyaml.sh --check-values SERVICE_NAME PORT REGISTRY +# --preflight write nothing; fail when configs are present and no +# KYAML-capable yq is on PATH. +# --check-values fail unless SERVICE_NAME is a DNS label (lower-case +# letters, digits and inner hyphens, at most 63), PORT is +# 1-65535 and REGISTRY is an image prefix (a host or an +# [IPv6] address in RFC 4291 text, an optional :port from +# 1 to 65535, then +# lower-case path components). +# Exit: 0 = formatted (or nothing to format: an archetype may prune +# build/container/), or values accepted; 1 = no KYAML-capable yq, a +# value refused, or the formatter refused a file (unparseable, data +# change), with the reason on stderr. +set -euo pipefail +if [ "${1:-}" = --check-values ]; then + name="${2:-}" port="${3:-}" registry="${4:-}" bad=0 + # The service name is an image name component, a compose service key, a + # bare TOML table key (stapeln/compose.toml) and the backend's hostname on + # the container network (http://NAME:PORT). A DNS label is valid as all + # four; a dot would nest the TOML table and an underscore is no hostname. + [[ "$name" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]] \ + || { echo "error: service name '$name' is not a DNS label (lower-case letters, digits and inner hyphens, at most 63)." >&2; bad=1; } + in_port_range() { [[ "$1" =~ ^[0-9]{1,5}$ ]] && [ "$((10#$1))" -ge 1 ] && [ "$((10#$1))" -le 65535 ]; } + in_port_range "$port" \ + || { echo "error: port '$port' is not a number from 1 to 65535." >&2; bad=1; } + # A registry is the image prefix: a host (DNS labels, or a bracketed IPv6 + # address), an optional :port, then lower-case path components, as the + # distribution reference grammar has it. + label='[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?' + host="($label(\.$label)*|\[[A-Fa-f0-9:]+\])" + comp='[a-z0-9]+((\.|_|__|-+)[a-z0-9]+)*' + # The grammar takes any digits as the port; a usable one is 1-65535. An + # IPv6 host ends in "]", so a trailing ":digits" before the path is a port. + registry_port="" + if [[ "${registry%%/*}" =~ :([0-9]+)$ ]]; then registry_port="${BASH_REMATCH[1]}"; fi + # The grammar's bracket form is any hex and colons; an address is RFC 4291 + # text: eight groups of 1-4 hex digits, or fewer around a single "::". + is_ipv6() { + local a="$1" g n=0 groups + [[ "$a" =~ ^[0-9A-Fa-f:]+$ ]] || return 1 + [[ "$a" != *:::* ]] || return 1 + [[ "$a" != :* || "$a" == ::* ]] || return 1 + [[ "$a" != *: || "$a" == *:: ]] || return 1 + IFS=: read -ra groups <<< "$a" + for g in "${groups[@]}"; do + [ -n "$g" ] || continue + [ "${#g}" -le 4 ] || return 1 + n=$((n + 1)) + done + case "$a" in + *::*::*) return 1 ;; + *::*) [ "$n" -le 7 ] ;; + *) [ "$n" -eq 8 ] ;; + esac + } + registry_v6="" + if [[ "$registry" == \[* ]]; then registry_v6="${registry#\[}"; registry_v6="${registry_v6%%\]*}"; fi + if ! [[ "$registry" =~ ^$host(:[0-9]+)?(/$comp)*$ ]] \ + || { [ -n "$registry_port" ] && ! in_port_range "$registry_port"; } \ + || { [[ "$registry" == \[* ]] && ! is_ipv6 "$registry_v6"; }; then + echo "error: registry '$registry' is not an image prefix: a host or [IPv6] address, an optional :port from 1 to 65535, then lower-case path components (letters, digits, . _ -)." >&2; bad=1 + fi + exit "$bad" +fi +PREFLIGHT=0 +if [ "${1:-}" = --preflight ]; then PREFLIGHT=1; shift; fi +cd "${1:-.}" + +files=() +for f in build/container/compose.yaml build/container/compose.example.yaml \ + build/container/stapeln/.gatekeeper.yaml; do + if [ -f "$f" ]; then files+=("$f"); fi +done +[ "${#files[@]}" -gt 0 ] || exit 0 + +if ! command -v yq >/dev/null 2>&1 || ! printf 'a: 1\n' | yq -p yaml -o kyaml '.' >/dev/null 2>&1; then + { + echo "error: the container configs need a yq that writes KYAML (mikefarah yq" + echo " v4.53.3+) once their tokens are filled, and none is on PATH." + echo " Run 'mise install' (mise.toml provides yq), or install mikefarah yq." + } >&2 + exit 1 +fi +[ "$PREFLIGHT" -eq 0 ] || exit 0 + +if ! bash scripts/kyaml-format.sh "${files[@]}"; then + echo "error: the KYAML formatter refused a container config (see above)." >&2 + exit 1 +fi diff --git a/scripts/gen-repo-map.sh b/scripts/gen-repo-map.sh index befe940f..bda9c95e 100755 --- a/scripts/gen-repo-map.sh +++ b/scripts/gen-repo-map.sh @@ -47,10 +47,19 @@ repo_files() { | while IFS= read -r -d '' f; do [ -e "$f" ] || [ -L "$f" ] && printf '%s\n' "$f"; done \ | sort -u else - git ls-files -- "$@" + # NUL-separated: plain output C-quotes a path holding a quote or a + # non-ASCII byte, which then read as a root entry named `"notes`. + git ls-files -z -- "$@" | tr "\0" "\n" fi } +# The map is built from lines, so a path holding a newline would read as two +# bogus entries (Codex review of #277). Refused by name, as the drift tooling does. +if [ "$MODE" = worktree ]; then NL_SET=(--cached --others --exclude-standard); else NL_SET=(--cached); fi +NL_PATHS="$(git ls-files -z "${NL_SET[@]}" | while IFS= read -r -d '' f; do + case "$f" in *$'\n'*) printf ' %q\n' "$f" ;; esac; done)" +[ -z "$NL_PATHS" ] || { printf 'ERROR: cannot map a path containing a newline:\n%s\n' "$NL_PATHS" >&2; exit 2; } + [ -f "$ALLOW" ] || { echo "ERROR: $ALLOW not found" >&2; exit 2; } mkdir -p "$(dirname "$OUT")" diff --git a/scripts/render-mise.sh b/scripts/render-mise.sh index 92dd92c6..a4fe5b8c 100755 --- a/scripts/render-mise.sh +++ b/scripts/render-mise.sh @@ -104,6 +104,7 @@ EOF case "$t" in trivy) echo "trivy = \"latest\" # just deps-audit" ;; shellcheck) echo "shellcheck = \"latest\" # the shell scripts" ;; + yq) echo "yq = \"latest\" # scripts/kyaml-format.sh (KYAML configs)" ;; just) echo "just = \"latest\"" ;; *) printf '%s = "latest"\n' "$t" ;; esac diff --git a/scripts/replay-mint.sh b/scripts/replay-mint.sh new file mode 100755 index 00000000..bea6c2e1 --- /dev/null +++ b/scripts/replay-mint.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# replay-mint.sh — mint a template commit into a fresh directory, from answers. +# +# The one place a mint is replayed. Used by scripts/check-mint-drift.sh (replay +# a child's own mint and diff the child against it) and scripts/chain-mint.sh +# (replay a child's mint and mint the same template commit with new answers, +# then carry the child's own work across). It runs the REAL recipe, from the +# pinned commit, exactly as "Use this template" would have started it. +# +# Usage: replay-mint.sh [--answers-from DEED] +# With --answers-from, the answers, date and archetype are the (mint-answers) +# clause of DEED: a replay of that repo's own mint. Without it, they come +# from the environment, under the names repo-init reads (ARCHETYPE too): +# PROJECT_NAME REPO OWNER AUTHOR AUTHOR_EMAIL AUTHOR_ORG AUTHOR_EMAIL_ALT +# AUTHOR_ORCID PROJECT_DESCRIPTION FORGE SECURITY_EMAIL CONDUCT_EMAIL +# PROJECT_TYPE WEBSITE OPENSSF_BP_ID SERVICE_NAME PORT REGISTRY RSR_MINT_DATE +# RSR_TEMPLATE_SLUG (a template whose Justfile names itself through env()) +# With --answers-from, the slug is DEED's (provenance :template-repo) and a +# julia-library package uuid its recorded one; without it, the uuid is derived. +# Nothing else from the caller's environment reaches the mint: no defaults +# file, no global git config (an ORCID or signing key there would leak in). +# Exit: 0 minted into ; 1 the mint failed (log tail on stderr); 2 usage. +set -euo pipefail +# This script runs git against other repositories (-C does not override +# these), so an inherited location (a hook, another git caller) cannot redirect +# it (CodeRabbit review of #275). +unset GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE GIT_OBJECT_DIRECTORY \ + GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_PREFIX GIT_NAMESPACE + +usage() { echo "usage: replay-mint.sh [--answers-from DEED]" >&2; exit 2; } +TDIR="${1:-}"; PIN="${2:-}"; OUT="${3:-}" +{ [ -n "$TDIR" ] && [ -n "$PIN" ] && [ -n "$OUT" ]; } || usage +ARCH="${ARCHETYPE:-}" +if [ $# -gt 3 ]; then + [ "$4" = "--answers-from" ] && [ -n "${5:-}" ] || usage + ANS_DEED="$5" + READ="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deed-field.sh" + bash "$READ" --has "$ANS_DEED" mint-answers \ + || { echo "replay-mint: $ANS_DEED has no (mint-answers) clause; its mint cannot be replayed" >&2; exit 2; } + ans() { bash "$READ" "$ANS_DEED" mint-answers "$1" 2>/dev/null || true; } + PROJECT_NAME="$(ans project-name)" REPO="$(ans repo)" OWNER="$(ans owner)" + AUTHOR="$(ans author)" AUTHOR_EMAIL="$(ans author-email)" AUTHOR_ORG="$(ans author-org)" + AUTHOR_EMAIL_ALT="$(ans author-email-alt)" AUTHOR_ORCID="$(ans author-orcid)" + PROJECT_DESCRIPTION="$(ans description)" FORGE="$(ans forge)" + SECURITY_EMAIL="$(ans security-email)" CONDUCT_EMAIL="$(ans conduct-email)" + PROJECT_TYPE="$(ans project-type)" WEBSITE="$(ans website)" + OPENSSF_BP_ID="$(ans openssf-bp-id)" SERVICE_NAME="$(ans service-name)" + PORT="$(ans port)" REGISTRY="$(ans registry)" RSR_MINT_DATE="$(ans mint-date)" + RSR_PACKAGE_UUID="$(ans package-uuid)" + ARCH="$(ans archetype)" + # The template identity the mint was given, for a template whose Justfile + # names itself through env() (Codex review of #275). + RSR_TEMPLATE_SLUG="$(bash "$READ" "$ANS_DEED" provenance template-repo 2>/dev/null || true)" +else + # A fresh mint (a chain mint's new side) derives its package uuid: only a + # recorded (mint-answers) may supply one, never the caller's environment. + RSR_PACKAGE_UUID="" +fi +TDIR="$(cd "$TDIR" && pwd)" +[ ! -e "$OUT" ] || { echo "replay-mint: $OUT already exists" >&2; exit 2; } +git -C "$TDIR" cat-file -e "${PIN}^{commit}" 2>/dev/null \ + || { echo "replay-mint: $TDIR does not contain commit $PIN" >&2; exit 2; } + +# g — git with no user or system configuration and no hooks (Codex review of +# #275): the synthetic "Use this template" commit cannot be changed or refused +# by this machine's settings. +g() { + GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 git -c user.name=replay -c user.email=replay@example.invalid \ + -c commit.gpgsign=false -c core.hooksPath=/dev/null "$@" +} +mkdir -p "$OUT" +OUT="$(cd "$OUT" && pwd)" +SCRATCH="$(mktemp -d)" +trap 'rm -rf "$SCRATCH"' EXIT +# Written as a checkout writes it, by git (checkout-index), never git archive, +# whose export-ignore and export-subst attributes change the tree (Codex review +# of #275). Through g: no global core.attributesFile shapes it (Codex review +# of #277). +GIT_INDEX_FILE="$SCRATCH/index" g -C "$TDIR" read-tree "$PIN" +# A gitlink has no files to write, so the copy would lose it: refused by name +# rather than replayed inexactly (Codex review of #277). +GL="$(GIT_INDEX_FILE="$SCRATCH/index" g -C "$TDIR" ls-files -s | awk -F '\t' '$1 ~ /^160000 / { print " " $2 }')" +if [ -n "$GL" ]; then + echo "replay-mint: the template commit $PIN carries a submodule (gitlink), which a replay cannot reproduce:" >&2 + printf '%s\n' "$GL" >&2 + exit 2 +fi +GIT_INDEX_FILE="$SCRATCH/index" g -C "$TDIR" checkout-index -a -f --prefix="$OUT/" +g -C "$OUT" init -q -b main +g -C "$OUT" add -A +g -C "$OUT" commit -q -m "Initial commit" +g -C "$OUT" remote add origin "git@${FORGE:-github.com}:${OWNER:-}/${REPO:-}.git" + +: > "$SCRATCH/gitconfig" +mkdir -p "$SCRATCH/cfg" +if ! (cd "$OUT" && env -i PATH="$PATH" HOME="$HOME" TMPDIR="${TMPDIR:-/tmp}" \ + ${RUSTUP_HOME:+RUSTUP_HOME="$RUSTUP_HOME"} ${CARGO_HOME:+CARGO_HOME="$CARGO_HOME"} \ + ${RSR_MINT_TOOL_CACHE:+RSR_MINT_TOOL_CACHE="$RSR_MINT_TOOL_CACHE"} \ + ${STANDARDS_UUID_V8_JS:+STANDARDS_UUID_V8_JS="$STANDARDS_UUID_V8_JS"} \ + XDG_CONFIG_HOME="$SCRATCH/cfg" GIT_CONFIG_GLOBAL="$SCRATCH/gitconfig" GIT_CONFIG_NOSYSTEM=1 \ + RSR_NON_INTERACTIVE=1 RSR_MINT_REPLAY=1 \ + RSR_TEMPLATE_URL="$TDIR" RSR_TEMPLATE_COMMIT="$PIN" RSR_MINT_DATE="${RSR_MINT_DATE:-}" \ + ${RSR_TEMPLATE_SLUG:+RSR_TEMPLATE_SLUG="$RSR_TEMPLATE_SLUG"} \ + PROJECT_NAME="${PROJECT_NAME:-}" REPO="${REPO:-}" OWNER="${OWNER:-}" \ + AUTHOR="${AUTHOR:-}" AUTHOR_EMAIL="${AUTHOR_EMAIL:-}" AUTHOR_ORG="${AUTHOR_ORG:-}" \ + AUTHOR_EMAIL_ALT="${AUTHOR_EMAIL_ALT:-}" AUTHOR_ORCID="${AUTHOR_ORCID:-}" \ + PROJECT_DESCRIPTION="${PROJECT_DESCRIPTION:-}" FORGE="${FORGE:-}" \ + SECURITY_EMAIL="${SECURITY_EMAIL:-}" CONDUCT_EMAIL="${CONDUCT_EMAIL:-}" \ + PROJECT_TYPE="${PROJECT_TYPE:-}" WEBSITE="${WEBSITE:-}" \ + OPENSSF_BP_ID="${OPENSSF_BP_ID:-}" SERVICE_NAME="${SERVICE_NAME:-}" \ + PORT="${PORT:-}" REGISTRY="${REGISTRY:-}" RSR_PACKAGE_UUID="${RSR_PACKAGE_UUID:-}" \ + just repo-init ${ARCH:+"$ARCH"} "$SCRATCH/replay.log" 2>&1; then + tail -n 30 "$SCRATCH/replay.log" >&2 + echo "replay-mint: the mint of $PIN failed (above)" >&2 + exit 1 +fi diff --git a/scripts/resolve-template-pin.sh b/scripts/resolve-template-pin.sh index d7af5f60..730a2712 100755 --- a/scripts/resolve-template-pin.sh +++ b/scripts/resolve-template-pin.sh @@ -91,9 +91,12 @@ fi # Candidate trees, strongest first. A root commit is what "Use this template" # wrote; it stays the template's tree even after the child grows commits. CANDS=() +CAND_TIME=() if [ -n "$HEAD_SHA" ]; then while IFS= read -r root; do - [ -n "$root" ] && CANDS+=("$(git -C "$DIR" rev-parse "${root}^{tree}")") + [ -n "$root" ] || continue + CANDS+=("$(git -C "$DIR" rev-parse "${root}^{tree}")") + CAND_TIME+=("$(git -C "$DIR" log -1 --format=%ct "$root")") done < <(git -C "$DIR" rev-list --max-parents=0 HEAD) fi # The work tree as `git add -A` would commit it (ignored paths excluded). Its @@ -153,23 +156,45 @@ if [ -n "$COMMIT" ]; then exit 0 fi -# Newest first along the default branch, then anywhere in the history. -REFS=() -[ -n "$DEFAULT" ] && REFS+=("refs/heads/$DEFAULT") -n=0 -for t in "${CANDS[@]}"; do - n=$((n + 1)) - for scope in "${REFS[@]}" --all; do - # awk reads to the end rather than exiting at the first hit: an early exit - # SIGPIPEs git log on a long history, and pipefail made that fatal - # (exit 141 against the real rsr-template-repo, 2026-10-20). - hit="$(git -C "$TMPL" log --format='%H %T' "$scope" | awk -v t="$t" '!found && $2 == t { print $1; found = 1 }')" - if [ -n "$hit" ]; then - if [ "$t" = "$wt_tree" ] && [ "$n" -eq "${#CANDS[@]}" ]; then src="work-tree"; else src="root-commit"; fi - printf 'branch=%s\ncommit=%s\ntree=%s\nsource=%s\n' "$(branch_of "$hit")" "$hit" "$t" "$src" +# A tree can belong to more than one commit: an empty commit, a revert, or a +# merge that changed nothing (29 trees repeat on rsr-template-repo's main, +# measured 2026-10-27). "Newest with this tree" then named a commit the child +# may not have come from (Codex review of #251). So: +# 1. A root commit carries its creation time. "Use this template" copies the +# default branch's tip at that moment, so the first-parent tip of the +# default branch as of that time is the exact source when its tree +# matches. That is a fact about the history, not a guess. +# 2. Otherwise every commit with a matching tree is collected. One is the +# answer; more than one is refused, loudly, with RSR_TEMPLATE_COMMIT as +# the way to say which. Equal trees are equal content, but provenance +# names a COMMIT, and it must not be a coin toss. +for i in "${!CANDS[@]}"; do + t="${CANDS[$i]}" + if [ "$t" = "$wt_tree" ] && [ "$i" -eq $(( ${#CANDS[@]} - 1 )) ]; then src="work-tree"; else src="root-commit"; fi + if [ "$src" = "root-commit" ] && [ -n "$DEFAULT" ] && [ -n "${CAND_TIME[$i]:-}" ]; then + tip="$(git -C "$TMPL" rev-list -1 --first-parent --before="@${CAND_TIME[$i]}" "refs/heads/$DEFAULT" 2>/dev/null || true)" + if [ -n "$tip" ] && [ "$(git -C "$TMPL" rev-parse "${tip}^{tree}")" = "$t" ]; then + printf 'branch=%s\ncommit=%s\ntree=%s\nsource=%s\n' "$DEFAULT" "$tip" "$t" "$src" exit 0 fi - done + fi + # awk reads to the end: an early exit SIGPIPEs git log on a long history, + # and pipefail made that fatal (exit 141 on the real template, 2026-10-20). + mapfile -t hits < <(git -C "$TMPL" log --all --format='%H %T' | awk -v t="$t" '$2 == t { print $1 }' | awk '!seen[$0]++') + if [ "${#hits[@]}" -eq 1 ]; then + printf 'branch=%s\ncommit=%s\ntree=%s\nsource=%s\n' "$(branch_of "${hits[0]}")" "${hits[0]}" "$t" "$src" + exit 0 + fi + if [ "${#hits[@]}" -gt 1 ]; then + { + echo "resolve-template-pin: tree $t belongs to ${#hits[@]} commits of ${SLUG}, so the" + echo " template commit is ambiguous:" + printf ' %s\n' "${hits[@]:0:10}" + [ "${#hits[@]}" -le 10 ] || echo " … and $(( ${#hits[@]} - 10 )) more" + echo " Say which with RSR_TEMPLATE_COMMIT=." + } >&2 + exit 1 + fi done { diff --git a/scripts/run-proven-suite.sh b/scripts/run-proven-suite.sh index 35a7c2c6..31e5d9bd 100755 --- a/scripts/run-proven-suite.sh +++ b/scripts/run-proven-suite.sh @@ -168,6 +168,8 @@ require_file tests/workflows/check_ijson_jcs_test.sh && \ run_lane "I-JSON/JCS Silence + Firing self-tests" bash tests/workflows/check_ijson_jcs_test.sh require_file tests/workflows/squabble_gate_test.sh && \ run_lane "CICD-Squabbler & required-context Silence + Firing self-tests" bash tests/workflows/squabble_gate_test.sh +require_file tests/workflows/secret_scanner_contract_test.sh && \ + run_lane "Secret scanner three-context contract Silence + Firing self-tests" bash tests/workflows/secret_scanner_contract_test.sh require_file tests/shape/check_root_shape_test.sh && \ run_lane "Root shape Silence + Firing self-tests" bash tests/shape/check_root_shape_test.sh require_file www/tests/run-all.sh && \ diff --git a/scripts/rust-tool.sh b/scripts/rust-tool.sh index 436fdbaa..8e9c3639 100644 --- a/scripts/rust-tool.sh +++ b/scripts/rust-tool.sh @@ -27,11 +27,24 @@ src="$scripts_dir/$name.rs" cache="${RSR_MINT_TOOL_CACHE:-${TMPDIR:-/tmp}/rsr-mint-tools}" mkdir -p "$cache" -bin="$cache/$name" +# The binary is named by the CONTENT of its source, not by the tool name and +# a modification time. Keyed by name with an mtime check, two checkouts with +# different versions of a tool shared one binary, and a source whose mtime +# was older than the cached build (any fresh clone or checkout can produce +# one) silently ran the OTHER version. strip-instruction-blocks changed its +# interface in #251 (files, not a directory), so a stale binary would now +# mis-run a mint rather than merely differ. +if command -v sha256sum >/dev/null 2>&1; then + digest="$(sha256sum "$src" | cut -c1-16)" +else + digest="$(shasum -a 256 "$src" | cut -c1-16)" +fi +bin="$cache/$name-$digest" -# Recompile only when the binary is missing or older than its source, so the -# common case is a straight exec. -if [ ! -x "$bin" ] || [ "$src" -nt "$bin" ]; then +# Compile only when no binary exists for this exact source, so the common +# case is a straight exec. Build to a temporary name and rename, so a +# concurrent mint never executes a half-written binary. +if [ ! -x "$bin" ]; then # The toolchain is needed to BUILD, not to RUN. Checking for rustc up # front made a cached binary unusable on a machine without one, which is # the common case in CI: the run is a straight exec. Only ask for rustc @@ -42,10 +55,13 @@ if [ ! -x "$bin" ] || [ "$src" -nt "$bin" ]; then echo " Install one from https://rustup.rs, then re-run." >&2 exit 2 } - rustc -O -o "$bin" "$src" >&2 || { + tmp_bin="$bin.tmp.$$" + rustc -O -o "$tmp_bin" "$src" >&2 || { + rm -f "$tmp_bin" echo "rust-tool: failed to compile $src" >&2 exit 2 } + mv -f "$tmp_bin" "$bin" fi exec "$bin" "$@" diff --git a/tests/e2e/chain_mint_test.sh b/tests/e2e/chain_mint_test.sh new file mode 100755 index 00000000..f6e02c11 --- /dev/null +++ b/tests/e2e/chain_mint_test.sh @@ -0,0 +1,600 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# chain_mint_test.sh — mint a repository made from a MINTED repository. +# +# Codex (review of #251) showed that minting a minted repo inherited the +# parent's author, contacts and identity, because the parent's tokens were +# already filled. The owner chose to support chains properly (2026-11-04): +# scripts/chain-mint.sh replays the parent's mint, mints the same template +# commit with the new answers, and carries the parent's own work across by a +# three-way merge. This mints parent -> grandchild end to end and checks: +# * nothing of the parent's identity or contacts survives in the grandchild; +# * the parent's own work (an edit, an addition, a removal) is carried, with +# its name rewritten, and declared in the grandchild's (variant (paths)); +# * provenance names the root template commit and the parent; +# * the grandchild's drift gate (a replay of ITS mint) is green; +# and, as negative controls, that a conflicting parent edit stops the chain +# before it writes, and that a parent without (mint-answers) is refused. +# Needs just, rustc, uuidgen, nickel, file. Network-free (RSR_TEMPLATE_URL). +# +# Usage: bash tests/e2e/chain_mint_test.sh [--keep] +set -euo pipefail +repo=$(git rev-parse --show-toplevel) +work=$(mktemp -d) +if [ "${1:-}" = "--keep" ]; then echo "keeping $work"; else trap 'rm -rf "$work"' EXIT; fi +fail() { echo "FAIL: $*" >&2; exit 1; } +pass() { echo "PASS: $*"; } +g() { git -c user.name=t -c user.email=t@example.org -c commit.gpgsign=false "$@"; } +for tool in just rustc uuidgen nickel file; do + command -v "$tool" >/dev/null 2>&1 || fail "$tool is required to run a real mint (not skipping)" +done + +# A legacy minted repository (minted before #275 recorded (mint-answers), so +# it has no build/templates/identity.deed.in and no answers to replay) cannot +# be minted again: repo-init refuses before it asks or writes anything (#277). +# The rest of this test needs a mintable tree, so in such a child it checks +# that refusal, by name, and stops. It does not skip silently: a legacy child +# that could be minted again would fail here. +legacy_deed="$(bash "$repo/scripts/deed-field.sh" --find "$repo" 2>/dev/null || true)" +if [ ! -f "$repo/build/templates/identity.deed.in" ] \ + && ! { [ -n "$legacy_deed" ] && bash "$repo/scripts/deed-field.sh" --has "$legacy_deed" mint-answers; }; then + legacy_cfg=$(mktemp -d) + if legacy_out=$(cd "$repo" && env -i PATH="$PATH" HOME="$legacy_cfg" XDG_CONFIG_HOME="$legacy_cfg" \ + RSR_NON_INTERACTIVE=1 PROJECT_NAME="Mint Check" REPO=mint-check OWNER=example-owner \ + AUTHOR="Example Author" AUTHOR_EMAIL=author@example.org CONFIRM=n \ + timeout 120 just repo-init &1); then + rm -rf "$legacy_cfg" + echo "$legacy_out" >&2 + echo "FAIL: a legacy minted tree (no (mint-answers)) was allowed to mint again" >&2; exit 1 + fi + rm -rf "$legacy_cfg" + grep -q 'cannot be minted again' <<<"$legacy_out" \ + || { echo "$legacy_out" >&2; echo "FAIL: the legacy minted tree was refused for the wrong reason" >&2; exit 1; } + echo "PASS: a legacy minted tree refuses to mint again, by name; the mint checks below need a mintable tree" + exit 0 +fi + +# ── The template: this checkout's tree, committed or not, on main ─────────── +T="$work/template" +g clone -q --no-local "$repo" "$T" +g -C "$T" checkout -q -B main +(cd "$repo" && git ls-files -z --cached --others --exclude-standard \ + | while IFS= read -r -d '' f; do { [ -e "$f" ] || [ -L "$f" ]; } && printf '%s\0' "$f"; done \ + | tar --null -T - -cf -) > "$work/tree.tar" +(cd "$T" && git ls-files -z | xargs -0 rm -f && tar xf "$work/tree.tar") +g -C "$T" add -A +g -C "$T" commit -q --allow-empty -m "template under test" +T_COMMIT=$(g -C "$T" rev-parse HEAD) +T_SLUG="$(sed -n -E 's/^OWNER[[:space:]]*:=[[:space:]]*"([^"]+)".*/\1/p' "$T/Justfile" | head -1)/$(sed -n -E 's/^REPO[[:space:]]*:=[[:space:]]*"([^"]+)".*/\1/p' "$T/Justfile" | head -1)" + +# use_template FROM COMMITISH DIR ORIGIN — what "Use this template" makes. +use_template() { + rm -rf "$3"; mkdir -p "$3" + # The tree as a checkout writes it: GitHub copies the tree, it does not + # git archive it (export-ignore, export-subst). + GIT_INDEX_FILE="$work/use_template.index" g -C "$1" read-tree "$2" + GIT_INDEX_FILE="$work/use_template.index" g -C "$1" checkout-index -a -f --prefix="$3/" + rm -f "$work/use_template.index" + # -f: a template copy keeps every tracked file, ignore rules or not. + g -C "$3" init -q -b main; g -C "$3" add -f -A; g -C "$3" commit -q -m "Initial commit" + g -C "$3" remote add origin "$4" +} +# mint DIR LOG REPO OWNER AUTHOR EMAIL DESC [ARCHETYPE [PROJECT_NAME]] — a +# non-interactive mint, isolated. The project name defaults to the repo name. +mint() { + local cfg="$work/cfg"; mkdir -p "$cfg"; printf '%s' "${MINT_GITCONFIG:-}" > "$cfg/gitconfig" + (cd "$1" && env -i PATH="$PATH" HOME="$HOME" TMPDIR="${TMPDIR:-/tmp}" \ + ${RUSTUP_HOME:+RUSTUP_HOME="$RUSTUP_HOME"} ${CARGO_HOME:+CARGO_HOME="$CARGO_HOME"} \ + XDG_CONFIG_HOME="$cfg" GIT_CONFIG_GLOBAL="$cfg/gitconfig" GIT_CONFIG_NOSYSTEM=1 \ + RSR_NON_INTERACTIVE=1 RSR_TEMPLATE_URL="$T" RSR_MINT_TOOL_CACHE="$work/tools" \ + PROJECT_NAME="${9:-$3}" REPO="$3" OWNER="$4" AUTHOR="$5" AUTHOR_EMAIL="$6" \ + PROJECT_DESCRIPTION="$7" timeout 1200 just repo-init ${8:+"$8"} "$2" 2>&1 +} + +# ── The parent: a minted repo, then its own work ──────────────────────────── +P="$work/parent" +use_template "$T" HEAD "$P" git@github.com:elder-owner/elder-proj.git +mint "$P" "$work/parent.log" elder-proj elder-owner "Eldra Author" elder@example.org "Elder tools" \ + || { tail -30 "$work/parent.log" >&2; fail 'the parent mint failed'; } +g -C "$P" add -A; g -C "$P" commit -q -m mint +printf '\nNotes for elder-proj users.\n' >> "$P/README.adoc" +mkdir -p "$P/notes"; printf 'elder-proj notes, see https://github.com/elder-owner/elder-proj\n' > "$P/notes/elder-proj-notes.md" +g -C "$P" rm -q .machine_readable/ai/PLACEHOLDERS.adoc +# A path holding characters special to sed and to deed strings, and a type +# change (a file the parent replaced with a symlink) (Codex review of #277). +printf 'odd name\n' > "$P/notes/R&D | \"q\".md" +rm "$P/SUPPORT.adoc"; ln -s README.adoc "$P/SUPPORT.adoc" +# A file listed in mint-verbatim.txt, deleted: the template's own preflights +# must not stop a chain mint over the parent's choices (Codex review of #277). +g -C "$P" rm -q tests/e2e/template_instantiation_test.sh +# Path bytes tar's line mode would mangle: a backslash and a leading dash; a +# file under archetypes/, which the mint declares removed; and an identity +# setting naming the parent's owner (all Codex review of #277). +printf 'slash\n' > "$P/notes/back\\slash.txt" +printf 'dash\n' > "$P/-lead.txt" +mkdir -p "$P/archetypes"; printf 'mine\n' > "$P/archetypes/custom.txt" +printf 'sonar.organization=elder-owner\nsonar.projectKey=elder-owner_elder-proj\n' > "$P/notes/extra-sonar.properties" +# A carried generator that writes one more file when it runs: its output is +# measured after it runs, and declared (Codex review of #277). +sed -i 's|^set -euo pipefail$|&\nprintf '"'"'extra\\n'"'"' > "$(git rev-parse --show-toplevel)/generated-extra.txt"|' "$P/.machine_readable/arrival-pack/generate.sh" +grep -q 'generated-extra.txt' "$P/.machine_readable/arrival-pack/generate.sh" || fail 'fixture: the generator was not changed' +# The same generator recreates a mint-produced file the parent deleted, with +# other content: its category moves from removed to diverged, and the +# declaration must follow it (Codex review of #277). +[ -f "$P/docs/usage.adoc" ] || fail 'fixture: the mint produced no docs/usage.adoc' +g -C "$P" rm -q docs/usage.adoc +sed -i 's|^set -euo pipefail$|&\nprintf '"'"'regenerated usage\\n'"'"' > "$(git rev-parse --show-toplevel)/docs/usage.adoc"|' "$P/.machine_readable/arrival-pack/generate.sh" +grep -q 'regenerated usage' "$P/.machine_readable/arrival-pack/generate.sh" || fail 'fixture: the usage generator was not added' +# The generator's intermediate data, force-added: an artefact, never carried +# or declared (Codex review of #277). +[ -f "$P/.machine_readable/arrival-pack/claude-md-data.json" ] || fail 'fixture: the mint left no claude-md-data.json' +g -C "$P" add -f .machine_readable/arrival-pack/claude-md-data.json +# A file the grandchild's checkout will turn into a directory (below). +printf 'plain\n' > "$P/notes/to-become-dir.txt" +# A file the parent tracks although an ignore rule matches it: still its own. +mkdir -p "$P/_build"; printf 'kept\n' > "$P/_build/keep.txt" +g -C "$P" check-ignore -q _build/keep.txt || fail 'fixture: _build/ is not ignored' +g -C "$P" add -f _build/keep.txt +# A file under a filter the grandchild's checkout configures (below), whose +# content the rename pass changes, so the write-back must check it out. +printf 'elder-proj filtered note\n' > "$P/notes/elder.bad" +# A carried gate with a side effect: what it writes while the gates run is +# not part of the result (Codex review of #277). +sed -i '1a printf "side\\n" > gate-output.txt' "$P/scripts/check-container-pins.sh" +grep -q 'gate-output.txt' "$P/scripts/check-container-pins.sh" || fail 'fixture: the gate side effect was not added' +# The identity template's path, added back as the parent's own file: the +# deed's (mint-answers) still marks the parent as minted, so the grandchild is +# chain-minted and the file carried as its work (Codex review of #277). +mkdir -p "$P/build/templates"; printf 'my own identity notes\n' > "$P/build/templates/identity.deed.in" +g -C "$P" add -A; g -C "$P" commit -q -m "the parent's own work" +pass "fixture: a minted parent with its own edit, addition and removal" + +# ── The grandchild: "Use this template" on the parent, then a chain mint ──── +G="$work/grandchild" +use_template "$P" HEAD "$G" git@github.com:other-owner/grand-check.git +# Uncommitted work in the checkout is carried too, and :derived-from-tree must +# name the snapshot actually used, not HEAD^{tree} (Codex review of #277). +printf 'local\n' > "$G/notes/local.txt" +# A tracked file turned into a directory holding an ignored file: the ignored +# file must not be read into the chain (Codex review of #277: tar recursed into +# the directory and carried it). +rm "$G/notes/to-become-dir.txt"; mkdir "$G/notes/to-become-dir.txt" +printf 'not for the chain\n' > "$G/notes/to-become-dir.txt/secret.txt" +printf '/notes/to-become-dir.txt/secret.txt\n' >> "$G/.git/info/exclude" +HEAD_TREE=$(g -C "$G" rev-parse 'HEAD^{tree}') +cp "$G/.git/index" "$work/snap.index" # tracked files stay, ignore rules or not +P_TREE=$(cd "$G" && GIT_INDEX_FILE="$work/snap.index" git add -A . && GIT_INDEX_FILE="$work/snap.index" git write-tree) +[ "$P_TREE" != "$HEAD_TREE" ] || fail 'fixture: the uncommitted file did not change the tree' +# Under a global init.templateDir whose info/attributes would re-encode the +# template clone's checkouts: the chain's clone must not take it (Codex review +# of #277). +mkdir -p "$work/inittpl/info"; printf 'README.adoc working-tree-encoding=UTF-16\n' > "$work/inittpl/info/attributes" +MINT_GITCONFIG="$(printf '[init]\n\ttemplateDir = %s\n' "$work/inittpl")" +mint "$G" "$work/chain.log" grand-check other-owner "Other Author" other@example.org "Grand tools" \ + || { tail -40 "$work/chain.log" >&2; fail 'the chain mint failed'; } +MINT_GITCONFIG="" +grep -q 'minted as a chain' "$work/chain.log" || fail 'repo-init did not take the chain path' +pass "a minted repo is minted again, as a chain" + +DEED="$G/grand-check_chora.deed" +[ -f "$DEED" ] && [ ! -e "$G/elder-proj_chora.deed" ] || fail 'the deed is not the grandchild'"'"'s alone' +field() { bash "$G/scripts/deed-field.sh" "$DEED" "$@"; } +[ "$(field provenance template-repo)" = "$T_SLUG" ] && [ "$(field provenance template-commit)" = "$T_COMMIT" ] \ + || fail "provenance names $(field provenance template-repo)@$(field provenance template-commit), not the root template" +[ "$(field provenance derived-from)" = elder-owner/elder-proj ] || fail 'provenance does not name the parent' +[ "$(field provenance derived-from-tree)" = "$P_TREE" ] || fail 'provenance does not name the parent tree' +[ "$(field mint-answers author)" = "Other Author" ] && [ "$(field mint-answers description)" = "Grand tools" ] \ + || fail '(mint-answers) are not the grandchild'"'"'s' +pass "provenance names the root template commit and the parent; the answers are the grandchild's" + +# Nothing of the parent's identity or contacts survives (the Codex finding). +# A file identical to the template's own copy is template text, not a leak +# (this test and the recipe name these fixtures on purpose). +leak=$(cd "$G" && git ls-files -co --exclude-standard | grep -vxF grand-check_chora.deed \ + | while IFS= read -r f; do + [ -f "$f" ] || continue + g -C "$T" show "$T_COMMIT:$f" 2>/dev/null | cmp -s -- - "$f" && continue + grep -lIE 'elder-proj|Eldra Author|elder@example\.org|Elder tools' -- "$f" + done || true) +[ -z "$leak" ] || { echo "$leak" >&2; fail 'the parent'"'"'s identity or contacts survive in the files above'; } +grep -q 'Eldra Author\|elder@example.org' "$DEED" && fail 'the deed carries the parent'"'"'s author' +grep -qF 'Grand tools' "$G/README.adoc" && grep -qF 'other@example.org' "$G/.github/SECURITY.adoc" 2>/dev/null \ + || grep -rqF 'other@example.org' "$G/.github" || fail 'the new contacts were not rendered' +pass "nothing of the parent's identity, author or contacts survives; the new answers are rendered" + +# The parent's own work is carried, renamed, and declared. The rename is the +# self-name rule: the repo name and the owner/repo slug, never a bare owner +# (an owner is shared with real estate repos, the same reason the template's +# owner pass is narrow). +tail -n 1 "$G/README.adoc" | grep -qx 'Notes for grand-check users.' || fail 'the parent'"'"'s README edit was not carried and renamed' +grep -qx 'grand-check notes, see https://github.com/other-owner/grand-check' "$G/notes/elder-proj-notes.md" \ + || fail 'the parent'"'"'s added file was not carried and renamed' +[ ! -e "$G/.machine_readable/ai/PLACEHOLDERS.adoc" ] || fail 'the parent'"'"'s removal was not carried' +[ ! -e "$G/tests/e2e/template_instantiation_test.sh" ] || fail 'the deletion of a verbatim-listed file was not carried' +[ -f "$G/notes/local.txt" ] || fail 'uncommitted work in the checkout was not carried' +bash "$G/scripts/deed-field.sh" --list "$DEED" variant/paths added | grep -qx notes/elder-proj-notes.md \ + && bash "$G/scripts/deed-field.sh" --list "$DEED" variant/paths diverged | grep -qx README.adoc \ + && bash "$G/scripts/deed-field.sh" --list "$DEED" variant/paths removed | grep -qx .machine_readable/ai/PLACEHOLDERS.adoc \ + || { grep -n ':added\|:removed\|:diverged' "$DEED" >&2; fail 'the carried work is not declared in (variant (paths))'; } +bash "$G/scripts/deed-field.sh" --list "$DEED" variant/paths added | grep -qxF 'notes/R&D | "q".md' \ + || { grep -n ':added' "$DEED" >&2; fail 'a path with & | and quotes was not declared intact'; } +[ -L "$G/SUPPORT.adoc" ] && bash "$G/scripts/deed-field.sh" --list "$DEED" variant/paths diverged | grep -qx SUPPORT.adoc \ + || fail 'the type change (file to symlink) was not carried and declared' +added() { bash "$G/scripts/deed-field.sh" --list "$DEED" variant/paths added; } +[ -f "$G/notes/back\\slash.txt" ] && [ -f "$G/-lead.txt" ] \ + && added | grep -qxF 'notes/back\slash.txt' && added | grep -qxF -- '-lead.txt' \ + || { grep -n ':added' "$DEED" >&2; fail 'a path holding a backslash or a leading dash was not carried and declared intact'; } +[ -f "$G/archetypes/custom.txt" ] && added | grep -qx archetypes/custom.txt \ + && added | grep -qx _build/keep.txt && [ -n "$(g -C "$G" ls-files -- _build/keep.txt)" ] \ + || fail 'a tracked file an ignore rule matches was not carried, tracked and declared' +[ "$(cat "$G/notes/extra-sonar.properties")" = "$(printf 'sonar.organization=other-owner\nsonar.projectKey=other-owner_grand-check')" ] \ + || { cat "$G/notes/extra-sonar.properties" >&2; fail 'a carried identity setting kept the parent'"'"'s owner'; } +pass "the parent's edit, addition and removal are carried, renamed, and declared" +pass "odd path bytes, an addition under a removed directory and a carried owner setting are handled" +[ "$(cat "$G/generated-extra.txt" 2>/dev/null)" = extra ] && added | grep -qx generated-extra.txt \ + || { grep -n ':added' "$DEED" >&2; fail 'a file written by a carried generator was not measured and declared'; } +diverged() { bash "$G/scripts/deed-field.sh" --list "$DEED" variant/paths diverged; } +removed() { bash "$G/scripts/deed-field.sh" --list "$DEED" variant/paths removed; } +[ "$(cat "$G/docs/usage.adoc" 2>/dev/null)" = 'regenerated usage' ] && diverged | grep -qx docs/usage.adoc \ + && ! removed | grep -qx docs/usage.adoc \ + || { grep -n ':removed\|:diverged' "$DEED" >&2; fail 'a deleted file a carried generator recreated is not declared diverged'; } +if grep -qF 'claude-md-data.json' "$DEED"; then fail 'the generator'"'"'s force-added intermediate data was declared'; fi +if grep -qF 'to-become-dir.txt/secret' "$DEED" || g -C "$G" ls-files -co --exclude-standard | grep -qF 'to-become-dir.txt/secret'; then + fail 'an ignored file inside a path that became a directory was carried' +fi +[ "$(cat "$G/notes/to-become-dir.txt/secret.txt")" = 'not for the chain' ] || fail 'the ignored file was changed' +pass "a carried generator's output is declared; an ignored file is never read into the chain" +[ "$(cat "$G/build/templates/identity.deed.in" 2>/dev/null)" = 'my own identity notes' ] \ + && added | grep -qx build/templates/identity.deed.in \ + || { grep -n ':added' "$DEED" >&2; fail 'a parent that added the identity template back was not chain-minted with it carried'; } +pass "a minted parent carrying build/templates/identity.deed.in is still chain-minted" +[ ! -e "$G/gate-output.txt" ] && ! grep -q 'gate-output.txt' "$DEED" \ + || fail 'a file a gate wrote while the gates ran was carried into the result' +pass "what the gates write while they run is not part of the result" + +# The grandchild's drift gate replays ITS mint: green, carried work declared. +g -C "$G" add -A; g -C "$G" commit -q -m "chain mint" +(cd "$G" && RSR_MINT_TOOL_CACHE="$work/tools" bash scripts/check-variant-drift.sh "$T") > "$work/drift.log" 2>&1 \ + || { cat "$work/drift.log" >&2; fail 'the grandchild'"'"'s drift gate is red'; } +(cd "$G" && bash scripts/gen-repo-map.sh . >/dev/null) && g -C "$G" diff --quiet -- docs/architecture/REPOSITORY-MAP.adoc \ + || fail 'the grandchild'"'"'s map is stale' +pass "the grandchild's drift gate (a replay of its own mint) is green; its map is fresh" + +# ── Negative control: a parent edit that conflicts with the new rendering ─── +# The parent rewrote the rendered description line; the new mint renders a +# different description on that line. That is a real conflict: stop, write +# nothing, name the file. +P2="$work/parent2" +g clone -q --no-local "$P" "$P2" +sed -i 's/^Elder tools$/Elder tools, rewritten by hand/' "$P2/README.adoc" +grep -qx 'Elder tools, rewritten by hand' "$P2/README.adoc" || fail 'fixture: the description line was not found' +g -C "$P2" commit -q -am "hand edit of a rendered line" +G2="$work/grandchild2" +use_template "$P2" HEAD "$G2" git@github.com:other-owner/grand-check.git +if mint "$G2" "$work/conflict.log" grand-check other-owner "Other Author" other@example.org "Grand tools"; then + fail 'a conflicting parent edit was merged silently' +fi +grep -q 'conflict' "$work/conflict.log" && grep -q 'README.adoc' "$work/conflict.log" \ + || { tail -20 "$work/conflict.log" >&2; fail 'the conflict was not reported with its file'; } +[ -z "$(g -C "$G2" status --porcelain)" ] || fail 'the refused chain mint wrote to the tree' +pass "(-) a conflicting parent edit stops the chain before it writes, and names the file" + +# ── Negative control: a parent minted before (mint-answers) is refused ────── +G3="$work/grandchild3" +use_template "$P" HEAD "$G3" git@github.com:other-owner/grand-check.git +bash "$G3/scripts/deed-clause.sh" replace "$G3/elder-proj_chora.deed" mint-answers /dev/null +! bash "$G3/scripts/deed-field.sh" --has "$G3/elder-proj_chora.deed" mint-answers || fail 'fixture: (mint-answers) still present' +# Nor the identity template the main parent added back: an old child has neither. +g -C "$G3" rm -q build/templates/identity.deed.in +g -C "$G3" commit -q -am "an old child" +if mint "$G3" "$work/old.log" grand-check other-owner "Other Author" other@example.org "Grand tools"; then + fail 'a parent without (mint-answers) was minted' +fi +grep -q 'records no (mint-answers)' "$work/old.log" || { tail -10 "$work/old.log" >&2; fail 'the refusal did not say why'; } +[ -z "$(g -C "$G3" status --porcelain)" ] || fail 'the refused mint wrote to the tree' +pass "(-) a parent minted before (mint-answers) is refused before anything is written" + +# ── Negative control: a failure after the merge writes nothing ────────────── +# The parent carries a broken arrival-pack source: both replays pass (they use +# the pinned template), the merge succeeds, then regenerating CLAUDE.md from +# the merged tree fails. Every step runs in a scratch copy first, so the +# grandchild is untouched (Codex review of #277: the checkout used to be +# replaced before the later steps ran). (A carried unfilled token is refused +# even earlier, by check-mint-tokens.sh, before anything is written.) +P4="$work/parent4" +g clone -q --no-local "$P" "$P4" +printf 'this is not nickel\n' >> "$P4/.machine_readable/arrival-pack/arrival-pack.ncl" +g -C "$P4" commit -q -am "a broken arrival-pack source" +G4="$work/grandchild4" +use_template "$P4" HEAD "$G4" git@github.com:other-owner/grand-check.git +if mint "$G4" "$work/late.log" grand-check other-owner "Other Author" other@example.org "Grand tools"; then + fail 'a chain mint carrying a broken arrival-pack source succeeded' +fi +grep -q 'arrival pack could not be regenerated; nothing was written' "$work/late.log" || { tail -20 "$work/late.log" >&2; fail 'the late failure did not say why'; } +[ -z "$(g -C "$G4" status --porcelain)" ] || { g -C "$G4" status --short | head >&2; fail 'a failure after the merge wrote to the tree'; } +pass "(-) a failure after the merge (a broken carried source) leaves the tree untouched" + +# ── Negative control: a chain mint keeps the parent's archetype ───────────── +# `just repo-init ` on a chain must not die on the absent +# archetypes/ (Codex review of #277); naming a different archetype is refused. +G5="$work/grandchild5" +use_template "$P" HEAD "$G5" git@github.com:other-owner/grand-check.git +if (cd "$G5" && env -i PATH="$PATH" HOME="$HOME" RSR_NON_INTERACTIVE=1 just repo-init julia-library "$work/arch.log" 2>&1; then + fail 'a chain mint accepted a different archetype' +fi +grep -q "was minted as archetype 'none'; a chain mint keeps it" "$work/arch.log" || { cat "$work/arch.log" >&2; fail 'the archetype refusal did not say why'; } +[ -z "$(g -C "$G5" status --porcelain)" ] || fail 'the refused archetype wrote to the tree' +pass "(-) a chain mint refuses an archetype other than the parent's, before writing" + +# ── Negative control: parent-controlled symlinks never redirect a write ───── +# The parent makes its identity file CLAUDE.md, and a path the mint writes +# (the arrival pack's generated data), symlinks to files outside the chain's +# scratch copy (Codex review of #277). The identity file is held in git's +# index, so its link is never written through; the generated path is refused +# by name before anything is written. Neither target changes. +P6="$work/parent6" +g clone -q --no-local "$P" "$P6" +printf 'untouched\n' > "$work/sentinel-identity"; printf 'untouched\n' > "$work/sentinel-output" +rm "$P6/CLAUDE.md"; ln -s "$work/sentinel-identity" "$P6/CLAUDE.md" +rm -f "$P6/.machine_readable/arrival-pack/claude-md-data.json" # the parent force-tracks it +ln -s "$work/sentinel-output" "$P6/.machine_readable/arrival-pack/claude-md-data.json" +g -C "$P6" add -f -A; g -C "$P6" commit -q -m "symlinks out" +G6="$work/grandchild6" +use_template "$P6" HEAD "$G6" git@github.com:other-owner/grand-check.git +if mint "$G6" "$work/links.log" grand-check other-owner "Other Author" other@example.org "Grand tools"; then + fail 'a chain mint wrote through a carried symlink' +fi +grep -q 'symlink at .machine_readable/arrival-pack/claude-md-data.json' "$work/links.log" || { tail -20 "$work/links.log" >&2; fail 'the carried output symlink was not refused by name'; } +[ "$(cat "$work/sentinel-identity")" = untouched ] && [ "$(cat "$work/sentinel-output")" = untouched ] \ + || fail 'a chain mint wrote through a parent-controlled symlink' +[ -z "$(g -C "$G6" status --porcelain)" ] || fail 'the refused chain mint wrote to the tree' +pass "(-) parent-controlled symlinks are never written through; one at a written path is refused" +# The arrival-pack generator writes temporary files too: a carried symlink at +# one is refused by name (Codex review of #277). +P8="$work/parent8" +g clone -q --no-local "$P" "$P8" +printf 'untouched\n' > "$work/sentinel-region" +ln -s "$work/sentinel-region" "$P8/.machine_readable/arrival-pack/.region.tmp" +g -C "$P8" add -f .machine_readable/arrival-pack/.region.tmp; g -C "$P8" commit -q -m "a symlink at a temporary path" +G8="$work/grandchild8" +use_template "$P8" HEAD "$G8" git@github.com:other-owner/grand-check.git +if mint "$G8" "$work/region.log" grand-check other-owner "Other Author" other@example.org "Grand tools"; then + fail 'a chain mint ran with a symlink at the generator'"'"'s temporary path' +fi +grep -q 'symlink at .machine_readable/arrival-pack/.region.tmp' "$work/region.log" \ + || { tail -20 "$work/region.log" >&2; fail 'the symlink at the temporary path was not refused by name'; } +[ "$(cat "$work/sentinel-region")" = untouched ] && [ -z "$(g -C "$G8" status --porcelain)" ] \ + || fail 'the refused chain mint wrote through the symlink or to the tree' +pass "(-) a symlink at the generator's temporary path is refused before anything is written" + +# The baselines replay the mint with the wrapper at the pin: a parent that +# edited its own copy cannot change what counts as its own work (Codex review +# of #277). This one removes README.adoc after every replay. +P11="$work/parent11" +g clone -q --no-local "$P" "$P11" +g -C "$P11" reset -q --hard HEAD~1 # the parent's mint, before its own work +printf 'rm -f "$OUT/README.adoc"\n' >> "$P11/scripts/replay-mint.sh" +g -C "$P11" commit -q -am "an edited replay wrapper" +G11="$work/grandchild11" +use_template "$P11" HEAD "$G11" git@github.com:other-owner/grand-check.git +mint "$G11" "$work/wrapper.log" grand-check other-owner "Other Author" other@example.org "Grand tools" \ + || { tail -40 "$work/wrapper.log" >&2; fail 'the chain mint of a parent with an edited replay wrapper failed'; } +if grep -qE 'elder-proj|Elder tools|Eldra Author' "$G11/README.adoc" \ + || bash "$G11/scripts/deed-field.sh" --list "$G11/grand-check_chora.deed" variant/paths added | grep -qx README.adoc; then + fail 'the parent'"'"'s edited replay wrapper redefined the baseline: its README was carried as its own work' +fi +grep -qF 'Grand tools' "$G11/README.adoc" || fail 'the README was not rendered for the grandchild' +# The grandchild carries the edited wrapper (its parent's work, declared), and +# its own drift gate replays with the wrapper at the pin too (Codex review of +# #277): green. +g -C "$G11" add -A; g -C "$G11" commit -q -m "chain mint" +(cd "$G11" && RSR_MINT_TOOL_CACHE="$work/tools" bash scripts/check-variant-drift.sh "$T") > "$work/wrapperdrift.log" 2>&1 \ + || { cat "$work/wrapperdrift.log" >&2; fail 'the drift gate replayed with the carried, edited wrapper'; } +pass "the baselines replay with the wrapper at the pin, not the parent's edited copy; so does the drift gate" + +# A path holding byte 0x01, the deed reader's record separator, cannot be +# declared: refused by name, before anything is written (Codex review of #277). +P12="$work/parent12" +g clone -q --no-local "$P" "$P12" +printf 'x\n' > "$P12/notes/soh"$'\001'"name.txt" +g -C "$P12" add -A; g -C "$P12" commit -q -m "a path holding 0x01" +G12="$work/grandchild12" +use_template "$P12" HEAD "$G12" git@github.com:other-owner/grand-check.git +if mint "$G12" "$work/soh.log" grand-check other-owner "Other Author" other@example.org "Grand tools"; then + fail 'a chain mint declared a path holding byte 0x01' +fi +grep -q 'newline or byte 0x01' "$work/soh.log" || { tail -20 "$work/soh.log" >&2; fail 'the 0x01 path was not refused by name'; } +[ -z "$(g -C "$G12" status --porcelain)" ] || fail 'the refused chain mint wrote to the tree' +pass "(-) a carried path holding byte 0x01 is refused before anything is written" + +# A smudge filter that fails during the write-back stops the chain before the +# checkout is touched: read-tree -u is not atomic (Codex review of #277). +G10="$work/grandchild10" +use_template "$P" HEAD "$G10" git@github.com:other-owner/grand-check.git +g -C "$G10" config filter.bad.clean cat; g -C "$G10" config filter.bad.smudge false; g -C "$G10" config filter.bad.required true +printf '*.bad filter=bad\n' >> "$G10/.gitattributes" +g10_before=$(g -C "$G10" status --porcelain --untracked-files=all); g10_readme=$(sha256sum < "$G10/README.adoc") +if mint "$G10" "$work/filter.log" grand-check other-owner "Other Author" other@example.org "Grand tools"; then + fail 'a chain mint went ahead with a smudge filter that fails' +fi +grep -q 'a filter failed' "$work/filter.log" || { tail -20 "$work/filter.log" >&2; fail 'the failing filter was not reported'; } +[ "$(g -C "$G10" status --porcelain --untracked-files=all)" = "$g10_before" ] \ + && [ "$(sha256sum < "$G10/README.adoc")" = "$g10_readme" ] && [ "$(cat "$G10/notes/elder.bad")" = 'elder-proj filtered note' ] \ + || { g -C "$G10" status --porcelain >&2; fail 'the chain mint wrote part of the result before the filter failed'; } +pass "(-) a smudge filter that fails is found before anything is written" + +# ── A declaration that changes only in category ───────────────────────────── +# A parent, just minted, deletes .clinerules and changes its carried generator +# to recreate it: measured before the generator runs, the path is removed; +# after, it is diverged, beside the generator itself. Both measures list the +# same paths in the same order, so only the categories tell them apart (Codex +# review of #277): the deed must declare it diverged, and the drift gate agree. +P7="$work/parent7" +g clone -q --no-local "$P" "$P7" +g -C "$P7" reset -q --hard HEAD~1 # the parent's mint, before its own work +[ -f "$P7/.clinerules" ] || fail 'fixture: the mint produced no .clinerules' +g -C "$P7" rm -q .clinerules +sed -i 's|^set -euo pipefail$|&\nprintf '"'"'regenerated rules\\n'"'"' > "$(git rev-parse --show-toplevel)/.clinerules"|' "$P7/.machine_readable/arrival-pack/generate.sh" +g -C "$P7" commit -q -am "the generator owns .clinerules" +G7="$work/grandchild7" +use_template "$P7" HEAD "$G7" git@github.com:other-owner/grand-check.git +mint "$G7" "$work/category.log" grand-check other-owner "Other Author" other@example.org "Grand tools" \ + || { tail -40 "$work/category.log" >&2; fail 'the category-only chain mint failed'; } +D7="$G7/grand-check_chora.deed" +[ "$(cat "$G7/.clinerules")" = 'regenerated rules' ] \ + && bash "$G7/scripts/deed-field.sh" --list "$D7" variant/paths diverged | grep -qx .clinerules \ + && ! bash "$G7/scripts/deed-field.sh" --list "$D7" variant/paths removed | grep -qx .clinerules \ + || { grep -n ':removed\|:diverged' "$D7" >&2; fail 'a path whose category alone changed was declared by its first measure'; } +g -C "$G7" add -A; g -C "$G7" commit -q -m "chain mint" +(cd "$G7" && RSR_MINT_TOOL_CACHE="$work/tools" bash scripts/check-variant-drift.sh "$T") > "$work/catdrift.log" 2>&1 \ + || { cat "$work/catdrift.log" >&2; fail 'the drift gate is red after a category-only change'; } +pass "a path whose category alone changes after the generators run is declared by its final category" + +# ── Negative control: a sparse checkout is refused ────────────────────────── +# Its absent paths would read as deletions of most of the repository (Codex +# review of #277). +G7="$work/grandchild7" +use_template "$P" HEAD "$G7" git@github.com:other-owner/grand-check.git +# The mint machinery stays checked out; docs/ and tests/ do not. +g -C "$G7" sparse-checkout set scripts build .machine_readable notes +[ ! -e "$G7/docs" ] && [ -e "$G7/scripts/chain-mint.sh" ] || fail 'fixture: the checkout is not sparse' +if mint "$G7" "$work/sparse.log" grand-check other-owner "Other Author" other@example.org "Grand tools"; then + fail 'a chain mint ran in a sparse checkout' +fi +grep -q 'this checkout is sparse' "$work/sparse.log" || { tail -20 "$work/sparse.log" >&2; fail 'the sparse refusal did not say why'; } +[ -z "$(g -C "$G7" status --porcelain)" ] || fail 'the refused sparse chain mint wrote to the tree' +pass "(-) a chain mint in a sparse checkout is refused before anything is written" + +# ── Negative controls: a checkout a chain mint cannot read faithfully ─────── +# An unresolved merge, and a tracked path beneath a symlink, are refused before +# anything is read or written (Codex review of #277). +GU="$work/grandchild-unmerged" +use_template "$P" HEAD "$GU" git@github.com:other-owner/grand-check.git +g -C "$GU" checkout -q -b side; printf 'side\n' >> "$GU/README.adoc"; g -C "$GU" commit -q -am side +g -C "$GU" checkout -q main; printf 'main\n' >> "$GU/README.adoc"; g -C "$GU" commit -q -am main +if g -C "$GU" merge -q side >/dev/null 2>&1; then fail 'fixture: the merge did not conflict'; fi +gu_before=$(g -C "$GU" status --porcelain) +if mint "$GU" "$work/unmerged.log" grand-check other-owner "Other Author" other@example.org "Grand tools"; then + fail 'a chain mint ran over an unresolved merge' +fi +grep -q 'unresolved merge' "$work/unmerged.log" || { tail -20 "$work/unmerged.log" >&2; fail 'the unresolved merge was not refused by name'; } +[ "$(g -C "$GU" status --porcelain)" = "$gu_before" ] || fail 'the refused chain mint wrote to the tree' +GL="$work/grandchild-link" +use_template "$P" HEAD "$GL" git@github.com:other-owner/grand-check.git +mv "$GL/notes" "$work/outside-notes"; ln -s "$work/outside-notes" "$GL/notes" +gl_before=$(g -C "$GL" status --porcelain) +if mint "$GL" "$work/linkdir.log" grand-check other-owner "Other Author" other@example.org "Grand tools"; then + fail 'a chain mint read tracked paths through a symlink' +fi +grep -q 'lies beneath the symlink notes' "$work/linkdir.log" || { tail -20 "$work/linkdir.log" >&2; fail 'the symlinked directory was not refused by name'; } +[ "$(g -C "$GL" status --porcelain)" = "$gl_before" ] || fail 'the refused chain mint wrote to the tree' +pass "(-) an unresolved merge, and tracked paths beneath a symlink, are refused before anything is read" + +# ── Negative control: the result must pass the child's own gates ──────────── +# A carried shell script without an SPDX header fails check-spdx-headers, as a +# normal mint would; the chain stops before writing (Codex review of #277). +P9="$work/parent9" +g clone -q --no-local "$P" "$P9" +printf '#!/bin/sh\necho hi\n' > "$P9/notes/tool.sh" +g -C "$P9" add -A; g -C "$P9" commit -q -m "a script without a header" +G9="$work/grandchild9" +use_template "$P9" HEAD "$G9" git@github.com:other-owner/grand-check.git +if mint "$G9" "$work/gates.log" grand-check other-owner "Other Author" other@example.org "Grand tools"; then + fail 'a chain mint wrote a tree that fails its own gates' +fi +grep -q 'fails check-spdx-headers' "$work/gates.log" && grep -q 'notes/tool.sh' "$work/gates.log" \ + || { tail -30 "$work/gates.log" >&2; fail 'the gate failure was not named'; } +[ -z "$(g -C "$G9" status --porcelain)" ] || fail 'the gate-refused chain mint wrote to the tree' +pass "(-) a result that fails the child's own gates is refused before writing" + +# ── An answer-dependent rename: a Julia package's entry point ─────────────── +# The mint names src/.jl after the project, so a parent's edit to its +# entry point lives at another path in the grandchild. The merge carries the +# edit across that rename; the declaration must name where it now lives +# (Codex review of #277), which the grandchild's drift gate checks. +JP="$work/jparent" +use_template "$T" HEAD "$JP" git@github.com:elder-owner/ElderPkg.jl.git +mint "$JP" "$work/jparent.log" ElderPkg.jl elder-owner "Eldra Author" elder@example.org "Elder tools" julia-library ElderPkg \ + || { tail -30 "$work/jparent.log" >&2; fail 'the Julia parent mint failed'; } +[ -f "$JP/src/ElderPkg.jl" ] || fail 'fixture: the Julia parent has no src/ElderPkg.jl' +g -C "$JP" add -A; g -C "$JP" commit -q -m mint +sed -i 's/^# --- implementation .*/&\nanswer() = 42/' "$JP/src/ElderPkg.jl" +grep -qx 'answer() = 42' "$JP/src/ElderPkg.jl" || fail 'fixture: the entry-point edit did not apply' +g -C "$JP" commit -q -am "the parent's own code" +JG="$work/jgrandchild" +use_template "$JP" HEAD "$JG" git@github.com:other-owner/GrandPkg.jl.git +mint "$JG" "$work/jchain.log" GrandPkg.jl other-owner "Other Author" other@example.org "Grand tools" julia-library GrandPkg \ + || { tail -40 "$work/jchain.log" >&2; fail 'the Julia chain mint failed'; } +[ -f "$JG/src/GrandPkg.jl" ] && [ ! -e "$JG/src/ElderPkg.jl" ] && grep -qx 'answer() = 42' "$JG/src/GrandPkg.jl" \ + || { ls "$JG/src" >&2; fail 'the entry-point edit was not carried across the rename'; } +JDEED="$JG/GrandPkg.jl_chora.deed" +bash "$JG/scripts/deed-field.sh" --list "$JDEED" variant/paths diverged | grep -qx src/GrandPkg.jl \ + || { grep -n ':added\|:removed\|:diverged' "$JDEED" >&2; fail 'the renamed entry point is not declared where it now lives'; } +if bash "$JG/scripts/deed-field.sh" --list "$JDEED" variant/paths diverged | grep -qx src/ElderPkg.jl; then + fail 'the deed declares the parent'"'"'s entry-point path' +fi +g -C "$JG" add -A; g -C "$JG" commit -q -m "chain mint" +(cd "$JG" && RSR_MINT_TOOL_CACHE="$work/tools" bash scripts/check-variant-drift.sh "$T") > "$work/jdrift.log" 2>&1 \ + || { cat "$work/jdrift.log" >&2; fail 'the Julia grandchild'"'"'s drift gate is red'; } +pass "an edit carried across an answer-dependent rename is declared where it lives; the drift gate is green" +# A path the result writes must be free, before anything is written: here an +# ignored directory at the grandchild's future entry point (Codex review of +# #277: the checkout used to be emptied before the copy found it). Ignored +# through this checkout's info/exclude, and through a .gitignore, where git's +# own check sees the rule and treats the directory as expendable. +for how in exclude gitignore; do + JG2="$work/jgrandchild2-$how" + use_template "$JP" HEAD "$JG2" git@github.com:other-owner/GrandPkg.jl.git + mkdir -p "$JG2/src/GrandPkg.jl"; printf 'mine\n' > "$JG2/src/GrandPkg.jl/notes.txt" + case "$how" in + exclude) printf '/src/GrandPkg.jl/\n' >> "$JG2/.git/info/exclude" ;; + gitignore) printf '/src/GrandPkg.jl/\n' >> "$JG2/.gitignore" ;; + esac + g -C "$JG2" check-ignore -q src/GrandPkg.jl/notes.txt || fail "fixture ($how): the directory is not ignored" + jg2_before=$(g -C "$JG2" status --porcelain --untracked-files=all --ignored) + if mint "$JG2" "$work/collide-$how.log" GrandPkg.jl other-owner "Other Author" other@example.org "Grand tools" julia-library GrandPkg; then + fail "a chain mint overwrote an ignored directory ($how)" + fi + grep -q 'without overwriting' "$work/collide-$how.log" && grep -qx ' src/GrandPkg.jl/' "$work/collide-$how.log" \ + || { tail -20 "$work/collide-$how.log" >&2; fail "the collision was not refused by name ($how)"; } + [ "$(cat "$JG2/src/GrandPkg.jl/notes.txt")" = mine ] \ + && [ "$(g -C "$JG2" status --porcelain --untracked-files=all --ignored)" = "$jg2_before" ] \ + && [ -f "$JG2/src/ElderPkg.jl" ] || fail "the refused chain mint wrote to the tree ($how)" +done +pass "(-) an ignored entry where the result writes is refused by name before anything is written (info/exclude and .gitignore)" +# A parent that deleted its entry point: the deletion wins across the rename +# too, where the fresh mint has src/GrandPkg.jl, not a rename/delete conflict +# (Codex review of #277). +g -C "$JP" checkout -q -b drop-entry +g -C "$JP" rm -q src/ElderPkg.jl; g -C "$JP" commit -q -m "drop the entry point" +JG3="$work/jgrandchild3" +use_template "$JP" drop-entry "$JG3" git@github.com:other-owner/GrandPkg.jl.git +mint "$JG3" "$work/jdrop.log" GrandPkg.jl other-owner "Other Author" other@example.org "Grand tools" julia-library GrandPkg \ + || { tail -40 "$work/jdrop.log" >&2; fail 'a parent deletion across an answer-dependent rename stopped the chain'; } +[ ! -e "$JG3/src/GrandPkg.jl" ] && [ ! -e "$JG3/src/ElderPkg.jl" ] || { ls "$JG3/src" >&2; fail 'the deleted entry point came back'; } +bash "$JG3/scripts/deed-field.sh" --list "$JG3/GrandPkg.jl_chora.deed" variant/paths removed | grep -qx src/GrandPkg.jl \ + || { grep -n ':removed' "$JG3/GrandPkg.jl_chora.deed" >&2; fail 'the deletion is not declared where the fresh mint has the file'; } +g -C "$JG3" add -A; g -C "$JG3" commit -q -m "chain mint" +(cd "$JG3" && RSR_MINT_TOOL_CACHE="$work/tools" bash scripts/check-variant-drift.sh "$T") > "$work/jdropdrift.log" 2>&1 \ + || { cat "$work/jdropdrift.log" >&2; fail 'the grandchild of a parent without its entry point has a red drift gate'; } +pass "a parent deletion is carried across an answer-dependent rename and declared; the drift gate is green" + +# A parent that changed its entry point's type (a file to a symlink): the +# fresh mint puts that file elsewhere, so the change cannot be carried there. +# Refused by name, not carried beside the fresh file (Codex review of #277). +g -C "$JP" checkout -q -b retype-entry main 2>/dev/null || g -C "$JP" checkout -q -b retype-entry master +g -C "$JP" rm -q src/ElderPkg.jl; ln -s ../README.adoc "$JP/src/ElderPkg.jl"; g -C "$JP" add src/ElderPkg.jl +g -C "$JP" commit -q -m "the entry point becomes a symlink" +JG4="$work/jgrandchild4" +use_template "$JP" retype-entry "$JG4" git@github.com:other-owner/GrandPkg.jl.git +if mint "$JG4" "$work/jretype.log" GrandPkg.jl other-owner "Other Author" other@example.org "Grand tools" julia-library GrandPkg; then + fail 'a type change across an answer-dependent rename was carried' +fi +grep -q 'changed the type of src/ElderPkg.jl, which this mint renames to src/GrandPkg.jl' "$work/jretype.log" \ + || { tail -20 "$work/jretype.log" >&2; fail 'the type change across the rename was not refused by name'; } +[ -z "$(g -C "$JG4" status --porcelain)" ] || fail 'the refused chain mint wrote to the tree' +pass "(-) a type change across an answer-dependent rename is refused before anything is written" + +echo "PASS: chain mint" diff --git a/tests/e2e/use_template_mint_test.sh b/tests/e2e/use_template_mint_test.sh index fb4652c3..442ef2ae 100755 --- a/tests/e2e/use_template_mint_test.sh +++ b/tests/e2e/use_template_mint_test.sh @@ -28,6 +28,15 @@ # Usage: bash tests/e2e/use_template_mint_test.sh [--keep] set -euo pipefail repo=$(git rev-parse --show-toplevel) + +# This test mints the TEMPLATE. A minted child has no archetypes/ (the mint +# removes it) and no unfilled tokens to answer, so there is nothing to mint. +# Same guard as tests/e2e/template_instantiation_test.sh. +if [ ! -d "$repo/archetypes" ]; then + echo "Self-skipping: archetypes/ not found (repo is already instantiated)." + exit 0 +fi + work=$(mktemp -d) if [ "${1:-}" = "--keep" ]; then echo "keeping $work"; else trap 'rm -rf "$work"' EXIT; fi @@ -72,28 +81,44 @@ T_OWNER=$(sed -n -E 's/^OWNER[[:space:]]*:=[[:space:]]*"([^"]+)".*/\1/p' "$T/Jus TEMPLATE_REPO=$(sed -n -E 's/^REPO[[:space:]]*:=[[:space:]]*"([^"]+)".*/\1/p' "$T/Justfile" | head -1) T_SLUG="$T_OWNER/$TEMPLATE_REPO" +# materialize COMMIT DIR — COMMIT's tree written into DIR as a checkout writes +# it. Not git archive: "Use this template" copies the tree, and archive drops +# export-ignore paths and expands export-subst (Codex review of #275). +materialize() { + GIT_INDEX_FILE="$work/materialize.index" g -C "$T" read-tree "$1" + GIT_INDEX_FILE="$work/materialize.index" g -C "$T" checkout-index -a -f --prefix="$2/" + rm -f "$work/materialize.index" +} # use_template DIR — what "Use this template" creates: one root commit with # the template's tree, no shared history, origin = the new repository. use_template() { rm -rf "$1"; mkdir -p "$1" - g -C "$T" archive "$T_COMMIT" | tar x -C "$1" + materialize "$T_COMMIT" "$1" g -C "$1" init -q -b main g -C "$1" add -A g -C "$1" commit -q -m "Initial commit" g -C "$1" remote add origin git@github.com:example-owner/mint-check.git } +# The description carries `&` and `|`, which the substitution used to read as +# sed syntax (`&` inserted the token itself). MINT_ENV adds per-call settings. +DESC='Tools & proofs | checked' +MINT_ENV=() +MINT_ARCH="" + # mint DIR LOG — the non-interactive mint, isolated from this host's defaults # file and global git config (an ORCID or signing key there would leak in). mint() { - local cfg="$work/cfg"; mkdir -p "$cfg"; : > "$cfg/gitconfig" + # The user's global excludesFile ignores *.mine-local (CodeRabbit review of #275). + local cfg="$work/cfg"; mkdir -p "$cfg"; printf '*.mine-local\n' > "$work/global-excludes" + printf '[core]\n\texcludesFile = %s\n' "$work/global-excludes" > "$cfg/gitconfig" (cd "$1" && env -i PATH="$PATH" HOME="$HOME" TMPDIR="${TMPDIR:-/tmp}" \ ${RUSTUP_HOME:+RUSTUP_HOME="$RUSTUP_HOME"} ${CARGO_HOME:+CARGO_HOME="$CARGO_HOME"} \ XDG_CONFIG_HOME="$cfg" GIT_CONFIG_GLOBAL="$cfg/gitconfig" GIT_CONFIG_NOSYSTEM=1 \ RSR_NON_INTERACTIVE=1 RSR_TEMPLATE_URL="$T" RSR_MINT_TOOL_CACHE="$work/tools" \ PROJECT_NAME="Mint Check" REPO=mint-check OWNER=example-owner \ - AUTHOR="Example Author" AUTHOR_EMAIL=author@example.org \ - timeout 900 just repo-init "$2" 2>&1 + AUTHOR="Example Author" AUTHOR_EMAIL=author@example.org PROJECT_DESCRIPTION="$DESC" \ + "${MINT_ENV[@]}" timeout 900 just repo-init ${MINT_ARCH:+"$MINT_ARCH"} "$2" 2>&1 } # ── 2. The child, with an ignored cache and an untracked probe ────────────── @@ -113,6 +138,16 @@ g -C "$C" check-ignore -q _build/dependencies/sibling/README.md || fail 'fixture # not-ignored file IS the repository's and must be rewritten. Without it, an # untouched cache could mean "the passes never ran" rather than "scoped". printf '%s\n' "$probe" > "$C/mint-probe.md" +# A pre-mint edit to a template file: the replay starts from the pinned tree, +# so the mint must declare this as the child's own (Codex review of #275). +printf '\nA local support note, written before the mint.\n' >> "$C/SUPPORT.adoc" +# A pre-mint edit to an INPUT of the mint: the docs seed it renders into +# docs/usage.adoc, then deletes. Only a replay sees what that changes (CodeRabbit +# review of #275). +printf '\nA local usage note, added to the seed before the mint.\n' >> "$C/build/docs-seed/usage.adoc" +# A file only the user's global excludesFile ignores: git add -A never commits +# it, so the mint must not declare it (CodeRabbit review of #275). +printf 'mine\n' > "$C/notes.mine-local" ignored_list() { (cd "$C" && git ls-files -o -i --exclude-standard | LC_ALL=C sort); } IGN_LIST=$(ignored_list) IGN_BEFORE=$(cd "$C" && printf '%s\n' "$IGN_LIST" | xargs -d '\n' sha256sum) @@ -139,7 +174,168 @@ grep -q 'stale entry' "$work/stale.log" || { cat "$work/stale.log" >&2; fail 'th [ -z "$(g -C "$M" status --porcelain)" ] || fail 'the stale-manifest mint wrote to the tree' pass "(b-) a stale mint-verbatim entry stops the mint before it writes" +# ── 4b. Control: a Justfile identity repo-init cannot read, no guess from origin +# The remote of a template-created child is the CHILD; falling back to it +# named the child as its own template (Codex review of #251). +J="$work/no-identity" +use_template "$J" +sed -i -E 's/^(OWNER|REPO)([[:space:]]*:=[[:space:]]*)"([^"]+)"/\1\2env("RSR_\1", "\3")/' "$J/Justfile" +grep -qE '^OWNER[[:space:]]*:=[[:space:]]*env\(' "$J/Justfile" || fail 'fixture: the OWNER declaration was not rewritten' +g -C "$J" commit -q --amend -am "Initial commit" +if mint "$J" "$work/no-identity.log"; then fail 'a Justfile without OWNER/REPO minted from a guessed template'; fi +grep -q 'RSR_TEMPLATE_SLUG' "$work/no-identity.log" \ + || { cat "$work/no-identity.log" >&2; fail 'the missing-identity abort did not say how to fix it'; } +[ -z "$(g -C "$J" status --porcelain)" ] || fail 'the missing-identity mint wrote to the tree' +pass "(a-) a Justfile identity repo-init cannot read stops the mint and names RSR_TEMPLATE_SLUG" + +# ── 4c. Control: an answer with a newline is refused before the first write ─ +# (Codex review of #274: the refusal used to come after the overlay, the +# prune and the docs seed had already written.) +NL="$work/newline" +use_template "$NL" +MINT_ENV=(PROJECT_DESCRIPTION=$'line one\nline two') +if mint "$NL" "$work/newline.log"; then fail 'an answer with a newline was accepted'; fi +MINT_ENV=() +grep -q 'contains a newline' "$work/newline.log" || { cat "$work/newline.log" >&2; fail 'the newline refusal did not say why'; } +[ -z "$(g -C "$NL" status --porcelain)" ] || { g -C "$NL" status --short | head >&2; fail 'the refused mint wrote to the tree'; } +pass "(a-) an answer containing a newline is refused before anything is written" + +# ── 4d. Control: an asserted pin whose tree this child does not hold ──────── +# The difference cannot be listed, so it cannot be declared: refused before +# writing (Codex review of #275), not minted into certain drift. +# The asserted commit is built here, on a side branch of the template, so the +# test does not depend on how much history the checkout has (CI clones shallow). +OLDTREE=$(cd "$T" && GIT_INDEX_FILE="$work/old.index" git read-tree "$T_TREE" \ + && GIT_INDEX_FILE="$work/old.index" git rm -q --cached SUPPORT.adoc \ + && GIT_INDEX_FILE="$work/old.index" git write-tree) +OLD=$(g -C "$T" commit-tree "$OLDTREE" -p "$T_COMMIT" -m "another template tree") +g -C "$T" branch -q another-tree "$OLD" +[ -n "$OLD" ] && [ "$OLDTREE" != "$T_TREE" ] || fail 'fixture: could not build another template tree' +AS="$work/asserted" +use_template "$AS" +MINT_ENV=(RSR_TEMPLATE_COMMIT="$OLD") +if mint "$AS" "$work/asserted.log"; then fail 'a mint against an asserted tree it cannot compare went ahead'; fi +MINT_ENV=() +grep -q 'which this repository does not hold' "$work/asserted.log" || { tail -20 "$work/asserted.log" >&2; fail 'the uncomparable asserted pin was not refused by name'; } +[ -z "$(g -C "$AS" status --porcelain)" ] || fail 'the refused mint wrote to the tree' +pass "(a-) an asserted template tree this child does not hold, and differs from, is refused before writing" + +# ── 4e. Control: a pre-mint change the mint would move is refused ─────────── +# The mint moves a root .well-known/ into www/.well-known/, so such a path +# could not be declared where it ends up (Codex review of #275). +WK="$work/wellknown" +use_template "$WK" +mkdir -p "$WK/.well-known"; printf 'local\n' > "$WK/.well-known/local.txt" +wk_before=$(g -C "$WK" status --porcelain --untracked-files=all) +if mint "$WK" "$work/wellknown.log"; then fail 'a pre-mint change under .well-known/ was minted'; fi +grep -q 'the mint moves .well-known/ into www/.well-known/' "$work/wellknown.log" || { tail -20 "$work/wellknown.log" >&2; fail 'the .well-known refusal did not say why'; } +[ "$(g -C "$WK" status --porcelain --untracked-files=all)" = "$wk_before" ] || fail 'the refused mint wrote to the tree' +pass "(a-) a pre-mint change under .well-known/, which the mint moves, is refused before writing" + +# ── 4f. Control: a pre-mint file at a path the mint itself writes ─────────── +# The docs seed writes docs/usage.adoc; a local file there would be +# overwritten, then declared as the child's own (Codex review of #275). +DS="$work/docsseed" +use_template "$DS" +mkdir -p "$DS/docs"; printf 'my own usage notes\n' > "$DS/docs/usage.adoc" +ds_before=$(g -C "$DS" status --porcelain --untracked-files=all) +if mint "$DS" "$work/docsseed.log"; then fail 'a pre-mint file at a path the mint writes was minted over'; fi +grep -q 'the mint writes that path itself' "$work/docsseed.log" && grep -q 'docs/usage.adoc' "$work/docsseed.log" \ + || { tail -20 "$work/docsseed.log" >&2; fail 'the overwrite refusal did not name the file'; } +[ "$(cat "$DS/docs/usage.adoc")" = 'my own usage notes' ] && [ "$(g -C "$DS" status --porcelain --untracked-files=all)" = "$ds_before" ] \ + || fail 'the refused mint wrote to the tree' +pass "(a-) a pre-mint file at a path the mint writes (the docs seed) is refused before writing" +# The same for an edited template file the overlay replaces: julia-library's +# overlay writes .machine_readable/descriptiles/provisioning_praxis.deed +# (Codex review of #275). +JO="$work/juliaoverlay" +use_template "$JO" +printf '; edited before the mint\n' >> "$JO/.machine_readable/descriptiles/provisioning_praxis.deed" +jo_before=$(g -C "$JO" status --porcelain --untracked-files=all) +MINT_ARCH=julia-library +if mint "$JO" "$work/juliaoverlay.log"; then fail 'an edited file the overlay replaces was minted over'; fi +MINT_ARCH="" +grep -q 'provisioning_praxis.deed changed before the mint' "$work/juliaoverlay.log" \ + || { tail -20 "$work/juliaoverlay.log" >&2; fail 'the overlay overwrite was not refused by name'; } +[ "$(g -C "$JO" status --porcelain --untracked-files=all)" = "$jo_before" ] || fail 'the refused mint wrote to the tree' +pass "(a-) an edited template file the archetype overlay replaces is refused before writing" +# And for any file the mint regenerates whole, whatever writes it: a dry run +# of the mint on this tree finds the edit has no effect (Codex review of #275: +# the roadmap is copied over, mise.toml rendered from the languages). Edits the +# mint keeps are not refused: README.adoc is rendered, and a CLAUDE.md note +# outside the arrival pack's markers is preserved by its generator. +RM="$work/roadmap" +use_template "$RM" +printf '\nmy own milestone\n' >> "$RM/docs/status/ROADMAP.adoc" +printf '\nmy own agent note\n' >> "$RM/CLAUDE.md" +printf '\n# my own tool pin\n' >> "$RM/mise.toml" +printf '\nA README note of my own.\n' >> "$RM/README.adoc" +rm_before=$(g -C "$RM" status --porcelain --untracked-files=all) +if mint "$RM" "$work/roadmap.log"; then fail 'an edit to a file the mint regenerates was minted over'; fi +grep -q 'the change would be discarded' "$work/roadmap.log" && grep -qx ' docs/status/ROADMAP.adoc' "$work/roadmap.log" \ + && grep -qx ' mise.toml' "$work/roadmap.log" \ + && ! grep -qx ' README.adoc' "$work/roadmap.log" && ! grep -qx ' CLAUDE.md' "$work/roadmap.log" \ + || { tail -20 "$work/roadmap.log" >&2; fail 'the discarded edits were not refused by name (and only they)'; } +[ "$(g -C "$RM" status --porcelain --untracked-files=all)" = "$rm_before" ] || fail 'the refused mint wrote to the tree' +pass "(a-) an edit the mint would discard (a regenerated file) is refused by name before writing" +# And a change gone from both results: an input the mint never copies (the +# seed's own README), a file in a directory it deletes. Each is checked by +# minting without it; one with an effect (a seed file it copies) is not +# refused (Codex review of #275). +LO="$work/lostinput" +use_template "$LO" +printf '\nmy own seed note\n' >> "$LO/build/docs-seed/README.adoc" +printf 'local\n' > "$LO/archetypes/premint-local.txt" +printf '\nA usage note of my own.\n' >> "$LO/build/docs-seed/usage.adoc" +lo_before=$(g -C "$LO" status --porcelain --untracked-files=all) +if mint "$LO" "$work/lostinput.log"; then fail 'pre-mint changes the mint destroys were minted over'; fi +grep -qx ' build/docs-seed/README.adoc' "$work/lostinput.log" && grep -qx ' archetypes/premint-local.txt' "$work/lostinput.log" \ + && ! grep -qx ' build/docs-seed/usage.adoc' "$work/lostinput.log" \ + || { tail -20 "$work/lostinput.log" >&2; fail 'the destroyed changes were not refused by name (and only they)'; } +[ "$(g -C "$LO" status --porcelain --untracked-files=all)" = "$lo_before" ] || fail 'the refused mint wrote to the tree' +pass "(a-) a pre-mint change the mint would destroy without effect is refused by name before writing" +# A pre-mint path holding byte 0x01 (the deed reader's record separator) or a +# newline cannot be declared: refused by name (Codex review of #277). +SO="$work/sohpath" +use_template "$SO" +printf 'x\n' > "$SO/notes-soh"$'\001'"name.txt" +so_before=$(g -C "$SO" status --porcelain --untracked-files=all) +if mint "$SO" "$work/sohpath.log"; then fail 'a pre-mint path holding byte 0x01 was minted'; fi +grep -q 'a path holding a newline or byte 0x01 cannot be declared' "$work/sohpath.log" \ + || { tail -20 "$work/sohpath.log" >&2; fail 'the 0x01 path was not refused by name'; } +[ "$(g -C "$SO" status --porcelain --untracked-files=all)" = "$so_before" ] || fail 'the refused mint wrote to the tree' +pass "(a-) a pre-mint path holding byte 0x01 is refused before writing" +# A git location naming ANOTHER repository cannot just be cleared: the mint +# would then act on a different repository than the caller named. Refused. +GD="$work/other-gitdir" +use_template "$GD" +gd_before=$(g -C "$GD" status --porcelain --untracked-files=all); gd_head=$(g -C "$T" rev-parse HEAD) +MINT_ENV=(GIT_DIR="$T/.git") +if mint "$GD" "$work/othergd.log"; then fail 'a mint ran with GIT_DIR naming another repository'; fi +MINT_ENV=() +grep -q 'name a repository other than this work tree' "$work/othergd.log" \ + || { tail -20 "$work/othergd.log" >&2; fail 'a foreign GIT_DIR was not refused by name'; } +[ "$(g -C "$GD" status --porcelain --untracked-files=all)" = "$gd_before" ] && [ "$(g -C "$T" rev-parse HEAD)" = "$gd_head" ] \ + || fail 'the refused mint wrote to a tree' +pass "(a-) a git location naming another repository is refused before anything is written" + # ── 5. The mint ───────────────────────────────────────────────────────────── +# The project type is an answer no template file renders: (mint-answers) +# must not record it (Codex review of #275), and the replay must still be green. +# GIT_DIR inherited, as a hook or another git caller leaves it, naming this +# child: the replay the pre-mint changes need must not commit into it +# (CodeRabbit review of #275). +C_HEAD=$(g -C "$C" rev-parse HEAD); C_COUNT=$(g -C "$C" rev-list --count HEAD) +# The project type is a value found only in archetypes/julia-library/ +# actions.lock, which this mint (no archetype) deletes: it must not count as +# written into the child (CodeRabbit review of #275). Chosen here, not written +# into this test, which the child keeps. +UNRENDERED="" +for w in $(tr -cs '0-9' '\n' < "$C/archetypes/julia-library/actions.lock" | awk 'length($0) >= 8' | sort -u); do + if [ "$(g -C "$C" grep -l -F "$w" | tr '\n' ' ')" = 'archetypes/julia-library/actions.lock ' ]; then UNRENDERED=$w; break; fi +done +[ -n "$UNRENDERED" ] || fail 'fixture: no value is found only in archetypes/julia-library/actions.lock' +MINT_ENV=(PROJECT_TYPE="$UNRENDERED" GIT_DIR="$C/.git") mint "$C" "$work/mint.log" || { cat "$work/mint.log" >&2 if [ "${GITHUB_ACTIONS:-}" = "true" ]; then @@ -148,6 +344,9 @@ mint "$C" "$work/mint.log" || { fi fail 'just repo-init failed on a Use-this-template child' } +MINT_ENV=() +[ "$(g -C "$C" rev-parse HEAD)" = "$C_HEAD" ] && [ "$(g -C "$C" rev-list --count HEAD)" = "$C_COUNT" ] \ + || { g -C "$C" log --oneline -3 >&2; fail 'the replay committed into the child through an inherited GIT_DIR'; } pass "the non-interactive mint completes on a Use-this-template child" DEED="$C/mint-check_chora.deed" [ -f "$DEED" ] || fail 'the child deed was not renamed to mint-check_chora.deed' @@ -161,6 +360,9 @@ field() { bash "$C/scripts/deed-field.sh" "$DEED" "$@"; } [ "$(field variant parent-pin)" = "$T_COMMIT" ] || fail "variant parent-pin is $(field variant parent-pin), want $T_COMMIT" if grep -qF "$C_HEAD" "$DEED"; then fail "the deed records the child's own commit $C_HEAD"; fi pass "(a) provenance and variant name template commit ${T_COMMIT:0:12} and its tree, never the child's HEAD" +grep -qF -- "$DESC" "$C/README.adoc" \ + || { grep -n 'Tools' "$C/README.adoc" >&2; fail 'the description did not render literally'; } +pass "an answer containing & and | renders literally" # ── (b) the fixture tests are carried unchanged, and pass in the child ────── while IFS= read -r v; do @@ -188,23 +390,205 @@ if grep -qF "${LB}OWNER${RB}" "$C/mint-probe.md"; then fail 'probe: the substitu # The probe stays: it was a repository file at mint, so the map lists it. pass "(c) ignored files are byte-identical, none created but the arrival-pack artefact; the same content in a repository file was rewritten" -# ── (d) the drift gate is no longer recommended for a minted repo ─────────── -if grep -q 'arm the drift gate' "$work/mint.log"; then fail 'repo-init still recommends arming the drift gate'; fi +# ── (d) the drift gate replays the mint, and a fresh mint is green ────────── +grep -q 'arm the drift gate' "$work/mint.log" || fail 'repo-init does not recommend arming the drift gate' grep -q 'scripts/check-template-conformance.sh' "$work/mint.log" || fail 'repo-init does not point at the conformance check' +# The answers are recorded, verbatim (the description carries & and |). +[ "$(field mint-answers description)" = "$DESC" ] || fail "(mint-answers :description) is '$(field mint-answers description)'" +[ "$(field mint-answers repo)" = mint-check ] && [ -n "$(field mint-answers mint-date)" ] \ + || fail '(mint-answers) lacks the repo or the mint date' +# Only values already written into the child are recorded (owner ruling). +if grep -q "$UNRENDERED\|:project-type" "$DEED"; then fail '(mint-answers) recorded an answer that no file renders'; fi +grep -q 'not recorded, written into no file here: project-type' "$work/mint.log" || fail 'the mint did not name the unrecorded answer' +# What the work tree held beyond the pinned tree before the mint (the probe, +# untracked; the SUPPORT.adoc edit) is the child's own: named, and declared +# (Codex review of #275), so the replay from the pin stays exact. +grep -q '^ :added ("mint-probe.md")' "$DEED" || { grep -n ':added' "$DEED" >&2; fail 'the pre-mint addition was not declared added'; } +bash "$C/scripts/deed-field.sh" --list "$DEED" variant/paths diverged | grep -qx SUPPORT.adoc \ + || { grep -n ':diverged' "$DEED" >&2; fail 'the pre-mint edit was not declared diverged'; } +grep -q 'already differ from the template tree' "$work/mint.log" && grep -q '^ SUPPORT.adoc$' "$work/mint.log" \ + || fail 'the mint did not name the pre-mint changes' +bash "$C/scripts/deed-field.sh" --list "$DEED" variant/paths diverged | grep -qx docs/usage.adoc \ + && grep -q 'A local usage note' "$C/docs/usage.adoc" \ + || { grep -n ':diverged' "$DEED" >&2; fail 'a pre-mint edit to the docs seed was not declared where it lands'; } +if grep -q 'notes.mine-local' "$DEED"; then fail 'a file only a global excludesFile ignores was declared'; fi +mv "$C/notes.mine-local" "$work/notes.mine-local" # this test's own git does not ignore it g -C "$C" add -A g -C "$C" commit -q -m "mint" +# Chain minting now supports children with recorded answers (owner ruling +# 2026-11-04; tests/e2e/chain_mint_test.sh). The refusal applies to legacy +# children without those replay inputs. Keep the fresh child intact for the +# conformance and drift checks below, and construct that legacy case explicitly. +LEGACY="$work/legacy-child" +cp -a "$C" "$LEGACY" +bash "$LEGACY/scripts/deed-clause.sh" replace "$LEGACY/$(basename "$DEED")" mint-answers /dev/null +if bash "$LEGACY/scripts/deed-field.sh" --has "$LEGACY/$(basename "$DEED")" mint-answers; then + fail 'legacy fixture still records mint answers' +fi +g -C "$LEGACY" add -A +g -C "$LEGACY" commit -q -m "legacy child without replay inputs" +if mint "$LEGACY" "$work/remint.log"; then fail 'a legacy child without mint answers was minted again'; fi +grep -q 'already been minted' "$work/remint.log" || { cat "$work/remint.log" >&2; fail 'the re-mint refusal did not say why'; } +grep -qF "records its template as ${T_SLUG}" "$work/remint.log" || fail 'the re-mint refusal does not name the template to use' +[ -z "$(g -C "$LEGACY" status --porcelain)" ] || fail 'the refused re-mint wrote to the tree' +pass "(e-) a legacy child without replay inputs stops before writing and names its template" (cd "$C" && bash scripts/check-template-conformance.sh --quiet) > "$work/conf.log" 2>&1 \ || { cat "$work/conf.log" >&2; fail 'the conformance check fails on a fresh mint'; } -# The gate itself must still FAIL here (no silent green), say why, and not -# list the mint's own removals, which the (variant) clause now declares. -if (cd "$C" && bash scripts/check-variant-drift.sh "$T") > "$work/drift.log" 2>&1; then - fail 'the drift gate turned green on a minted repo without (normalise) support: suspicious' -fi -grep -q 'records :role "minted-repo"' "$work/drift.log" || fail 'the drift gate does not explain its minted-repo findings' -if grep -q 'declare in paths.removed' "$work/drift.log"; then - grep 'declare in paths.removed' "$work/drift.log" >&2; fail 'the mint removed paths it did not declare' -fi -pass "(d) stage 8 points at the conformance check (green); the drift gate still fails loudly, removals declared" +# A fresh mint is identical to a replay of itself: green, by construction. +drift() { (cd "$C" && RSR_MINT_TOOL_CACHE="$work/tools" bash scripts/check-variant-drift.sh "$T") > "$work/drift.log" 2>&1; } +drift || { cat "$work/drift.log" >&2; fail 'the drift gate is red on a fresh mint'; } +grep -q 'identical to a replay of the mint' "$work/drift.log" || fail 'the drift gate did not replay the mint' +# The same file, present, under the same global configuration: not this repo's +# file, so not drift (CodeRabbit review of #275). +mv "$work/notes.mine-local" "$C/notes.mine-local" +(cd "$C" && GIT_CONFIG_GLOBAL="$work/cfg/gitconfig" RSR_MINT_TOOL_CACHE="$work/tools" bash scripts/check-variant-drift.sh "$T") > "$work/drift.log" 2>&1 \ + || { cat "$work/drift.log" >&2; fail 'the drift gate counts a file the global excludesFile ignores'; } +rm "$C/notes.mine-local" +pass "(d) the drift gate replays the mint from (provenance) and (mint-answers): green on a fresh mint" +# Negative control: an edit to a file the mint produced, and an undeclared +# addition, are each named. Then restored, and green again. +printf '\nedited after the mint\n' >> "$C/README.adoc" +printf 'stray\n' > "$C/stray.txt" +if drift; then fail 'the drift gate missed an edit and an undeclared addition'; fi +grep -q '^DRIFT: README.adoc differs' "$work/drift.log" || { cat "$work/drift.log" >&2; fail 'the edited README.adoc was not named'; } +grep -q '^DRIFT: stray.txt exists here' "$work/drift.log" || fail 'the undeclared stray.txt was not named' +[ "$(grep -c '^DRIFT:' "$work/drift.log")" -eq 2 ] || { cat "$work/drift.log" >&2; fail 'the drift gate reported more than the two changes'; } +g -C "$C" checkout -q -- README.adoc; rm "$C/stray.txt" +pass "(d-) an edited file and an undeclared addition are named; nothing else is" +# Each declaration is a claim the gate checks (Codex review of #275): a +# mode-only change, a file recreated under a declared-removed directory, and a +# declared addition that is gone are each named. +chmod +x "$C/README.adoc" +mkdir -p "$C/archetypes"; printf 'back\n' > "$C/archetypes/revived.txt" +rm "$C/mint-probe.md" +if drift; then fail 'the drift gate missed a mode change, a revived removal and a missing addition'; fi +grep -q '^DRIFT: README.adoc differs .*mode' "$work/drift.log" || { cat "$work/drift.log" >&2; fail 'the mode-only change was not named'; } +grep -q '^DRIFT: archetypes/revived.txt exists here but is declared removed' "$work/drift.log" || fail 'the revived removal was not named' +grep -q '^DRIFT: declared-added mint-probe.md is missing' "$work/drift.log" || fail 'the missing declared addition was not named' +[ "$(grep -c '^DRIFT:' "$work/drift.log")" -eq 3 ] || { cat "$work/drift.log" >&2; fail 'the drift gate reported more than the three changes'; } +chmod -x "$C/README.adoc"; rm -r "$C/archetypes"; g -C "$C" checkout -q -- mint-probe.md +pass "(d-) a mode change, a revived removal and a missing addition are each named" +# Membership is the repository file set (Codex review of #275): an ignored +# artefact the replay also generates, force-added here, is still an addition. +AP=.machine_readable/arrival-pack/claude-md-data.json +[ -f "$C/$AP" ] || fail "fixture: the mint did not generate $AP" +g -C "$C" add -f -- "$AP" +if drift; then fail 'the drift gate passed a force-added ignored artefact'; fi +grep -qF "DRIFT: $AP exists here but the mint did not produce it" "$work/drift.log" || { cat "$work/drift.log" >&2; fail 'the force-added artefact was not named'; } +[ "$(grep -c '^DRIFT:' "$work/drift.log")" -eq 1 ] || { cat "$work/drift.log" >&2; fail 'the drift gate reported more than the force-added artefact'; } +g -C "$C" rm -q --cached -- "$AP" +drift || { cat "$work/drift.log" >&2; fail 'the drift gate is not green again after the controls'; } +pass "(d-) a force-added ignored artefact the replay also generates is named; green again once untracked" +# The most specific declaration wins (Codex review of #277): a file the child +# adds, and declares added, under archetypes/ (which the mint declares +# removed) is an addition, not a revived removal. +cp "$DEED" "$work/deed.before-specific" +sed -i 's|^ :added ("mint-probe.md")| :added ("mint-probe.md" "archetypes/custom.txt")|' "$DEED" +grep -q '"archetypes/custom.txt")' "$DEED" || fail 'fixture: could not declare archetypes/custom.txt' +mkdir -p "$C/archetypes"; printf 'mine\n' > "$C/archetypes/custom.txt" +drift || { cat "$work/drift.log" >&2; fail 'a declared addition under a declared-removed directory was flagged'; } +rm -r "$C/archetypes"; cp "$work/deed.before-specific" "$DEED" +pass "(d) a declared addition under a declared-removed directory is an addition: the most specific declaration wins" +# A path declared diverged must be one the mint produces (Codex review of +# #275): a new file declared diverged is an undeclared addition. +cp "$DEED" "$work/deed.before-div" +sed -i 's|^ :diverged ("| :diverged ("new-thing.txt" "|' "$DEED" +grep -q ':diverged ("new-thing.txt"' "$DEED" || fail 'fixture: could not declare new-thing.txt diverged' +printf 'new\n' > "$C/new-thing.txt" +if drift; then fail 'the drift gate passed a new file declared diverged'; fi +grep -q '^DRIFT: new-thing.txt is declared diverged, but the mint did not produce it' "$work/drift.log" || { cat "$work/drift.log" >&2; fail 'the new file declared diverged was not named'; } +rm "$C/new-thing.txt"; cp "$work/deed.before-div" "$DEED" +pass "(d-) a new file declared diverged is named as an addition" +# The replay's own git runs isolated from this machine's configuration: a +# global pre-commit hook that rejects every commit changes nothing (Codex +# review of #275). +mkdir -p "$work/hooks"; printf '#!/bin/sh\nexit 1\n' > "$work/hooks/pre-commit"; chmod +x "$work/hooks/pre-commit" +# And a global attributes file that drops README.adoc from every archive: the +# replay must not lose it (Codex review of #277). +printf 'README.adoc export-ignore\n' > "$work/global.attributes" +printf '[core]\n\thooksPath = %s\n\tattributesFile = %s\n' "$work/hooks" "$work/global.attributes" > "$work/hooky.gitconfig" +(cd "$C" && GIT_CONFIG_GLOBAL="$work/hooky.gitconfig" RSR_MINT_TOOL_CACHE="$work/tools" bash scripts/check-variant-drift.sh "$T") > "$work/drift.log" 2>&1 \ + || { cat "$work/drift.log" >&2; fail 'a global pre-commit hook broke the replay'; } +pass "(d) the replay is isolated from global git hooks and attributes" +# Inherited git location variables (a hook's GIT_DIR, GIT_INDEX_FILE) cannot +# redirect the gate's git (CodeRabbit review of #275). +g init -q "$work/elsewhere" +(cd "$C" && GIT_DIR="$work/elsewhere/.git" GIT_INDEX_FILE="$work/elsewhere/.git/index" \ + RSR_MINT_TOOL_CACHE="$work/tools" bash scripts/check-variant-drift.sh "$T") > "$work/drift.log" 2>&1 \ + || { cat "$work/drift.log" >&2; fail 'an inherited GIT_DIR redirected the drift gate'; } +pass "(d) inherited git location variables do not redirect the gate" +# The last path git lists, deleted, is named like any other: it used to fail +# the file listing itself and stop the gate silently (CodeRabbit review of #275). +LAST=$(g -C "$C" ls-files | tail -n 1) +rm -- "$C/$LAST" +if drift; then fail "the drift gate missed the deletion of $LAST"; fi +grep -qF "DRIFT: $LAST was produced by the mint but is absent here" "$work/drift.log" || { cat "$work/drift.log" >&2; fail "the deleted last path $LAST was not named"; } +g -C "$C" checkout -q -- "$LAST" +pass "(d-) the deletion of the last path git lists is named" +# A path the line-based lists cannot hold is refused by name, not misread. +NLF="$C/two"$'\n'"lines.txt"; printf 'x\n' > "$NLF" +if drift; then fail 'the drift gate passed a path containing a newline'; fi +grep -q 'cannot check a path containing a newline' "$work/drift.log" || { cat "$work/drift.log" >&2; fail 'the newline path was not refused by name'; } +rm -- "$NLF" +pass "(d-) a path containing a newline is refused by name" +# A template commit carrying a submodule (a gitlink: no files to copy) cannot +# be replayed exactly: the gate refuses it by name (Codex review of #277). +GL_TREE=$(cd "$T" && GIT_INDEX_FILE="$work/gl.index" git read-tree "$T_TREE" \ + && GIT_INDEX_FILE="$work/gl.index" git update-index --add --cacheinfo "160000,$T_COMMIT,vendor/sub" \ + && GIT_INDEX_FILE="$work/gl.index" git write-tree) +GL_COMMIT=$(g -C "$T" commit-tree "$GL_TREE" -p "$T_COMMIT" -m "a template carrying a submodule") +g -C "$T" branch -q with-gitlink "$GL_COMMIT" +GLC="$work/gitlinkchild"; cp -a "$C" "$GLC" +sed -i -E "s/(:template-commit \")[0-9a-f]{40}\"/\1${GL_COMMIT}\"/" "$GLC/mint-check_chora.deed" +grep -q ":template-commit \"$GL_COMMIT\"" "$GLC/mint-check_chora.deed" || fail 'fixture: the gitlink pin was not written' +rc=0; (cd "$GLC" && RSR_MINT_TOOL_CACHE="$work/tools" bash scripts/check-mint-drift.sh "$T") > "$work/gldrift.log" 2>&1 || rc=$? +[ "$rc" -eq 2 ] && grep -q 'carries a submodule' "$work/gldrift.log" && grep -q 'vendor/sub' "$work/gldrift.log" \ + || { cat "$work/gldrift.log" >&2; fail "a template commit carrying a gitlink was not refused by name (exit $rc)"; } +pass "(d-) a template commit carrying a submodule is refused by name, not replayed inexactly" +# …and the mint refuses such a tree itself, clean or not: its drift gate could +# never pass (Codex review of #275). +GLM="$work/gitlinkmint" +rm -rf "$GLM"; mkdir -p "$GLM"; materialize "$GL_COMMIT" "$GLM" +g -C "$GLM" init -q -b main; g -C "$GLM" add -A +g -C "$GLM" update-index --add --cacheinfo "160000,$T_COMMIT,vendor/sub"; g -C "$GLM" commit -q -m "Initial commit" +g -C "$GLM" remote add origin git@github.com:example-owner/mint-check.git +[ "$(g -C "$GLM" rev-parse 'HEAD^{tree}')" = "$GL_TREE" ] || fail 'fixture: the gitlink child is not the gitlink template tree' +MINT_ENV=(RSR_TEMPLATE_COMMIT="$GL_COMMIT") +if mint "$GLM" "$work/glmint.log"; then fail 'a tree carrying a gitlink was minted'; fi +MINT_ENV=() +grep -q 'carries a submodule' "$work/glmint.log" && grep -q 'vendor/sub' "$work/glmint.log" \ + || { tail -20 "$work/glmint.log" >&2; fail 'the gitlink was not refused by name at mint'; } +[ -z "$(g -C "$GLM" status --porcelain)" ] || fail 'the refused mint wrote to the tree' +pass "(a-) a tree carrying a submodule is refused at mint, clean or not" + +# ── A template whose identity needs RSR_TEMPLATE_SLUG, replayed ───────────── +# repo-init supports a template whose Justfile names its identity through +# env(...), given RSR_TEMPLATE_SLUG. The drift gate's replay must pass the +# recorded slug on, or it cannot run at all (Codex review of #275). The same +# template marks SUPPORT.adoc export-ignore: the replay must write the tree as +# a checkout does, not as git archive does (Codex review of #275). +SL_TREE=$(cd "$T" && GIT_INDEX_FILE="$work/slug.index" git read-tree "$T_TREE" && cp Justfile "$work/Justfile.slug" \ + && sed -i -E 's/^(OWNER|REPO)([[:space:]]*:=[[:space:]]*)"([^"]+)"/\1\2env("RSR_\1", "\3")/' "$work/Justfile.slug" \ + && GIT_INDEX_FILE="$work/slug.index" git update-index --add --cacheinfo "100644,$(git hash-object -w "$work/Justfile.slug"),Justfile" \ + && { cat .gitattributes; printf 'SUPPORT.adoc export-ignore\n'; } > "$work/gitattributes.slug" \ + && GIT_INDEX_FILE="$work/slug.index" git update-index --add --cacheinfo "100644,$(git hash-object -w "$work/gitattributes.slug"),.gitattributes" \ + && GIT_INDEX_FILE="$work/slug.index" git write-tree) +SL_COMMIT=$(g -C "$T" commit-tree "$SL_TREE" -p "$T_COMMIT" -m "a template naming itself through env()") +g -C "$T" branch -q env-identity "$SL_COMMIT" +SLC="$work/slugchild" +rm -rf "$SLC"; mkdir -p "$SLC" +materialize "$SL_COMMIT" "$SLC" +[ -f "$SLC/SUPPORT.adoc" ] && ! g -C "$T" archive "$SL_COMMIT" | tar t | grep -qx SUPPORT.adoc \ + || fail 'fixture: SUPPORT.adoc is not export-ignored in the second template commit' +g -C "$SLC" init -q -b main; g -C "$SLC" add -A; g -C "$SLC" commit -q -m "Initial commit" +g -C "$SLC" remote add origin git@github.com:example-owner/mint-check.git +MINT_ENV=(RSR_TEMPLATE_SLUG="$T_SLUG" RSR_TEMPLATE_COMMIT="$SL_COMMIT") +mint "$SLC" "$work/slug.log" || { tail -30 "$work/slug.log" >&2; fail 'the env()-identity template did not mint with RSR_TEMPLATE_SLUG'; } +MINT_ENV=() +g -C "$SLC" add -A; g -C "$SLC" commit -q -m mint +(cd "$SLC" && RSR_MINT_TOOL_CACHE="$work/tools" bash scripts/check-variant-drift.sh "$T") > "$work/slugdrift.log" 2>&1 \ + || { cat "$work/slugdrift.log" >&2; fail 'the drift gate could not replay a template that needs RSR_TEMPLATE_SLUG'; } +pass "(d) a template naming itself through env() and marking a path export-ignore is replayed exactly: green" # ── The repository map is regenerated last ────────────────────────────────── # Same check Estate Rules runs, on the committed mint: regenerate from the @@ -216,13 +600,40 @@ g -C "$C" diff --quiet -- docs/architecture/REPOSITORY-MAP.adoc \ # commit, after a removal), reproduces the stale map lintel shipped. SM="$work/stale-map" use_template "$SM" -(cd "$SM" && rm -rf archetypes && bash scripts/gen-repo-map.sh . >/dev/null) -grep -q '^| `archetypes' "$SM/docs/architecture/REPOSITORY-MAP.adoc" \ +# Create the control explicitly: minted descendants no longer carry archetypes/. +# The assertion must test index staleness, not assume a template-only directory. +printf 'map staleness control\n' > "$SM/map-staleness-control.txt" +g -C "$SM" add map-staleness-control.txt +rm "$SM/map-staleness-control.txt" +(cd "$SM" && bash scripts/gen-repo-map.sh . >/dev/null) +grep -qF 'map-staleness-control.txt' "$SM/docs/architecture/REPOSITORY-MAP.adoc" \ || fail 'control: index mode no longer shows the pre-commit staleness; this check is vacuous' +(cd "$SM" && bash scripts/gen-repo-map.sh --worktree . >/dev/null) +if grep -qF 'map-staleness-control.txt' "$SM/docs/architecture/REPOSITORY-MAP.adoc"; then + fail 'control: worktree mode retained a deleted source' +fi pass "the committed map is fresh (the index-mode control reproduces lintel's stale map)" (cd "$C" && env -u GITHUB_REPOSITORY bash scripts/check-no-placeholders.sh .) > "$work/ph.log" 2>&1 \ || { cat "$work/ph.log" >&2; fail 'the minted child fails its own placeholder gate'; } pass "the minted child passes its own placeholder gate" +# The mint removes build/docs-seed/; an exemption left for it is stale, and +# the child's Hypatia exemption gate fails on it (seen minting +# unified-api-adapter, 2026-11-05). +[ ! -e "$C/build/docs-seed" ] || fail 'fixture: the mint kept build/docs-seed/; this check is vacuous' +(cd "$C" && bash scripts/check-hypatia-ignore.sh .) > "$work/hi.log" 2>&1 \ + || { cat "$work/hi.log" >&2; fail 'the minted child fails its own Hypatia exemption gate'; } +pass "the minted child passes its own Hypatia exemption gate" +# The rule-order policy must lose the same module, or it classifies an +# exemption that no longer exists. +(cd "$C" && bash scripts/check-hypatia-rule-order.sh .) > "$work/ro.log" 2>&1 \ + || { cat "$work/ro.log" >&2; fail 'the minted child fails its own Hypatia rule-order gate'; } +pass "the minted child passes its own Hypatia rule-order gate" +if command -v bun >/dev/null 2>&1 || command -v ijson-jcs >/dev/null 2>&1; then + (cd "$C" && bash scripts/check-ijson-jcs.sh --enforce .machine_readable/policies/hypatia-rule-order.json) > "$work/jcs.log" 2>&1 \ + || { cat "$work/jcs.log" >&2; fail 'the minted rule-order policy is not canonical JSON'; } + pass "the minted rule-order policy stays canonical JSON" +fi + echo "PASS: Use-this-template mint" diff --git a/tests/ffi_strings_test.sh b/tests/ffi_strings_test.sh index 64d86f71..220c5bd9 100644 --- a/tests/ffi_strings_test.sh +++ b/tests/ffi_strings_test.sh @@ -16,6 +16,18 @@ fi cp -R "$repo/src/interface/ffi" "$work/ffi" sed -i -e 's/secret_types/test_project/g' -e 's/SECRET_TYPES/TEST_PROJECT/g' \ "$work/ffi/build.zig" "$work/ffi/src/main.zig" "$work/ffi/test/integration_test.zig" +# A minted repository has its own export prefix where the template has the +# token; map that prefix too, so the appended tests name the same functions. +# Only the exported symbols are renamed: a slug such as `root` must not turn +# Zig's `.root_module` into `.test_project_module`. +prefix=$(sed -nE 's/^export fn ([a-z0-9_]+)_init\(\).*/\1/p' "$work/ffi/src/main.zig" | head -1) +[[ -n "$prefix" ]] || { echo 'FAIL: no `export fn _init()` in src/interface/ffi/src/main.zig' >&2; exit 1; } +if [[ "$prefix" != test_project ]]; then + while IFS= read -r sym; do + sed -i -E "s/\\b${prefix}_${sym}\\b/test_project_${sym}/g" \ + "$work/ffi/build.zig" "$work/ffi/src/main.zig" "$work/ffi/test/integration_test.zig" + done < <(sed -nE "s/^export fn ${prefix}_([a-z0-9_]+)\\(.*/\\1/p" "$work/ffi/src/main.zig") +fi # Tests share the production module so the private helper can be exercised # with the leak-detecting allocator without changing the public FFI surface. cat >> "$work/ffi/src/main.zig" <<'ZIG' diff --git a/tests/run_proven_suite_test.sh b/tests/run_proven_suite_test.sh index b6f15267..7d1bec90 100644 --- a/tests/run_proven_suite_test.sh +++ b/tests/run_proven_suite_test.sh @@ -31,7 +31,8 @@ for file in scripts/check-escape-hatches.sh scripts/emit-aggregate-inputs.sh \ scripts/check-hypatia-rule-order.sh \ .github/hooks/validate-deed.sh .github/hooks/validate-k9.sh \ tests/proven_suite_test.sh tests/workflows/check_ijson_jcs_test.sh \ - tests/workflows/squabble_gate_test.sh tests/shape/check_root_shape_test.sh www/tests/run-all.sh; do + tests/workflows/squabble_gate_test.sh tests/workflows/secret_scanner_contract_test.sh \ + tests/shape/check_root_shape_test.sh www/tests/run-all.sh; do mkdir -p "$work/tree/$(dirname "$file")" cp "$work/lane" "$work/tree/$file" done @@ -49,7 +50,7 @@ grep -qF 'ALL LANES PASSED' "$work/out" || fail 'missing success outcome' jq -e '.schema_version == 1 and .entries[0].provenance == "Static-Checked" and .entries[0].proof_ladder == [{proof_file:"src/Proof.idr"}]' \ "$work/reports with spaces/run.json" >/dev/null || fail 'static report overclaims or loses proof files' -[[ "$(wc -l < "$LANE_CALLS")" -eq 16 ]] || fail 'clean run did not dispatch all required lanes' +[[ "$(wc -l < "$LANE_CALLS")" -eq 17 ]] || fail 'clean run did not dispatch all required lanes' echo 'PASS: clean runner executes all lanes and emits an honest report at a path with spaces' FAIL_GATE=check-root-shape.sh run 1 "$runner" diff --git a/tests/workflows/chain_tree_of_test.sh b/tests/workflows/chain_tree_of_test.sh new file mode 100755 index 00000000..f626e106 --- /dev/null +++ b/tests/workflows/chain_tree_of_test.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# chain-mint.sh reads every checkout into a tree through git (tree_of), never +# by copying files: git keeps a submodule as its gitlink, does not recurse into +# a path that became a directory, and reads nothing an ignore rule matches +# (Codex review of #277). This sources tree_of from the script itself and +# checks those three properties in a second, without a mint. +set -euo pipefail +repo=$(git rev-parse --show-toplevel) +work=$(mktemp -d); trap 'rm -rf "$work"' EXIT +fail() { echo "FAIL: $*" >&2; exit 1; } +g0() { git -c user.name=t -c user.email=t@example.invalid -c commit.gpgsign=false -c protocol.file.allow=always "$@"; } + +# The functions under test, exactly as the script defines them. +sed -n '/^g() {/,/^}/p; /^link_objects() {/,/^}/p; /^tree_of() {/,/^}/p' "$repo/scripts/chain-mint.sh" > "$work/fns.sh" +grep -q '^tree_of() {' "$work/fns.sh" || fail 'tree_of not found in scripts/chain-mint.sh' +# shellcheck disable=SC1091 +. "$work/fns.sh" + +g0 init -q "$work/sub"; echo s > "$work/sub/f"; g0 -C "$work/sub" add -A; g0 -C "$work/sub" commit -q -m s +D="$work/d" +g0 init -q "$D" +printf 'plain\n' > "$D/becomes-dir"; printf 'a\n' > "$D/a" +g0 -C "$D" add -A; g0 -C "$D" commit -q -m a +g0 -C "$D" submodule -q add "$work/sub" vendor/sub; g0 -C "$D" commit -q -m sub +SUB_COMMIT=$(g0 -C "$work/sub" rev-parse HEAD) +# A tracked file turned into a directory holding an ignored file, and an +# untracked file no rule ignores. +rm "$D/becomes-dir"; mkdir "$D/becomes-dir"; printf 'secret\n' > "$D/becomes-dir/key" +printf '/becomes-dir/key\n' >> "$D/.git/info/exclude" +printf 'new\n' > "$D/untracked.txt" + +WORK="$work/w"; mkdir -p "$WORK" +M="$work/m"; g init -q "$M"; mkdir -p "$M/.git/objects/info" +T=$(tree_of "$D" d) +g -C "$M" ls-tree -r "$T" > "$work/tree" + +grep -qP "^160000 commit ${SUB_COMMIT}\tvendor/sub$" "$work/tree" \ + || { cat "$work/tree" >&2; fail 'the submodule did not stay a gitlink at its commit'; } +if grep -q 'vendor/sub/' "$work/tree"; then fail 'the submodule worktree was vendored as files'; fi +if grep -q 'becomes-dir' "$work/tree"; then cat "$work/tree" >&2; fail 'the ignored file in a path that became a directory was read'; fi +grep -qP '\tuntracked.txt$' "$work/tree" && grep -qP '\ta$' "$work/tree" \ + || { cat "$work/tree" >&2; fail 'the work tree was not read as git add -A would commit it'; } +# The checkout's own .git was not written: its index still lists becomes-dir. +g0 -C "$D" ls-files --error-unmatch becomes-dir >/dev/null 2>&1 || fail 'tree_of wrote to the checkout index' + +# A linked worktree: its exclusion file is the COMMON directory's info/exclude, +# not one under .git/worktrees/ (Codex review of #277). +g0 -C "$D" add -A; g0 -C "$D" commit -q -m b +g0 -C "$D" worktree add -q "$work/wt" -b wt +printf 'secret\n' > "$work/wt/wt-secret.txt" +printf '/wt-secret.txt\n' >> "$D/.git/info/exclude" +g -C "$M" ls-tree -r --name-only "$(tree_of "$work/wt" wt own)" > "$work/wt.tree" +if grep -qx 'wt-secret.txt' "$work/wt.tree"; then fail 'a linked worktree: a file the common info/exclude ignores was read'; fi +grep -qx a "$work/wt.tree" || { cat "$work/wt.tree" >&2; fail 'a linked worktree was not read'; } + +# The checkout's own configuration, global included, read as its `git status` +# would (Codex review of #277): core.autocrlf=true checks LF blobs out as CRLF +# and cleans them back, so a clean CRLF checkout is no edit; its global +# excludesFile is honoured, so a file it ignores is not carried. +E="$work/e" +g0 init -q "$E"; printf 'one\ntwo\n' > "$E/text.txt"; g0 -C "$E" add -A; g0 -C "$E" commit -q -m e +LF_BLOB=$(g0 -C "$E" rev-parse HEAD:text.txt) +printf 'one\r\ntwo\r\n' > "$E/text.txt" +printf 'personal\n' > "$E/my-notes.local" +printf '*.local\n' > "$work/global-excludes" +printf '[core]\n\tautocrlf = true\n\texcludesFile = %s\n' "$work/global-excludes" > "$work/global-config" +ET=$(GIT_CONFIG_GLOBAL="$work/global-config" tree_of "$E" e own) +[ "$(g -C "$M" rev-parse "$ET:text.txt")" = "$LF_BLOB" ] \ + || fail 'a CRLF checkout under core.autocrlf=true was read as an edit' +if g -C "$M" ls-tree -r --name-only "$ET" | grep -qx my-notes.local; then fail 'a file the global excludesFile ignores was read'; fi +# …and only "own" reads it so: a replay's tree does not depend on this machine. +ER=$(GIT_CONFIG_GLOBAL="$work/global-config" tree_of "$E" er) +g -C "$M" ls-tree -r --name-only "$ER" | grep -qx my-notes.local \ + || fail 'a tree read without "own" honoured the global configuration' +echo 'PASS: tree_of keeps gitlinks, reads no ignored file (linked worktrees too), follows the checkout'"'"'s own configuration, and leaves the checkout alone' diff --git a/tests/workflows/check_no_a2ml_test.sh b/tests/workflows/check_no_a2ml_test.sh index 52519f1f..5a41dda5 100755 --- a/tests/workflows/check_no_a2ml_test.sh +++ b/tests/workflows/check_no_a2ml_test.sh @@ -34,10 +34,23 @@ printf '[x]\n' > "$REPO/AI.A2ML" git -C "$REPO" add AI.A2ML expect_fail "$REPO" "AI.A2ML" +# Shrink-only ledger: a listed file passes, an unlisted one beside it still +# fails, and 9-archive/ is out of scope. +git -C "$REPO" rm -q --cached AI.A2ML && rm "$REPO/AI.A2ML" +mkdir -p "$REPO/.machine_readable" "$REPO/9-archive/old" +printf '[x]\n' > "$REPO/LISTED.a2ml" +printf '[x]\n' > "$REPO/9-archive/old/ARCHIVED.a2ml" +printf '# ledger\nLISTED.a2ml # waiting for kyX\n' > "$REPO/.machine_readable/a2ml-legacy-allow.txt" +git -C "$REPO" add LISTED.a2ml 9-archive .machine_readable +bash "$CHECKER" "$REPO" | grep -q '^PASS:' +printf '[x]\n' > "$REPO/NEW.a2ml" +git -C "$REPO" add NEW.a2ml +expect_fail "$REPO" "NEW.a2ml" + PLAIN="$FIXTURE/plain"; mkdir -p "$PLAIN/sub" printf '[x]\n' > "$PLAIN/sub/ANCHOR.a2ml" expect_fail "$PLAIN" "sub/ANCHOR.a2ml" rm "$PLAIN/sub/ANCHOR.a2ml" bash "$CHECKER" "$PLAIN" | grep -q '^PASS:' -echo "PASS: check-no-a2ml controls (clean, tracked, upper-case, untracked)" +echo "PASS: check-no-a2ml controls (clean, tracked, upper-case, untracked, ledger)" diff --git a/tests/workflows/container_kyaml_test.sh b/tests/workflows/container_kyaml_test.sh index 1d1971c4..60fe4c5c 100644 --- a/tests/workflows/container_kyaml_test.sh +++ b/tests/workflows/container_kyaml_test.sh @@ -129,6 +129,29 @@ for port in 1 65535; do done echo 'PASS: container-init substitutions preserve service references and string ports at 1 and 65535' +# Both recipes refuse a value before writing anything when it would make an +# image reference container tooling rejects. Each refused value is a mutant +# of an accepted one, so a check that accepts everything fails here. +check_values() { bash "$repo/scripts/format-container-kyaml.sh" --check-values "$@" 2>/dev/null; } +for registry in ghcr.io/acme localhost:5000/team host:65535/team registry.example.com '[2001:db8::1]:5000/team' '[::1]/x' '[::]' '[1:2:3:4:5:6:7:8]' '[1:2:3:4:5:6::8]'; do + check_values demo-api 8080 "$registry" || fail "registry '$registry' was refused" +done +for registry in / ghcr.io/Acme host:bad-port ghcr.io//acme ghcr.io/ '[]' '[2001:db8::1' '2001:db8::1:5000/team' '[g::1]' '[::1]:' 'ghcr.io/a|b' host:0/team host:65536/team '[::1]:70000/team' \ + '[::::]:5000/team' '[1::2::3]/team' '[:1::2]' '[1::2:]' '[1:2:3]' '[1:2:3:4:5:6:7:8:9]' '[1::2:3:4:5:6:7:8]' '[12345::1]'; do + ! check_values demo-api 8080 "$registry" || fail "registry '$registry' was accepted" +done +# The service name is also a bare TOML key and a hostname, so it is a DNS +# label: a dot would nest stapeln/compose.toml's table, an underscore is no +# hostname, and a label is at most 63 characters. +long63=$(printf 'a%.0s' {1..63}) +for name in demo-api a a1 "$long63"; do check_values "$name" 8080 ghcr.io/acme || fail "service name '$name' was refused"; done +for name in Demo-Api -api api- a.b a_b a__b 'a b' 'a"b' '' "${long63}a"; do ! check_values "$name" 8080 ghcr.io/acme || fail "service name '$name' was accepted"; done +for port in 0 65536 08x ''; do ! check_values demo-api "$port" ghcr.io/acme || fail "port '$port' was accepted"; done +sed -e 's|{{SERVICE_NAME}}|demo-api|g' -e 's|{{PORT}}|8080|g' \ + -e 's|{{REGISTRY}}|[2001:db8::1]:5000/team|g' "$template" > "$scratch/compose-v6.yaml" +assert_value "$scratch/compose-v6.yaml" '.services."demo-api".image' '"[2001:db8::1]:5000/team/demo-api:latest"' 'bracketed IPv6 registry substitution' +echo 'PASS: --check-values accepts DNS-label service names and image-prefix registries (IPv6 included) and refuses malformed values' + assert_value "$policy" '.version' '"1.0"' 'policy version remains a string' assert_value "$policy" '.auth.public' '[{"path":"/health","methods":["GET"]},{"path":"/ready","methods":["GET"]},{"path":"/metrics","methods":["GET"]}]' 'public probes require GET' assert_value "$policy" '.auth.authenticated' '[{"path":"/api/v1/*","methods":["GET","POST","PUT","DELETE"]}]' 'application routes require authentication' diff --git a/tests/workflows/governance_badge_test.sh b/tests/workflows/governance_badge_test.sh index 27034f99..12020c83 100644 --- a/tests/workflows/governance_badge_test.sh +++ b/tests/workflows/governance_badge_test.sh @@ -207,7 +207,9 @@ wiring_checks() { fi echo " --- the badge URL and the orphan guarantee survive ---" - has "README still reads the stable raw URL" 'raw\.githubusercontent\.com/\{\{OWNER\}\}/\{\{REPO\}\}/governance-badges/badges/k9\.json' "$README" + # The template's README carries the tokens; a minted child's carries its + # own owner and repo. Either is the stable raw URL. + has "README still reads the stable raw URL" 'raw\.githubusercontent\.com/(\{\{OWNER\}\}/\{\{REPO\}\}|[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)/governance-badges/badges/k9\.json' "$README" has "the orphan commit is built with no parents" 'parents: \[\]' "$HELPER" hasnt "the helper never names a source branch as a ref" 'refs/heads/(main|master)' "$HELPER" diff --git a/tests/workflows/governance_policy_test.sh b/tests/workflows/governance_policy_test.sh index bab332eb..f631b4d5 100644 --- a/tests/workflows/governance_policy_test.sh +++ b/tests/workflows/governance_policy_test.sh @@ -4,7 +4,8 @@ # PR #244 configuration regressions: the retired runtime policy must not ship, # and governance must run for pull requests targeting any branch with a locked # checkout of metadatastician/standards at a full 40-hex SHA. -# PR #257 adds the proof-policy mapping and adopts canon 2.1.3. Since that +# PR #257 adds the proof-policy mapping and adopts canon 2.1.3, and canon 2.1.4 (an editorial PATCH: +# the criteria and gates hashes are unchanged) is adopted here. Since that # change is documentation and configuration only, check its published contract # here without invoking any proof compiler or making network requests. # Usage: bash tests/workflows/governance_policy_test.sh @@ -191,9 +192,9 @@ console.log("PASS: new proof instructions require needs, verification, rechecks const field = (key) => execFileSync("bash", [resolve(root, "scripts/deed-field.sh"), resolve(root, "secret-types_chora.deed"), "canon", key], { encoding: "utf8" }).trim(); assert.deepEqual([field("version"), field("criteria-sha256"), field("gates-sha256")], [ - "2.1.3", + "2.1.4", "6a5aa8857bd0d0d58ef48827938ca17c251b6b854dacf59d306388d61694d82a", "2f31f46d4753432e0aa8c9c28f339e18e062c98dc6e6ef4ecbb76993cf37638b", -], "canon 2.1.3 must retain the criteria identity and adopt the new gates identity"); -console.log("PASS: the deed adopts the complete canon 2.1.3 identity"); +], "canon 2.1.4 keeps the criteria and gates identities of 2.1.3 (an editorial PATCH)"); +console.log("PASS: the deed adopts the complete canon 2.1.4 identity"); JS diff --git a/tests/workflows/julia_library_test.sh b/tests/workflows/julia_library_test.sh index d5d9a7bf..6a69a7db 100644 --- a/tests/workflows/julia_library_test.sh +++ b/tests/workflows/julia_library_test.sh @@ -49,7 +49,7 @@ chmod +x "$fixture/python/uuidgen" derive() { env -i PATH="$1:$fixture/common" OWNER="$2" REPO="$3" \ STANDARDS_UUID_V8_JS="${4:-}" ARCHETYPE="${5:-julia-library}" \ - REPO_UUID="${6:-UNASSIGNED}" BUN_LOG="$fixture/bun.args" \ + REPO_UUID="${6:-UNASSIGNED}" RSR_PACKAGE_UUID="${7:-}" RSR_MINT_REPLAY="${8:-0}" BUN_LOG="$fixture/bun.args" \ "$bash_bin" -c ' set -euo pipefail LB="{{" RB="}}" PACKAGE_UUID=UNTOUCHED @@ -182,4 +182,20 @@ for job in fmt jet test; do ' "$workflow") equal "$actual" "$minutes" "$job job timeout" done +# A replay supplies the recorded uuid (mint-answers :package-uuid), so the +# drift gate gives the same answer on a host with neither bun nor python3 +# (Codex review of #275). A recorded UNASSIGNED replays as UNASSIGNED even +# where a generator exists; a malformed value is ignored, not trusted. +equal "$(derive "$fixture/empty" example-owner MintCheck.jl '' julia-library UNASSIGNED "$expected" 1)" "$expected" \ + 'a recorded package uuid replays without bun or python3' +equal "$(derive "$fixture/python" example-owner MintCheck.jl '' julia-library UNASSIGNED UNASSIGNED 1)" UNASSIGNED \ + 'a recorded UNASSIGNED replays as UNASSIGNED' +equal "$(derive "$fixture/empty" example-owner MintCheck.jl '' julia-library UNASSIGNED 'not-a-uuid' 1)" UNASSIGNED \ + 'a malformed recorded uuid is ignored' +# Outside a replay the value is never trusted (Codex review of #275): an +# ordinary mint derives the uuid even when the environment carries one. +equal "$(derive "$fixture/python" example-owner MintCheck.jl '' julia-library UNASSIGNED 00000000-0000-8000-8000-000000000000)" "$expected" \ + 'an ordinary mint ignores RSR_PACKAGE_UUID and derives the uuid' +equal "$(derive "$fixture/python" example-owner MintCheck.jl '' julia-library UNASSIGNED UNASSIGNED)" "$expected" \ + 'an ordinary mint ignores a stray UNASSIGNED' echo "PASS: $checks Julia library UUID and timeout checks; generator failures rejected" diff --git a/tests/workflows/kyaml_ratchet_test.sh b/tests/workflows/kyaml_ratchet_test.sh index 2536798d..94984898 100644 --- a/tests/workflows/kyaml_ratchet_test.sh +++ b/tests/workflows/kyaml_ratchet_test.sh @@ -64,7 +64,7 @@ case "$url" in https://github.com/mikefarah/yq/releases/download/v4.53.3/yq_linux_amd64) asset=yq [[ "$output" = "$RUNNER_TEMP/yq-bin/yq" ]] || exit 92 ;; - https://raw.githubusercontent.com/metadatastician/standards/8e48b1caade08a552646adeab66558ac43b4360d/scripts/kyaml-format.sh) + https://raw.githubusercontent.com/metadatastician/standards/e064539116c2dcfdf282a4749dbd58c69961c7e6/scripts/kyaml-format.sh) asset=kyaml-format.sh [[ "$output" = "$RUNNER_TEMP/kyaml-format.sh" ]] || exit 92 ;; *) echo "unexpected download: $url" >&2; exit 93 ;; diff --git a/tests/workflows/mint_cleanup_test.sh b/tests/workflows/mint_cleanup_test.sh index f5a3b34e..c8bbee90 100644 --- a/tests/workflows/mint_cleanup_test.sh +++ b/tests/workflows/mint_cleanup_test.sh @@ -51,4 +51,20 @@ cmp "$fixture/cache/README.md" "$fixture/cache.before" || { echo 'FAIL: an unlis rc=0; bash "$repo/scripts/rust-tool.sh" strip-instruction-blocks "$fixture/cache" 2>/dev/null || rc=$? [ "$rc" -eq 2 ] || { echo "FAIL: a directory argument exited $rc, want 2" >&2; exit 1; } cmp "$fixture/cache/README.md" "$fixture/cache.before" || { echo 'FAIL: a refused directory was edited' >&2; exit 1; } -echo 'PASS: selected ecosystems, nonempty updates, comment boundaries, idempotence, listed files only' +# The compile cache is keyed by source content. Keyed by name with an mtime +# check, a second checkout whose tool source is OLDER on disk than the cached +# build ran the first checkout's binary. Two versions, one cache, v2 older: +cache_dir="$fixture/tool-cache" +for v in 1 2; do + mkdir -p "$fixture/co$v/scripts" + cp "$repo/scripts/rust-tool.sh" "$fixture/co$v/scripts/" + printf 'fn main() { println!("v%s"); }\n' "$v" > "$fixture/co$v/scripts/probe-tool.rs" +done +touch -d '2001-01-01' "$fixture/co2/scripts/probe-tool.rs" +[ "$(RSR_MINT_TOOL_CACHE="$cache_dir" bash "$fixture/co1/scripts/rust-tool.sh" probe-tool)" = v1 ] \ + || { echo 'FAIL: the first version did not run' >&2; exit 1; } +[ "$(RSR_MINT_TOOL_CACHE="$cache_dir" bash "$fixture/co2/scripts/rust-tool.sh" probe-tool)" = v2 ] \ + || { echo 'FAIL: an older-mtime source ran the cached binary of another version' >&2; exit 1; } +[ "$(RSR_MINT_TOOL_CACHE="$cache_dir" bash "$fixture/co1/scripts/rust-tool.sh" probe-tool)" = v1 ] \ + || { echo 'FAIL: the first version lost its own binary' >&2; exit 1; } +echo 'PASS: selected ecosystems, nonempty updates, comment boundaries, idempotence, listed files only, content-keyed cache' diff --git a/tests/workflows/mint_gates_test.sh b/tests/workflows/mint_gates_test.sh index c185099b..2c91aac7 100644 --- a/tests/workflows/mint_gates_test.sh +++ b/tests/workflows/mint_gates_test.sh @@ -232,7 +232,7 @@ DD="$WORK/detect-depth"; mkdir -p "$DD/src/interface/ffi" printf '[package]\nname = "x"\n' > "$DD/Cargo.toml" printf 'package x\n' > "$DD/src/interface/abi.ipkg" printf 'x\n' > "$DD/src/interface/ffi/build.zig" -printf '[tools]\njust = "latest"\nshellcheck = "latest"\ntrivy = "latest"\nrust = "latest"\nidris2 = "latest"\nzig = "latest"\n' > "$DD/mise.toml" +printf '[tools]\njust = "latest"\nshellcheck = "latest"\nyq = "latest"\ntrivy = "latest"\nrust = "latest"\nidris2 = "latest"\nzig = "latest"\n' > "$DD/mise.toml" expect_pass "--detect finds markers below the root, as provision-lib does" \ mise --root "$DD" --detect diff --git a/tests/workflows/non_interactive_mint_test.sh b/tests/workflows/non_interactive_mint_test.sh index d8eef46e..5b9f31f8 100755 --- a/tests/workflows/non_interactive_mint_test.sh +++ b/tests/workflows/non_interactive_mint_test.sh @@ -10,6 +10,30 @@ set -euo pipefail repo=$(git rev-parse --show-toplevel) cd "$repo" +# A legacy minted repository (minted before #275 recorded (mint-answers), so +# it has no build/templates/identity.deed.in and no answers to replay) cannot +# be minted again: repo-init refuses before it asks or writes anything (#277). +# The rest of this test needs a mintable tree, so in such a child it checks +# that refusal, by name, and stops. It does not skip silently: a legacy child +# that could be minted again would fail here. +legacy_deed="$(bash "$repo/scripts/deed-field.sh" --find "$repo" 2>/dev/null || true)" +if [ ! -f "$repo/build/templates/identity.deed.in" ] \ + && ! { [ -n "$legacy_deed" ] && bash "$repo/scripts/deed-field.sh" --has "$legacy_deed" mint-answers; }; then + legacy_cfg=$(mktemp -d) + if legacy_out=$(cd "$repo" && env -i PATH="$PATH" HOME="$legacy_cfg" XDG_CONFIG_HOME="$legacy_cfg" \ + RSR_NON_INTERACTIVE=1 PROJECT_NAME="Mint Check" REPO=mint-check OWNER=example-owner \ + AUTHOR="Example Author" AUTHOR_EMAIL=author@example.org CONFIRM=n \ + timeout 120 just repo-init &1); then + rm -rf "$legacy_cfg" + echo "$legacy_out" >&2 + echo "FAIL: a legacy minted tree (no (mint-answers)) was allowed to mint again" >&2; exit 1 + fi + rm -rf "$legacy_cfg" + grep -q 'cannot be minted again' <<<"$legacy_out" \ + || { echo "$legacy_out" >&2; echo "FAIL: the legacy minted tree was refused for the wrong reason" >&2; exit 1; } + echo "PASS: a legacy minted tree refuses to mint again, by name; the mint checks below need a mintable tree" + exit 0 +fi recipe=build/just/repo-init.just cfg=$(mktemp -d); trap 'rm -rf "$cfg"' EXIT before=$(git status --porcelain --untracked-files=all) @@ -32,6 +56,23 @@ if out=$(env -i PATH="$PATH" HOME="$cfg" XDG_CONFIG_HOME="$cfg" RSR_NON_INTERACT fi grep -q 'Error: project name required' <<<"$out" || { echo "$out" >&2; echo "FAIL: missing PROJECT_NAME did not fail by name" >&2; exit 1; } if grep -q 'unbound variable' <<<"$out"; then echo "$out" >&2; echo "FAIL: unbound variable" >&2; exit 1; fi +# 3b. RSR_MINT_DATE is validated in bash, without GNU `date -d` (macOS has no +# such option and rejected every date): real dates reach "Proceed?", +# impossible ones are refused by name. +date_mint() { + env -i PATH="$PATH" HOME="$cfg" XDG_CONFIG_HOME="$cfg" RSR_NON_INTERACTIVE=1 RSR_MINT_DATE="$1" \ + PROJECT_NAME="Mint Check" REPO=mint-check OWNER=example-owner \ + AUTHOR="Example Author" AUTHOR_EMAIL=author@example.org CONFIRM=n \ + timeout 120 just repo-init &1 +} +for d in 2024-02-29 2026-12-31 2000-02-29; do + out=$(date_mint "$d") && grep -q '^Aborted\.$' <<<"$out" \ + || { echo "$out" >&2; echo "FAIL: the real date $d was refused" >&2; exit 1; } +done +for d in 2025-02-29 2026-04-31 2026-13-01 2026-00-10 1900-02-29 26-01-01; do + if out=$(date_mint "$d"); then echo "FAIL: the impossible date $d was accepted" >&2; exit 1; fi + grep -q 'RSR_MINT_DATE must be a real date' <<<"$out" || { echo "$out" >&2; echo "FAIL: $d refused for the wrong reason" >&2; exit 1; } +done # 4. Nothing was written (compared with the tree as this test found it). [ "$(git status --porcelain --untracked-files=all)" = "$before" ] || { git status --short >&2; echo "FAIL: the declined mint modified the tree" >&2; exit 1; } echo 'PASS: non-interactive prompts resolve from the environment; missing answers fail by name; no read() shim' diff --git a/tests/workflows/replay_mint_env_test.sh b/tests/workflows/replay_mint_env_test.sh new file mode 100755 index 00000000..9f6868c9 --- /dev/null +++ b/tests/workflows/replay_mint_env_test.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# replay-mint.sh runs the real recipe under RSR_MINT_REPLAY=1, where repo-init +# trusts RSR_PACKAGE_UUID. Only a recorded (mint-answers :package-uuid) may +# supply it: a fresh mint (a chain mint's new side) must not inherit one from +# the caller's environment, or a stray value would become the child's Julia +# package identity (Codex review of #275). A stub `just` records what the +# replay passes, so no mint runs and this takes a second. +set -euo pipefail +repo=$(git rev-parse --show-toplevel) +work=$(mktemp -d); trap 'rm -rf "$work"' EXIT +fail() { echo "FAIL: $*" >&2; exit 1; } +g() { git -c user.name=t -c user.email=t@example.invalid -c commit.gpgsign=false "$@"; } + +T="$work/template"; mkdir -p "$T"; printf 'x\n' > "$T/f" +g -C "$T" init -q -b main; g -C "$T" add -A; g -C "$T" commit -q -m t +PIN=$(g -C "$T" rev-parse HEAD) +mkdir -p "$work/bin" +cat > "$work/bin/just" <<'FAKE_JUST' +#!/usr/bin/env bash +printf '%s|%s\n' "${RSR_PACKAGE_UUID:-}" "${RSR_MINT_REPLAY:-}" > .env-probe +FAKE_JUST +chmod +x "$work/bin/just" +replay() { PATH="$work/bin:$PATH" bash "$repo/scripts/replay-mint.sh" "$T" "$PIN" "$@" >/dev/null; } +STRAY=00000000-0000-8000-8000-000000000000 +RECORDED=9271df15-3174-8475-ac0b-136d649c1f76 + +# 1. A fresh mint ignores a package uuid in the caller's environment. +RSR_PACKAGE_UUID="$STRAY" OWNER=o REPO=r replay "$work/fresh" +[ "$(cat "$work/fresh/.env-probe")" = "|1" ] \ + || fail "a fresh replay passed '$(cat "$work/fresh/.env-probe")'; expected no package uuid under RSR_MINT_REPLAY=1" + +# 2. A replay from a deed passes the recorded uuid, over the environment's. +printf '(repo-deed\n (mint-answers\n :repo "r"\n :owner "o"\n :package-uuid "%s"))\n' "$RECORDED" > "$work/x_chora.deed" +RSR_PACKAGE_UUID="$STRAY" replay "$work/recorded" --answers-from "$work/x_chora.deed" +[ "$(cat "$work/recorded/.env-probe")" = "$RECORDED|1" ] \ + || fail "a recorded replay passed '$(cat "$work/recorded/.env-probe")'; expected the recorded uuid" +echo "PASS: replay-mint passes a package uuid only from recorded answers" diff --git a/tests/workflows/repo_map_newline_test.sh b/tests/workflows/repo_map_newline_test.sh new file mode 100644 index 00000000..98cb201a --- /dev/null +++ b/tests/workflows/repo_map_newline_test.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# gen-repo-map.sh builds the map from lines, so a path holding a newline would +# be mapped as two bogus entries (Codex review of #277). It is refused by name, +# in both modes; a repository without one is not. +set -euo pipefail +repo=$(git rev-parse --show-toplevel) +work=$(mktemp -d); trap 'rm -rf "$work"' EXIT +fail() { echo "FAIL: $*" >&2; exit 1; } +g0() { git -c user.name=t -c user.email=t@example.invalid -c commit.gpgsign=false "$@"; } + +R="$work/r" +g0 init -q "$R" +mkdir -p "$R/notes"; printf 'x\n' > "$R/notes/plain.txt" +g0 -C "$R" add -A +for mode in "" --worktree; do + # Control: no such path, no such refusal (whatever else the run reports). + if bash "$repo/scripts/gen-repo-map.sh" $mode "$R" 2>"$work/err" >/dev/null || true; then :; fi + if grep -q 'containing a newline' "$work/err"; then fail "refused a repository with no newline path (mode '${mode:-index}')"; fi +done +printf 'y\n' > "$R/notes/a +b" +g0 -C "$R" add -A +for mode in "" --worktree; do + rc=0; bash "$repo/scripts/gen-repo-map.sh" $mode "$R" 2>"$work/err" >/dev/null || rc=$? + [ "$rc" -eq 2 ] && grep -q 'containing a newline' "$work/err" && grep -qF 'notes/a\nb' "$work/err" \ + || { cat "$work/err" >&2; fail "a newline path was not refused by name (mode '${mode:-index}', exit $rc)"; } +done +echo 'PASS: gen-repo-map.sh refuses a path holding a newline, by name, in both modes' diff --git a/tests/workflows/resolve_template_pin_test.sh b/tests/workflows/resolve_template_pin_test.sh index d2f241fe..167fa62b 100755 --- a/tests/workflows/resolve_template_pin_test.sh +++ b/tests/workflows/resolve_template_pin_test.sh @@ -118,7 +118,44 @@ out=$(bash "$pin" --slug "$SLUG" --url "$L" --dir "$work/lchild") || fail "a lon [ "$(field commit "$out")" = "$LC" ] || fail "long history resolved to $(field commit "$out"), want $LC" echo 'PASS: a 3000-commit history resolves its newest commit without SIGPIPE' -# 9. repo-init must take provenance from the resolver, not from local HEAD. +# 9. Repeated trees (Codex review of #251). Commits A1 and A2 (empty) share a +# tree; B follows with another. Dates are pinned so creation time decides. +A="$work/amb" +mkdir -p "$A"; g -C "$A" init -q -b main +at() { GIT_COMMITTER_DATE="@$1 +0000" GIT_AUTHOR_DATE="@$1 +0000" g -C "$A" "${@:2}"; } +printf 'a\n' > "$A/README.adoc"; g -C "$A" add -A; at 1700000100 commit -qm A1 +A1=$(g -C "$A" rev-parse HEAD) +at 1700000200 commit -q --allow-empty -m A2; A2=$(g -C "$A" rev-parse HEAD) +printf 'b\n' > "$A/README.adoc"; at 1700000300 commit -qam B +# amb_child DIR EPOCH — a Use-this-template child with A1's tree, created at EPOCH. +amb_child() { + rm -rf "$1"; mkdir -p "$1"; g -C "$A" archive "$A1" | tar x -C "$1" + g -C "$1" init -q -b main; g -C "$1" add -A + GIT_COMMITTER_DATE="@$2 +0000" GIT_AUTHOR_DATE="@$2 +0000" g -C "$1" commit -qm "Initial commit" +} +# Created while A2 was the tip: the exact source is A2, by creation time. +amb_child "$work/amb-c1" 1700000250 +out=$(bash "$pin" --slug "$SLUG" --url "$A" --dir "$work/amb-c1") || fail 'a child created at A2 did not resolve' +[ "$(field commit "$out")" = "$A2" ] || fail "child created at A2 resolved to $(field commit "$out")" +# Created while A1 was the tip: A1, although A2 is newer with the same tree. +amb_child "$work/amb-c0" 1700000150 +[ "$(field commit "$(bash "$pin" --slug "$SLUG" --url "$A" --dir "$work/amb-c0")")" = "$A1" ] \ + || fail 'a child created at A1 was not pinned to A1' +# Created after B: the tip no longer has the tree, two commits do: refused. +amb_child "$work/amb-c2" 1700000400 +if bash "$pin" --slug "$SLUG" --url "$A" --dir "$work/amb-c2" >/dev/null 2>"$work/err"; then + fail 'an ambiguous tree resolved to one commit silently' +fi +grep -q 'ambiguous' "$work/err" && grep -q "$A1" "$work/err" && grep -q "$A2" "$work/err" \ + || fail 'the ambiguity refusal does not list both commits' +[ "$(field commit "$(bash "$pin" --slug "$SLUG" --url "$A" --dir "$work/amb-c2" --commit "$A1" 2>/dev/null)")" = "$A1" ] \ + || fail 'an asserted commit did not settle the ambiguity' +# An uncommitted copy has no creation time: refused too. +rm -rf "$work/amb-wt"; mkdir -p "$work/amb-wt"; g -C "$A" archive "$A1" | tar x -C "$work/amb-wt"; g -C "$work/amb-wt" init -q +if bash "$pin" --slug "$SLUG" --url "$A" --dir "$work/amb-wt" >/dev/null 2>&1; then fail 'an ambiguous work tree resolved silently'; fi +echo 'PASS: repeated trees resolve by creation time, or are refused and listed' + +# 10. repo-init must take provenance from the resolver, not from local HEAD. recipe="$repo/build/just/repo-init.just" grep -q 'bash scripts/resolve-template-pin.sh' "$recipe" || fail 'repo-init does not call the resolver' if grep -nE 'PARENT_(PIN|BRANCH|TREE)=\$\(git ' "$recipe"; then fail 'repo-init reads the parent pin from local git again'; fi diff --git a/tests/workflows/secret_scanner_contract_test.sh b/tests/workflows/secret_scanner_contract_test.sh new file mode 100755 index 00000000..b3446f6e --- /dev/null +++ b/tests/workflows/secret_scanner_contract_test.sh @@ -0,0 +1,230 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# secret_scanner_contract_test.sh — the three scanner check contexts are this +# repository's own, stable, least-privileged and lockable. +# +# WHY THIS EXISTS, in one measurement. +# +# The estate canonical secret scanner is a single job-level call: +# +# jobs: +# scan: +# uses: metadatastician/standards/.github/workflows/secret-scanner-reusable.yml@ +# secrets: inherit +# +# A reusable call reports ` / `, so that wrapper emits +# `scan / gitleaks`, `scan / rust-secrets` and `scan / shell-secrets` — good +# names — but they are produced by jobs defined in a REMOTE file. Three +# consequences, each observed: +# +# 1. `secrets: inherit` forwards every repository and organisation secret to +# a scanner that declares no `workflow_call.secrets` and reads no +# `secrets.*` (CWE-250). Measured across the estate on 2026-11-08: +# hyperpolymath/modshells and hyperpolymath/docmatrix both carry it, and +# docmatrix's header still claims inherit is REQUIRED. It is not. +# 2. Renaming a job inside the callee renames the caller's check contexts. +# A required context that moves when someone edits another repository is +# not a contract. +# 3. `gh actions-lock` cannot lock a job-level remote reusable. A hand-added +# record for one is worse than none: present-but-unresolvable fails the +# run at startup with zero jobs, absent is merely unverified. See the +# header of scripts/check-lock-sync.sh. +# +# This repository therefore runs the same three scanners as ordinary inline +# jobs whose `name:` is the context. This test is the control that keeps it +# that way, and it is deliberately shaped as a SILENCE test (the shipped tree +# passes) plus FIRING tests (mutants fail), so it cannot pass vacuously. +# +# Checks, in order: +# silence shipped workflow: three jobs, exact names, read-only, no secrets:, +# pinned actions, triggers on pull_request and on push to the +# default branch, and a concurrency group +# silence every mapped required context resolves to a job of that name +# silence no workflow in the tree carries `secrets: inherit` +# firing each property above, mutated, fails the check +# wiring scripts/check-required-contexts.sh still certifies the real tree +# +# Usage: bash tests/workflows/secret_scanner_contract_test.sh +set -uo pipefail + +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +WF="$REPO_DIR/.github/workflows/secret-scanner.yml" +MAP="$REPO_DIR/.machine_readable/required-contexts-allow.txt" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +pass=0 +fail=0 +ok() { pass=$((pass + 1)); echo " PASS: $1"; } +bad() { + fail=$((fail + 1)) + echo " FAIL: $1" >&2 + [ -f "$WORK/why" ] && sed 's/^/ /' "$WORK/why" >&2 + echo "::error::secret-scanner contract test failed: $1" # NOSONAR +} + +# check FILE — assert every contract property on a candidate workflow file. +# Writes the reason to $WORK/why on failure and returns non-zero. +check() { + local f="$1" want ctx jobs out + : > "$WORK/why" + + # 1. exactly the three scanner jobs, by exact name + for ctx in "scan / gitleaks" "scan / rust-secrets" "scan / shell-secrets"; do + if ! grep -qxF " name: \"$ctx\"" "$f"; then + echo "no job named exactly \"$ctx\"" >> "$WORK/why" + return 1 + fi + done + want=3 + + # 2. no OTHER `scan / …` context: a fourth scanner needs a deliberate edit + # here too, so a context cannot appear in a generated repo unnoticed. + mapfile -t out < <(grep -oE '^ name: "scan / [a-z-]+"' "$f") + if [ "${#out[@]}" -ne "$want" ]; then + printf 'expected %s scan job(s), found %s: %s\n' \ + "$want" "${#out[@]}" "${out[*]}" >> "$WORK/why" + return 1 + fi + + # 3. a job-level remote reusable call would make the names remote: refuse + if grep -qE '^[[:space:]]+uses:[[:space:]]*[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/\.github/workflows/' "$f"; then + echo "job-level remote reusable call: the check names would belong to the callee" >> "$WORK/why" + return 1 + fi + + # 4. least privilege: workflow AND every job is contents: read only + if ! grep -qxE 'permissions:' "$f"; then + echo "no top-level permissions:" >> "$WORK/why" + return 1 + fi + if grep -qE '^[[:space:]]+(actions|checks|pull-requests|issues|statuses|packages|id-token|pages|deployments):' "$f"; then + echo "a job or the workflow grants more than contents: read" >> "$WORK/why" + return 1 + fi + + # 5. no secret access of any kind + if grep -qE '^[[:space:]]*secrets:' "$f"; then + echo "the workflow passes secrets: to a scanner that declares none" >> "$WORK/why" + return 1 + fi + if grep -qE 'secrets\.[A-Za-z_]' "$f"; then + echo "the workflow reads a secrets.* context" >> "$WORK/why" + return 1 + fi + + # 6. every uses: is a full 40-hex pin (the lock then has nothing to + # invent, and a tag cannot move under the run) + if grep -nE '^[[:space:]]+-?[[:space:]]*uses:' "$f" | grep -vE '@[0-9a-f]{40}([[:space:]]*#.*)?$' > "$WORK/unpinned" 2>/dev/null; then + echo "uses: refs that are not 40-hex pins:" >> "$WORK/why" + cat "$WORK/unpinned" >> "$WORK/why" + return 1 + fi + + # 7. triggers: pull_request bare (every PR, no path filter) and push to the + # real default branch. main is this repository's default branch; master + # is not, so it must not be the only value. + grep -qE '^ pull_request:' "$f" || { echo "no pull_request trigger" >> "$WORK/why"; return 1; } + grep -qE '^ push:' "$f" || { echo "no push trigger" >> "$WORK/why"; return 1; } + grep -qE '^[[:space:]]+branches:.*\bmain\b' "$f" \ + || { echo "push does not target main (the default branch)" >> "$WORK/why"; return 1; } + + # 8. concurrency: a superseded PR scan must not keep burning a runner + grep -qE '^concurrency:' "$f" || { echo "no concurrency group" >> "$WORK/why"; return 1; } + grep -qE '^[[:space:]]+cancel-in-progress:[[:space:]]*true' "$f" \ + || { echo "concurrency does not cancel superseded runs" >> "$WORK/why"; return 1; } + + return 0 +} + +echo "=== 1. The workflow as it ships ===" +if check "$WF"; then ok "shipped secret-scanner.yml satisfies the three-context contract"; else bad "shipped secret-scanner.yml violates the contract"; fi + +echo "=== 2. Every mapped context has a job of exactly that name ===" +if [ ! -f "$MAP" ]; then + bad "mapping file $MAP is missing" +else + mapped=0 + while IFS= read -r line; do + line="${line%%$'\r'}" + [ -n "$line" ] || continue + case "$line" in \#*) continue ;; esac + ctx="${line%%=>*}" + wf="${line##*=>}" + ctx="$(printf '%s' "$ctx" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" + wf="$(printf '%s' "$wf" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" + mapped=$((mapped + 1)) + if [ ! -f "$REPO_DIR/$wf" ]; then + bad "mapped context '$ctx' names missing workflow '$wf'" + continue + fi + if grep -qxF " name: \"$ctx\"" "$REPO_DIR/$wf" || grep -qxF " name: \"$ctx\"" "$REPO_DIR/$wf"; then + ok "mapped context '$ctx' is a job name in $wf" + else + bad "mapped context '$ctx' has no job of that name in $wf" + fi + done < "$MAP" + [ "$mapped" -ge 3 ] && ok "$mapped required context(s) mapped" \ + || bad "only $mapped context(s) mapped; all three scanner jobs should be" +fi + +echo "=== 3. No workflow passes secrets: inherit ===" +if hits="$(grep -rn '^[[:space:]]*secrets:[[:space:]]*inherit' "$REPO_DIR/.github/workflows/" 2>/dev/null)"; then + printf '%s\n' "$hits" > "$WORK/why" + bad "a workflow forwards every secret with secrets: inherit" +else + ok "no workflow passes secrets: inherit" +fi + +echo "=== 4. Firing: each contract property, mutated, is caught ===" +mutate() { # mutate LABEL SED-EXPR + cp "$WF" "$WORK/m.yml" + sed -i "$2" "$WORK/m.yml" + if cmp -s "$WF" "$WORK/m.yml"; then + bad "$1: the mutation changed nothing (control is wrong, not the tree)" + return + fi + if check "$WORK/m.yml"; then + bad "$1: the mutant passed the contract check" + else + ok "$1: caught" + fi +} + +mutate "a renamed scanner job" 's| name: "scan / rust-secrets"| name: "scan / rust-secrets-v2"|' +mutate "a missing scanner job" '/^ rust-secrets:$/,/^ shell-secrets:$/{/^ shell-secrets:$/!d}' +mutate "secrets: inherit added" 's|^ permissions:$| secrets: inherit\n permissions:|' +mutate "a secrets.* read added" 's|^ set -euo pipefail$| set -euo pipefail; echo "${{ secrets.TOKEN }}"|' +mutate "a broader permission granted" 's|^permissions:$|permissions:\n actions: read|' +mutate "an unpinned action" 's|@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1|@v7.0.1|' +mutate "push no longer targets the default branch" 's|^ branches: \[main\]$| branches: [master]|' +mutate "no pull_request trigger" 's|^ pull_request:$| schedule:|' +mutate "no concurrency group" 's|^concurrency:$|# concurrency:|' +mutate "a job-level remote reusable call" \ + 's|^ shell-secrets:$| delegated:\n permissions:\n contents: read\n uses: metadatastician/standards/.github/workflows/secret-scanner-reusable.yml@e064539116c2dcfdf282a4749dbd58c69961c7e6\n shell-secrets:|' + +echo "=== 5. Wire-up: the real tree still certifies all three contexts ===" +if command -v bun >/dev/null 2>&1; then + if bash "$REPO_DIR/scripts/check-required-contexts.sh" "$REPO_DIR" > "$WORK/rc.log" 2>&1; then + if grep -q '3 required status context' "$WORK/rc.log"; then + ok "check-required-contexts.sh certifies 3 contexts on the real tree" + else + cp "$WORK/rc.log" "$WORK/why" + bad "check-required-contexts.sh passed but certified a different count" + fi + else + cp "$WORK/rc.log" "$WORK/why" + bad "check-required-contexts.sh fails on the real tree" + fi +else + echo " SKIP: bun is not installed, so check-required-contexts.sh cannot parse YAML" +fi + +echo "" +if [ "$fail" -ne 0 ]; then + echo "FAIL: secret scanner contract suite (${pass} passed, ${fail} failed)" >&2 + exit 1 +fi +echo "PASS: secret scanner contract suite (${pass} checks)" diff --git a/tests/workflows/self_name_pass_test.sh b/tests/workflows/self_name_pass_test.sh index be41e796..17c4e014 100755 --- a/tests/workflows/self_name_pass_test.sh +++ b/tests/workflows/self_name_pass_test.sh @@ -32,13 +32,17 @@ grep -qE 'TEMPLATE_SLUG="\$\{TEMPLATE_OWNER\}/\$\{TEMPLATE_REPO\}"' "$recipe" \ || { echo "FAIL: repo-init does not compose TEMPLATE_SLUG" >&2; exit 1; } grep -qE 'PARENT_SLUG="\$TEMPLATE_SLUG"' "$recipe" \ || { echo "FAIL: repo-init does not record the derived parent in provenance" >&2; exit 1; } -# Outside comments and the documented last-resort default, no code line may +# Outside comments, no code line may # name the original template or a deed file: each such line was a mint that # silently did nothing in a second-generation child. hard=$(grep -nE 'rsr-template-repo|hyperpolymath|[A-Za-z0-9-]_chora\.deed' "$recipe" \ - | grep -vE '^[0-9]+: *#' \ - | grep -vE '^[0-9]+: *TEMPLATE_(REPO|OWNER)="\$\{TEMPLATE_(REPO|OWNER):-(rsr-template-repo|hyperpolymath)\}"$' || true) + | grep -vE '^[0-9]+: *#' || true) [ -z "$hard" ] || { echo "$hard" >&2; echo "FAIL: repo-init hardcodes a template name or deed file again" >&2; exit 1; } +# The template identity must never fall back to the git remote: in a +# template-created child the remote is the CHILD (Codex review of #251). +if sed -n '/--- The template'"'"'s own identity/,/TEMPLATE_SLUG=/p' "$recipe" | grep -v '^ *#' | grep -q 'remote\.origin'; then + echo "FAIL: repo-init derives the template identity from the git remote again" >&2; exit 1 +fi fail=0 # check INPUT EXPECTED — the pass must turn INPUT into EXPECTED. @@ -94,5 +98,65 @@ OWNER=example-owner REPO=mint-check check 'project := "Geometry.jl"' 'project := "mint-check"' check 'Geometry.jl ships it; GeometryXjl is another string' 'mint-check ships it; GeometryXjl is another string' +# ── A short, common parent name (Codex review of #251) ─────────────────────── +# Unbounded, a parent named `app` corrupted every word containing "app". +TEMPLATE_OWNER=example-owner TEMPLATE_REPO=app +TEMPLATE_SLUG="${TEMPLATE_OWNER}/${TEMPLATE_REPO}" +OWNER=other-owner REPO=new +# shellcheck disable=SC1090 +. "$rule_script" +check 'application mapping app' 'application mapping new' +check 'my-app app-config apps happy' 'my-new new-config apps happy' +check 'project := "app"' 'project := "new"' +check 'app app app' 'new new new' +check 'example-owner/app and example-owner/apps' 'other-owner/new and example-owner/apps' +check 'sonar.projectKey=example-owner_app' 'sonar.projectKey=example-owner_new' +check 'app_chora.deed and app#12' 'new_chora.deed and app#12' +# A new name containing the old one terminates and is not rewritten twice. +TEMPLATE_OWNER=example-owner TEMPLATE_REPO=geo +TEMPLATE_SLUG="${TEMPLATE_OWNER}/${TEMPLATE_REPO}" +OWNER=example-owner REPO=geo-tools +# shellcheck disable=SC1090 +. "$rule_script" +check 'geo and geo' 'geo-tools and geo-tools' + +# ── Literal marker-like text survives (Codex review of #274) ───────────────── +# The pass marks matches before replacing them; markers that are plain text +# rewrote a user's literal "__TEMPLATE_NAME__". They are control characters now. +TEMPLATE_OWNER=example-owner TEMPLATE_REPO=app +TEMPLATE_SLUG="${TEMPLATE_OWNER}/${TEMPLATE_REPO}" +OWNER=other-owner REPO=new +# shellcheck disable=SC1090 +. "$rule_script" +check 'app handles __TEMPLATE_NAME__ and __TEMPLATE_SLUG__ text' 'new handles __TEMPLATE_NAME__ and __TEMPLATE_SLUG__ text' + +# ── The self-owner rule: every form the identity parser accepts ───────────── +# One definition (SELF_OWNER_SED), used by the mint and by chain-mint.sh on +# the parent's carried files (Codex review of #277). It touches exactly the +# three identity lines; estate dependencies under the old owner stay. +oprog=$(grep -m1 -E '^ +SELF_OWNER_SED="' "$recipe") \ + || { echo "FAIL: no SELF_OWNER_SED line in $recipe" >&2; exit 1; } +grep -qE '^ +sed -i -E "\$SELF_OWNER_SED" Justfile sonar-project.properties$' "$recipe" \ + || { echo "FAIL: the self-owner pass does not apply SELF_OWNER_SED" >&2; exit 1; } +printf '%s\n' "$oprog" > "$rule_script" +sed_rhs() { printf '%s' "$1" | sed -e 's/[\\&|]/\\&/g'; } +TEMPLATE_OWNER=example-owner OWNER=other-owner +# shellcheck disable=SC1090 +. "$rule_script" +ocheck() { + local got; got=$(printf '%s\n' "$1" | sed -E "$SELF_OWNER_SED") + if [ "$got" = "$2" ]; then echo " PASS owner: $1"; else echo " FAIL owner: $1 -> $got (want $2)" >&2; fail=1; fi +} +ocheck 'OWNER := "example-owner"' 'OWNER := "other-owner"' +ocheck "OWNER := 'example-owner'" "OWNER := 'other-owner'" +ocheck 'export OWNER := "example-owner"' 'export OWNER := "other-owner"' +ocheck 'sonar.organization=example-owner' 'sonar.organization=other-owner' +ocheck 'sonar.projectKey=example-owner_elder-proj' 'sonar.projectKey=other-owner_elder-proj' +# Controls: dependencies, longer owners and other keys are left alone. +ocheck 'uses: example-owner/standards/.github/workflows/x.yml@main' 'uses: example-owner/standards/.github/workflows/x.yml@main' +ocheck 'sonar.organization=example-owner-two' 'sonar.organization=example-owner-two' +ocheck 'OWNER := "example-owner-two"' 'OWNER := "example-owner-two"' +ocheck 'REPO_OWNER := "example-owner"' 'REPO_OWNER := "example-owner"' + [ "$fail" -eq 0 ] || { echo "FAIL: self-name pass" >&2; exit 1; } echo 'PASS: self-name pass rewrites the derived template name, keeps issue references, provenance and detectors' diff --git a/tests/workflows/squabble_gate_test.sh b/tests/workflows/squabble_gate_test.sh index ec8625be..2e4447a7 100755 --- a/tests/workflows/squabble_gate_test.sh +++ b/tests/workflows/squabble_gate_test.sh @@ -123,6 +123,7 @@ on: - 'src/**' jobs: scan: + name: scan / gitleaks runs-on: ubuntu-latest steps: - run: echo hi @@ -139,6 +140,7 @@ on: pull_request: jobs: scan: + name: scan / gitleaks runs-on: ubuntu-latest steps: - run: echo hi @@ -175,6 +177,7 @@ name: Secret Scanner on: [push, pull_request] jobs: scan: + name: scan / gitleaks runs-on: ubuntu-latest steps: - run: echo hi @@ -281,13 +284,96 @@ fresh_context() { rm -rf "$context_tree" mkdir -p "$context_tree/.github/workflows" "$context_tree/.machine_readable" printf '# mapping\r\n\r\n scan / gitleaks => .github/workflows/scan.yaml \r\n' > "$context_tree/.machine_readable/required-contexts-allow.txt" - printf 'name: Scan\non: pull_request\njobs: {}\n' > "$context_tree/.github/workflows/scan.yaml" + printf 'name: Scan\non: pull_request\njobs:\n gitleaks:\n name: scan / gitleaks\n runs-on: ubuntu-latest\n steps: [{run: echo hi}]\n' > "$context_tree/.github/workflows/scan.yaml" } fresh_context assert_rc 0 'scalar PR event, yaml extension, comments, CRLF, and whitespace are accepted' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" -printf 'name: Scan\non: pull_request_target\njobs: {}\n' > "$context_tree/.github/workflows/scan.yaml" +sed -i 's|name: scan / gitleaks|name: scan / renamed|' "$context_tree/.github/workflows/scan.yaml" +assert_rc 1 'a renamed job no longer produces the required context' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +printf 'name: Scan\non: pull_request\njobs: {}\n' > "$context_tree/.github/workflows/scan.yaml" +assert_rc 1 'a PR-triggered workflow with no jobs produces no context' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +printf 'name: Scan\non: pull_request\njobs:\n gitleaks:\n name: scan / gitleaks\n steps: [{run: echo hi}]\n' > "$context_tree/.github/workflows/scan.yaml" +assert_rc 1 'a job with neither runs-on nor uses produces no context' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +# A job that calls a reusable workflow produces ` / `, +# never its own name alone; a remote reusable cannot be certified from here. +printf 'name: Inner\non: workflow_call\njobs:\n g:\n name: gitleaks\n runs-on: ubuntu-latest\n steps: [{run: echo hi}]\n' > "$context_tree/.github/workflows/inner.yaml" +printf 'name: Scan\non: pull_request\njobs:\n scan:\n uses: ./.github/workflows/inner.yaml\n' > "$context_tree/.github/workflows/scan.yaml" +assert_rc 0 'a local reusable produces / ' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +printf 'name: Scan\non: pull_request\njobs:\n scan:\n name: scan / gitleaks\n uses: ./.github/workflows/inner.yaml\n' > "$context_tree/.github/workflows/scan.yaml" +assert_rc 1 'a reusable caller named like the context does not produce it' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +printf 'name: Scan\non: pull_request\njobs:\n scan:\n name: scan / gitleaks\n uses: example/repo/.github/workflows/inner.yaml@0123456789012345678901234567890123456789\n' > "$context_tree/.github/workflows/scan.yaml" +assert_rc 1 'a remote reusable produces nothing the gate can certify' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +# GitHub names matrix runs `name (v1, v2)`, or evaluates a name that refers to +# the matrix; anything the gate cannot expand statically is refused, by name. +printf 'name: Scan\non: pull_request\njobs:\n scan:\n name: scan\n strategy: {matrix: {tool: [gitleaks, trufflehog]}}\n runs-on: ubuntu-latest\n steps: [{run: echo hi}]\n' > "$context_tree/.github/workflows/scan.yaml" +printf 'scan (gitleaks) => .github/workflows/scan.yaml\n' > "$context_tree/.machine_readable/required-contexts-allow.txt" +assert_rc 0 'a literal matrix names its runs `name (value)`' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +printf 'name: Scan\non: pull_request\njobs:\n scan:\n name: scan / ${{ matrix.tool }}\n strategy: {matrix: {tool: [gitleaks]}}\n runs-on: ubuntu-latest\n steps: [{run: echo hi}]\n' > "$context_tree/.github/workflows/scan.yaml" +printf 'scan / gitleaks => .github/workflows/scan.yaml\n' > "$context_tree/.machine_readable/required-contexts-allow.txt" +assert_rc 0 'a name that refers to the matrix is evaluated per run' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +printf 'name: Scan\non: pull_request\njobs:\n scan:\n name: scan / ${{ matrix.tool }}\n strategy: {matrix: {tool: [gitleaks], include: [{tool: trufflehog}]}}\n runs-on: ubuntu-latest\n steps: [{run: echo hi}]\n' > "$context_tree/.github/workflows/scan.yaml" +assert_rc 1 'a matrix with include is refused, not guessed' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +cp "$WORK/out" "$WORK/refusal" +assert_rc 0 'the include refusal names the job and says why' grep -q "not resolvable statically.*scan / \${{ matrix.tool }}" "$WORK/refusal" +printf 'name: Scan\non: pull_request\njobs:\n scan:\n name: scan / ${{ matrix.tool }}\n strategy:\n matrix: ${{ fromJSON(vars.TOOLS) }}\n runs-on: ubuntu-latest\n steps: [{run: echo hi}]\n' > "$context_tree/.github/workflows/scan.yaml" +assert_rc 1 'a computed matrix is refused, not guessed' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +cp "$WORK/out" "$WORK/refusal" +assert_rc 0 'the computed-matrix refusal names the job and says why' grep -q "not resolvable statically.*scan / \${{ matrix.tool }}" "$WORK/refusal" +printf 'name: Scan\non: pull_request\njobs:\n scan:\n name: scan / ${{ matrix.tool }}\n strategy: {matrix: {TOOL: [gitleaks]}}\n runs-on: ubuntu-latest\n steps: [{run: echo hi}]\n' > "$context_tree/.github/workflows/scan.yaml" +assert_rc 0 'matrix property names match case-insensitively' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +printf 'name: Scan\non: pull_request\njobs:\n scan:\n name: scan / ${{ matrix.tool }}\n strategy: {matrix: {TOOL: [gitleaks], tool: [trufflehog]}}\n runs-on: ubuntu-latest\n steps: [{run: echo hi}]\n' > "$context_tree/.github/workflows/scan.yaml" +assert_rc 1 'keys differing only in case are refused as ambiguous' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +printf 'name: Scan\non: pull_request\njobs:\n scan:\n name: scan / ${{ matrix.1tool }}\n strategy: {matrix: {1tool: [gitleaks]}}\n runs-on: ubuntu-latest\n steps: [{run: echo hi}]\n' > "$context_tree/.github/workflows/scan.yaml" +assert_rc 1 'a dereference GitHub rejects (matrix.1tool) is refused' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +# A called file counts only when it declares on: workflow_call; GitHub nests +# at most ten workflows (the caller and nine reusables); a cycle is refused. +printf 'name: Scan\non: pull_request\njobs:\n scan:\n uses: ./.github/workflows/inner.yaml\n' > "$context_tree/.github/workflows/scan.yaml" +printf 'name: Inner\non: push\njobs:\n g:\n name: gitleaks\n runs-on: ubuntu-latest\n steps: [{run: echo hi}]\n' > "$context_tree/.github/workflows/inner.yaml" +assert_rc 1 'a called file without on: workflow_call produces nothing' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +chain_tree() { # : scan.yaml calls r1 ... rN; the last runs job gitleaks + local n=$1 i ctx=scan + printf 'name: Scan\non: pull_request\njobs:\n scan:\n uses: ./.github/workflows/r1.yaml\n' > "$context_tree/.github/workflows/scan.yaml" + for ((i = 1; i < n; i++)); do + printf 'name: R\non: workflow_call\njobs:\n l%d:\n uses: ./.github/workflows/r%d.yaml\n' "$i" "$((i + 1))" > "$context_tree/.github/workflows/r$i.yaml" + ctx="$ctx / l$i" + done + printf 'name: R\non: workflow_call\njobs:\n g:\n name: gitleaks\n runs-on: ubuntu-latest\n steps: [{run: echo hi}]\n' > "$context_tree/.github/workflows/r$n.yaml" + printf '%s / gitleaks => .github/workflows/scan.yaml\n' "$ctx" > "$context_tree/.machine_readable/required-contexts-allow.txt" +} +chain_tree 9 +assert_rc 0 'nine nested reusables (ten workflows) resolve' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +chain_tree 10 +assert_rc 1 'a tenth nested reusable exceeds GitHub'"'"'s limit' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +rm -f "$context_tree"/.github/workflows/r*.yaml +printf 'name: Scan\non: [pull_request, workflow_call]\njobs:\n scan:\n uses: ./.github/workflows/scan.yaml\n' > "$context_tree/.github/workflows/scan.yaml" +printf 'scan / scan => .github/workflows/scan.yaml\n' > "$context_tree/.machine_readable/required-contexts-allow.txt" +assert_rc 1 'a workflow calling itself is refused, not followed' timeout 20 bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +printf 'name: Inner\non: workflow_call\njobs:\n g:\n name: gitleaks\n runs-on: ubuntu-latest\n steps: [{run: echo hi}]\n' > "$context_tree/.github/workflows/inner.yaml" +printf 'scan / gitleaks => .github/workflows/scan.yaml\n' > "$context_tree/.machine_readable/required-contexts-allow.txt" +# `$/.github/workflows/…` is GitHub's recommended same-repository form, and a +# caller with a literal matrix produces ` / `. +printf 'name: Scan\non: pull_request\njobs:\n scan:\n uses: $/.github/workflows/inner.yaml\n' > "$context_tree/.github/workflows/scan.yaml" +assert_rc 0 'a $/ same-repository call resolves like ./' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +printf 'name: Scan\non: pull_request\njobs:\n scan:\n uses: $/.github/workflows/inner.yaml@v1\n' > "$context_tree/.github/workflows/scan.yaml" +assert_rc 1 'a $/ call with an @ref (which GitHub forbids) produces nothing' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +printf 'name: Scan\non: pull_request\njobs:\n scan:\n strategy: {matrix: {target: [dev, prod]}}\n uses: ./.github/workflows/inner.yaml\n' > "$context_tree/.github/workflows/scan.yaml" +printf 'scan (prod) / gitleaks => .github/workflows/scan.yaml\n' > "$context_tree/.machine_readable/required-contexts-allow.txt" +assert_rc 0 'a matrix caller produces / ' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +# GitHub runs only files directly in .github/workflows/: a subdirectory or a +# traversal is not a reusable workflow, whatever file it reaches. +mkdir -p "$context_tree/.github/workflows/sub" +cp "$context_tree/.github/workflows/inner.yaml" "$context_tree/.github/workflows/sub/inner.yaml" +printf 'scan / gitleaks => .github/workflows/scan.yaml\n' > "$context_tree/.machine_readable/required-contexts-allow.txt" +printf 'name: Scan\non: pull_request\njobs:\n scan:\n uses: ./.github/workflows/sub/inner.yaml\n' > "$context_tree/.github/workflows/scan.yaml" +assert_rc 1 'a reusable in a subdirectory of .github/workflows produces nothing' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +printf 'name: Scan\non: pull_request\njobs:\n scan:\n uses: ./.github/workflows/sub/../inner.yaml\n' > "$context_tree/.github/workflows/scan.yaml" +assert_rc 1 'a traversal in a reusable path produces nothing' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +rm -r "$context_tree/.github/workflows/sub" +printf 'scan / gitleaks => .github/workflows/scan.yaml\n' > "$context_tree/.machine_readable/required-contexts-allow.txt" +rm "$context_tree/.github/workflows/inner.yaml" +printf 'name: Scan\non: pull_request_target\njobs:\n gitleaks:\n name: scan / gitleaks\n runs-on: ubuntu-latest\n steps: [{run: echo hi}]\n' > "$context_tree/.github/workflows/scan.yaml" assert_rc 1 'pull_request_target alone cannot produce a required PR check' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" -printf 'name: Scan\non:\n pull_request:\n paths-ignore: []\njobs: {}\n' > "$context_tree/.github/workflows/scan.yaml" +printf 'name: Scan\non:\n pull_request:\n paths-ignore: []\njobs:\n gitleaks:\n name: scan / gitleaks\n runs-on: ubuntu-latest\n steps: [{run: echo hi}]\n' > "$context_tree/.github/workflows/scan.yaml" assert_rc 1 'paths-ignore is forbidden even when empty' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" for mapping in '# only comments' 'missing separator' ' => .github/workflows/scan.yaml' \ 'scan / gitleaks =>' 'scan => file => another'; do diff --git a/verification/proofs/README.adoc b/verification/proofs/README.adoc index 16005f97..b228c0fe 100644 --- a/verification/proofs/README.adoc +++ b/verification/proofs/README.adoc @@ -43,6 +43,28 @@ bounds. For Rust, *Kani* (unsafe and FFI seams, reported `TESTED` with bounds) and *Aeneas* (Rust that implements an Agda-proved theorem) are standing exceptions that run alongside Creusot, never instead of it. +== Proof grades + +The only estate proof grade is the *Proof Witness Grade* (PWG) of policy §8. +It applies to theory theorems: + +* `PWG-3` means the Agda proof checks under the canonical invocation. +* `PWG-2i` adds one recheck leg (Rocq or Lean) that counts. +* `PWG-2ii` means both legs count, which discharges the policy's recheck + obligation. +* `PWG-1` adds a current receipt, per commit, from a pinned headless + checker for every witness. + +A leg counts only when it is kernel-checked and its statement +correspondence is `RECONCILED`. Grades are derived from receipts and the +correspondence records, never asserted. Record them in +`docs/status/PROOF-STATUS.adoc` beside the §5 status tokens, in a separate +field. + +Always write the `PWG-` prefix. A bare "Grade 2" collides with the CRG, +TRG, ARG and FRG letter grades. Publish counts as a census with their +denominator, never as a percentage. + == Third-party code Dependencies (crates, libraries, vendored or forked code) are not converted, From 255364955f7e0dcd482a723ea1112f6b1aa2c686 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 13:11:13 +0000 Subject: [PATCH 2/5] chore(canon): adopt canon 2.1.5 (gate table 0.3.1; criteria unchanged) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Canon lockstep is red on main. standards' canon.lock records 2.1.5 (#1424: gate table 0.3.1 and the applicability policy changed; the criteria did not), and this deed declared 2.1.3. Port of hyperpolymath/rsr-template-repo#298, the spine's adoption. - secret-types_chora.deed (canon): :version 2.1.3 to 2.1.5, and :gates-sha256 2f31f46d… to 5c510bba…. The criteria hash is unchanged. The (provenance) clause keeps 2.1.3: it records the canon this repository was minted against, which is history. - tests/workflows/governance_policy_test.sh expects the 2.1.5 tuple. That test already fails on main earlier, on the governance.yml pull_request filter that #15's caller carries; this does not change that. canon.lock marks 2.1.5 status "draft"; this adopts the identity the lock records, which is what the lockstep gate compares. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BYbQRtNu216R13jtZLmoqs (cherry picked from commit 402f898cdb8bbdc4ffbc8bf3f70b70eb8ea80628) --- secret-types_chora.deed | 4 ++-- tests/workflows/governance_policy_test.sh | 13 +++++++------ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/secret-types_chora.deed b/secret-types_chora.deed index 8c4a14d1..bbcb71b7 100644 --- a/secret-types_chora.deed +++ b/secret-types_chora.deed @@ -30,9 +30,9 @@ ; equal canon.lock [canon.artifacts]; canon.lock [canon.lockstep].order is ; spine-adopts-then-canon-releases, so this clause moves FIRST. (canon - :version "2.1.3" + :version "2.1.5" :criteria-sha256 "6a5aa8857bd0d0d58ef48827938ca17c251b6b854dacf59d306388d61694d82a" - :gates-sha256 "2f31f46d4753432e0aa8c9c28f339e18e062c98dc6e6ef4ecbb76993cf37638b" + :gates-sha256 "5c510bba025be718deebb12d0333bfe7db2da43c66578b728b1a334df65290b1" :lockstep-since "2026-09-17" ; A canon change this repo cannot satisfy is a change made in the WRONG ; ORDER, not a rejected change: land the spine's adoption first, then the diff --git a/tests/workflows/governance_policy_test.sh b/tests/workflows/governance_policy_test.sh index f631b4d5..a2fe91f5 100644 --- a/tests/workflows/governance_policy_test.sh +++ b/tests/workflows/governance_policy_test.sh @@ -4,8 +4,9 @@ # PR #244 configuration regressions: the retired runtime policy must not ship, # and governance must run for pull requests targeting any branch with a locked # checkout of metadatastician/standards at a full 40-hex SHA. -# PR #257 adds the proof-policy mapping and adopts canon 2.1.3, and canon 2.1.4 (an editorial PATCH: -# the criteria and gates hashes are unchanged) is adopted here. Since that +# PR #257 adds the proof-policy mapping and adopts canon 2.1.3; canon 2.1.5 (a +# PATCH: the gate table 0.3.1 and the applicability policy changed, the +# criteria did not) is adopted here, after 2.1.4 (editorial). Since that # change is documentation and configuration only, check its published contract # here without invoking any proof compiler or making network requests. # Usage: bash tests/workflows/governance_policy_test.sh @@ -192,9 +193,9 @@ console.log("PASS: new proof instructions require needs, verification, rechecks const field = (key) => execFileSync("bash", [resolve(root, "scripts/deed-field.sh"), resolve(root, "secret-types_chora.deed"), "canon", key], { encoding: "utf8" }).trim(); assert.deepEqual([field("version"), field("criteria-sha256"), field("gates-sha256")], [ - "2.1.4", + "2.1.5", "6a5aa8857bd0d0d58ef48827938ca17c251b6b854dacf59d306388d61694d82a", - "2f31f46d4753432e0aa8c9c28f339e18e062c98dc6e6ef4ecbb76993cf37638b", -], "canon 2.1.4 keeps the criteria and gates identities of 2.1.3 (an editorial PATCH)"); -console.log("PASS: the deed adopts the complete canon 2.1.4 identity"); + "5c510bba025be718deebb12d0333bfe7db2da43c66578b728b1a334df65290b1", +], "canon 2.1.5 keeps the criteria identity and adopts the gate table 0.3.1 identity"); +console.log("PASS: the deed adopts the complete canon 2.1.5 identity"); JS From 9eb8b2fe5e5abb522373731fd6566eb1664a212b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 14:28:03 +0000 Subject: [PATCH 3/5] fix(governance): keep the reusable caller, run on every PR (port of template #308) The synced governance_policy_test.sh required an inline standards checkout and an unfiltered pull_request trigger; this repository's governance.yml (since #15) calls standards' governance-reusable.yml at a 40-hex SHA and only ran for PRs into main/master. Owner ruling: keep the caller, fix the test at source. - Port hyperpolymath/rsr-template-repo#308: the test accepts a locked, SHA-pinned call of governance-reusable.yml as well as the inline checkout, and checks either in full. - Drop the pull_request branch filter, so governance runs on stacked PRs too (the push filter stays). governance_policy_test.sh passes in full here, canon 2.1.5 included; lock sync, workflow edges and required contexts pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BYbQRtNu216R13jtZLmoqs --- .github/workflows/governance.yml | 1 - tests/workflows/governance_policy_test.sh | 60 ++++++++++++++++------- 2 files changed, 42 insertions(+), 19 deletions(-) diff --git a/.github/workflows/governance.yml b/.github/workflows/governance.yml index 38f2547b..5563c81e 100644 --- a/.github/workflows/governance.yml +++ b/.github/workflows/governance.yml @@ -6,7 +6,6 @@ on: push: branches: [main, master] pull_request: - branches: [main, master] workflow_dispatch: concurrency: diff --git a/tests/workflows/governance_policy_test.sh b/tests/workflows/governance_policy_test.sh index a2fe91f5..eb1d31e5 100644 --- a/tests/workflows/governance_policy_test.sh +++ b/tests/workflows/governance_policy_test.sh @@ -2,8 +2,10 @@ # SPDX-License-Identifier: MPL-2.0 # # PR #244 configuration regressions: the retired runtime policy must not ship, -# and governance must run for pull requests targeting any branch with a locked -# checkout of metadatastician/standards at a full 40-hex SHA. +# and governance must run for pull requests targeting any branch, running the +# standards policy at a full 40-hex SHA: a locked checkout of +# metadatastician/standards, or a locked call of its reusable governance +# workflow. # PR #257 adds the proof-policy mapping and adopts canon 2.1.3; canon 2.1.5 (a # PATCH: the gate table 0.3.1 and the applicability policy changed, the # criteria did not) is adopted here, after 2.1.4 (editorial). Since that @@ -50,23 +52,45 @@ assert.ok(Object.hasOwn(governance.on, "workflow_dispatch"), "manual governance dispatch must remain available"); console.log("PASS: unrestricted pull requests coexist with filtered pushes and manual dispatch"); -// Governance must pin metadatastician/standards at a full 40-hex SHA and lock -// every step-level action it invokes in actions.lock. -const steps = governance.jobs.governance.steps ?? []; -const standardsCheckout = steps.find( - (s) => s.with && /^(metadatastician|hyperpolymath)\/standards$/.test(String(s.with.repository ?? "")) +// Governance must run the standards policy pinned at a full 40-hex SHA, and +// actions.lock must record what it runs. Two shapes satisfy that, and each is +// checked in full: +// * inline: a step checks out standards at the SHA and runs its scripts +// (the template's own governance.yml); +// * caller: the governance job calls standards' reusable governance +// workflow at the SHA, so the policy is defined once, in standards (the +// form children such as secret-types use). +// Anything else fails: a missing pin, a tag or branch ref, or a lock record +// that does not match. +const job = governance.jobs.governance; +const lockRecord = lock.workflows[".github/workflows/governance.yml"] ?? []; +const caller = String(job.uses ?? "").match( + /^(metadatastician|hyperpolymath)\/standards\/\.github\/workflows\/governance-reusable\.yml@([a-f0-9]{40})$/ ); -assert.ok(standardsCheckout, "governance must check out standards policy scripts"); -assert.match(String(standardsCheckout.with.ref ?? ""), /^[a-f0-9]{40}$/, - "governance must pin standards at a full 40-hex commit SHA"); -const checkoutUse = String(standardsCheckout.uses ?? ""); -assert.ok( - (lock.workflows[".github/workflows/governance.yml"] ?? []).includes(checkoutUse), - "governance's lock record must match its checkout action" -); -assert.ok(lock.dependencies[checkoutUse]?.commit, - "governance's checkout action must have a corresponding locked dependency commit"); -console.log("PASS: governance executes the pinned standards policy with locked actions"); +if (job.uses !== undefined) { + assert.ok(caller, + "a governance caller must call standards' governance-reusable.yml at a full 40-hex commit SHA"); + const callerUse = `${caller[1]}/standards@${caller[2]}`; + assert.ok(lockRecord.includes(callerUse), + "governance's lock record must match the reusable workflow it calls"); + assert.ok(lock.dependencies[callerUse]?.commit, + "governance's reusable workflow must have a corresponding locked dependency commit"); + console.log("PASS: governance calls the pinned standards policy, and the call is locked"); +} else { + const steps = job.steps ?? []; + const standardsCheckout = steps.find( + (s) => s.with && /^(metadatastician|hyperpolymath)\/standards$/.test(String(s.with.repository ?? "")) + ); + assert.ok(standardsCheckout, "governance must check out standards policy scripts"); + assert.match(String(standardsCheckout.with.ref ?? ""), /^[a-f0-9]{40}$/, + "governance must pin standards at a full 40-hex commit SHA"); + const checkoutUse = String(standardsCheckout.uses ?? ""); + assert.ok(lockRecord.includes(checkoutUse), + "governance's lock record must match its checkout action"); + assert.ok(lock.dependencies[checkoutUse]?.commit, + "governance's checkout action must have a corresponding locked dependency commit"); + console.log("PASS: governance executes the pinned standards policy with locked actions"); +} const root = process.argv[2]; const read = (path) => readFileSync(resolve(root, path), "utf8"); From 612a04b01f730a1675be553bead59939c8dd573c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 15:10:42 +0000 Subject: [PATCH 4/5] docs(readme): show the K9 conformance badge the governance-badge suite wires tests/workflows/governance_badge_test.sh requires README.adoc to read the stable raw URL governance-badges/badges/k9.json. The template README carries it with {{OWNER}}/{{REPO}} tokens; this repository's README never had it, and the earlier re-mint failure in estate-rules stopped the job before it reached this suite. The governance-badges branch already publishes badges/k9.json here, so the badge renders. governance_badge_test.sh passes in full (behaviour, wiring, firing controls); all 174 .adoc files render clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BYbQRtNu216R13jtZLmoqs --- README.adoc | 1 + 1 file changed, 1 insertion(+) diff --git a/README.adoc b/README.adoc index bcfcd936..5f552272 100644 --- a/README.adoc +++ b/README.adoc @@ -12,6 +12,7 @@ image:https://img.shields.io/badge/Docs-CC--BY--SA--4.0-blue.svg?logo=creativeco // ── Status ────────────────────────────────────────────────────────────────── image:https://img.shields.io/badge/Proof-core_theorem_proven-brightgreen[Core theorem proven,link="PROOF-STATUS.adoc"] image:https://img.shields.io/badge/RSR-Rhodium_Standard-9C27B0[Rhodium Standard Repository,link="https://github.com/hyperpolymath/rhodium-standard-repositories"] +image:https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/hyperpolymath/secret-types/governance-badges/badges/k9.json[K9 conformance,link="https://github.com/hyperpolymath/secret-types/actions/workflows/dogfood-gate.yml"] Confidentiality labels that constrain value flow, with declassification only at explicit audited points: a standalone information-flow typing project. From c23ac75285b3f8e7f2fe408d0169ab0e86f0d8b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 15:50:33 +0000 Subject: [PATCH 5/5] fix(container): write the filled service-name keys as canonical KYAML Estate Rules' "Converted YAML stays KYAML" step (synced from the template) failed: build/container/compose.yaml is not KYAML. The file is exactly the template's compose.yaml with its tokens filled. The template quotes token keys ("{{SERVICE_NAME}}") because a brace token is not a plain scalar; once filled, canonical KYAML writes the key unquoted (scripts/format-container-kyaml.sh says so, and runs at mint). This repository was minted before that formatter existed, so three filled keys kept their quotes: "secret-types" (twice) and "secret-types-data". Unquoted, matching the unquoted rokur-audit key the template's own file carries and passes with. Parsed data is unchanged (compared as sorted JSON). The byte-exact check needs mikefarah yq 4.53.3, which could not be installed in this environment; CI runs it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BYbQRtNu216R13jtZLmoqs --- build/container/compose.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/build/container/compose.yaml b/build/container/compose.yaml index f88d5768..0628a56f 100644 --- a/build/container/compose.yaml +++ b/build/container/compose.yaml @@ -21,7 +21,7 @@ # ────────────────────────────────────────────────────────────────────── # Primary application service # ────────────────────────────────────────────────────────────────────── - "secret-types": { + secret-types: { build: { context: "..", dockerfile: "build/container/Containerfile", @@ -74,7 +74,7 @@ "rokur-audit:/var/log/rokur", ], depends_on: { - "secret-types": { + secret-types: { condition: "service_healthy", }, }, @@ -128,7 +128,7 @@ }, }, volumes: { - "secret-types-data": null, + secret-types-data: null, rokur-audit: null, }, }