diff --git a/.github/workflows/container-build.yml b/.github/workflows/container-build.yml index e578cd2..0986016 100644 --- a/.github/workflows/container-build.yml +++ b/.github/workflows/container-build.yml @@ -31,7 +31,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Tooling check run: | diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml index 652ace4..7136e09 100644 --- a/.github/workflows/dependabot-automerge.yml +++ b/.github/workflows/dependabot-automerge.yml @@ -49,13 +49,17 @@ permissions: jobs: automerge: # Only run for PRs actually authored by Dependabot. - if: github.actor == 'dependabot[bot]' && github.event.pull_request.user.login == 'dependabot[bot]' + if: github.actor_id == '49699333' && github.event.pull_request.user.login == 'dependabot[bot]' runs-on: ubuntu-latest timeout-minutes: 15 steps: + - name: Harden Runner + uses: step-security/harden-runner@5c7944e73c4c2a096b17a9cb74d65b6c2bbafbde + with: + egress-policy: audit - name: Fetch Dependabot metadata id: meta - uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0 + uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 with: github-token: ${{ secrets.GITHUB_TOKEN }} # --- Policy gate ------------------------------------------------------- diff --git a/.github/workflows/dogfood-gate.yml b/.github/workflows/dogfood-gate.yml index e882b69..22f8784 100644 --- a/.github/workflows/dogfood-gate.yml +++ b/.github/workflows/dogfood-gate.yml @@ -30,7 +30,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Check for A2ML files id: detect @@ -71,7 +71,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Check for K9 files id: detect @@ -117,7 +117,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Scan for invisible characters id: lint @@ -213,7 +213,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Check for Groove manifest id: groove @@ -272,7 +272,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Check and validate eclexiaiser manifest id: eclex @@ -324,7 +324,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Generate dogfooding scorecard run: | diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 39da946..646dfa0 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -43,7 +43,7 @@ jobs: timeout-minutes: 15 steps: - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Run E2E harness run: | if [ -f tests/e2e.sh ]; then diff --git a/.github/workflows/estate-rules.yml b/.github/workflows/estate-rules.yml index 140ca4c..5cf9350 100644 --- a/.github/workflows/estate-rules.yml +++ b/.github/workflows/estate-rules.yml @@ -26,7 +26,7 @@ jobs: timeout-minutes: 15 steps: - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Root shape allowlist run: bash scripts/check-root-shape.sh . - name: AsciiDoc by default (no .md under docs/) diff --git a/.github/workflows/guix-policy.yml b/.github/workflows/guix-policy.yml index f8e3fff..d6c2442 100644 --- a/.github/workflows/guix-policy.yml +++ b/.github/workflows/guix-policy.yml @@ -21,7 +21,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Enforce Guix-only package policy run: | # Guix is the sole package manager estate-wide. Guix is BANNED. diff --git a/.github/workflows/hypatia-scan.yml b/.github/workflows/hypatia-scan.yml index fe0ed88..846951f 100644 --- a/.github/workflows/hypatia-scan.yml +++ b/.github/workflows/hypatia-scan.yml @@ -26,4 +26,4 @@ permissions: jobs: scan: - uses: hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml@8f2ee50841e216cd8c192eeb68953118190f105c + uses: hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml@da2c748aad55c1a1dcba00b60fe4a35017bc6540 diff --git a/.github/workflows/openssf-compliance.yml b/.github/workflows/openssf-compliance.yml index 5061adb..d18fd1d 100644 --- a/.github/workflows/openssf-compliance.yml +++ b/.github/workflows/openssf-compliance.yml @@ -21,7 +21,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: persist-credentials: false - name: Check SECURITY.md exists and has substance diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index aa6a989..3f58081 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -20,9 +20,9 @@ jobs: image: ghcr.io/stefan-hoeck/idris2-pack@sha256:f0758996a931fb35d9ecb1de273c4d59dabe2a09b433afc7e357f65a08b7e1ff steps: - name: Checkout Site - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Checkout Ddraig SSG - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: repository: hyperpolymath/ddraig-ssg path: .ddraig-ssg @@ -39,7 +39,7 @@ jobs: fi ./.ddraig-ssg/build/exec/ddraig build src _site https://hyperpolymath.github.io/${GITHUB_REPOSITORY#*/} - name: Upload artifact - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5 + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 with: path: '_site' deploy: @@ -52,4 +52,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5 + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 669fd2b..137ee4a 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -22,7 +22,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Check file permissions run: | find . -type f -perm /111 -name "*.sh" | head -10 || true @@ -34,7 +34,7 @@ jobs: run: | find . -type f -size +1M -not -path "./.git/*" | head -10 || echo "No large files" - name: EditorConfig check - uses: editorconfig-checker/action-editorconfig-checker@51f63319f592f97930c73d9c46184d20bd206393 # v3.0.0 + uses: editorconfig-checker/action-editorconfig-checker@51f63319f592f97930c73d9c46184d20bd206393 continue-on-error: true docs: runs-on: ubuntu-latest @@ -42,7 +42,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Check documentation run: | MISSING="" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fa0bb6b..e4f4284 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Detect project type and build id: build run: | @@ -85,7 +85,7 @@ jobs: changelog: ${{ steps.cliff.outputs.content }} version: ${{ steps.version.outputs.version }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: fetch-depth: 0 - name: Extract version from tag @@ -110,7 +110,7 @@ jobs: run: | git cliff --output CHANGELOG.md - name: Upload updated CHANGELOG.md - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: changelog path: CHANGELOG.md @@ -125,7 +125,7 @@ jobs: id-token: write # mint the OIDC token attestation provenance is signed with attestations: write # write the build-provenance attestation (the "claim") steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # TODO: Download build artifacts if uploading to the release (pin # actions/download-artifact to a full commit SHA when enabling): # - uses: actions/download-artifact@ # vX.Y.Z @@ -133,7 +133,7 @@ jobs: # name: release-artifacts # path: artifacts/ - name: Create GitHub Release - uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v2 + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 with: body: ${{ needs.changelog.outputs.changelog }} draft: false @@ -154,6 +154,6 @@ jobs: # (must match the `files:` uploaded above, e.g. artifacts/*). - name: Attest build provenance if: ${{ hashFiles('artifacts/*') != '' }} # skip until real artifacts are wired - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 with: subject-path: 'artifacts/*' diff --git a/.github/workflows/rhodibot.yml b/.github/workflows/rhodibot.yml index d7aabe9..3a3f3c4 100644 --- a/.github/workflows/rhodibot.yml +++ b/.github/workflows/rhodibot.yml @@ -34,7 +34,7 @@ jobs: timeout-minutes: 15 steps: - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: fetch-depth: 1 - name: Rhodibot — detect drift (no mutations) diff --git a/.github/workflows/runtime-policy.yml b/.github/workflows/runtime-policy.yml index 8727561..2e3c1c1 100644 --- a/.github/workflows/runtime-policy.yml +++ b/.github/workflows/runtime-policy.yml @@ -36,7 +36,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Report runtime tier and reject mixed toolchains run: | diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 7aae3c2..55f9636 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -15,7 +15,7 @@ permissions: jobs: scorecard: - uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@8f2ee50841e216cd8c192eeb68953118190f105c + uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@da2c748aad55c1a1dcba00b60fe4a35017bc6540 permissions: contents: read security-events: write diff --git a/.github/workflows/security-policy.yml b/.github/workflows/security-policy.yml index 57ffa67..b20d25c 100644 --- a/.github/workflows/security-policy.yml +++ b/.github/workflows/security-policy.yml @@ -21,7 +21,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Security checks run: | FAILED=false diff --git a/.github/workflows/static-analysis-gate.yml b/.github/workflows/static-analysis-gate.yml index 8179fae..b59bdda 100644 --- a/.github/workflows/static-analysis-gate.yml +++ b/.github/workflows/static-analysis-gate.yml @@ -23,7 +23,7 @@ jobs: timeout-minutes: 15 steps: - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: fetch-depth: 0 - name: Install panic-attack (if available) @@ -70,7 +70,7 @@ jobs: TOTAL=$(jq '. | length' panic-attack-findings.json 2>/dev/null || echo 0) CRITICAL=$(jq '[.[] | select(.severity == "critical")] | length' panic-attack-findings.json 2>/dev/null || echo 0) HIGH=$(jq '[.[] | select(.severity == "high")] | length' panic-attack-findings.json 2>/dev/null || echo 0) - MEDIUM=$(jq '[.[] | select(.severity == "medium")] | length' panic-attack-findings.json 2>/dev/null || echo 0) + MEDIUM=$(jq '[.[] | select(.severity == "medium" or .severity == "warn")] | length' panic-attack-findings.json 2>/dev/null || echo 0) LOW=$(jq '[.[] | select(.severity == "low")] | length' panic-attack-findings.json 2>/dev/null || echo 0) echo "total=$TOTAL" >> "$GITHUB_OUTPUT" @@ -120,7 +120,7 @@ jobs: echo "" >> "$GITHUB_STEP_SUMMARY" echo "Skipped: panic-attack not available in this environment." >> "$GITHUB_STEP_SUMMARY" - name: Upload panic-attack findings - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: panic-attack-findings path: panic-attack-findings.json @@ -147,13 +147,13 @@ jobs: timeout-minutes: 15 steps: - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: fetch-depth: 0 - name: Setup Elixir for Hypatia scanner id: beam continue-on-error: true - uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.18.2 + uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 with: elixir-version: '1.19.4' otp-version: '28.3' @@ -206,7 +206,7 @@ jobs: TOTAL=$(jq '. | length' hypatia-findings.json 2>/dev/null || echo 0) CRITICAL=$(jq '[.[] | select(.severity == "critical")] | length' hypatia-findings.json 2>/dev/null || echo 0) HIGH=$(jq '[.[] | select(.severity == "high")] | length' hypatia-findings.json 2>/dev/null || echo 0) - MEDIUM=$(jq '[.[] | select(.severity == "medium")] | length' hypatia-findings.json 2>/dev/null || echo 0) + MEDIUM=$(jq '[.[] | select(.severity == "medium" or .severity == "warn")] | length' hypatia-findings.json 2>/dev/null || echo 0) LOW=$(jq '[.[] | select(.severity == "low")] | length' hypatia-findings.json 2>/dev/null || echo 0) echo "total=$TOTAL" >> "$GITHUB_OUTPUT" @@ -254,7 +254,7 @@ jobs: echo "" >> "$GITHUB_STEP_SUMMARY" echo "Skipped: Hypatia scanner not available in this environment." >> "$GITHUB_STEP_SUMMARY" - name: Upload hypatia findings - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: hypatia-findings path: hypatia-findings.json @@ -273,7 +273,7 @@ jobs: timeout-minutes: 15 steps: - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: fetch-depth: 0 - name: Install panic-attack (if available) @@ -335,7 +335,7 @@ jobs: echo "" >> "$GITHUB_STEP_SUMMARY" echo "Skipped: panic-attack not available in this environment." >> "$GITHUB_STEP_SUMMARY" - name: Upload bridge report - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: bridge-report path: bridge-report.json @@ -357,17 +357,17 @@ jobs: if: always() steps: - name: Download panic-attack findings - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v4 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: name: panic-attack-findings path: findings/ - name: Download hypatia findings - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v4 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: name: hypatia-findings path: findings/ - name: Download bridge report - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v4 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: name: bridge-report path: findings/ @@ -418,7 +418,7 @@ jobs: TOTAL=$(jq '.findings | length' findings/unified-findings.json) CRITICAL=$(jq '[.findings[] | select(.severity == "critical")] | length' findings/unified-findings.json) HIGH=$(jq '[.findings[] | select(.severity == "high")] | length' findings/unified-findings.json) - MEDIUM=$(jq '[.findings[] | select(.severity == "medium")] | length' findings/unified-findings.json) + MEDIUM=$(jq '[.findings[] | select(.severity == "medium" or .severity == "warn")] | length' findings/unified-findings.json) LOW=$(jq '[.findings[] | select(.severity == "low")] | length' findings/unified-findings.json) echo "total=$TOTAL" >> "$GITHUB_OUTPUT" @@ -427,7 +427,7 @@ jobs: echo "medium=$MEDIUM" >> "$GITHUB_OUTPUT" echo "low=$LOW" >> "$GITHUB_OUTPUT" - name: Upload unified findings (fleet scanner picks these up) - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: unified-findings path: findings/unified-findings.json diff --git a/.github/workflows/wellknown-enforcement.yml b/.github/workflows/wellknown-enforcement.yml index 06e8f2d..c7ee552 100644 --- a/.github/workflows/wellknown-enforcement.yml +++ b/.github/workflows/wellknown-enforcement.yml @@ -26,7 +26,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: RFC 9116 security.txt validation run: | SECTXT="" diff --git a/.github/workflows/workflow-linter.yml b/.github/workflows/workflow-linter.yml index db1795d..4d9b128 100644 --- a/.github/workflows/workflow-linter.yml +++ b/.github/workflows/workflow-linter.yml @@ -28,7 +28,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Check SPDX Headers run: | diff --git a/Cargo.lock b/Cargo.lock index 7657665..2ffb575 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -114,6 +114,12 @@ dependencies = [ "syn", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -183,6 +189,12 @@ dependencies = [ "wasi", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "http" version = "1.4.2" @@ -366,6 +378,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "itoa" version = "1.0.18" @@ -595,6 +617,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "shlex" version = "2.0.1" @@ -665,6 +700,7 @@ dependencies = [ name = "squabble-fight" version = "0.1.0" dependencies = [ + "serde_yaml_ng", "squabble-core", ] @@ -817,6 +853,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index b6c6edd..f22686c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,4 +22,5 @@ squabble-core = { path = "crates/squabble-core" } squabble-fight = { path = "crates/squabble-fight" } serde = { version = "1", features = ["derive"] } serde_json = "1" +serde_yaml_ng = "0.10" thiserror = "2" diff --git a/crates/squabble-cli/src/fetch.rs b/crates/squabble-cli/src/fetch.rs index 0ec3c47..6545421 100644 --- a/crates/squabble-cli/src/fetch.rs +++ b/crates/squabble-cli/src/fetch.rs @@ -24,8 +24,12 @@ use std::process::Command; #[derive(Debug, Deserialize)] struct RollupEntry { + // GitHub's rollup is a union: commit statuses use context/state, while + // check runs use name/conclusion. CodeRabbit commonly supplies a status. + #[serde(alias = "context")] name: String, status: Option, + #[serde(alias = "state")] conclusion: Option, /// `https://github.com/O/R/actions/runs//job/` — the only place /// the rollup exposes a job id, which is what the jobs API needs. @@ -270,6 +274,35 @@ pub fn run_with_greens(slug: &str, pr: &str) -> Result<(Gate, Vec), mod tests { use super::*; + #[test] + fn mixed_check_runs_and_commit_statuses_parse_without_losing_failures() { + let json = r#"{"baseRefName":"main","statusCheckRollup":[ + {"__typename":"CheckRun","name":"CI","status":"COMPLETED","conclusion":"SUCCESS"}, + {"__typename":"StatusContext","context":"CodeRabbit","state":"SUCCESS"}, + {"__typename":"StatusContext","context":"External review","state":"FAILURE"}, + {"__typename":"StatusContext","context":"Pending review","state":"PENDING"} + ]}"#; + let parsed: PrView = serde_json::from_str(json).expect("both GitHub rollup variants"); + assert_eq!(parsed.status_check_rollup.len(), 4); + assert_eq!( + parse_rollup(&parsed.status_check_rollup[0]), + CheckRun::Passed + ); + assert_eq!( + parse_rollup(&parsed.status_check_rollup[1]), + CheckRun::Passed + ); + assert_eq!( + parse_rollup(&parsed.status_check_rollup[2]), + CheckRun::Failed + ); + assert_eq!( + parse_rollup(&parsed.status_check_rollup[3]), + CheckRun::Pending + ); + assert!(greens_from_rollup(&parsed.status_check_rollup).is_empty()); + } + fn entry(name: &str, status: Option<&str>, conclusion: Option<&str>) -> RollupEntry { RollupEntry { name: name.to_string(), diff --git a/crates/squabble-fight/Cargo.toml b/crates/squabble-fight/Cargo.toml index e42827a..c336f42 100644 --- a/crates/squabble-fight/Cargo.toml +++ b/crates/squabble-fight/Cargo.toml @@ -12,3 +12,4 @@ rust-version.workspace = true [dependencies] squabble-core = { workspace = true } +serde_yaml_ng = { workspace = true } diff --git a/crates/squabble-fight/src/lib.rs b/crates/squabble-fight/src/lib.rs index 7b284de..f1b9923 100644 --- a/crates/squabble-fight/src/lib.rs +++ b/crates/squabble-fight/src/lib.rs @@ -327,6 +327,8 @@ mod tests { job_names: vec![], reusable_repos: reusable.iter().map(|s| s.to_string()).collect(), path_filtered, + retired_descriptile_policy: false, + empty_jobs: false, kind, } } diff --git a/crates/squabble-fight/src/workflows.rs b/crates/squabble-fight/src/workflows.rs index 4b310dd..a3c529a 100644 --- a/crates/squabble-fight/src/workflows.rs +++ b/crates/squabble-fight/src/workflows.rs @@ -45,6 +45,10 @@ pub struct WorkflowInfo { pub reusable_repos: Vec, /// True if the file declares an `on.*.paths` trigger filter. pub path_filtered: bool, + /// Executable policy contradicts the canonical descriptile location. + pub retired_descriptile_policy: bool, + /// A bare jobs block contains only whitespace or commented examples. + pub empty_jobs: bool, pub kind: WorkflowKind, } @@ -108,16 +112,42 @@ impl WorkflowFacts { /// workflow could be attributed — the caller then falls back to the pure /// engine's conservative default rather than guessing. /// + /// Workflows marked as using a retired descriptile policy or lacking + /// uncommented job definitions are flagged as non-functional before + /// ownership and lane classification. + /// /// `slug` is the current repo's `owner/repo`; a reusable workflow whose /// `owner/repo` differs is owned upstream. The check's realised [`CheckRun`] /// matters: the path-filter trap only manifests as a *Missing* check (the /// workflow never triggered off-path), so the appliable pass-through move is /// proposed only then — a check that actually ran and *Failed* is a /// different problem the filter cannot explain. + /// + /// A workflow that checks a retired descriptile path, or has only commented + /// jobs, is classified as a non-functional gate regardless of [`CheckRun`]. pub fn classify(&self, check: &RequiredCheck, slug: &str) -> Option { let name = check.required_context.as_str(); let w = self.find_emitting(name)?; + if w.retired_descriptile_policy { + return Some(Move::FlagNonFunctionalGate { + check: name.to_string(), + evidence: format!( + "`{}` requires a retired descriptile path; reconcile its policy with .machine_readable/descriptiles/ and SD004 before retrying", + w.file + ), + }); + } + if w.empty_jobs { + return Some(Move::FlagNonFunctionalGate { + check: name.to_string(), + evidence: format!( + "`{}` contains only commented jobs; GitHub cannot create a check from this template", + w.file + ), + }); + } + // 1. Owned upstream: the job delegates to a reusable workflow living in // another repo. The fix belongs there, not on this PR. if let Some(repo) = w.reusable_repos.iter().find(|r| r.as_str() != slug) { @@ -252,10 +282,167 @@ fn parse_workflow(file: &str, text: &str) -> WorkflowInfo { job_names, reusable_repos, path_filtered, + retired_descriptile_policy: has_retired_descriptile_policy(text), + empty_jobs: has_empty_jobs(text), kind, } } +enum BlockState { + None, + Run { min_indent: usize, scalar: String }, + Other(usize), +} + +/// Return whether a `run` scalar directly checks a known descriptile at either +/// retired `.machine_readable` location. +/// +/// Quoted inline scalars are YAML-decoded. Text outside `run` scalars and +/// commands that do not begin with a supported file-existence check are ignored. +fn has_retired_descriptile_policy(text: &str) -> bool { + let mut state = BlockState::None; + + for line in text.lines() { + if line.trim().is_empty() { + if let BlockState::Run { scalar, .. } = &mut state { + scalar.push('\n'); + } + continue; + } + let indent = line.chars().take_while(|c| c.is_whitespace()).count(); + let trimmed = line[indent..].trim_end(); + + match &mut state { + BlockState::Run { min_indent, scalar } if indent > *min_indent => { + scalar.push_str(line); + scalar.push('\n'); + continue; + } + BlockState::Other(min_indent) if indent > *min_indent => { + continue; + } + BlockState::Run { scalar, .. } => { + if decoded_scalar_has_retired_policy(scalar) { + return true; + } + } + BlockState::None | BlockState::Other(_) => {} + } + state = BlockState::None; + + let is_run_key = trimmed.starts_with("- run:") || trimmed.starts_with("run:"); + let is_block_start = trimmed.ends_with('|') + || trimmed.ends_with('>') + || trimmed.ends_with("|-") + || trimmed.ends_with(">-"); + + if is_run_key { + let scalar = trimmed + .strip_prefix("- run:") + .or_else(|| trimmed.strip_prefix("run:")) + .unwrap() + .trim_start(); + if scalar.starts_with('|') || scalar.starts_with('>') { + state = BlockState::Run { + min_indent: indent, + scalar: format!("{scalar}\n"), + }; + } else { + let scalar_trim = scalar.trim(); + let decoded = if scalar_trim.starts_with(['\'', '"']) { + let Ok(value) = serde_yaml_ng::from_str::(scalar_trim) else { + continue; + }; + value + } else { + scalar_trim.to_string() + }; + if command_has_retired_policy(decoded.trim()) { + return true; + } + } + } else if is_block_start { + state = BlockState::Other(indent); + } + } + + matches!(state, BlockState::Run { ref scalar, .. } if decoded_scalar_has_retired_policy(scalar)) +} + +/// Return whether a YAML block scalar contains a recognised retired-path check. +/// Invalid scalars and scalars without a matching command line return `false`. +fn decoded_scalar_has_retired_policy(scalar: &str) -> bool { + serde_yaml_ng::from_str::(scalar) + .is_ok_and(|decoded| decoded.lines().any(command_has_retired_policy)) +} + +/// Return whether a command starts with a supported existence check for a +/// retired descriptile path. Shell condition keywords and negation are allowed +/// before `check_file`, `test`, `[` or `[[` checks. +fn command_has_retired_policy(command: &str) -> bool { + let mut words = command.split_whitespace().peekable(); + if matches!(words.peek(), Some(&"if" | &"elif" | &"while" | &"until")) { + words.next(); + } + if words.peek() == Some(&"!") { + words.next(); + } + let target = match words.next() { + Some("check_file") => words.next(), + Some("test" | "[" | "[[") => { + if words.peek() == Some(&"!") { + words.next(); + } + if matches!(words.next(), Some("-f" | "-e")) { + words.next() + } else { + None + } + } + _ => None, + }; + let Some(target) = target else { + return false; + }; + let target = target.trim_end_matches(';').trim_matches(['\'', '"']); + [ + "STATE", + "META", + "ECOSYSTEM", + "AGENTIC", + "NEUROSYM", + "PLAYBOOK", + "ANCHOR", + ] + .iter() + .any(|name| { + target == format!(".machine_readable/{name}.a2ml") + || target == format!(".machine_readable/6a2/{name}.a2ml") + }) +} + +/// Return whether a bare top-level `jobs:` block contains no uncommented job. +fn has_empty_jobs(text: &str) -> bool { + let mut in_jobs = false; + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + if in_jobs { + // A non-comment indented value is outside this narrow diagnosis. + return !line.starts_with(char::is_whitespace); + } + if line.strip_prefix("jobs:").is_some_and(|rest| { + let rest = rest.trim(); + rest.is_empty() || rest.starts_with('#') + }) { + in_jobs = true; + } + } + in_jobs +} + /// Extract `owner/repo` from a reusable-workflow `uses:` line, i.e. one whose /// target contains `/.github/workflows/`. Action uses (`owner/repo@sha`) are /// ignored — they are not gate-emitting reusables. @@ -536,4 +723,80 @@ jobs: ) .is_none()); } + #[test] + fn retired_policy_is_a_gate_conflict_with_a_canonical_negative_control() { + let bad = "name: Compliance\njobs:\n compliance:\n steps:\n - run: test -f .machine_readable/STATE.a2ml\n"; + let parsed = parse_workflow("compliance.yml", bad); + assert!(parsed.retired_descriptile_policy); + let facts = WorkflowFacts { + workflows: vec![parsed], + }; + assert!(matches!( + facts.classify(&req("compliance", CheckRun::Missing), "owner/repo"), + Some(Move::FlagNonFunctionalGate { .. }) + )); + let fixed = bad.replace( + ".machine_readable/STATE", + ".machine_readable/descriptiles/STATE", + ); + assert!(!parse_workflow("compliance.yml", &fixed).retired_descriptile_policy); + assert!(!has_retired_descriptile_policy( + "# test -f .machine_readable/STATE.a2ml" + )); + assert!(!has_retired_descriptile_policy( + "- run: echo 'test -f .machine_readable/STATE.a2ml'" + )); + for scalar in [ + r#"run: "test -f .machine_readable/STATE.a2ml""#, + r#"run: 'test -f .machine_readable/STATE.a2ml'"#, + r#"run: "test\x20-f\u0020.machine_readable/STATE.a2ml""#, + r#"run: "test -f \".machine_readable/STATE.a2ml\"""#, + ] { + assert!(has_retired_descriptile_policy(scalar), "{scalar}"); + } + assert!(!has_retired_descriptile_policy( + r#"- run: "printf '%s\n' '# test -f .machine_readable/STATE.a2ml'""# + )); + } + + #[test] + fn folded_retired_policy_command_is_a_non_functional_gate() { + let workflow = r#"name: Compliance +jobs: + compliance: + steps: + - run: > + test -f + .machine_readable/STATE.a2ml +"#; + let facts = WorkflowFacts { + workflows: vec![parse_workflow("compliance.yml", workflow)], + }; + + assert!(matches!( + facts.classify(&req("compliance", CheckRun::Missing), "owner/repo"), + Some(Move::FlagNonFunctionalGate { .. }) + )); + } + + #[test] + fn commented_jobs_cannot_supply_a_check() { + let template = "name: E2E\njobs:\n # test:\n # runs-on: ubuntu-latest\n"; + let parsed = parse_workflow("e2e.yml", template); + assert!(parsed.empty_jobs); + let facts = WorkflowFacts { + workflows: vec![parsed], + }; + assert!(matches!( + facts.classify(&req("E2E", CheckRun::Missing), "owner/repo"), + Some(Move::FlagNonFunctionalGate { .. }) + )); + assert!(!has_empty_jobs("jobs:\n test:\n steps: []\n")); + assert!(!has_empty_jobs("# jobs:\n")); + assert!(has_empty_jobs("jobs: # template\n # test:\n")); + assert!(!has_empty_jobs( + "jobs: # real jobs\n test:\n steps: []\n" + )); + assert!(!has_empty_jobs("jobs: { test: {} }\n")); + } }