diff --git a/.github/workflows/main-estate-audit.yml b/.github/workflows/main-estate-audit.yml index 758b056..ea74de1 100644 --- a/.github/workflows/main-estate-audit.yml +++ b/.github/workflows/main-estate-audit.yml @@ -56,7 +56,12 @@ jobs: done echo "=== cicd-suite: spdx-license-check ===" + # Mirrors cicd-suite actions/spdx-license-check at this pin: a root + # licence file is enforced; no SPDX line anywhere is only a warning. test -f LICENSE.txt || test -f LICENSE || test -f LICENSE.md || test -f LICENSE.adoc + if [ "$({ git grep -i "SPDX-License-Identifier" || true; } | wc -l)" -eq 0 ]; then + echo "::warning::No SPDX-License-Identifier lines found in the repository." + fi echo "=== cicd-suite: idris2-abi & zig-unified-api-adapter ===" if find . -name '*.zig' -not -path './.git/*' | grep -q .; then diff --git a/.github/workflows/secret-scanner.yml b/.github/workflows/secret-scanner.yml index eaea280..d3a4123 100644 --- a/.github/workflows/secret-scanner.yml +++ b/.github/workflows/secret-scanner.yml @@ -67,18 +67,22 @@ jobs: - name: Gitleaks working-tree pass run: | set -euo pipefail - CONFIG=".gitleaks-estate.toml" + # 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 - CONFIG=".gitleaks.toml" + CONFIGS+=(".gitleaks.toml") fi - "$RUNNER_TEMP/gitleaks" detect \ - --source . \ - --no-git \ - --redact \ - --no-banner \ - --verbose \ - --config "$CONFIG" \ - --exit-code 1 + 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: | @@ -87,17 +91,21 @@ jobs: echo "::error::checkout is shallow; refusing vacuous history scan" exit 1 fi - CONFIG=".gitleaks-estate.toml" + # 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 - CONFIG=".gitleaks.toml" + CONFIGS+=(".gitleaks.toml") fi - "$RUNNER_TEMP/gitleaks" detect \ - --source . \ - --redact \ - --no-banner \ - --verbose \ - --config "$CONFIG" \ - --exit-code 1 + 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: | @@ -117,17 +125,21 @@ jobs: echo "No AsciiDoc files present." exit 0 fi - CONFIG="$PWD/.gitleaks-estate.toml" + # 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 - CONFIG="$PWD/.gitleaks.toml" + CONFIGS+=("$PWD/.gitleaks.toml") fi - "$RUNNER_TEMP/gitleaks" detect \ - --source "$MIRROR" \ - --no-git \ - --redact \ - --no-banner \ - --config "$CONFIG" \ - --exit-code 1 + for CONFIG in "${CONFIGS[@]}"; do + "$RUNNER_TEMP/gitleaks" detect \ + --source "$MIRROR" \ + --no-git \ + --redact \ + --no-banner \ + --config "$CONFIG" \ + --exit-code 1 + done shell-secrets: name: "scan / shell-secrets" diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 46bc008..1237481 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -1,5 +1,21 @@ == Changelog +== 2026-10-27 — Review fixes for the template CI port + +* `build/just/repo-init.just` is the template's at `8034fc9`, verbatim as + `.machine_readable/mint-verbatim.txt` lists it. The port brought the + template's mint scripts but kept the older generator, which called + `strip-instruction-blocks .` (the new tool takes a file list), ignored the + verbatim manifest and mapped the index instead of the worktree. The + generator's tests still are not ported; they need template-only inputs. +* Secret Scanner always runs the estate gitleaks baseline. A repository + `.gitleaks.toml` adds a second pass. Before, it replaced the baseline, so a + pull request could pass with a weaker local config. +* `scripts/check-required-contexts.sh` also requires a job whose check-run + name is the mapped context, so a renamed job cannot strand pull requests. +* The inlined SPDX step mirrors cicd-suite's gate at its pin, including its + warning when no SPDX line exists. + == 2026-10-27 — CI follows standards to metadatastician/standards * `hyperpolymath/standards` moved to `metadatastician/standards`. A diff --git a/build/just/repo-init.just b/build/just/repo-init.just index 4ca36f7..ad15183 100644 --- a/build/just/repo-init.just +++ b/build/just/repo-init.just @@ -37,6 +37,87 @@ repo-init archetype="": fi } + # The mint edits only what belongs to the repository, so it needs git to + # say what that is (see mint_files below). Checked first: nothing is + # asked, fetched or written for a tree that cannot be minted. + if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "Error: repo-init must run inside a git work tree (run 'git init' first)." + exit 1 + fi + + # --- The template's own identity --------------------------------------- + # repo-init is carried into every minted repo UNCHANGED (it is listed in + # .machine_readable/mint-verbatim.txt: it is the generator, not generated + # content), so it cannot assume it is running in rsr-template-repo. By the + # second generation the deed on disk is _chora.deed, the literal to + # rewrite is , and the provenance parent is /. + # Hardcoding the original template made a mint from a minted repo rename + # nothing, rewrite nothing, and abort at the identity step. Measured on + # 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. + 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)" + 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##*[/:]}" + fi + fi + TEMPLATE_REPO="${TEMPLATE_REPO:-rsr-template-repo}" + TEMPLATE_OWNER="${TEMPLATE_OWNER:-hyperpolymath}" + TEMPLATE_SLUG="${TEMPLATE_OWNER}/${TEMPLATE_REPO}" + + # --- 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` + # would commit), minus the files .machine_readable/mint-verbatim.txt says + # the mint carries unchanged. Every rewrite pass and every completeness + # check below reads this list, and nothing else. + # + # The passes used to `find .` the whole directory. That walked git-ignored + # caches: minting lintel, the instruction-block pass edited the pinned + # sibling checkouts under _build/dependencies/, which broke those pins. It + # also rewrote template machinery whose fixtures must keep the template's + # literal name, so tests/workflows/self_name_pass_test.sh failed in the + # child, and stripped the very fixture blocks the leftover check below + # exempts. "Untracked and not ignored" is included so that a copy which + # was never committed (tests/e2e/template_instantiation_test.sh) still + # mints; in a repository created from the template every file is tracked. + # + # Paths are printed relative and without "./"; symlinks are skipped, as + # `find -type f` skipped them. + 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 + mint_files() { + git ls-files -z --cached --others --exclude-standard \ + | while IFS= read -r -d '' _f; do + [ -f "$_f" ] && [ ! -L "$_f" ] || continue + printf '%s\n' "$_f" + done \ + | grep -vxF -f <(printf '%s\n' "${MINT_VERBATIM[@]}") \ + | sort -u || true + } + echo "═══════════════════════════════════════════════════" echo " RSR Project Bootstrap" echo "═══════════════════════════════════════════════════" @@ -221,6 +302,45 @@ repo-init archetype="": ask "Proceed? [Y/n] " CONFIRM [[ "${CONFIRM:-Y}" =~ ^[Nn] ]] && echo "Aborted." && exit 0 + # 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 + # report each file as an undeclared removal (18 lines on a fresh mint). + MINT_REMOVES=() + for _d in archetypes build/docs-seed build/templates; do + [ -d "$_d" ] && MINT_REMOVES+=("$_d/") + done + + # --- Which template commit is in hand (provenance) --------------------- + # Resolved BEFORE anything is written, so an unresolvable parent stops the + # mint with the tree untouched instead of half-minted. The child's own HEAD + # is never recorded as the template's: in a repository created with "Use + # this template" it is a commit of the child (lintel recorded its own + # claude/mint-lintel tip, 2026-10-14). scripts/resolve-template-pin.sh owns + # the rule and says why; tests/workflows/resolve_template_pin_test.sh + # holds its controls. + # RSR_TEMPLATE_URL read the template's history from here instead + # RSR_TEMPLATE_COMMIT assert the template commit (verified to exist) + # RSR_ALLOW_UNPINNED=1 record UNASSIGNED instead of aborting (offline) + PIN_ARGS=(--slug "$TEMPLATE_SLUG") + [ -z "${RSR_TEMPLATE_URL:-}" ] || PIN_ARGS+=(--url "$RSR_TEMPLATE_URL") + [ -z "${RSR_TEMPLATE_COMMIT:-}" ] || PIN_ARGS+=(--commit "$RSR_TEMPLATE_COMMIT") + if PIN_OUT="$(bash scripts/resolve-template-pin.sh "${PIN_ARGS[@]}")"; then + PARENT_BRANCH="$(sed -n 's/^branch=//p' <<<"$PIN_OUT")" + PARENT_PIN="$(sed -n 's/^commit=//p' <<<"$PIN_OUT")" + PARENT_TREE="$(sed -n 's/^tree=//p' <<<"$PIN_OUT")" + echo "Template: ${TEMPLATE_SLUG}@${PARENT_PIN:0:12} ($(sed -n 's/^source=//p' <<<"$PIN_OUT"))" + elif [ "${RSR_ALLOW_UNPINNED:-0}" = "1" ]; then + PARENT_BRANCH=UNASSIGNED PARENT_PIN=UNASSIGNED PARENT_TREE=UNASSIGNED + echo "WARNING: template commit UNASSIGNED (RSR_ALLOW_UNPINNED=1)." + echo " scripts/check-template-conformance.sh fails until it is filled." + else + echo "ERROR: mint aborted before writing anything — the template commit this" + echo " tree came from cannot be named (reason above). Re-run with" + echo " RSR_ALLOW_UNPINNED=1 only if an unpinned mint is genuinely wanted." + exit 1 + fi + echo "" echo "Replacing placeholders..." @@ -341,18 +461,32 @@ repo-init archetype="": SED_ARGS+=(-e "s|${LB}${tok}${RB}|UNASSIGNED|g") done - # julia-library archetype: derive the package uuid at mint (owner ruling - # 2026-09-19: generate at mint). Same DERIVABLE class as the REPO uuid - # above - computed, never guessed. Namespace = the repo's own derived - # uuid, name = julia:: stable across re-mints of the same repo, - # never collides with the repo uuid. No uuidgen AND no python3 on the - # operator machine -> UNASSIGNED (the honest fallback at :437); Pkg - # then refuses the package loudly until the owner assigns one. + # julia-library archetype: mint the package uuid as UUIDv8 profile C + # (ADR-008 amendment: the strict UUIDv8 gate rejects the UUIDv5 mint, + # and D320 requires unregistered packages at v8). Profile C is + # DETERMINISTIC: bytes 0..15 of SHA-256("julia-package-identity" ":" + # "github.com//"), version nibble 8, variant 10 -- stable + # across re-mints of the same repo, derivable by any operator machine + # with python3 (or bun over standards' scripts/uuid-v8.js), never + # colliding with the repo uuid. The domain is REGISTERED in standards' + # ADR-008-profile-c-domains.json. No python3 and no bun -> UNASSIGNED + # (the honest fallback at :437); Pkg then refuses the package loudly + # until the owner assigns one. if [ "$ARCHETYPE" = "julia-library" ]; then - if command -v uuidgen >/dev/null 2>&1; then - PACKAGE_UUID=$(uuidgen --sha1 --namespace "${REPO_UUID}" --name "julia:${REPO}") + # 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 + 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 "import uuid; print(uuid.uuid5(uuid.UUID('${REPO_UUID}'), 'julia:${REPO}'))") + PACKAGE_UUID=$(python3 -c ' + import hashlib, sys + pre = ("julia-package-identity:github.com/%s/%s" % (sys.argv[1], sys.argv[2])).encode() + b = bytearray(hashlib.sha256(pre).digest()[:16]) + b[6] = (b[6] & 0x0F) | 0x80 # version 8 + b[8] = (b[8] & 0x3F) | 0x80 # variant 10 + h = b.hex() + print(f"{h[0:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:32]}")' "${OWNER_LC}" "${REPO}") else PACKAGE_UUID=UNASSIGNED fi @@ -406,22 +540,33 @@ repo-init archetype="": # "resource-algebra"` (docs/man/resource_algebra.1, an image tagged with # the underscored name). The self-name pass below still rewrites the # template literal in these files; that is the edit they actually need. - find . -type f \ - -not -path './.git/*' \ - -not -name 'MPL-2.0.txt' \ - -not -name 'Justfile' -not -name '*.just' \ - -not -name '*.png' -not -name '*.jpg' -not -name '*.gif' \ - -not -name '*.woff' -not -name '*.woff2' \ - | while read -r file; do - if file --brief "$file" | grep -qi 'text\|ascii\|utf'; then + # Return success when $1 is classified as text, using a byte-content probe + # (which accepts empty files) when the file command is unavailable. + is_text_file() { + if command -v file >/dev/null 2>&1; then + file --brief "$1" | grep -qi 'text\|ascii\|utf' + else + [ ! -s "$1" ] || LC_ALL=C grep -Iq . "$1" + fi + } + + mint_files | while IFS= read -r file; do + case "${file##*/}" in + MPL-2.0.txt|Justfile|*.just|*.png|*.jpg|*.gif|*.woff|*.woff2) continue ;; + esac + if is_text_file "$file"; then sed -i "${SED_ARGS[@]}" "$file" fi done - # Also replace [YOUR-REPO-NAME] and [YOUR-NAME/ORG] in the repo deed - # (the family-7 allocation manifest folded into it — standards#837 pilot). - sed -i "s|\[YOUR-REPO-NAME\]|${PROJECT_NAME}|g" rsr-template-repo_chora.deed 2>/dev/null || true - sed -i "s|\[YOUR-NAME/ORG\]|${OWNER}|g" rsr-template-repo_chora.deed 2>/dev/null || true + # [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 + # a repository minted from a minted one it is _chora.deed, and the + # 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" # --- Instruction-block pass -------------------------------------------- # Delete the "TEMPLATE INSTRUCTIONS (delete this block before publishing)" @@ -439,7 +584,7 @@ repo-init archetype="": if [ "$REPO" != "${REPO%-template-repo}" ]; then echo " instruction blocks: skipped (this IS a template repo)" else - bash scripts/rust-tool.sh strip-instruction-blocks . + mint_files | bash scripts/rust-tool.sh strip-instruction-blocks - fi # --- Dependabot ecosystem pass ----------------------------------------- @@ -483,61 +628,78 @@ repo-init archetype="": # 2026-10-14 on resource-algebra) - the same "detector eaten by the # filling" defect the :reject-if-matches comment in the deed records for # tokens. - if [ "$REPO" != "rsr-template-repo" ]; then + # + # An issue or PR reference `rsr-template-repo#N` names the TEMPLATE's own + # issue, and stays so. Rewritten it pointed at an issue of the child that + # does not exist, or worse, an unrelated one: resource-algebra carried 20 + # such references (`resource-algebra#209` for the deed-clause migration) + # after its mint on 2026-10-14. tests/workflows/self_name_pass_test.sh + # runs these two lines on fixtures. + if [ "$REPO" != "$TEMPLATE_REPO" ]; then SELF_PROV='instantiated-from|parent|upstream|chain =|lineage|minted from|created from|Template: |reject-if-contains' - find . -type f \ - -not -path './.git/*' \ - -not -path './build/just/repo-init.just' \ - -not -path './docs/decisions/*' \ - -not -name 'CHANGELOG.md' \ - -not -name 'TEMPLATE-STANDARDS-AUDIT.adoc' \ - -not -name 'TEMPLATE-LINEAGE-AUDIT.adoc' \ - -not -name '*.png' -not -name '*.jpg' -not -name '*.gif' \ - -not -name '*.woff' -not -name '*.woff2' \ - | while read -r file; do - grep -q 'rsr-template-repo' "$file" 2>/dev/null || continue - file --brief "$file" | grep -qi 'text\|ascii\|utf' || continue + # 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. + 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;}" + # 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 + # at all; it is listed in .machine_readable/mint-verbatim.txt. + mint_files | while IFS= read -r file; do + case "$file" in + docs/decisions/*|CHANGELOG.md|*/CHANGELOG.md) continue ;; + TEMPLATE-STANDARDS-AUDIT.adoc|*/TEMPLATE-STANDARDS-AUDIT.adoc) continue ;; + TEMPLATE-LINEAGE-AUDIT.adoc|*/TEMPLATE-LINEAGE-AUDIT.adoc) continue ;; + *.png|*.jpg|*.gif|*.woff|*.woff2) continue ;; + esac + grep -qF -- "$TEMPLATE_REPO" "$file" 2>/dev/null || continue + is_text_file "$file" || continue # sed -i preserves the mode bit; rewriting via a temp file would not. - sed -i -E "/${SELF_PROV}/!{s|hyperpolymath/rsr-template-repo|${OWNER}/${REPO}|g;s|rsr-template-repo|${REPO}|g;}" "$file" + sed -i -E "$SELF_NAME_SED" "$file" done - echo " self-name: rewrote template literal -> ${REPO} (provenance lines preserved)" + echo " self-name: rewrote ${TEMPLATE_REPO} -> ${REPO} (provenance lines preserved)" # The repo deed's FILENAME carries the repo slug (deed dispatch: # _chora.deed — deed.abnf v1.0.0). The self-name pass rewrote # the slug inside the file; the file itself is renamed here. Plain mv: # mint runs before the new repo's first commit. Renames (not tokens) - # are how filenames change — a {{REPO}} token in a FILENAME is the + # are how filenames change — a REPO token in a FILENAME is the # brace-collapse hazard class repo-init has already been burnt by. - if [ -f rsr-template-repo_chora.deed ]; then - mv rsr-template-repo_chora.deed "${REPO}_chora.deed" - echo " repo deed: renamed rsr-template-repo_chora.deed -> ${REPO}_chora.deed" + # The deed was found by shape above (DEED_IN), never by the original + # template's name, so a mint from a minted repo renames it too. + if [ "${DEED_IN#./}" != "${REPO}_chora.deed" ]; then + mv -- "$DEED_IN" "${REPO}_chora.deed" + echo " repo deed: renamed ${DEED_IN#./} -> ${REPO}_chora.deed" fi + fi - # ── self-OWNER pass ────────────────────────────────────────────────── - # - # Same class as the self-name leak above, and missed by it: the - # template's own OWNER is a plain literal, so nothing rewrote it. A repo - # minted under a different owner shipped: - # - # Justfile OWNER := "hyperpolymath" - # sonar-project.properties sonar.projectKey=hyperpolymath_ - # - # i.e. it declared the wrong owner and reported its code analysis into - # the wrong SonarCloud project. Measured on a metadatastician mint. - # - # This pass is deliberately NARROW — three exact, identity-bearing - # lines. A blanket `hyperpolymath` -> ${OWNER} rewrite would be WRONG: - # `hyperpolymath/standards`, `hyperpolymath/proven` and the other estate - # dependencies are real repos every child genuinely points at, and - # rewriting those would break the child's canon, CI and badges. - if [ "$OWNER" != "hyperpolymath" ]; then - sed -i -E 's|^(OWNER[[:space:]]*:=[[:space:]]*)"hyperpolymath"|\1"'"${OWNER}"'"|' Justfile - sed -i -E 's|^sonar\.organization=hyperpolymath$|sonar.organization='"${OWNER}"'|' sonar-project.properties - sed -i -E 's|^sonar\.projectKey=hyperpolymath_|sonar.projectKey='"${OWNER}"'_|' sonar-project.properties - echo " self-owner: rewrote owner literal -> ${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." - fi + # ── self-OWNER pass ────────────────────────────────────────────────────── + # + # Same class as the self-name leak above, and missed by it: the template's + # own OWNER is a plain literal, so nothing rewrote it. A repo minted under a + # different owner shipped: + # + # Justfile OWNER := "hyperpolymath" + # sonar-project.properties sonar.projectKey=hyperpolymath_ + # + # i.e. it declared the wrong owner and reported its code analysis into the + # wrong SonarCloud project. Measured on a metadatastician mint. The owner + # compared against is the TEMPLATE's (derived above), so a repo minted from + # a metadatastician child is handled the same way. + # + # This pass is deliberately NARROW — three exact, identity-bearing lines. A + # blanket owner rewrite would be WRONG: `hyperpolymath/standards`, + # `hyperpolymath/proven` and the other estate dependencies are real repos + # 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 + 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." fi echo "" @@ -562,8 +724,10 @@ repo-init archetype="": # DERIVABLE -> computed, never guessed (uuid name, owner, canonical-name) # JUDGEMENT -> left UNASSIGNED, fails loudly until a human chooses (clade) ID_IN="build/templates/identity.deed.in" - DEED="${REPO}_chora.deed" - [ -f "$DEED" ] || DEED="rsr-template-repo_chora.deed" + # Whatever one deed is present (renamed for the child above, or already + # named for it): never a hardcoded template file name. + DEED="$(bash scripts/deed-field.sh --find .)" \ + || { echo "Error: the tree must carry exactly one *_chora.deed at its root."; exit 1; } if [ -f "$ID_IN" ] && [ -f "$DEED" ]; then if [ "$(grep -c '^ ; >>> repo-identity' "$DEED")" != 1 ] \ || [ "$(grep -c '^ ; <<< repo-identity' "$DEED")" != 1 ]; then @@ -695,30 +859,15 @@ repo-init archetype="": # Provenance — the estate's answer-file (ADR-0002 contract shape, used # per ADR-0003 with direction=generated-from-core). # - # repo-init runs INSIDE the template checkout, so the LOCAL git state is - # the truth about what was actually copied. Prefer it. The previous - # implementation read the LIVE remote tip (`git ls-remote ... HEAD`), - # which records where the remote is *now* rather than the tree in hand: - # mint from a branch, or from a checkout with unpushed commits, and - # PROVENANCE named a commit the child never contained. That defeated the - # answer-file's whole purpose. Remote lookup remains only as a fallback, - # and the symref form also recovers the source BRANCH, which was never - # recorded at all (#203). - PARENT_SLUG="hyperpolymath/rsr-template-repo" - PARENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true) - PARENT_PIN=$(git rev-parse --verify -q HEAD 2>/dev/null || true) - PARENT_TREE=$(git rev-parse --verify -q 'HEAD^{tree}' 2>/dev/null || true) - - if [ -z "$PARENT_PIN" ]; then - # Not a checkout (tarball, shallow copy, or offline): fall back to the - # remote, and recover the branch from the symref. - PARENT_BRANCH=$(git ls-remote --symref "https://github.com/${PARENT_SLUG}.git" HEAD 2>/dev/null \ - | sed -n 's|^ref: refs/heads/\([^[:space:]]*\).*|\1|p' | head -1 || true) - PARENT_PIN=$(git ls-remote "https://github.com/${PARENT_SLUG}.git" HEAD 2>/dev/null | cut -f1 || true) - fi - - # Shape, not presence: a value that is not a 40-hex object name (an - # ls-remote error body, a literal "HEAD" from an unborn branch) is not a pin. + # PARENT_BRANCH / PARENT_PIN / PARENT_TREE were resolved before the first + # write (see "Which template commit is in hand" above). Reading the live + # remote tip recorded where the remote is NOW, not the tree in hand (#203); + # reading local HEAD recorded the CHILD's own commit whenever the repo was + # created with "Use this template" (lintel, 2026-10-14). Tree equality + # against the template's history is the rule that is right in both cases. + PARENT_SLUG="$TEMPLATE_SLUG" + + # Shape, not presence: a value that is not a 40-hex object name is not a pin. [[ "$PARENT_PIN" =~ ^[0-9a-f]{40}$ ]] || PARENT_PIN="" [[ "$PARENT_TREE" =~ ^[0-9a-f]{40}$ ]] || PARENT_TREE="" [ "$PARENT_BRANCH" != "HEAD" ] || PARENT_BRANCH="" @@ -738,12 +887,13 @@ repo-init archetype="": { echo " ; variant — provenance contract (ADR-0002 shape; ADR-0003 use)." - echo " ; Records what this repo was minted from so the drift gate" - echo " ; (scripts/check-variant-drift.sh ) and future" - echo " ; re-templating tooling have a defined input. Placeholder rendering" - echo " ; means many files differ from the parent by design; folding rendered" - echo " ; answers into (normalise) is future work (ADR-0003), so the gate is" - echo " ; informational for minted repos until then." + 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 " (variant" echo " :project \"${REPO}\"" echo " :schema-version \"0.1.0\"" @@ -758,7 +908,11 @@ repo-init archetype="": echo " ; Files a minted repo owns from birth are (paths :diverged …)." echo " (paths" echo " :added ()" - echo " :removed ()" + _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\")))" } | put_clause variant if [ "$PARENT_PIN" = "UNASSIGNED" ]; then @@ -852,6 +1006,28 @@ repo-init archetype="": done fi + # ── www/ site-operations bundle (issue #53, stage 5) ────────────────── + # + # A repository minted from this template already carries www/.well-known/ + # as the canonical location and has no root .well-known/, so in the + # ordinary case the migration below does nothing at all. It exists for the + # case that is not ordinary: repo-init run over a tree that predates #53 + # and still has a root .well-known/. The migrator moves it — quarantining + # anything divergent rather than overwriting it — and the bundle's own + # suite (under Validation) then proves the result. It runs HERE, before the + # map and the arrival pack are regenerated, because it moves files: the map + # must be the last thing written, or it describes a tree that no longer + # exists (lintel shipped a stale map and Estate Rules failed until lintel#8). + # + # #106 described this wiring as already present. It was not: nothing + # invoked the migrator or the suite at mint, which is why the estate + # still carries root .well-known/ directories years into the convention. + if [ -d .well-known ] && [ -f scripts/migrate-wellknown-to-www.sh ]; then + echo "" + echo "Migrating legacy root .well-known/ into www/.well-known/..." + bash scripts/migrate-wellknown-to-www.sh + 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 @@ -865,12 +1041,19 @@ repo-init archetype="": # above is written and every template-only path is gone. Fail-closed: a # generator that cannot run (no nickel on PATH) stops the mint rather than # shipping a pack that names the wrong repository. + # + # This is the LAST step that writes the tree; everything after it only + # checks (tests/e2e/use_template_mint_test.sh commits the result and runs + # the freshness check on it). The map is read from the work tree + # (--worktree), not the index: the mint runs before the first commit, so + # the index still lists archetypes/ and the template's deed name, which is + # how lintel shipped a map that failed Estate Rules from birth. if [ -f .machine_readable/arrival-pack/generate.sh ]; then bash .machine_readable/arrival-pack/generate.sh echo " arrival pack: CLAUDE.md regenerated from ${DEED_OUT}" fi if [ -f scripts/gen-repo-map.sh ]; then - bash scripts/gen-repo-map.sh . >/dev/null + bash scripts/gen-repo-map.sh --worktree . >/dev/null echo " repo map: docs/architecture/REPOSITORY-MAP.adoc regenerated" fi @@ -907,17 +1090,24 @@ repo-init archetype="": # gate sensibly exempting metasyntax must let the block through. The two # checks here are therefore complementary, not redundant — the block can # only be caught by name, which the check below does. - # The four allowlisted paths are copied verbatim from - # scripts/check-no-placeholders.sh so the two gates cannot drift apart. - # Each legitimately names tokens: the token vocabulary itself, prose - # explaining that tokens exist, the checker's own pattern, and the e2e - # test's answer list. + # The allowlisted paths are copied from scripts/check-no-placeholders.sh so + # the two gates cannot drift apart. Each legitimately names tokens: the + # token vocabulary itself, prose explaining that tokens exist, and the + # checker's own pattern. Template machinery that names tokens in its + # fixtures (the e2e answer list, the Julia UUID test) is in + # .machine_readable/mint-verbatim.txt, so mint_files never lists it, and + # the checker reads the same manifest. PATTERN="${LB}[A-Z_]*${RB}" META='PLACEHOLDER|ANYTHING|TOKEN|UPPER_SNAKE' - REMAINING=$(grep -rl "$PATTERN" . --include='*.md' --include='*.adoc' --include='*.yml' --include='*.yaml' --include='*.deed' --include='*.toml' --include='*.scm' --include='*.ncl' --include='*.nix' --include='*.json' --include='*.sh' 2>/dev/null | grep -v '.git/' | while IFS= read -r f; do - case "${f#./}" in - .machine_readable/ai/PLACEHOLDERS.adoc|EXPLAINME.adoc|scripts/check-no-placeholders.sh|tests/e2e/template_instantiation_test.sh) continue ;; + REMAINING=$(mint_files | while IFS= read -r f; do + case "$f" in + *.md|*.adoc|*.yml|*.yaml|*.deed|*.toml|*.scm|*.ncl|*.nix|*.json|*.sh) ;; + *) continue ;; esac + case "$f" in + .machine_readable/ai/PLACEHOLDERS.adoc|EXPLAINME.adoc|scripts/check-no-placeholders.sh) continue ;; + esac + grep -q "$PATTERN" "$f" 2>/dev/null || continue # Bracketed [{]{2} rather than a doubled brace written literally, for # two independent reasons. First, `grep -E` is ugrep on some estate # machines and rejects a bare doubled brace as an "empty @@ -951,15 +1141,17 @@ repo-init archetype="": # token, and once its literal metasyntax is the only doubled-brace text left # the check above cannot distinguish the two. Fail on it by name. # - # The strip pass, this recipe and the e2e test are excluded because they - # necessarily NAME the marker in order to describe or assert on it — the fix - # for the defect would otherwise be flagged by the check for the defect, and - # so would the test that guards the fix, and the cleanup test whose - # fixture prose must keep the phrase to prove prose is not collateral. - # All four are tooling, not + # Read over mint_files, so the files that necessarily NAME the marker in + # order to describe or assert on it (the strip tool, this recipe, the e2e + # test, the cleanup test whose fixture must keep the phrase) are exempt by + # the same manifest that kept the strip pass out of them: they are listed + # in .machine_readable/mint-verbatim.txt. Before, this list exempted them + # here while the strip pass rewrote them anyway. All are tooling, not # community-health documents, so a mention in them cannot mislead a reader # about what this project's Code of Conduct says. - LEFTOVER_BLOCKS=$(grep -rl 'TEMPLATE INSTRUCTIONS' . --exclude-dir=.git 2>/dev/null | grep -vE '^\./(scripts/strip-instruction-blocks\.rs|build/just/repo-init\.just|tests/e2e/template_instantiation_test\.sh|tests/workflows/mint_cleanup_test\.sh)$' || true) + LEFTOVER_BLOCKS=$(mint_files | while IFS= read -r f; do + grep -q 'TEMPLATE INSTRUCTIONS' "$f" 2>/dev/null && echo "$f" + done || true) if [ -n "$LEFTOVER_BLOCKS" ]; then echo "" echo "Un-deleted TEMPLATE INSTRUCTIONS block in:" @@ -968,25 +1160,6 @@ repo-init archetype="": exit 1 fi - # ── www/ site-operations bundle (issue #53, stage 5) ────────────────── - # - # A repository minted from this template already carries www/.well-known/ - # as the canonical location and has no root .well-known/, so in the - # ordinary case both steps below do nothing at all. They exist for the - # case that is not ordinary: repo-init run over a tree that predates #53 - # and still has a root .well-known/. The migrator moves it — quarantining - # anything divergent rather than overwriting it — and the bundle's own - # suite then proves the result. - # - # #106 described this wiring as already present. It was not: nothing - # invoked the migrator or the suite at mint, which is why the estate - # still carries root .well-known/ directories years into the convention. - if [ -d .well-known ] && [ -f scripts/migrate-wellknown-to-www.sh ]; then - echo "" - echo "Migrating legacy root .well-known/ into www/.well-known/..." - bash scripts/migrate-wellknown-to-www.sh - fi - # Fail-closed, like `just verify` below: the suite tolerates unminted # double-brace placeholder tokens by design (it runs in the template # itself), no archetype overlay touches www/, and the substituted Expires @@ -1033,5 +1206,8 @@ 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 — arm the drift gate against your recorded parent-pin:" - echo " scripts/check-variant-drift.sh " + 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)." diff --git a/scripts/check-required-contexts.sh b/scripts/check-required-contexts.sh index 235f28b..e74e26c 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. @@ -123,6 +124,14 @@ 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). Without a + // job of exactly that name the context is never produced, however the + // workflow is triggered. + const jobs = doc?.jobs && typeof doc.jobs === "object" ? doc.jobs : {}; + const checkNames = Object.entries(jobs).map(([id, job]) => String(job?.name ?? id)); + if (!checkNames.includes(ctx)) { + fail(`required context '${ctx}' producer '${relWf}' has no job named '${ctx}' (jobs: ${checkNames.join(", ") || "none"})`); + } } // 4. Enforce squabble != bypass across all workflows: no workflow may call diff --git a/tests/workflows/squabble_gate_test.sh b/tests/workflows/squabble_gate_test.sh index ec8625b..203f7e9 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,17 @@ 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_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