diff --git a/.editorconfig b/.editorconfig index b042ff9b..e570f658 100644 --- a/.editorconfig +++ b/.editorconfig @@ -11,6 +11,8 @@ insert_final_newline = true trim_trailing_whitespace = true [*.md] trim_trailing_whitespace = false +[*.{js,mjs,cjs}] +indent_size = 2 [*.adoc] [*.rs] indent_size = 4 diff --git a/.gitattributes b/.gitattributes index 87ba82f9..6615ff3a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,6 +7,8 @@ *.ex text eol=lf diff=elixir *.exs text eol=lf diff=elixir *.jl text eol=lf +*.mjs text eol=lf +*.js text eol=lf *.res text eol=lf *.resi text eol=lf *.ada text eol=lf diff=ada diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 66acc03c..a5f6d5a6 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -14,6 +14,16 @@ updates: patterns: - "*" open-pull-requests-limit: 2 + # Ruby is a banned language for this estate, so Ruby tooling is not a + # dependency we maintain — the correct response to a `ruby/setup-ruby` bump + # is to delete the action, not to bump it. Ignoring these two keeps + # Dependabot from spending a slot (open-pull-requests-limit: 2) on a PR that + # policy requires closing, in this repo and in any repo still carrying the + # retired Jekyll Pages workflow while the estate-wide exit completes. + # See docs/decisions/ADR-0002-ruby-banned-except-adapters.adoc. + ignore: + - dependency-name: "ruby/setup-ruby" + - dependency-name: "actions/jekyll-build-pages" # Rust/Cargo - package-ecosystem: "cargo" diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 632768ff..223a8d85 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -22,21 +22,14 @@ workflows: - 'actions/checkout@v7.0.1' '.github/workflows/instant-sync.yml': - 'peter-evans/repository-dispatch@v4.0.1' - '.github/workflows/jekyll-gh-pages.yml': - - 'actions/checkout@v7.0.1' - - 'actions/configure-pages@v6.0.0' - - 'actions/deploy-pages@v5.0.1' - - 'actions/jekyll-build-pages@v1.0.13' - - 'actions/upload-pages-artifact@v5.0.0' - '.github/workflows/jekyll.yml': + '.github/workflows/label-triage.yml': [] + '.github/workflows/labels.yml': [] + '.github/workflows/mirror.yml': [] + '.github/workflows/pages.yml': - 'actions/checkout@v7.0.1' - 'actions/configure-pages@v6.0.0' - 'actions/deploy-pages@v5.0.1' - 'actions/upload-pages-artifact@v5.0.0' - - 'ruby/setup-ruby@v1.321.0' - '.github/workflows/label-triage.yml': [] - '.github/workflows/labels.yml': [] - '.github/workflows/mirror.yml': [] '.github/workflows/push-email-notify.yml': - 'hyperpolymath/smtp-notify-action@v0.3.0' '.github/workflows/quality.yml': @@ -72,11 +65,6 @@ dependencies: commit: 'sha1-368f82528645a54fb793d4d04e342629a3f51346' owner_id: 44036562 repo_id: 438112499 - 'actions/jekyll-build-pages@v1.0.13': - ref: 'v1.0.13' - commit: 'sha1-44a6e6beabd48582f863aeeb6cb2151cc1716697' - owner_id: 44036562 - repo_id: 438323626 'actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f': ref: 'v7.0.0' commit: 'sha1-bbbca2ddaa5d8feaa63e36b76fdaad77386f024f' @@ -124,8 +112,3 @@ dependencies: commit: 'sha1-28959ce8df70de7be546dd1250a005dd32156697' owner_id: 18365890 repo_id: 220359305 - 'ruby/setup-ruby@v1.321.0': - ref: 'v1.321.0' - commit: 'sha1-95ef2b042f9d7a56d8268cba8559e2842e2ad01b' - owner_id: 210414 - repo_id: 231208785 diff --git a/.github/workflows/jekyll-gh-pages.yml b/.github/workflows/jekyll-gh-pages.yml deleted file mode 100644 index 8b84650a..00000000 --- a/.github/workflows/jekyll-gh-pages.yml +++ /dev/null @@ -1,95 +0,0 @@ -# This workflow is managed by gh actions-lock. -# SPDX-License-Identifier: MPL-2.0 -# This workflow is managed by gh actions-lock. -# This workflow is managed by gh actions-lock. -# Sample workflow for building and deploying a Jekyll site to GitHub Pages -name: Deploy Jekyll with GitHub Pages dependencies preinstalled - -on: - # Runs on pushes targeting the default branch - push: - branches: ["main"] - - # Allows you to run this workflow manually from the Actions tab - workflow_dispatch: - -# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages -permissions: - contents: read - pages: write - id-token: write - -# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. -# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. -concurrency: - group: "pages" - cancel-in-progress: false - -jobs: - # Build job - build: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v7.0.1 - - name: Setup Pages - uses: actions/configure-pages@v6.0.0 - - name: Build with Jekyll - uses: actions/jekyll-build-pages@v1.0.13 - with: - source: ./ - destination: ./_site - - name: Overlay the canonical www bundle - shell: bash - run: | - set -euo pipefail - # actions/jekyll-build-pages runs in a container and writes _site as - # root, so the runner user cannot create directories inside it. - # Without this, the bundle copy below fails with "Permission denied". - if [ -d _site ] && [ ! -w _site ]; then - sudo chown -R "$(id -u):$(id -g)" _site - fi - # Publish the canonical bundle alongside whatever the SSG produced. - # - # www/public/ -> the servable site root - # www/.well-known/ -> must be reachable at /.well-known/ on this - # origin (RFC 9116, and RSR check - # www/.well-known/security.txt) - # - # Everything else under www/ (dns/, policies/, profiles/, runbooks/, - # errors/) is source material and is deliberately NOT published, so - # only these two subtrees are copied rather than all of www/. - # - # Deliberately additive: the generated site is left intact, so this - # cannot regress the existing pages. - if [ -d www/public ]; then - cp -a www/public/. _site/ - fi - if [ -d www/.well-known ]; then - mkdir -p _site/.well-known - cp -a www/.well-known/. _site/.well-known/ - fi - # Jekyll copied the repository tree, so the bundle *source* would be - # served at /www/. The canonical location is the root, above. - rm -rf _site/www - echo "published tree:" - find _site -maxdepth 2 | sort - - name: Upload artifact - uses: actions/upload-pages-artifact@v5.0.0 - with: - # actions/upload-pages-artifact strips dot-entries by default - # (its tar runs --exclude=.[^/]*), which silently removed - # .well-known/ from the artifact. Publish it deliberately. - include-hidden-files: true - - # Deployment job - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - needs: build - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v5.0.1 diff --git a/.github/workflows/jekyll.yml b/.github/workflows/jekyll.yml deleted file mode 100644 index 54b4a018..00000000 --- a/.github/workflows/jekyll.yml +++ /dev/null @@ -1,69 +0,0 @@ -# This workflow is managed by gh actions-lock. -# SPDX-License-Identifier: MPL-2.0 -# This workflow is managed by gh actions-lock. -# This workflow is managed by gh actions-lock. -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. - -# Sample workflow for building and deploying a Jekyll site to GitHub Pages -name: Deploy Jekyll site to Pages - -on: - # Runs on pushes targeting the default branch - push: - branches: ["main"] - - # Allows you to run this workflow manually from the Actions tab - workflow_dispatch: - -# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages -permissions: - contents: read - pages: write - id-token: write - -# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. -# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. -concurrency: - group: "pages" - cancel-in-progress: false - -jobs: - # Build job - build: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v7.0.1 - - name: Setup Ruby - # https://github.com/ruby/setup-ruby/releases/tag/v1.207.0 - uses: ruby/setup-ruby@v1.324.0 - with: - ruby-version: '3.1' # Not needed with a .ruby-version file - bundler-cache: true # runs 'bundle install' and caches installed gems automatically - cache-version: 0 # Increment this number if you need to re-download cached gems - - name: Setup Pages - id: pages - uses: actions/configure-pages@v6.0.0 - - name: Build with Jekyll - # Outputs to the './_site' directory by default - run: bundle exec jekyll build --baseurl "${{ steps.pages.outputs.base_path }}" - env: - JEKYLL_ENV: production - - name: Upload artifact - # Automatically uploads an artifact from the './_site' directory by default - uses: actions/upload-pages-artifact@v5.0.0 - - # Deployment job - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - needs: build - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v5.0.1 diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 00000000..258284bd --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,117 @@ +# SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. +# +# Pages deploy for verisimdb-data — Ruby-free by policy. +# +# Replaces two retired workflows, both of which put Ruby on the critical path: +# * jekyll.yml — ruby/setup-ruby + `bundle exec jekyll build`. It had no +# Gemfile to bundle against and failed with startup_failure +# on every push to main (last observed 2026-09-21). +# * jekyll-gh-pages.yml — actions/jekyll-build-pages, a container whose only job is +# to run Jekyll (Ruby). It published the whole repository tree. +# +# The build is now scripts/build-site.mjs, run under Bun (tier 1 per LANGUAGE-POLICY §1) +# with a Node >= 18 fallback. Zero dependencies, so no lockfile and no package-manager +# tier to reconcile with runtime-policy.yml. Publish surface is the allowlist in +# site.json. Ruby is banned for this estate; see docs/decisions/ADR-0002. +name: Deploy Pages site + +on: + push: + branches: ["main"] + paths: + - "index.json" + - "scans/**" + - "dispatch/**" + - "outcomes/**" + - "patterns/**" + - "recipes/**" + - "health/**" + - "policy/**" + - "docs/**" + - "www/**" + - "README.adoc" + - "LICENSE" + - "site.json" + - "scripts/build-site.mjs" + - ".github/workflows/pages.yml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7.0.1 + + - name: Setup Pages + id: pages + uses: actions/configure-pages@v6.0.0 + + - name: Build site and prove reproducibility + env: + BASE_PATH: ${{ steps.pages.outputs.base_path }} + run: | + set -euo pipefail + if command -v bun >/dev/null 2>&1; then + runtime="bun run" + else + runtime="node" + fi + echo "build runtime: $runtime (Bun tier 1; Node >= 18 is the preinstalled fallback)" + $runtime scripts/build-site.mjs --baseurl "$BASE_PATH" | tee /tmp/build-1.log + rm -rf _site + $runtime scripts/build-site.mjs --baseurl "$BASE_PATH" | tee /tmp/build-2.log + first=$(sed -n 's/.*\(sha256=[0-9a-f]\{64\}\).*/\1/p' /tmp/build-1.log | head -1) + second=$(sed -n 's/.*\(sha256=[0-9a-f]\{64\}\).*/\1/p' /tmp/build-2.log | head -1) + if [ -z "$first" ] || [ "$first" != "$second" ]; then + echo "::error::site build is not reproducible ($first vs $second)" + exit 1 + fi + echo "reproducible site build: $first" + + - name: Guard the machine-readable root + run: | + set -euo pipefail + # '/' on this site is an API endpoint: GitHub Pages serves index.json as the + # directory index, and a root index.html would take that over and change the + # content type consumers see. The human hub lives at /hub/ instead. + if [ -e _site/index.html ]; then + echo "::error::refusing to publish _site/index.html; '/' must serve index.json" + exit 1 + fi + for required in index.json .well-known/security.txt; do + if [ ! -f "_site/$required" ]; then + echo "::error::required published path missing from the build: $required" + exit 1 + fi + done + echo "publish surface OK ($(find _site -type f | wc -l | tr -d ' ') files)" + + - name: Upload artifact + uses: actions/upload-pages-artifact@v5.0.0 + with: + path: _site + # upload-pages-artifact strips dot-entries by default (its tar runs + # --exclude=.[^/]*), which silently removes .well-known/ and .nojekyll. + include-hidden-files: true + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5.0.1 diff --git a/.github/workflows/rsr-antipattern.yml b/.github/workflows/rsr-antipattern.yml index 36e96c58..c54d025f 100644 --- a/.github/workflows/rsr-antipattern.yml +++ b/.github/workflows/rsr-antipattern.yml @@ -5,8 +5,12 @@ # RSR Anti-Pattern CI Check # SPDX-License-Identifier: MPL-2.0 # -# Enforces: No TypeScript, No Go, No Python (except SaltStack), No npm +# Enforces: No Ruby, No TypeScript, No Go, No Python (except SaltStack), No npm # Allows: AffineScript, Deno, WASM, Rust, OCaml, Haskell, Guile/Scheme +# +# Ruby is a banned language for this estate; the sole exception is an adapter +# under a path that declares itself one (bindings|integrations|adapters)/*/ruby/ +# — see docs/decisions/ADR-0002-ruby-banned-except-adapters.adoc. name: RSR Anti-Pattern Check @@ -57,6 +61,77 @@ jobs: fi echo "✅ No non-SaltStack Python files" + - name: Check for Ruby (banned for this estate; adapters only) + run: | + set -euo pipefail + # Ruby is a banned implementation language. The only permitted Ruby is an + # *adapter* that lets a Ruby host application call into estate code, and it + # must declare itself as one by living under bindings/|integrations/|adapters/. + # Exemptions below are policy, not convenience: + # */ruby/, */helpers/ — adapter code, permitted by ADR-0002 + # Formula/, */homebrew/ — Homebrew formulae are a Ruby DSL; the formula is + # packaging metadata, not estate implementation + # vendor/, satellites/, macports-ports/ — mirrors of other projects' trees, + # which this policy does not own + RUBY_SOURCES=$(find . \( -name '*.rb' -o -name '*.rake' -o -name '*.gemspec' \ + -o -name 'Gemfile' -o -name 'Gemfile.lock' -o -name 'Rakefile' \ + -o -name '.ruby-version' \) \ + -not -path './.git/*' -type f 2>/dev/null \ + | grep -Ev '^\./(bindings|integrations|adapters)(/[A-Za-z0-9._-]+)?/(ruby|helpers)/' \ + | grep -Ev '^\./(Formula|Casks)/|/(homebrew|tap)/' \ + | grep -Ev '^\./(vendor|macports-ports)/|/satellites/' \ + || true) + if [ -n "$RUBY_SOURCES" ]; then + echo "::error::Ruby source detected. Ruby is banned for this estate." + echo "$RUBY_SOURCES" + echo "" + echo "Convert it: Bun/Node for tooling and site builds, Julia or Rust for" + echo "computation, Zig for FFI, Elixir for concurrency, AffineScript where a" + echo "face exists. Adapter Ruby is fine only under" + echo "bindings/*/ruby/ or integrations/*/ruby/. See" + echo "docs/decisions/ADR-0002-ruby-banned-except-adapters.adoc." + exit 1 + fi + echo "✅ No Ruby outside permitted adapter paths" + + - name: Check for Ruby on the build path + run: | + set -euo pipefail + # A repo can pass the file audit and still boot Ruby in CI — that is + # exactly the shape this estate's dependabot noise came from: a bump PR + # for ruby/setup-ruby in a repo with nothing Ruby left to build. So the + # pin/invocation surface is audited too, not just *.rb files. + # + # Three deliberate constraints: + # --exclude=rsr-antipattern.yml — this file contains the pattern list, + # so it would match itself (a bug this estate has hit before). + # comment lines are dropped — prose that *describes* the retired Ruby + # tooling is how a migration explains itself; only instructions count. + # patterns are anchored to invocation shapes, not substrings. A loose + # substring match flags `echidna`, whose CI greps for the literal text + # "bundle install failed" in a container build log — it has no Ruby. + # An estate-wide check that cries wolf gets switched off by the next + # person who has to triage it, so precision here is the whole game. + # (the `.?` on the second line is the quote in a lockfile entry, e.g. + # ` - 'ruby/setup-ruby@v1.324.0'` — written as `.` because a + # single quote cannot appear inside this single-quoted shell string.) + RUBY_CI=$(grep -rnIE --exclude=rsr-antipattern.yml \ + -e 'uses:[[:space:]]*(ruby/setup-ruby|actions/jekyll-build-pages)' \ + -e '^[[:space:]]*(-[[:space:]]+)?.?(ruby/setup-ruby|actions/jekyll-build-pages)@' \ + -e '^[[:space:]]*(-[[:space:]]+)?(run:[[:space:]]*)?(sudo[[:space:]]+)?(gem install|bundle exec|bundle install|rake[[:space:]])' \ + .github/workflows .github/actions .gitlab-ci.yml Justfile justfile 2>/dev/null \ + | grep -vE '^[^:]+:[0-9]+:[[:space:]]*#' || true) + if [ -n "$RUBY_CI" ]; then + echo "::error::Ruby on the build path. Ruby is banned for this estate." + echo "$RUBY_CI" + echo "" + echo "Pages/docs builds use scripts/build-site.mjs (Bun tier 1, Node >= 18" + echo "fallback) driven by site.json; see" + echo "docs/decisions/ADR-0002-ruby-banned-except-adapters.adoc." + exit 1 + fi + echo "✅ No Ruby in CI configuration" + - name: Check for npm lockfiles run: | if [ -f "package-lock.json" ] || [ -f "yarn.lock" ]; then @@ -90,5 +165,6 @@ jobs: echo "║ Allowed: AffineScript, Deno, WASM, Rust, OCaml, Haskell, ║" echo "║ Guile/Scheme, SaltStack (Python) ║" echo "║ ║" - echo "║ Blocked: TypeScript, Go, npm, Python (non-Salt) ║" + echo "║ Blocked: Ruby (outside adapters), TypeScript, Go, npm, ║" + echo "║ Python (non-Salt) ║" echo "╚════════════════════════════════════════════════════════════╝" diff --git a/.github/workflows/workflow-linter.yml b/.github/workflows/workflow-linter.yml index 935abe4b..3f0b3064 100644 --- a/.github/workflows/workflow-linter.yml +++ b/.github/workflows/workflow-linter.yml @@ -110,7 +110,6 @@ jobs: has_go=$(find . -name "*.go" -path "*/src/*" -o -path "*/cmd/*" -o -path "*/pkg/*" 2>/dev/null | head -1) has_rs=$(find . -name "*.rs" -path "*/src/*" 2>/dev/null | head -1) has_java=$(find . -name "*.java" -path "*/src/*" 2>/dev/null | head -1) - has_rb=$(find . -name "*.rb" -path "*/lib/*" -o -path "*/app/*" 2>/dev/null | head -1) echo "Detected languages:" [ -n "$has_js" ] && echo " - javascript-typescript" @@ -118,7 +117,12 @@ jobs: [ -n "$has_go" ] && echo " - go" [ -n "$has_rs" ] && echo " - rust (note: CodeQL rust is limited)" [ -n "$has_java" ] && echo " - java-kotlin" - [ -n "$has_rb" ] && echo " - ruby" + # Ruby is deliberately absent from the matrix. It is a banned + # implementation language for this estate, so "add ruby to CodeQL" + # is never the right fix for a finding here — removing the Ruby is. + # rsr-antipattern.yml owns that check; this lane only refuses to + # launder Ruby back in through a security-tooling config. + # See docs/decisions/ADR-0002-ruby-banned-except-adapters.adoc. # Check for over-reach if grep -q "language:.*'go'" .github/workflows/codeql.yml && [ -z "$has_go" ]; then @@ -130,8 +134,12 @@ jobs: if grep -q "language:.*'java'" .github/workflows/codeql.yml && [ -z "$has_java" ]; then echo "WARNING: CodeQL configured for Java but no Java files found" fi - if grep -q "language:.*'ruby'" .github/workflows/codeql.yml && [ -z "$has_rb" ]; then - echo "WARNING: CodeQL configured for Ruby but no Ruby files found" + if grep -q "language:.*'ruby'" .github/workflows/codeql.yml; then + echo "ERROR: CodeQL is configured for Ruby." + echo "Ruby is banned for this estate (ADR-0002). Do not widen analysis to" + echo "cover it — remove the Ruby from the matrix and delete the source, or" + echo "relocate it under bindings/*/ruby/ if it is genuinely an adapter." + exit 1 fi echo "CodeQL check complete" diff --git a/.gitignore b/.gitignore index e9d7f367..98080781 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ Thumbs.db .vscode/ # Build /target/ +# Pages output; built by scripts/build-site.mjs from the allowlist in site.json. +/_site/ /_build/ /build/ /dist/ diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 90ca682b..44ec8837 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -18,6 +18,23 @@ https://semver.org/spec/v2.0.0.html[Semantic Versioning]. ==== Added +* feat(pages): Ruby-free Pages publisher — `scripts/build-site.mjs` + (Bun tier 1, Node >= 18 fallback, zero deps) driven by the publish + allowlist in `site.json`, with a reproducibility digest on every build +* feat(ci): `rsr-antipattern.yml` now fails Ruby source outside a declared + adapter path, and Ruby on the build path — matched as invocations + (`uses: ruby/setup-ruby`, a lockfile pin, `gem install`, `bundle exec`, + `rake`), not as substrings, so prose that merely quotes those words does + not trip it +* feat(just): `site`, `site-check`, `site-verify`, `site-clean` recipes +* chore(deps): dependabot ignores `ruby/setup-ruby` and + `actions/jekyll-build-pages` — bumps to deleted tooling are not + dependencies this estate maintains +* docs: ADR-0002 — Ruby is banned for the estate; adapters are the only + exemption (includes the cross-repo Ruby census) +* recipes: `recipe-ruby-jekyll-pages-to-static`, + `recipe-asciidoctor-gem-to-asciidoctor-core`, + `recipe-ruby-source-to-estate-runtime` * feat(crg): add crg-grade and crg-badge justfile recipes * feat: add self-health VeriSimDB Zig module to close observability loop * feat: migrate generate-summaries from Python to Julia @@ -26,6 +43,17 @@ https://semver.org/spec/v2.0.0.html[Semantic Versioning]. * feat: deploy UX Manifesto infrastructure * feat: initialize verisimdb-data as standalone repo +==== Removed + +* chore(ci)!: delete `jekyll.yml` and `jekyll-gh-pages.yml` (Ruby/Jekyll + Pages builds) and drop `ruby/setup-ruby` + `actions/jekyll-build-pages` + from `actions.lock`. `jekyll.yml` had been failing with `startup_failure` + on every push to `main`; `jekyll-gh-pages.yml` published the entire + repository tree. #130 was auto-merged while this work was in flight and + bumped `jekyll.yml` to `ruby/setup-ruby@v1.324.0` while leaving + `actions.lock` at `v1.321.0` — a workflow out of sync with its own lockfile. + Deleting both sides is what actually settles it. + ==== Fixed * fix(ci): Phase-2 fleet submission must not fail the security gate (#9) diff --git a/Justfile b/Justfile index d98e6537..57903e82 100644 --- a/Justfile +++ b/Justfile @@ -70,3 +70,25 @@ crg-badge: D) color="orange" ;; E) color="red" ;; F) color="critical" ;; \ *) color="lightgrey" ;; esac; \ echo "[![CRG $$grade](https://img.shields.io/badge/CRG-$$grade-$$color?style=flat-square)](https://github.com/hyperpolymath/standards/tree/main/component-readiness-grades)" + +# ── Pages site (Ruby-free: see docs/decisions/ADR-0002) ─────────────────── +# Build the Pages site into _site/. Bun is tier 1; Node >= 18 is the fallback. +site: + @if command -v bun >/dev/null 2>&1; then bun run scripts/build-site.mjs; else node scripts/build-site.mjs; fi + +# Plan the publish surface without writing anything +site-check: + @if command -v bun >/dev/null 2>&1; then bun run scripts/build-site.mjs --dry-run; else node scripts/build-site.mjs --dry-run; fi + +# Prove the build is reproducible: two runs must agree on the digest +site-verify: + @set -eu; \ + build() { if command -v bun >/dev/null 2>&1; then bun run scripts/build-site.mjs; else node scripts/build-site.mjs; fi; }; \ + first=$$(build | grep -o 'sha256=[0-9a-f]*' | head -1); \ + second=$$(build | grep -o 'sha256=[0-9a-f]*' | head -1); \ + if [ -z "$$first" ] || [ "$$first" != "$$second" ]; then echo "NOT reproducible: $$first vs $$second"; exit 1; fi; \ + echo "reproducible: $$first" + +# Drop the generated site +site-clean: + @rm -rf _site && echo "removed _site" diff --git a/README.adoc b/README.adoc index fd15aaef..6e371951 100644 --- a/README.adoc +++ b/README.adoc @@ -34,6 +34,8 @@ This repository serves *two explicit purposes*, per | `health/` | Health-state snapshots | data | `index.json` | Master index of stored data | data | `ffi/zig/` | Zig FFI implementation | ABI dogfood +| `scripts/` | Ingest, index regen, and `build-site.mjs` | both +| `site.json` | Pages publish allowlist (no Ruby, no Jekyll) | both | `.machine_readable/` | Contractile machinery (org-wide) | both | `.github/workflows/` | CI (ingest, mirrors, governance) | both | `docs/decisions/` | ADRs | both @@ -106,6 +108,23 @@ Each scan result in `scans/` is a JSON file with the structure: committed index must match what the regen script produces from the files on disk). See `scripts/regen-index.sh`. +=== Pages publishing + +`https://hyperpolymath.github.io/verisimdb-data/` serves `index.json` at `/` +plus the trees allowlisted in `site.json`. The builder is +`scripts/build-site.mjs` — Bun (tier 1) with a Node >= 18 fallback, zero +dependencies. There is no Jekyll on this path: Ruby is a banned language for +this estate, so the retired `ruby/setup-ruby` / `actions/jekyll-build-pages` +workflows were replaced rather than re-pinned. See +`docs/decisions/ADR-0002-ruby-banned-except-adapters.adoc`. + +[source,bash] +---- +just site-check # plan the publish surface +just site # build _site/ +just site-verify # two builds, one digest (reproducible) +---- + == Purpose 2 — ABI dogfood `ffi/zig/` is a working Zig FFI implementation of the hyperpolymath diff --git a/docs/decisions/ADR-0002-ruby-banned-except-adapters.adoc b/docs/decisions/ADR-0002-ruby-banned-except-adapters.adoc new file mode 100644 index 00000000..a7d22da4 --- /dev/null +++ b/docs/decisions/ADR-0002-ruby-banned-except-adapters.adoc @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += ADR-0002: Ruby exits the estate; only adapters may keep it +:revdate: 2026-09-21 +:status: Proposed + +== Status + +Proposed — 2026-09-21. Implemented here in `verisimdb-data`; filed as the +estate-wide rule for the remaining repos listed in <>. + +== Context + +Ruby arrived in this estate as boilerplate, not as a decision. GitHub's +"Deploy Jekyll site to Pages" sample workflow was copied into repos that +have no Jekyll site, and it dragged a toolchain in with it: `Gemfile`less +`bundle exec`, the `actions/jekyll-build-pages` container (whose only +contents are Ruby + Jekyll), and in `hypatia` five `gem install asciidoctor*` +invocations standing between a push and `_site/docs`. + +Three things about that state are worth writing down: + +. *It is already broken.* `verisimdb-data`'s `jekyll.yml` failed with + `startup_failure` on every push to `main` (last observed 2026-09-21), + because `bundle exec` has no `Gemfile` to resolve. The sibling + `jekyll-gh-pages.yml` "succeeded" by mirroring the entire repository tree + into `_site` — including `scans/`, `dispatch/` and `outcomes/` — and + publishing it on `hyperpolymath.github.io`. A build that both fails and + over-publishes is not infrastructure anybody chose. +. *Its maintenance noise is not free.* Dependabot PR #130 + (`ruby/setup-ruby` 1.321.0 → 1.324.0, actions group) was auto-merged on + 2026-09-21. It edited `jekyll.yml` and nothing else: `actions.lock` still + pinned `v1.321.0` afterwards, so the repo was left with a workflow that + disagrees with its own lockfile — on a lane that does not run. The bump was + approved by policy and still produced only drift. +. *Ruby is not this estate's language.* The estate runs Julia, Rust, Zig, + Elixir, Nickel, Guile/Scheme, ReScript and AffineScript. The scan corpus + indexed in `scans/` — 536 repo snapshots — reports *zero* repos whose + primary language is Ruby. Ruby here has always been a means of reaching a + gem, never a place where logic lives. + +== Decision + +Ruby is a banned implementation and build language. Ruby source is replaced, +not re-pinned. Enforcement lives in `rsr-antipattern.yml`, which now fails on +Ruby files *and* on Ruby on the build path (`ruby/setup-ruby`, +`jekyll-build-pages`, `bundle exec|install`, `gem install`, `rake`), so a repo +cannot pass a file audit while still booting a Ruby VM in CI. + +Conversion targets, chosen by role rather than by taste: + +[cols="1,2,2",options="header"] +|=== +| Role | Target | Why this one + +| Pages / static asset publishing +| Bun (`scripts/build-site.mjs`), Node >= 18 fallback +| Bun is tier 1 per LANGUAGE-POLICY §1; zero dependencies, so no lockfile and + no package-manager tier for `runtime-policy.yml` to police. + +| AsciiDoc → HTML rendering +| `@asciidoctor/core` under Bun/Node (deferred), or publish source +| It is the same upstream Asciidoctor engine, ported; the Ruby gem and the JS + package are the same project. `verisimdb-data` publishes `.adoc` as source + because it has no docs site to build. + +| Numeric / data analysis +| Julia +| Already the estate default here: `scripts/generate-summaries.jl` replaced + its Python equivalent, and `stapeln.toml` installs `julia` in the image. + +| FFI, ABI, low-level +| Zig +| Existing estate standard (`ffi/zig/`, shared with `proven`, `burble`, + `gossamer`). + +| Long-lived services, concurrency +| Elixir +| 32 estate repos already primary-Elixir; `mix` is a first-class dependabot + ecosystem here. + +| Safety-critical tooling, proofs +| Rust +| 110 estate repos; `recipes/recipe-panic-to-result.json` already encodes the + panics discipline. + +| Surface-syntax experiments +| AffineScript +| Estate-owned, but v0.2 and self-described as "not yet for production" — + correct for language work, not for a deploy pipeline. +|=== + +[[exemptions]] +== Exemptions + +Ruby survives only where it is *not* the language the estate is written in, +but a socket into somebody else's runtime: + +. *Adapters.* Paths shaped `bindings/*/ruby/`, `integrations/*/ruby/`, + `adapters/*/ruby/`, and Ruby template helpers under `bindings/*/…/helpers/`. + This covers the Ruby bindings in `proven` and `proven-servers`, which the + policy names explicitly, and the SSG family, where a Ruby adapter is a + supported host integration. +. *Vendored mirrors.* `vendor/`, `macports-ports/`, and `*/satellites/` trees: + this policy does not own other projects' file layouts, and rewriting a + mirror breaks provenance for no gain. +. *Homebrew formulae* (`Formula/`, `packaging/homebrew/`, + `release/homebrew/`). A formula is a Ruby DSL — the format admits no other + language — so this is packaging metadata, not implementation. The estate's + real answer for these is Guix (`guix.scm` is already primary in + `guix-nix-policy.yml`); "delete the formulae, ship Guix packages" is a + separate, larger change and is *not* what this ADR authorises. + +== Consequences + +In this repo: + +* `jekyll.yml` and `jekyll-gh-pages.yml` are deleted; `.github/workflows/pages.yml` + replaces both, and `actions.lock` loses `ruby/setup-ruby` and + `actions/jekyll-build-pages`. +* The publish surface becomes an allowlist (`site.json`) rather than "whatever + the SSG walked". URL compatibility was checked against the live deployment + before the change, not after: `index.json`, `scans/**`, `dispatch/**`, + `outcomes/**`, `patterns/**`, `recipes/**`, `health/**`, `policy/**`, + `docs/**`, `README.adoc` and `.well-known/**` all served 200 and still do. + `Justfile`, `setup.sh`, `guix.scm`, `mise.toml`, `stapeln.toml`, + `repo-paths.json`, `examples/**` and `ffi/**` stop being served; the git tree + remains canonical for them. +* `/` keeps serving `index.json` — GitHub Pages falls back to it for a + directory index — so the builder writes its HTML hub to `/hub/` and + `pages.yml` fails the build if a root `index.html` appears. A "harmless" + landing page would have silently changed the content type of a machine + endpoint. +* `.github/dependabot.yml` ignores `ruby/setup-ruby` and + `actions/jekyll-build-pages`. Bumping a tool that policy requires deleting + spends one of two open PR slots on a guaranteed-close PR. +* Builds are proven reproducible in CI (two builds, one digest) — something the + Jekyll container never offered, since its output depended on the image tag. + +[[scope]] +The cross-repo numbers in the paragraph above come from a census of all 366 +`hyperpolymath` repos at their default-branch `HEAD` on 2026-09-21, classified +by the gate this ADR introduces. It is written up, with per-repo file lists and +the rollout order, in +`docs/reports/ruby-exit-estate-census-2026-09-21.adoc`: 21 of 366 repos carry +Ruby on the build path — 10 of them a dead Jekyll Pages workflow that converts by +file swap, 10 where Ruby is doing a check whose semantics must be reproduced — +plus 17 Ruby artefacts across 5 repos flagged for conversion, and +142 files in `proven`, `proven-servers` and `palimpsest-license` pass as +adapters. No repo in the fleet's own scan corpus — 536 snapshots — reports Ruby +as its primary language. + +== Open questions + +. Is `palimpsest-license/integrations/server/ruby/` an adapter under this + policy? It is shaped like one (a client for Ruby host apps) but the repo is + not in the SSG family and is not `proven`. +. `awesome-coq` / `awesome-ipfs` run `gem install awesome_bot` for link + checking. Retire the tool (there are JS link checkers) or take the exception? +. `explore` is a `github-pages`-gem Jekyll site whose tests exercise Jekyll + plugins. Converting it means choosing a different SSG, which is an SSG-family + decision, not a `verisimdb-data` one. + +== Alternatives considered + +Keep Ruby, pin it, and merge the dependabot bumps:: +Rejected. The pin is what makes the estate depend on a runtime nobody +maintains here, and #130 is the cost of that: a bump PR for a workflow that +does not run. + +Swap Jekyll for another SSG (Zola, Cobalt, Antora, Hugo):: +Rejected *for this repo*. There is no content pipeline here to preserve: the +site is `index.json` plus static trees. A new SSG is a new binary in the +supply chain and a new template to keep in sync across 366 repos, to produce +output a publisher script already produces deterministically. An SSG remains +the right answer for repos that genuinely render markup — that belongs in the +SSG family, not in a data store. + +Render AsciiDoc in `verisimdb-data` CI with `@asciidoctor/core`:: +Deferred rather than rejected. It adds a lockfile and `node_modules` to a +`guix.scm`-managed repo to serve a page nobody has asked for. Adopt it when a +docs surface needs it; the ban is satisfied either way because it is not Ruby. + +Delete Pages entirely from this repo:: +Considered, and honest: nothing in `README.adoc` promises a site. Rejected +because `.well-known/security.txt` (RFC 9116, and `wellknown-enforcement.yml`) +is served from that origin and is a contractual surface, and `index.json` is +already fetched from it. diff --git a/docs/reports/ruby-exit-estate-census-2026-09-21.adoc b/docs/reports/ruby-exit-estate-census-2026-09-21.adoc new file mode 100644 index 00000000..d302e03c --- /dev/null +++ b/docs/reports/ruby-exit-estate-census-2026-09-21.adoc @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += Ruby exit — estate census and conversion plan +:revdate: 2026-09-21 +:status: Data report + +== Method + +All 366 repositories on the `hyperpolymath` account were listed at `HEAD` of +their default branch on 2026-09-21 by blobless partial clone +(`git clone --filter=blob:none --no-checkout`, 0 clone failures) and searched +for `*.rb`, `*.rake`, `*.gemspec`, `Gemfile`, `Gemfile.lock`, `Rakefile`, +`.ruby-version`, `_config.yml`, `.jekyllrc` and for `ruby/`, `jekyll/`, +`satellites/` path segments. The 219 repos with no `actions.lock` were +additionally shallow-cloned and had `.github/workflows` grepped, so Ruby in CI +is covered where a lockfile audit cannot see it. + +Then 22 of those repos were re-checked with `scripts/estate-ruby-exit.mjs` +(added here), whose `classify()`/`planFor()` applies +*the same* exemption rules as `rsr-antipattern.yml` — so "exempt" and "flagged" +below are the gate's verdicts, not a human's guesses. + +Limitations, stated plainly: default branch only; submodules not expanded; +reusable-workflow inheritance was looked for and not found in the repos checked — see <>; and +`scans/*.json` here are historical snapshots of *other* projects, so they +describe the fleet, not the current tree of an estate repo. +`phantom-metal-taste` and `social-media-polygraph` are classified from +workflow-file evidence only (not re-run through the tool). + +== Baseline: Ruby was never this estate's language + +Counting the `language` field of the 536 scan snapshots in `scans/`: + +|=== +| rust | shell | julia | nickel | idris | elixir | affinescript | javascript | zig | rescript | ada | *ruby* +| 110 | 98 | 65 | 38 | 33 | 32 | 28 | 25 | 17 | 17 | 15 | *0* +|=== + +Zero repos report Ruby as primary. Every Ruby artefact found is either a +pasted build step or a binding that lets a Ruby program call estate code, which +is why the exit is cheap: 171 Ruby-path artefacts exist across 13 repos, and +154 of them are exempt by policy. 17 need porting. + +== A. Ruby on the build path — 21 repos of 366 + +Counted with the patterns `.github/workflows/rsr-antipattern.yml` now uses — +invocation shapes, not substrings — over every public repo at +`main`@`6f4a77b`-day, reading `.github/workflows`, `.github/actions`, +`.gitlab-ci.yml` and the Justfile, ignoring comment lines. 21 flagged, 343 +clean. The scan is reproducible from `/home/user/ci-ruby-pass4.sh` in the +session workspace; the line numbers below are from that pass. + +|=== +| Repo | What the scan finds | Lane + +| verisimdb-data | `actions.lock` pins only — this branch deletes both sides | done here +| bebop-ffi | `jekyll-gh-pages.yml:39` (jekyll-build-pages) and `jekyll.yml:43` `ruby/setup-ruby` + `:53 bundle exec jekyll build` | mechanical +| betlang | `jekyll-gh-pages.yml:39` only — its real Pages job is Idris2 (`pages.yml`) | mechanical (delete) +| candy-crash | `ci.yml:41` + `:74` `ruby/setup-ruby`, `:53 bundle install`; plus one `.rb`, a gemspec and a Rakefile | split: Pages mechanical, the suite needs an owner +| deed-validate-action | `jekyll.yml:40` + `:50 bundle exec jekyll build` | mechanical +| ensaid-spec | `casket-pages.yml:32` (jekyll-build-pages, despite the name) | mechanical +| explore | `collections-renames.yml:22`; `Gemfile` pins `github-pages`, 10 `.rb` files | human +| filesoup | `jekyll-gh-pages.yml:37` | mechanical +| hypatia | `quality.yml:29` + `:36-38 gem install asciidoctor{,-diagram,-rouge}` | mechanical (docs render) +| lol | `jekyll-gh-pages.yml:39` | mechanical +| marid | `estate-rules.yml:59 sudo gem install asciidoctor -v 2.0.26 --no-document` | mechanical (docs render) +| phantom-metal-taste | `jekyll-gh-pages.yml:31` | mechanical +| protocol-squisher | `docs.yml:25 sudo gem install --no-document asciidoctor` | mechanical (docs render) +| qubes-sdp | `jekyll-gh-pages.yml:38`, in a repo whose `_config.yml` says "No Jekyll" | mechanical (delete) +| rsr-template-repo | `estate-rules.yml:59`, byte-identical to `marid`'s line | *first*: it is the template +| safe-brute-force | `jekyll-gh-pages.yml:34` | mechanical +| social-media-polygraph | `jekyll-gh-pages.yml:36` | mechanical +| standards | `readme-derive-reusable.yml:129 sudo gem install asciidoctor asciidoctor-reducer`; 4 live `scripts/*.rb` | human +| the-nash-equilibrium | `quality.yml:67 gem install asciidoctor --no-document` | mechanical (docs render) +| awesome-coq | `awesome-bot.yml:20` + `:24 gem install awesome_bot` | human (retire the tool) +| awesome-ipfs | `pull_requests.yml:25` + `:31 gem install awesome_bot` | human (retire the tool) +|=== + +Ten of the 21 are pure file swaps — `pages.yml`, `scripts/build-site.mjs` and +`site.json` in; the Jekyll workflow and the two `actions.lock` pins out — which +is what `estate-ruby-exit.mjs --apply` does, stopping short of committing. The +other ten need a person, and `estate-ruby-exit.mjs` reaches the same verdict on +each of them ("needing a human"); verisimdb-data is finished by this PR. + +The second group is bigger than "delete a workflow" for a reason worth stating, +because each case is a *check* whose semantics live in the Ruby tool: + +[cols="1,2",options="header"] +|=== +| Repo | Why it is not a swap + +| marid, rsr-template-repo +| the gate's own comment says the asciidoctor version is pinned "because the + verdict rests on a stderr comparison" — a replacement must reproduce that + stderr, not merely parse +| protocol-squisher +| installs asciidoctor to syntax-check `*.adoc` plus `provenance/PROVENANCE.adoc` + file by file; the value is the non-zero exit, not the HTML +| the-nash-equilibrium +| same install, then `scripts/check-docs-render.sh .`. The workflow notes a + companion "pure syntax scan" that needs no asciidoctor, so part of this may be + deletable rather than portable +| hypatia +| installs `asciidoctor-diagram`, `asciidoctor-pdf` and `rouge` and builds + `_site/docs`; diagram and PDF output are outside `rust-asciidoc`, so this is a + feature decision, not a swap +| standards +| `readme-derive-reusable.yml:129` (pandoc + asciidoctor + reducer for derived + READMEs), plus four `scripts/*.rb` that other files in the repo reference (3 + and 2 referrers) — live tooling, not cruft +| candy-crash +| Pages plus a real Ruby suite: `rspec`, `rubocop`, `brakeman`, `bundler-audit`, + a `Rakefile` and a gemspec +| explore +| a Jekyll *plugin* gem — Ruby is the product; see <> +| awesome-coq, awesome-ipfs +| the entire Ruby step is `gem install awesome_bot`, a link checker. Retiring the + step (or checking links in the estate runtime) is cheaper than maintaining a + Ruby job for one binary +|=== + +The four AsciiDoc-check repos are one job — "make `.adoc` fail the build when it +is malformed" — so they should convert as a set, against one replacement with a +documented exit-code and stderr contract. + +Two repos the scan does *not* flag, recorded because they are the interesting +edge of a precision-first gate: + +* `tree-navigator` requires `gollum` (a Ruby gem) in a Justfile recipe, but + only *mentions* `gem install gollum` inside an echo, so the anchored pattern + lets it pass. The dependency is real; the gate deliberately reads strings. +* `echidna` matched an earlier, looser pattern on the literal text + `bundle install failed` inside a `grep` of a container build log. That false + positive is why the gate is anchored to invocations, and why `tree-navigator` + is listed here instead of being silently dropped. + +[[inherit]] +=== Where the docs-render Ruby comes from, and what is *not* true + +`rsr-template-repo/.github/workflows/estate-rules.yml:59` and +`marid/.github/workflows/estate-rules.yml:59` are byte-identical, so that +`gem install asciidoctor -v 2.0.26` line is spread by the template: every repo +cloned from it inherits it, and fixing the template is worth more than fixing +the copies. `standards/.github/workflows/readme-derive-reusable.yml:129` +installs the same two gems for README derivation. + +What the scan does *not* support, and what an earlier draft of this report +wrongly claimed: + +* `standards`' `.github/` contains **one** `gem install` line (the reusable + above) and **no** `jekyll` reference at all — not "four estate scripts", and + its `docs-build-reusable.yml` has no `ruby:` input to flip. +* Nothing inherited through reusable workflows showed up in the repos checked: + of a 12-repo sample, none calls `readme-derive-reusable.yml` (`marid` has a + readme workflow that does not). Whether some other repo calls it is + **unverified** — the code-search API is rate-limited here, so the honest + statement is that inheritance was looked for and not found in the sample. + +So the "a repo can show no Ruby and still boot one" hazard is real in principle +(the gate would miss `gem install` text in a file it does not read, as with +`tree-navigator`) but is *not* the mechanism by which these 21 got Ruby. They +got it from their own workflow files, and from the template. + +== B. Ruby source outside a declared adapter — 17 artefacts, 5 repos + +|=== +| Repo | Flagged | What it is + +| explore | 10 | `.ruby-version`, `Gemfile`, `Gemfile.lock`, `Rakefile`, `_config.yml`, five minitest files — the whole project shape (see <>) +| standards | 4 | `scripts/plan-ruleset-constraint-repair.rb` (53 l.), `scripts/reconcile-scorecard-actions-lock.rb` (99 l.), `scripts/tests/reconcile-scorecard-actions-lock-test.rb`, `scripts/tests/science-ci-security-test.rb` +| dafniser | 1 | `tests/ci/static_analysis_gate_test.rb` (51 l.) — loads `static-analysis-gate.yml`, plants fake `panic-attack`/`git` bins, asserts the shell the gate emits +| hypatia | 1 | `test/dogfood_manifest_detection_test.rb` (28 l.) — same shape against `dogfood-gate.yml` +| academic-workflow-suite | 1 | `examples/integrations/canvas/awap_integration.rb` +|=== + +Four of these (both `standards` scripts and both CI meta-tests) depend on a +YAML parser that reports *line numbers*: Ruby uses `Psych` and walks +`Psych::Nodes::Mapping` for `key.start_line`. In JS that is the `yaml` package +(its CST exposes `range`/`linePos`) — **not** `js-yaml`, which discards +positions, and a tool that names line numbers to a human without real ones is +worse than no tool. + +[[explore]] +=== `explore` is the hard case, and it is a policy question + +`explore` pins `gem "github-pages", "~> 232", group: :jekyll_plugins`, and its +Rakefile drives Jekyll, rubocop and minitest. Ruby there is not the +implementation language, it is the *platform*: Jekyll plugins are Ruby by +construction. Either treat `explore` as SSG-family (exempt, with a named owner +and a reason recorded) or move the behaviour into the publisher and retire the +gem. Option 2 is the only one consistent with "Ruby is banned"; it is also a +real project, so schedule it instead of smuggling it into a cleanup PR. + +== C. Permitted, and staying that way — 154 artefacts + +|=== +| Repo | Files | Why exempt + +| proven-servers | 104 | `bindings/ruby/lib/proven_servers/**` — adapter, named in the policy +| proven | 32 | `bindings/ruby/lib/proven/**`, `bindings/mustache/helpers/proven-helpers.rb` — adapter +| palimpsest-license | 6 | `integrations/server/ruby/**` — adapter-shaped; *needs a yes/no*, ADR-0002 open question 1 +| standards | 6 | `rhodium-standard-repositories/satellites/palimpsest-license/**` — vendored mirror +| boj-server, bunsenite, cloud-sync-tuner, k9-ecosystem, homebrew-tap, academic-workflow-suite | 6 | Homebrew formulae: the format *is* a Ruby DSL, so "convert" is not an available action — retire them for Guix, or accept them as packaging metadata + +|=== + +== Rollout order + +. Land this PR. It proves the Pages replacement end to end and deletes the pin + dependabot is bumping (#130). +. Copy `pages.yml`, `scripts/build-site.mjs`, `site.json` and the two + `rsr-antipattern.yml` steps into `rsr-template-repo` and `standards`, so new + repos are born without Ruby. +. Fan out the 10 mechanical repos with `estate-ruby-exit.mjs --apply`; review + each diff, run `gh actions-lock`, and check the published URL surface before + merge (`/` on a repo whose Pages root serves `index.json` must not gain a + root `index.html` — the tool sets `index_page: hub/index.html` when + `index.json` exists, but confirm it). +. Convert `standards` (`readme-derive-reusable.yml` + 4 scripts). That single + PR also un-Ruby-ifies `boj-server` and `standards/doc-format.yml`. +. `hypatia` (docs gems → `@asciidoctor/core`, or publish source; plus its + meta-test), then `awesome-coq`/`awesome-ipfs` (retire `awesome_bot`), then + `candy-crash` (real Ruby app: port the suite or record an exception). +. `explore`: decide <> first; do not start it as cleanup. +. `proven` / `proven-servers` / SSG adapters: no work. Kept out of the ban by + path, and now *tested* by the gate's exemption, so an adapter cannot quietly + become an implementation. + +Delete the workflow and its lock entry together. #130 in this repo is the +cautionary example: it bumped `jekyll.yml` alone, leaving `actions.lock` behind +by one version — the estate's own tooling for that class of drift is +`standards/scripts/reconcile-scorecard-actions-lock.rb`, which is itself Ruby and +is in the porting list above. + +Per repo: regenerate `actions.lock`, add the two `ignore:` entries to +`dependabot.yml`, and delete any `Gemfile.lock`/`.ruby-version` in the same +commit — a leftover lockfile keeps the ecosystem alive and Dependabot +interested. diff --git a/recipes/recipe-asciidoctor-gem-to-asciidoctor-core.json b/recipes/recipe-asciidoctor-gem-to-asciidoctor-core.json new file mode 100644 index 00000000..37ad9694 --- /dev/null +++ b/recipes/recipe-asciidoctor-gem-to-asciidoctor-core.json @@ -0,0 +1,28 @@ +{ + "action": "substitute_text", + "auto_fixable": false, + "confidence": 0.8, + "description": "Replace the Ruby AsciiDoc toolchain (asciidoctor, asciidoctor-diagram, asciidoctor-rouge, asciidoctor-pdf, rouge gems installed over ruby/setup-ruby) with @asciidoctor/core under Bun/Node, the same upstream engine ported to JS, or with publishing AsciiDoc source when no rendered surface is required. Ruby is a banned language for the estate (verisimdb-data ADR-0002).", + "failed_fixes": 0, + "false_positives": 0, + "fix_script": null, + "id": "recipe-asciidoctor-gem-to-asciidoctor-core", + "languages": [ + "ruby", + "yaml", + "shell" + ], + "match": "'gem install asciidoctor' (any variant) in a CI step, or a docs job whose only toolchain is ruby/setup-ruby", + "pattern_ids": [ + "PA121-ruby-on-build-path" + ], + "proven_module": null, + "replacement": "bun add -D @asciidoctor/core && bun scripts/render-docs.mjs (or publish .adoc source via scripts/build-site.mjs and drop the render step); keep asciidoctor-diagram equivalents only if a JS plugin exists, otherwise move diagrams to pre-rendered SVG committed as data", + "successful_fixes": 0, + "total_attempts": 0, + "triangle_tier": "substitute", + "notes": [ + "PDF output and PlantUML/Mermaid diagram blocks have no drop-in @asciidoctor/core equivalent; those two features are why hypatia still boots Ruby. Decide per repo: drop PDF, pre-render diagrams, or take a written exception — do not keep Ruby as the default answer.", + "A JS render adds a lockfile: keep exactly one package manager (runtime-policy.yml fails mixed toolchains)." + ] +} diff --git a/recipes/recipe-ruby-jekyll-pages-to-static.json b/recipes/recipe-ruby-jekyll-pages-to-static.json new file mode 100644 index 00000000..161e58a0 --- /dev/null +++ b/recipes/recipe-ruby-jekyll-pages-to-static.json @@ -0,0 +1,29 @@ +{ + "action": "substitute_workflow", + "auto_fixable": true, + "confidence": 0.9, + "description": "Retire Jekyll-based GitHub Pages builds: delete the ruby/setup-ruby + actions/jekyll-build-pages workflows and publish from an explicit allowlist with scripts/build-site.mjs (Bun tier 1, Node >= 18 fallback). Ruby is a banned language for the estate; the Pages lane is where it hid. See verisimdb-data ADR-0002.", + "failed_fixes": 0, + "false_positives": 0, + "fix_script": null, + "id": "recipe-ruby-jekyll-pages-to-static", + "languages": [ + "ruby", + "yaml", + "shell" + ], + "match": ".github/workflows/*.yml containing 'ruby/setup-ruby', 'actions/jekyll-build-pages', 'bundle exec jekyll', or a Jekyll '_site' upload", + "pattern_ids": [ + "PA121-ruby-on-build-path" + ], + "proven_module": null, + "replacement": ".github/workflows/pages.yml running 'bun run scripts/build-site.mjs' (node fallback), site.json publish allowlist, '.nojekyll' marker, upload-pages-artifact with include-hidden-files: true, and actions.lock regenerated so no ruby/* pin survives", + "successful_fixes": 0, + "total_attempts": 0, + "triangle_tier": "eliminate", + "notes": [ + "Verify before replacing: if '/' on the current site serves index.json (GitHub Pages falls back to index.json for a directory index), do NOT emit a root index.html — put the human hub at /hub/.", + "Measure the live URL surface first (curl -o /dev/null -w '%{http_code}' per path) and carry forward the data endpoints; the Jekyll mirror's incidental exposure of repo-internal files is deliberately not preserved.", + "Also add ruby/setup-ruby and actions/jekyll-build-pages to dependabot.yml ignores, or the bump PRs keep arriving for pins that should be deleted." + ] +} diff --git a/recipes/recipe-ruby-source-to-estate-runtime.json b/recipes/recipe-ruby-source-to-estate-runtime.json new file mode 100644 index 00000000..d169ccd2 --- /dev/null +++ b/recipes/recipe-ruby-source-to-estate-runtime.json @@ -0,0 +1,26 @@ +{ + "action": "rewrite_language", + "auto_fixable": false, + "confidence": 0.65, + "description": "Port remaining Ruby source (CI meta-tests, ruleset and lockfile tooling, host-app examples) to an estate language instead of deleting it. Ruby files outside a declared adapter path fail rsr-antipattern.yml; this recipe is the way to make that green without weakening the ban.", + "failed_fixes": 0, + "false_positives": 0, + "fix_script": null, + "id": "recipe-ruby-source-to-estate-runtime", + "languages": [ + "ruby" + ], + "match": "*.rb, Gemfile, Gemfile.lock, Rakefile, *.gemspec, .ruby-version outside bindings/*/ruby/, integrations/*/ruby/ and adapters/*/ruby/", + "pattern_ids": [ + "PA122-ruby-source-outside-adapters" + ], + "proven_module": null, + "replacement": "Tooling and CI meta-tests -> Bun/Node (.mjs; the 'yaml' package covers the Psych YAML AST uses, and exposes real line numbers, which js-yaml does not); numeric or report generation -> Julia; services -> Elixir; ABI/FFI -> Zig; anything asserting memory or panic-freedom -> Rust. Delete .ruby-version, Gemfile.lock and Rakefile in the same commit — a leftover lockfile keeps the dependabot ecosystem alive.", + "successful_fixes": 0, + "total_attempts": 0, + "triangle_tier": "eliminate", + "notes": [ + "Ruby-only platforms are not source, they are configuration: Homebrew formulae (a Ruby DSL) and Jekyll plugin repos stay exempt as packaging/host-platform surfaces and are tracked as exceptions, not conversions.", + "Vendored mirrors (vendor/, macports-ports/, */satellites/) are exempt: this policy does not own other projects' trees and rewriting a mirror destroys provenance." + ] +} diff --git a/scripts/README.adoc b/scripts/README.adoc index a6befb5b..cd26cd18 100644 --- a/scripts/README.adoc +++ b/scripts/README.adoc @@ -118,3 +118,68 @@ mix run test_integration.exs This will: - Load all scans from verisimdb-data - Generate Logtalk facts - Show summary statistics + +=== Pages and policy scripts + +==== `+build-site.mjs+` + +Builds the GitHub Pages site into `+_site/+` from the publish allowlist in +`+site.json+`. Runs under Bun (tier 1) or Node >= 18; zero dependencies, so +there is no lockfile and no package-manager tier for `+runtime-policy.yml+` to +police. It exists because Ruby is a banned language for this estate: the +workflows it replaced ran `+ruby/setup-ruby+` and +`+actions/jekyll-build-pages+`. + +*Usage:* + +[source,bash] +---- +just site-check # plan only, write nothing +just site # build _site/ +just site-verify # two builds must agree on the digest +node scripts/build-site.mjs --baseurl /verisimdb-data # project-page prefix +---- + +*Contract:* only allowlisted paths are published; symlinks are never followed +out of the repo; a missing `+"required": true+` source fails the build; and a +copy that would land `+index.html+` at the site root fails the build with an +explanation, because `+/+` on this origin serves `+index.json+` — a root +`+index.html+` would take it over and change the content type consumers see. +The human hub is therefore written to `+hub/index.html+` (or wherever +`+"index_page"+` says). `+pages.yml+` guards the same property again after +building, so a mistake fails in CI even if the build was skipped; failing in +`+just site+` as well is what keeps it from being a surprise. + +*Config:* `+copy+` entries are `+{"from": path, "to": dir, "required": bool}+`, +and a bare string is accepted as `+{"from": string, "to": "."}+`. Paths are +resolved against the repo root — not the caller's working directory — and +absolute paths, `+..+` and globs are rejected as config errors rather than +sanitised, so `+--config+`/`+--out+` are relative to the repo too. Every other +malformed entry is reported as `+build-site: config copy[N]: expected a string or +{from,to}, got ...+`; a stack trace out of a build script is how a repo ends up +with a hand-rolled publisher nobody trusts. + +==== `+estate-ruby-exit.mjs+` + +Applies the mechanical half of the estate's Ruby exit to *another* repo: deletes +the Jekyll Pages workflows, writes the three replacement files from this repo +(`+--assets-dir+`, default: this checkout), strips Ruby pins from +`+actions.lock+`, and adds the Dependabot ignores. Dry run by default. It +refuses to touch a repo whose Ruby is behaviour rather than plumbing — +`+rspec+`, `+rubocop+`, `+bundle exec rake+` — because deleting a test suite to +make a gate green is not a conversion. + +*Usage:* + +[source,bash] +---- +node scripts/estate-ruby-exit.mjs ../filesoup # classify only +node scripts/estate-ruby-exit.mjs ../filesoup --apply # write, review, commit +node scripts/estate-ruby-exit.mjs --all ~/hyperpolymath-repos +---- + +The exemption rules are shared with +`+.github/workflows/rsr-antipattern.yml+` (adapters under +`+bindings/*/ruby/+`, `+integrations/*/ruby/+`, `+adapters/*/ruby/+`; Homebrew +formulae; vendored mirrors). Keep the two in sync, or a repo the tool "fixed" +will still fail CI. diff --git a/scripts/batch-scan-all.sh b/scripts/batch-scan-all.sh old mode 100755 new mode 100644 diff --git a/scripts/build-site.mjs b/scripts/build-site.mjs new file mode 100644 index 00000000..34544025 --- /dev/null +++ b/scripts/build-site.mjs @@ -0,0 +1,421 @@ +#!/usr/bin/env bun +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// build-site.mjs — GitHub Pages publisher for verisimdb-data. +// +// Replaces the retired Jekyll pipeline (`ruby/setup-ruby` + `bundle exec jekyll +// build`, and the `actions/jekyll-build-pages` container build). Ruby is a banned +// implementation language for this estate, so the site is produced by this script. +// +// Runtime: Bun (tier 1 per LANGUAGE-POLICY §1) or Node >= 18, whichever is on +// PATH. Zero dependencies: no lockfile is introduced, so runtime-policy.yml has +// no package-manager tier to police and nothing here needs vendoring. +// +// Usage: +// bun scripts/build-site.mjs [--config site.json] [--out _site] [--baseurl PATH] +// node scripts/build-site.mjs --dry-run +// +// Contract: +// * Only paths named in site.json reach the site root. The repository tree is +// never mirrored — the old Jekyll build did exactly that as a side effect, +// which put scan payloads on a public origin without anyone deciding to. +// * Output is deterministic for a given input tree + baseurl (no timestamps, +// sorted entries, LF separators), so CI can prove reproducibility. +// * Fails closed: a missing `required: true` source is an error. Serving +// /.well-known/security.txt is a contractual obligation (RFC 9116, RSR). +// * Symlinks are never published, so the allowlist cannot become a +// read-any-file primitive. + +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { createHash } from "node:crypto"; +import { basename, dirname, join, posix, resolve, sep } from "node:path"; +import { argv, env, exit, stderr, stdout } from "node:process"; +import { pathToFileURL } from "node:url"; + +const ROOT = resolve(dirname(dirname(realpathSync(argv[1] ?? import.meta.url.pathname)))); + +// ── CLI ─────────────────────────────────────────────────────────────── + +function parseArgs(raw) { + const opts = { config: "site.json", out: null, baseurl: "", dryRun: false }; + for (let i = 0; i < raw.length; i += 1) { + const arg = raw[i]; + if (arg === "--dry-run") opts.dryRun = true; + else if (arg === "--config") opts.config = raw[++i] ?? opts.config; + else if (arg === "--out") opts.out = raw[++i]; + else if (arg === "--baseurl") opts.baseurl = raw[++i] ?? ""; + else fail(`unknown argument: ${arg}`); + } + return opts; +} + +function fail(message) { + stderr.write(`build-site: ${message}\n`); + exit(1); +} + +// ── config ──────────────────────────────────────────────────────────── + +function loadConfig(path) { + let parsed; + try { + parsed = JSON.parse(readFileSync(path, "utf8")); + } catch (err) { + return fail(`cannot read config ${path}: ${err.message}`); + } + for (const key of ["title", "output", "copy"]) { + if (parsed[key] === undefined) fail(`config ${path}: missing "${key}"`); + } + if (!Array.isArray(parsed.copy) || parsed.copy.length === 0) { + fail(`config ${path}: "copy" must be a non-empty array`); + } + // A bare string is the shape a person reaches for first ("copy": ["data"]), + // so it means "publish this to the site root" rather than a stack trace; any + // other shape is reported as the config error it is. + parsed.copy = parsed.copy.map((entry, i) => { + if (typeof entry === "string") return { from: entry, to: "." }; + if (entry !== null && typeof entry === "object" && entry.from !== undefined) return entry; + return fail(`config copy[${i}]: expected a string or {from,to}, got ${JSON.stringify(entry)}`); + }); + for (const entry of parsed.copy) { + assertRelative(entry.from, "copy.from"); + assertRelative(entry.to ?? "", "copy.to"); + } + if (parsed.index_page !== undefined) assertRelative(parsed.index_page, "index_page"); + parsed.listing = (parsed.listing ?? []).map((entry, i) => + typeof entry === "string" ? { root: entry } : entry, + ); + for (const entry of parsed.listing) { + assertRelative(entry.root ?? ".", "listing.root"); + if (!Array.isArray(entry.match) || entry.match.length === 0) { + fail(`config: listing entry for "${entry.root}" needs a non-empty "match" array`); + } + if (entry.link_target !== "source" && entry.link_target !== "repo") { + fail(`config: listing entry for "${entry.root}" needs link_target "source" or "repo"`); + } + } + return parsed; +} + +// Anything that could make the publisher read or write outside the repo is a +// config bug, not something to sanitise later: no absolute paths, no "..", no +// globs (list a directory and the walker expands it). +function assertRelative(path, label) { + if (path === "") return; + if (path.startsWith("/") || path.startsWith("\\") || /^[A-Za-z]:/.test(path)) { + fail(`config ${label}: absolute path "${path}" is not allowed`); + } + const parts = path.split(/[\\/]/); + if (parts.includes("..")) fail(`config ${label}: path traversal in "${path}" is not allowed`); + if (parts.some((part) => part.includes("*"))) { + fail(`config ${label}: globs are not supported ("${path}")`); + } +} + +// ── filesystem helpers ──────────────────────────────────────────────── + +function walk(dir) { + const files = []; + const stack = [dir]; + while (stack.length > 0) { + const current = stack.pop(); + let entries = []; + try { + entries = readdirSync(current, { withFileTypes: true }); + } catch { + continue; // unreadable directory: skip, the plan/verify steps re-check + } + for (const entry of entries) { + if (SKIP.has(entry.name)) continue; // never descend into .git or the output dir + const abs = join(current, entry.name); + if (entry.isSymbolicLink()) continue; + if (entry.isDirectory()) stack.push(abs); + else if (entry.isFile()) files.push(abs); + } + } + return files.map(toPosixAbs).sort(); +} + +function toPosixAbs(abs) { + return abs.slice(ROOT.length + 1).split(sep).join(posix.sep); +} + +// Directory names that are never descended into: VCS state, installed +// dependencies, and the site output itself (walking _site while writing _site +// is how a "publish everything" build turns into a fixed point that never ends). +const SKIP = new Set([".git", "node_modules", "target", "_site", ".venv"]); + +function byteSize(relPath) { + try { + return statSync(resolve(ROOT, relPath)).size; + } catch { + return 0; + } +} + +function joinSite(dest, sub) { + const prefix = dest === undefined || dest === "" || dest === "." ? "" : dest; + return prefix === "" ? sub : posix.join(prefix, sub); +} + +function esc(text) { + return String(text) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +function urlFor(baseurl, sitePath) { + const clean = String(sitePath).replace(/^\.\/?/, ""); + if (clean === "" || clean === ".") return baseurl === "" ? "/" : `${baseurl}/`; + return baseurl === "" ? `/${clean}` : `${baseurl}/${clean}`; +} + +function normaliseBaseurl(raw) { + const value = String(raw ?? "").trim().replace(/\/+$/, ""); + if (value === "" || value === "/") return ""; + const candidate = value.startsWith("/") ? value : `/${value}`; + if (!/^\/[A-Za-z0-9._~/-]+$/.test(candidate)) fail(`refusing unsafe baseurl: ${raw}`); + return candidate; +} + +// ── build plan ──────────────────────────────────────────────────────── + +// copy: verbatim trees/files from site.json. +function planCopies(config) { + const items = []; + for (const entry of config.copy ?? []) { + const srcAbs = resolve(ROOT, entry.from); + if (!existsSync(srcAbs)) { + if (entry.required) fail(`required publish source missing: ${entry.from}`); + stdout.write(`build-site: optional source absent, skipped: ${entry.from}\n`); + continue; + } + const stat = lstatSync(srcAbs); + if (stat.isSymbolicLink()) fail(`publish source is a symlink: ${entry.from}`); + if (stat.isFile()) { + // For a single file, `to` is the destination path itself (empty/"." + // means "same name at the site root"). + const dest = entry.to === undefined || entry.to === "" || entry.to === "." + ? basename(entry.from) + : entry.to; + items.push({ src: entry.from, site: dest }); + continue; + } + for (const file of walk(srcAbs)) { + const sub = file.slice(entry.from.length + 1); + items.push({ src: file, site: joinSite(entry.to, sub) }); + } + } + return items; +} + +// listing: index sections. link_target "source" also publishes the file, +// link_target "repo" links to the repository browser and publishes nothing. +function planListings(config) { + const sections = []; + const published = []; + for (const entry of config.listing ?? []) { + const root = entry.root ?? "."; + const rootAbs = resolve(ROOT, root); + if (!existsSync(rootAbs)) { + stdout.write(`build-site: listing root absent, skipped: ${root}\n`); + continue; + } + const items = walk(rootAbs) + .filter((file) => entry.match.some((ext) => file.endsWith(ext))) + .map((file) => ({ + src: file, + // Keep the tree shape under the listing root; flattening basenames + // collides the moment two directories both hold a README. + site: joinSite(root, root === "." ? file : file.slice(root.length + 1)), + })); + if (entry.link_target === "source") published.push(...items); + sections.push({ label: entry.label ?? root, linkTarget: entry.link_target, items }); + } + return { sections, published }; +} + +function dedupe(items) { + const bySite = new Map(); + for (const item of items) { + const existing = bySite.get(item.site); + if (existing !== undefined && existing.src !== item.src) { + fail(`collision: "${item.src}" and "${existing.src}" both publish to /${item.site}`); + } + bySite.set(item.site, item); + } + return [...bySite.values()].sort((a, b) => (a.site < b.site ? -1 : 1)); +} + +// ── build ───────────────────────────────────────────────────────────── + +function build(opts) { + const config = loadConfig(resolve(ROOT, opts.config)); + const outDir = resolve(ROOT, opts.out ?? config.output); + const baseurl = normaliseBaseurl(opts.baseurl); + SKIP.add(basename(outDir)); + + const listings = planListings(config); + const published = dedupe([...planCopies(config), ...listings.published]); + + const outputs = new Map(); + for (const item of published) outputs.set(item.site, readFileSync(resolve(ROOT, item.src))); + + const manifest = published.map((item) => ({ path: item.site, source: item.src, bytes: byteSize(item.src) })); + // The hub page is deliberately NOT at the site root: GitHub Pages serves + // index.json for '/' here, and a root index.html would take that over and + // change the content type of a machine-readable endpoint. + const indexPage = config.index_page === undefined ? "hub/index.html" : config.index_page; + const atRoot = published.find((item) => item.site === "index.html"); + if (atRoot !== undefined && indexPage !== "index.html") { + fail( + `"${atRoot.src}" would publish to /index.html and take over '/' (this site's root is ` + + `index.json). Publish it under a directory, or set "index_page": "index.html" to say ` + + `you really mean it. CI guards this too, so failing here is the earlier, kinder half.`, + ); + } + if (indexPage !== "") { + outputs.set(indexPage, Buffer.from(renderIndex({ config, baseurl, sections: listings.sections, manifest }), "utf8")); + } + outputs.set( + "publish-manifest.json", + Buffer.from(`${JSON.stringify({ generator: "scripts/build-site.mjs", baseurl: baseurl || "/", files: manifest }, null, 2)}\n`, "utf8"), + ); + if (config.nojekyll !== false) outputs.set(".nojekyll", Buffer.from("\n", "utf8")); + + const hash = createHash("sha256"); + for (const site of [...outputs.keys()].sort()) { + hash.update(`${site}\u0000`); + hash.update(outputs.get(site)); + hash.update("\n"); + } + const digest = hash.digest("hex"); + stdout.write( + `build-site: ${outputs.size} files, ${published.length} sources copied, baseurl="${baseurl || "/"}", sha256=${digest}\n`, + ); + + if (opts.dryRun) { + stdout.write("build-site: --dry-run, nothing written\n"); + return { outDir, outputs, digest, published }; + } + + rmSync(outDir, { recursive: true, force: true }); + mkdirSync(outDir, { recursive: true }); + for (const [site, buffer] of outputs) writeSiteFile(outDir, site, buffer); + stdout.write(`build-site: wrote ${outDir.split(sep).join(posix.sep)}\n`); + return { outDir, outputs, digest, published }; +} + +function writeSiteFile(outDir, site, buffer) { + if (site.includes("..")) fail(`refusing site path with traversal: ${site}`); + const dest = join(outDir, site); + const parent = dirname(dest); + mkdirSync(parent, { recursive: true }); + const realRoot = realpathSync(outDir); + const realParent = realpathSync(parent); + if (realParent !== realRoot && !realParent.startsWith(`${realRoot}${sep}`)) { + fail(`refusing to write outside the site root: ${site}`); + } + writeFileSync(dest, buffer); +} + +// ── html ────────────────────────────────────────────────────────────── + +function repoUrl(src) { + const slug = env.GITHUB_REPOSITORY ?? "hyperpolymath/verisimdb-data"; + const ref = env.GITHUB_REF_NAME ?? "main"; + return `https://github.com/${slug}/blob/${ref}/${src.split("/").map(encodeURIComponent).join("/")}`; +} + +function renderIndex({ config, baseurl, sections, manifest }) { + const blocks = []; + for (const section of sections) { + if (section.items.length === 0) continue; + const rows = section.items.map((item) => { + const href = section.linkTarget === "repo" ? repoUrl(item.src) : urlFor(baseurl, item.site); + return `
  • ${esc(item.src)}
  • `; + }); + blocks.push(`
    +

    ${esc(section.label)}

    +
      +${rows.join("\n")} +
    +
    `); + } + blocks.push(`
    +

    Published files (first 200 of ${manifest.length}; full list in publish-manifest.json)

    +
      +${manifest.slice(0, 200).map((item) => `
    • ${esc(item.path)} ${item.bytes} B
    • `).join("\n")} +
    +
    `); + + return ` + + + + + ${esc(config.title)} + + + + + +
    +

    ${esc(config.title)}

    +

    + Flat-file data store for VeriSimDB scan, drift and outcome records. + Built by scripts/build-site.mjs (Bun / Node — no Ruby, no Jekyll) + from the publish allowlist in site.json. +

    +
    +${blocks.join("\n")} +
    +

    + The canonical dataset is the git tree, not this site. Machine-readable index: + index.json. + Security contacts: security.txt. +

    +
    + + +`; +} + +function slugify(text) { + return String(text) + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); +} + +if (import.meta.url === pathToFileURL(argv[1] ?? "").href) build(parseArgs(argv.slice(2))); + +export { build, dedupe, normaliseBaseurl, parseArgs, planCopies, planListings, urlFor, walk }; diff --git a/scripts/estate-ruby-exit.mjs b/scripts/estate-ruby-exit.mjs new file mode 100644 index 00000000..57ac05a0 --- /dev/null +++ b/scripts/estate-ruby-exit.mjs @@ -0,0 +1,368 @@ +#!/usr/bin/env bun +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// estate-ruby-exit.mjs — perform the mechanical half of the estate's Ruby exit +// in one repo at a time. +// +// Ruby is a banned language for the estate (verisimdb-data ADR-0002). Most of +// the remaining Ruby is not code, it is a pasted "Deploy Jekyll site to Pages" +// workflow: a Ruby toolchain standing between a push and a directory copy. That +// part is mechanical, so it should be a tool, not 15 PRs of hand-editing. +// +// What it does, given a checkout: +// 1. classify every Ruby artefact it finds (adapter / packaging DSL / vendored +// / needs-a-human) using the SAME exemptions as rsr-antipattern.yml, so the +// tool and the gate can never disagree about what is permitted; +// 2. if the Ruby is only on the Pages path, write the reference replacement +// (pages.yml + scripts/build-site.mjs + site.json, copied from the assets +// dir, which defaults to verisimdb-data — the repo where this landed first), +// delete the Jekyll workflows, and strip Ruby pins from actions.lock; +// 3. otherwise stop and report. `rake`, `rspec` and `gem install` are not +// mechanical: they mean the repo has real Ruby behaviour to port, and a +// tool that quietly deleted it would be worse than no tool at all. +// +// Dry-run by default. --apply writes. Nothing is committed, nothing is pushed. +// +// Usage: +// node estate-ruby-exit.mjs /path/to/repo +// node estate-ruby-exit.mjs /path/to/repo --apply --assets-dir /path/to/verisimdb-data +// node estate-ruby-exit.mjs --all /path/to/clone-root // every subdir with .git + +import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync, mkdirSync, cpSync } from "node:fs"; +import { basename, dirname, join, posix, resolve } from "node:path"; +import { argv, cwd, exit, stderr, stdout } from "node:process"; +import { spawnSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; + +const RUBY_FILE = /(\.rb|\.rake|\.gemspec|Gemfile(\.lock)?$|Rakefile$|\.ruby-version$|_config\.yml$|\.jekyllrc$)/; + +// Mirrors rsr-antipattern.yml exactly. Keep the two in sync or the fleet gets +// repos the tool "fixed" that CI still fails. +const EXEMPT = [ + { re: /^(?:\.\/)?(?:bindings|integrations|adapters)(?:\/[^/]+)?\/(?:ruby|helpers)\//, why: "adapter (permitted by ADR-0002)" }, + { re: /^(?:\.\/)?(?:Formula|Casks)\//, why: "Homebrew formula: the format is a Ruby DSL" }, + { re: /\/(?:homebrew|tap)\//, why: "Homebrew formula: the format is a Ruby DSL" }, + { re: /^(?:\.\/)?(?:vendor|macports-ports)\//, why: "vendored mirror of another project" }, + { re: /\/satellites\//, why: "vendored mirror of another project" }, +]; + +// Ruby that a tool must not touch, because it is behaviour rather than plumbing. +const NEEDS_HUMAN = /\b(rspec|rubocop|gem install|bundle (exec|install)|rake)\b/; +// A line that mentions Jekyll at all is Pages plumbing: those workflows are +// deleted wholesale, so `bundle exec jekyll build` is mechanical even though +// `bundle exec rake test` is not. Kept separate from the *detection* pattern, +// which must not treat the `.nojekyll` marker as Ruby usage. +const PAGES_ONLY = /jekyll|ruby\/setup-ruby|github-pages/i; + +function trackedFiles(dir) { + const r = spawnSync("git", ["-C", dir, "ls-files"], { encoding: "utf8" }); + if (r.status !== 0) fail(`${dir}: not a git checkout? (${r.stderr.trim() || "git ls-files failed"})`); + return r.stdout.split("\n").filter(Boolean); +} + +function fail(message) { + stderr.write(`ruby-exit: ${message}\n`); + exit(1); +} + +function classify(dir) { + const files = trackedFiles(dir); + const ruby = files.filter((f) => RUBY_FILE.test(f)); + const exempt = []; + const convert = []; + for (const f of ruby) { + const hit = EXEMPT.find((e) => e.re.test(`./${f}`)); + (hit ? exempt : convert).push({ file: f, why: hit?.why ?? null }); + } + // The policy file that implements this ban contains the pattern list, so it + // matches itself; same for this tool's own name. Skip those, exactly as the + // CI step's --exclude does, or every converted repo looks "not yet done". + const SELF = /(rsr-antipattern|ruby-exit|language-policy|dependabot)[^/]*\.(yml|yaml)$/; + const ciFiles = files.filter( + (f) => !SELF.test(f) && (f.startsWith(".github/workflows/") || f === ".gitlab-ci.yml" || f === "Justfile" || f === "justfile"), + ); + const ci = []; + for (const f of ciFiles) { + const text = readFileSync(join(dir, f), "utf8").split("\n"); + text.forEach((line, i) => { + // A comment describing the retired tooling is not usage, and neither is a + // trailing one: `bundler-cache: true # runs 'bundle install' and caches…` is + // GitHub's own boilerplate comment, and treating it as a Ruby invocation kept + // two purely-mechanical repos (bebop-ffi, deed-validate-action) out of reach. + if (/^\s*#/.test(line)) return; + line = line.replace(/\s+#(?!\{)[^"]*$/, ""); + // Same alternation as the CI gate. Note `jekyll-build-pages`, not a bare + // `jekyll`: `.nojekyll` is a marker file the *replacement* writes, so a loose + // match would flag every migrated repo as still carrying Ruby (it did, on + // filesoup/proven, whose casket-pages.yml is a Haskell build). + // Anchored to invocation shapes, exactly as rsr-antipattern.yml now is: + // a `uses:` step, a lockfile pin, or a command at the start of a run line. + // Substring matching flags repos that merely *quote* the words — `echidna` + // greps its container log for "bundle install failed" and has no Ruby. + const INVOCATION = /uses:\s*(ruby\/setup-ruby|actions\/jekyll-build-pages)|^\s*(-\s+)?['\"]?(ruby\/setup-ruby|actions\/jekyll-build-pages)@|^\s*(-\s+)?(run:\s*)?(sudo\s+)?(gem install|bundle exec|bundle install|rake\s)/; + if (!INVOCATION.test(line)) return; + ci.push({ file: f, line: i + 1, text: line.trim(), mechanical: !NEEDS_HUMAN.test(line) || PAGES_ONLY.test(line) }); + }); + } + return { convert, exempt, ci, all: files }; +} + +// The replacement's publish surface. The old Jekyll build published "whatever +// it walked"; an allowlist has to be derived per repo, and the derivation is +// deliberately conservative: top-level data directories plus the well-known +// bundle plus a README. Anything else is a decision a human should make. +function deriveSiteConfig(dir, title, files) { + const all = files; + const topDirs = new Set(); + for (const f of all) { + const [head] = f.split("/"); + // `www/` is handled by its two explicit entries above (public/ and + // .well-known/); mirroring the directory as well would publish the bundle + // source at /www/ too, which is the artefact the Jekyll workflow had to + // `rm -rf` as a cleanup step. scripts/, tests/ and tool dirs are not site + // content either. + if (!f.includes("/")) continue; + if (head.startsWith(".") || ["scripts", "node_modules", "www", "test", "tests", "ffi", ".github"].includes(head)) continue; + topDirs.add(head); + } + const copy = [{ from: "www/public", to: ".", required: false }, { from: "www/.well-known", to: ".well-known", required: false }]; + for (const d of [...topDirs].sort()) copy.push({ from: d, to: d, required: false }); + for (const f of ["README.adoc", "README.md", "index.json"]) { + if (all.includes(f)) copy.push({ from: f, to: f, required: false }); + } + const listing = all.some((f) => f.startsWith("docs/")) + ? [{ root: "docs", label: "Documentation", match: [".adoc", ".md"], link_target: "source" }] + : []; + return { + title, + output: "_site", + index_page: all.includes("index.json") ? "hub/index.html" : "index.html", + copy, + listing, + nojekyll: true, + notes: [ + "Derived by scripts/estate-ruby-exit.mjs — review before merge.", + "index_page is hub/index.html when the repo publishes index.json: GitHub Pages", + "serves index.json for '/' if no index.html exists, and some consumers rely on it.", + ], + }; +} + +function buildPagesWorkflow(siteJsonPath) { + return `# SPDX-License-Identifier: MPL-2.0 +# +# Generated by scripts/estate-ruby-exit.mjs — Ruby-free Pages deploy. +# Replaces the Jekyll workflow (ruby/setup-ruby or actions/jekyll-build-pages): +# Ruby is banned for this estate, see hyperpolymath/verisimdb-data ADR-0002. +name: Deploy Pages site + +on: + push: + branches: ["main", "master"] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7.0.1 + - name: Setup Pages + id: pages + uses: actions/configure-pages@v6.0.0 + - name: Build site and prove reproducibility + env: + BASE_PATH: \${{ steps.pages.outputs.base_path }} + run: | + set -euo pipefail + if command -v bun >/dev/null 2>&1; then runtime="bun run"; else runtime="node"; fi + echo "build runtime: $runtime" + $runtime scripts/build-site.mjs --config ${siteJsonPath} --baseurl "$BASE_PATH" | tee /tmp/build-1.log + rm -rf _site + $runtime scripts/build-site.mjs --config ${siteJsonPath} --baseurl "$BASE_PATH" | tee /tmp/build-2.log + first=$(sed -n 's/.*\\(sha256=[0-9a-f]\\{64\\}\\).*/\\1/p' /tmp/build-1.log | head -1) + second=$(sed -n 's/.*\\(sha256=[0-9a-f]\\{64\\}\\).*/\\1/p' /tmp/build-2.log | head -1) + if [ -z "$first" ] || [ "$first" != "$second" ]; then + echo "::error::site build is not reproducible ($first vs $second)" + exit 1 + fi + - name: Upload artifact + uses: actions/upload-pages-artifact@v5.0.0 + with: + path: _site + include-hidden-files: true + + deploy: + environment: + name: github-pages + url: \${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5.0.1 +`; +} + +// actions.lock is machine-generated, so the honest edit is to delete the Ruby +// pins and say "regenerate me"; leaving them would keep dependabot interested. +const RUBY_LOCK_KEY = /ruby\/setup-ruby|jekyll|github-pages/; + +// actions.lock is machine-generated, so the honest edit is: drop the Ruby keys +// whole and leave a note to regenerate. Deleting a single `uses:` line under a +// workflow key while its siblings stay behind produces invalid YAML, so an +// entry is removed as a unit — key line plus every line indented under it. +function stripLockPins(dir) { + const path = join(dir, ".github/workflows/actions.lock"); + if (!existsSync(path)) return null; + const lines = readFileSync(path, "utf8").split("\n"); + const out = []; + let dropped = 0; + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + const isKey = /^ {4}'[^']*':/.test(line); + if (isKey && RUBY_LOCK_KEY.test(line)) { + dropped += 1; + while (i + 1 < lines.length && /^ {8,}\S/.test(lines[i + 1])) { i += 1; dropped += 1; } + continue; + } + if (!isKey && RUBY_LOCK_KEY.test(line)) { dropped += 1; continue; } + out.push(line); + } + return { text: out.join("\n"), dropped }; +} + +function planFor(dir, opts) { + const { convert, exempt, ci, all } = classify(dir); + const nonMechanical = ci.filter((c) => !c.mechanical); + const actions = []; + const removals = [...new Set(ci.map((c) => c.file))].filter((f) => f.startsWith(".github/workflows/")).sort(); + const jekyllWorkflows = removals.filter((f) => /jekyll|pages/i.test(basename(f))); + + if (convert.length > 0 || nonMechanical.length > 0) { + actions.push("STOP: not mechanical"); + } + if (convert.length > 0) actions.push(`port ${convert.length} Ruby file(s) by hand first`); + if (nonMechanical.length > 0) actions.push(`resolve ${nonMechanical.length} non-Pages Ruby CI invocation(s) first`); + if (jekyllWorkflows.length > 0) { + actions.push(`delete ${jekyllWorkflows.join(", ")}`); + actions.push("write .github/workflows/pages.yml + scripts/build-site.mjs + site.json"); + actions.push("strip Ruby pins from actions.lock, then run `gh actions-lock`"); + actions.push("add ruby/setup-ruby + actions/jekyll-build-pages to dependabot ignores"); + } + return { convert, exempt, ci, all, nonMechanical, jekyllWorkflows, actions }; +} + +function applyTo(dir, plan, opts) { + const assets = resolve(opts.assetsDir); + const title = basename(dir.replace(/\/+$/, "")); + const site = deriveSiteConfig(dir, title, plan.all); + const written = []; + + for (const wf of plan.jekyllWorkflows) { + rmSync(join(dir, wf), { force: true }); + written.push(`deleted ${wf}`); + } + mkdirSync(join(dir, "scripts"), { recursive: true }); + cpSync(join(assets, "scripts/build-site.mjs"), join(dir, "scripts/build-site.mjs")); + written.push("wrote scripts/build-site.mjs"); + writeFileSync(join(dir, "site.json"), `${JSON.stringify(site, null, 2)}\n`); + written.push("wrote site.json"); + writeFileSync(join(dir, ".github/workflows/pages.yml"), buildPagesWorkflow("site.json")); + written.push("wrote .github/workflows/pages.yml"); + const lock = stripLockPins(dir); + if (lock !== null && lock.dropped > 0) { + writeFileSync(join(dir, ".github/workflows/actions.lock"), lock.text); + written.push(`stripped ${lock.dropped} Ruby line(s) from actions.lock`); + } + if (!existsSync(join(dir, ".gitignore")) || !readFileSync(join(dir, ".gitignore"), "utf8").includes("_site")) { + const gi = existsSync(join(dir, ".gitignore")) ? readFileSync(join(dir, ".gitignore"), "utf8") : ""; + writeFileSync(join(dir, ".gitignore"), `${gi}\n# Pages output\n/_site/\n`); + written.push("added /_site/ to .gitignore"); + } + const db = join(dir, ".github/dependabot.yml"); + if (existsSync(db)) { + let text = readFileSync(db, "utf8"); + if (!text.includes("ruby/setup-ruby")) { + text = text.replace( + /(package-ecosystem:\s*"github-actions"[^\n]*\n)/, + `$1 # Ruby is banned for this estate: bump the action, or delete it? Delete.\n ignore:\n - dependency-name: "ruby/setup-ruby"\n - dependency-name: "actions/jekyll-build-pages"\n`, + ); + writeFileSync(db, text); + written.push("added dependabot ignores for Ruby tooling"); + } + } + return written; +} + +function report(dir, plan, opts) { + const name = basename(dir.replace(/\/+$/, "")); + const nothing = plan.ci.length === 0 && plan.convert.length === 0 && plan.exempt.length === 0; + const mechanical = plan.convert.length === 0 && plan.nonMechanical.length === 0 && plan.jekyllWorkflows.length > 0; + if (nothing) { + stdout.write(`\n=== ${name} ===\n no Ruby anywhere: nothing to do\n`); + return "clean"; + } + stdout.write(`\n=== ${name} ===\n`); + stdout.write(` Ruby on build path: ${plan.ci.length} | source to port: ${plan.convert.length} | exempt: ${plan.exempt.length}\n`); + for (const c of plan.ci) stdout.write(` ${c.file}:${c.line} ${c.mechanical ? "[mechanical]" : "[NEEDS HUMAN]"} ${c.text.slice(0, 90)}\n`); + for (const f of plan.convert) stdout.write(` port: ${f.file}\n`); + for (const f of plan.exempt.slice(0, 6)) stdout.write(` exempt (${f.why}): ${f.file}\n`); + if (plan.exempt.length > 6) stdout.write(` … and ${plan.exempt.length - 6} more exempt\n`); + for (const a of plan.actions) stdout.write(` → ${a}\n`); + if (plan.jekyllWorkflows.length === 0 && plan.convert.length === 0 && plan.exempt.length > 0) { + stdout.write(" → nothing to do: Ruby here is only permitted adapters\n"); + return "clean"; + } + if (mechanical && opts.apply) { + for (const line of applyTo(dir, plan, opts)) stdout.write(` ✓ ${line}\n`); + return "done"; + } + if (mechanical) { + stdout.write(" (dry run: pass --apply to write the replacement)\n"); + return "ready"; + } + return "blocked"; +} + +function main() { + const opts = { apply: false, assetsDir: "/home/user/verisimdb-data", all: null }; + const dirs = []; + for (let i = 2; i < argv.length; i += 1) { + const a = argv[i]; + if (a === "--apply") opts.apply = true; + else if (a === "--assets-dir") opts.assetsDir = argv[++i]; + else if (a === "--all") { const root = resolve(argv[++i]); for (const d of readdirSync(root)) { const p = join(root, d); if (existsSync(join(p, ".git"))) dirs.push(p); } } + else dirs.push(resolve(a)); + } + if (dirs.length === 0) fail("usage: estate-ruby-exit.mjs [--apply] [--assets-dir DIR] | --all "); + if (!existsSync(join(resolve(opts.assetsDir), "scripts/build-site.mjs"))) { + fail(`assets dir ${opts.assetsDir} has no scripts/build-site.mjs (point --assets-dir at the reference repo)`); + } + const tally = { clean: 0, ready: 0, done: 0, blocked: 0 }; + for (const dir of dirs) tally[report(dir, planFor(dir, opts), opts)] += 1; + const parts = []; + if (tally.done) parts.push(`${tally.done} converted`); + if (tally.ready) parts.push(`${tally.ready} ready to convert (dry run)`); + if (tally.blocked) parts.push(`${tally.blocked} needing a human`); + if (tally.clean) parts.push(`${tally.clean} already clear`); + stdout.write(`\nruby-exit: ${parts.join(", ") || "nothing to do"}\n`); + // Non-zero only when a --apply run left something behind, so a fleet loop can + // tell "needs a human" apart from "finished" without parsing prose. + exit(tally.blocked > 0 && opts.apply ? 2 : 0); +} + +if (import.meta.url === pathToFileURL(argv[1] ?? "").href) main(); + +export { classify, deriveSiteConfig, planFor, stripLockPins, EXEMPT, RUBY_FILE }; diff --git a/scripts/ingest-scan.sh b/scripts/ingest-scan.sh old mode 100755 new mode 100644 diff --git a/scripts/scan-all.sh b/scripts/scan-all.sh old mode 100755 new mode 100644 diff --git a/setup.sh b/setup.sh old mode 100755 new mode 100644 diff --git a/site.json b/site.json new file mode 100644 index 00000000..39678231 --- /dev/null +++ b/site.json @@ -0,0 +1,40 @@ +{ + "title": "VeriSimDB Data", + "output": "_site", + "index_page": "hub/index.html", + "copy": [ + { "from": "www/public", "to": ".", "required": false }, + { "from": "www/.well-known", "to": ".well-known", "required": true }, + { "from": "index.json", "to": "index.json", "required": true }, + { "from": "scans", "to": "scans", "required": true }, + { "from": "dispatch", "to": "dispatch", "required": false }, + { "from": "outcomes", "to": "outcomes", "required": false }, + { "from": "patterns", "to": "patterns", "required": true }, + { "from": "recipes", "to": "recipes", "required": true }, + { "from": "health", "to": "health", "required": false }, + { "from": "policy", "to": "policy", "required": false }, + { "from": "docs", "to": "docs", "required": true }, + { "from": "README.adoc", "to": "README.adoc", "required": true }, + { "from": "LICENSE", "to": "LICENSE", "required": false } + ], + "listing": [ + { "root": "docs", "label": "Documentation", "match": [".adoc", ".md"], "link_target": "source" } + ], + "nojekyll": true, + "notes": [ + "Publish allowlist for scripts/build-site.mjs (Bun / Node — no Ruby, no Jekyll).", + "Nothing reaches the site root unless it is named here.", + "", + "URL compatibility with the retired Jekyll build (checked 2026-09-21 against the", + "live deployment): index.json, scans/**, dispatch/**, outcomes/**, patterns/**,", + "recipes/**, health/**, policy/**, docs/**, README.adoc and .well-known/** all", + "served HTTP 200 and still do. Two deliberate differences:", + " 1. There is no root index.html, so '/' keeps serving index.json. GitHub Pages", + " falls back to index.json for a directory index; adding index.html would", + " silently change the content type of the machine endpoint.", + " 2. Root-level tooling files the Jekyll mirror exposed by accident are no", + " longer published: Justfile, setup.sh, guix.scm, mise.toml, stapeln.toml,", + " repo-paths.json, contractile.just, examples/**, ffi/**. The git tree stays", + " canonical for those; add a copy entry if a consumer really needs one." + ] +}