From 1f9a55fae1ab40e429067425960e986d0886abc9 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:27:51 +0000 Subject: [PATCH] fix(ci): give every workflow a recorded state and reconcile the issues it raises MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #138 tracked two "pre-existing reds" both named lint-workflows — one check rendered twice. That check is green on main (workflow-linter run 38608640962), but measuring the repository instead of the check name found five workflows that are red, stale, superseded or dormant, none of them triaged anywhere: mirror.yml red mirror-gitea exit 1, mirror-disroot exit 128 cflite_batch.yml stale run predates the in-file harness repairs cflite_pr.yml stale run predates the in-file harness repairs casket-pages.yml superseded cancelled since 2026-09-22, no successful deploy instant-sync.yml dormant disabled_manually, last run 2026-07-14 push-email-notify.yml dormant disabled_manually, last run 2026-07-14 No gate is weakened and no capability is hidden: * docs/audits/ci-check-ledger.json records the state of all 19 workflows with the run that proves it, and for every non-green one a reason, the owner action that clears it, the issue (#147) and a review date. * scripts/ci-check-ledger.sh validates that record offline — it must cover exactly the workflow files on disk, evidence must be an actions run from this repository, review dates must be live and inside 90 days — and verifies it online on pushes to the default branch: a workflow recorded as green that has gone red fails, while benign transitions are reported for recording. An unreadable API means "not verified", never "verified". * cargo-audit.yml keeps both audits blocking and passes their output to scripts/reconcile-audit-issue.sh, which maintains exactly one issue carrying the findings and acceptance criteria and closes it with a green run on the default branch. The old publisher's "Run `cargo audit` locally for details" issue, never closed, is how #143 and #113 both exist. * mirror.yml gains a preflight job that names an enabled-but-undeclared or enabled-but-unconfigured forge before the fan-out — including the empty vars.GITEA_HOST that made ssh-keyscan exit 1 — and actions.lock gains the checkout ref that job uses. Each script has a fail-closed control suite (21, 10 and 7 controls), wired into just ci-check; the determinations and the evidence for every entry are in docs/CI-RED-DETERMINATIONS.adoc. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/actions.lock | 1 + .github/workflows/cargo-audit.yml | 61 +-- .github/workflows/mirror.yml | 40 ++ .github/workflows/workflow-linter.yml | 21 + Justfile | 22 +- docs/AUDIT.adoc | 6 +- docs/CI-CD-REPAIR.adoc | 9 + docs/CI-RED-DETERMINATIONS.adoc | 161 ++++++++ docs/audits/ci-check-ledger.json | 333 ++++++++++++++++ scripts/check-mirror-config.sh | 108 +++++ scripts/ci-check-ledger.sh | 415 ++++++++++++++++++++ scripts/reconcile-audit-issue.sh | 199 ++++++++++ scripts/tests/ci-check-ledger-test.sh | 232 +++++++++++ scripts/tests/mirror-config-test.sh | 110 ++++++ scripts/tests/reconcile-audit-issue-test.sh | 151 +++++++ 15 files changed, 1841 insertions(+), 28 deletions(-) create mode 100644 docs/CI-RED-DETERMINATIONS.adoc create mode 100644 docs/audits/ci-check-ledger.json create mode 100644 scripts/check-mirror-config.sh create mode 100644 scripts/ci-check-ledger.sh create mode 100755 scripts/reconcile-audit-issue.sh create mode 100755 scripts/tests/ci-check-ledger-test.sh create mode 100644 scripts/tests/mirror-config-test.sh create mode 100755 scripts/tests/reconcile-audit-issue-test.sh diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 075bf10..bfeb2a8 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -36,6 +36,7 @@ workflows: '.github/workflows/labels.yml': [] '.github/workflows/lock-sync-gate.yml': [] '.github/workflows/mirror.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - 'hyperpolymath/standards@84355587cb2a1f86e6882de83514a32db2646e7a' '.github/workflows/push-email-notify.yml': - 'hyperpolymath/smtp-notify-action@c1c9fa07992a02c1fd3d67a0dc1b08cccb852aef' diff --git a/.github/workflows/cargo-audit.yml b/.github/workflows/cargo-audit.yml index 2e3e1a6..cfc8b27 100644 --- a/.github/workflows/cargo-audit.yml +++ b/.github/workflows/cargo-audit.yml @@ -1,6 +1,13 @@ # SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. -# Prevention workflow - audits Rust dependencies for vulnerabilities +# Prevention workflow - audits Rust dependencies for vulnerabilities. +# +# The audit steps are blocking and unchanged in meaning. What changed is what +# happens to the issue: previously every failing run created a duplicate whose +# whole body was "Run `cargo audit` locally for details", and nothing ever +# closed one. Now one issue carries the findings, the run that produced them and +# acceptance criteria, and the gate that opened it closes it again with a green +# run on the default branch (scripts/reconcile-audit-issue.sh). name: Cargo Audit on: push: @@ -20,22 +27,6 @@ jobs: audit: runs-on: ubuntu-latest timeout-minutes: 15 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - name: Install cargo-audit - run: cargo install cargo-audit --version 0.22.2 --locked - - name: Run cargo audit - run: cargo audit --deny warnings - - name: Check for unmaintained crates - run: cargo audit --deny unmaintained - # Optional: Create issues for vulnerabilities - create-issue: - runs-on: ubuntu-latest - timeout-minutes: 15 - needs: audit - if: failure() && github.event_name != 'pull_request' permissions: contents: read issues: write @@ -43,14 +34,34 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: Create vulnerability issue + - name: Install cargo-audit + run: cargo install cargo-audit --version 0.22.2 --locked + - name: Run cargo audit + id: audit + # Both audits run so the report shows unmaintained crates as well as + # advisories; the step still fails if either fails. + run: | + status=0 + { + echo '--- cargo audit --deny warnings ---' + cargo audit --deny warnings 2>&1 || status=$? + echo '--- cargo audit --deny unmaintained ---' + cargo audit --deny unmaintained 2>&1 || status=$? + } | tee "$RUNNER_TEMP/cargo-audit.txt" + exit "$status" + - name: Reconcile the vulnerability issue + # Runs whatever the audit did, so a passing audit closes the issue it + # opened. Skipped on pull requests (including forks) and when the audit + # never ran, because an install failure is not a vulnerability finding. + if: always() && github.event_name != 'pull_request' && steps.audit.outcome != 'skipped' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + AUDIT_STATUS: ${{ steps.audit.outcome == 'success' && '0' || '1' }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | - EXISTING=$(gh issue list --label "security,dependencies" --state open --json number -q '.[0].number') - if [ -z "$EXISTING" ]; then - gh issue create \ - --title "Security: Dependency vulnerabilities detected" \ - --body "cargo audit found vulnerabilities. Run \`cargo audit\` locally for details." \ - --label "security,dependencies" - fi + bash scripts/reconcile-audit-issue.sh \ + --status "$AUDIT_STATUS" \ + --summary "$RUNNER_TEMP/cargo-audit.txt" \ + --run "$RUN_URL" \ + --event "${{ github.event_name }}" \ + --repo "${{ github.repository }}" diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml index 8659b1f..1509052 100644 --- a/.github/workflows/mirror.yml +++ b/.github/workflows/mirror.yml @@ -1,5 +1,24 @@ # SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. +# Mirror to Git Forges. +# +# The fan-out itself lives in hyperpolymath/standards (mirror-reusable.yml), +# where each forge job is gated on a repository variable and skips itself when +# its key is absent. Two failure modes that gate cannot see are caught here +# first, by the preflight job below: +# +# * a forge enabled for this repository that META.a2ml does not declare, so +# the enabled set and the declared set disagree; and +# * a forge enabled but not actually configured — in particular the gitea leg, +# which reads `vars.GITEA_HOST` and fails inside ssh-keyscan (exit 1, no +# push attempted) when that variable is empty. +# +# The preflight reads variables only, never the forge keys: an empty key is +# handled by the reusable (skip plus notice), and no job here needs to hold one. +# +# On 2026-09-15 the mirror legs here went red in exactly that second shape: +# mirror-gitea exit 1 and mirror-disroot exit 128, with nothing in the run to +# say which configuration was missing. The preflight names it. name: Mirror to Git Forges on: push: @@ -9,6 +28,27 @@ permissions: actions: read contents: read jobs: + mirror-preflight: + name: Mirror configuration + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + env: + CODEBERG_MIRROR_ENABLED: ${{ vars.CODEBERG_MIRROR_ENABLED }} + GITLAB_MIRROR_ENABLED: ${{ vars.GITLAB_MIRROR_ENABLED }} + BITBUCKET_MIRROR_ENABLED: ${{ vars.BITBUCKET_MIRROR_ENABLED }} + SOURCEHUT_MIRROR_ENABLED: ${{ vars.SOURCEHUT_MIRROR_ENABLED }} + RADICLE_MIRROR_ENABLED: ${{ vars.RADICLE_MIRROR_ENABLED }} + GITEA_MIRROR_ENABLED: ${{ vars.GITEA_MIRROR_ENABLED }} + GITEA_HOST: ${{ vars.GITEA_HOST }} + DISROOT_MIRROR_ENABLED: ${{ vars.DISROOT_MIRROR_ENABLED }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Check the enabled mirror set against the declared one + run: bash scripts/check-mirror-config.sh mirror: uses: hyperpolymath/standards/.github/workflows/mirror-reusable.yml@84355587cb2a1f86e6882de83514a32db2646e7a secrets: inherit diff --git a/.github/workflows/workflow-linter.yml b/.github/workflows/workflow-linter.yml index 601c38d..5253608 100644 --- a/.github/workflows/workflow-linter.yml +++ b/.github/workflows/workflow-linter.yml @@ -6,8 +6,16 @@ on: pull_request: push: branches: [main, master] + schedule: + # Weekly, after the Monday cargo-audit run: a workflow can go red without a + # push (the ClusterFuzzLite batch is on a Sunday schedule), and a red that + # nobody pushes near would otherwise go unrecorded until the next commit. + - cron: '30 6 * * 1' permissions: contents: read + # Reading the run list is what lets the ledger verify recorded states against + # the latest runs on the default branch. + actions: read jobs: lint-workflows: runs-on: ubuntu-latest @@ -44,3 +52,16 @@ jobs: run: | bash scripts/check-repository-health.sh bash scripts/tests/repository-health-test.sh + - name: Verify the CI check ledger + # Offline structure always; the recorded states are verified against the + # latest runs on the default branch, where a green workflow that has + # gone red must not pass unnoticed (docs/CI-RED-DETERMINATIONS.adoc). + env: + GH_TOKEN: ${{ github.token }} + run: | + bash scripts/ci-check-ledger.sh --check + if [ '${{ github.event_name }}' = 'pull_request' ]; then + echo "pull request: recorded states are verified on the default branch" + else + bash scripts/ci-check-ledger.sh --verify + fi diff --git a/Justfile b/Justfile index 213b48d..694385b 100644 --- a/Justfile +++ b/Justfile @@ -163,13 +163,33 @@ crg-badge: secret-scan-trufflehog: @command -v trufflehog >/dev/null && trufflehog filesystem . --only-verified || true -# Offline repository quality and lock regression controls (mikefarah yq v4 + jq). +# Offline repository quality, lock and CI-ledger controls (mikefarah yq v4 + jq). ci-check: bash scripts/check-lock-sync.sh bash scripts/tests/check-lock-sync-test.sh bash scripts/tests/actions-lock-update-test.sh bash scripts/check-repository-health.sh bash scripts/tests/repository-health-test.sh + bash scripts/ci-check-ledger.sh --check + bash scripts/tests/ci-check-ledger-test.sh + bash scripts/tests/reconcile-audit-issue-test.sh + bash scripts/tests/mirror-config-test.sh + +# Recorded state and written determination for every workflow (offline). +ci-ledger-check: + bash scripts/ci-check-ledger.sh --check + +# Refresh the ledger from the latest runs on the default branch. Human fields +# (determination, reason, owner_action, issue, review_by) are left alone: a new +# red has to be determined by hand, which is the point. +ci-ledger-write: + bash scripts/ci-check-ledger.sh --write + +# Name each enabled-but-undeclared and enabled-but-unconfigured mirror. The +# mirror variables only exist in Actions, so locally this reports the declared +# set as switched off. +check-mirror-config: + bash scripts/check-mirror-config.sh # Pin the security-audit tool and reject warnings/unmaintained crates. audit: diff --git a/docs/AUDIT.adoc b/docs/AUDIT.adoc index 2e82640..6182589 100644 --- a/docs/AUDIT.adoc +++ b/docs/AUDIT.adoc @@ -4,8 +4,10 @@ Audit scope: CI quality, action-graph integrity, supply-chain controls, community-health surfaces and RSR template applicability, 2026-10-14. -See link:STANDARDS-ALIGNMENT.adoc[authority revisions, findings, checks and gaps] -and link:CI-CD-REPAIR.adoc[the original multi-gate CI repair]. +See link:STANDARDS-ALIGNMENT.adoc[authority revisions, findings, checks and gaps], +link:CI-CD-REPAIR.adoc[the original multi-gate CI repair] and +link:CI-RED-DETERMINATIONS.adoc[the recorded state and determination for every +workflow] (ledger: link:audits/ci-check-ledger.json[audits/ci-check-ledger.json]). This is a maintainer/agent review, not an independent penetration test or formal verification of the whole application. Do not infer release readiness or legal certification from this file's presence. diff --git a/docs/CI-CD-REPAIR.adoc b/docs/CI-CD-REPAIR.adoc index e87df20..2555e5e 100644 --- a/docs/CI-CD-REPAIR.adoc +++ b/docs/CI-CD-REPAIR.adoc @@ -53,6 +53,15 @@ not evidence that those repositories were changed or their CI became green. the crates.io index; GitHub's locked build and security audit validate the resulting graph. +== Follow-up: reds that had no determination + +This repair made the workflows run again, which exposed checks that were already +red. The follow-up measured every workflow's state on the default branch, recorded +a determination for each non-green one, and wired the record into CI so a red +cannot go unrecorded again: +link:CI-RED-DETERMINATIONS.adoc[CI red-check determinations] and +link:audits/ci-check-ledger.json[the ledger] (triage issue #147). + == Preventing recurrence `check-lock-sync.sh` reads YAML through mikefarah/yq v4 and compares structured diff --git a/docs/CI-RED-DETERMINATIONS.adoc b/docs/CI-RED-DETERMINATIONS.adoc new file mode 100644 index 0000000..56d697e --- /dev/null +++ b/docs/CI-RED-DETERMINATIONS.adoc @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 += CI red-check determinations +:toc: +:toclevels: 3 + +== Why this file exists + +Issue hyperpolymath/rpa-elysium#138[#138] was filed to triage "2 pre-existing red +checks" exposed when the `actions.lock` cure revived this repository's workflows. +The two entries in that issue were both named `lint-workflows` — one check +rendered twice, which is the shape the estate-wide survey in +hyperpolymath/standards#994[standards#994] records for this class. That check is +green here now. + +Measuring the repository instead of the check name found more than the issue +listed: five workflows that are red, stale, superseded or dormant on `main`, none +of them triaged anywhere. They are now determined in +link:audits/ci-check-ledger.json[the CI check ledger] and owned by +hyperpolymath/rpa-elysium#147[#147]. + +The lesson this file records is not "there were more reds than expected". It is +that reds could accumulate unrecorded because nothing in the repository said what +each workflow's state was supposed to be. Determinations now live in a checked-in +ledger that CI verifies, so the question "is this red known?" has a mechanical +answer. + +== What was measured + +Measured 2026-10-14 against `main` at `ac5946d`, from the GitHub Actions API: the +latest *completed* run per workflow on the default branch, plus each workflow's +own enabled/disabled state. 19 workflow files: **13 green, 6 with a written +determination**. + +|=== +|workflow |state |evidence |determination + +|`mirror.yml` |red |run https://github.com/hyperpolymath/rpa-elysium/actions/runs/38608642035[38608642035] — `mirror-gitea` exit 1, `mirror-disroot` exit 128 |#147 — declare-or-disable, plus the preflight below +|`cflite_batch.yml` |stale |run 2026-10-11 precedes the in-file repairs |#147 — re-measure, then fix or retire +|`cflite_pr.yml` |stale |run 2026-09-20 precedes the in-file repairs |#147 — re-measure on a `**/*.rs` pull request +|`casket-pages.yml` |superseded |`cancelled` since 2026-09-22, no successful deploy |#147 — enable Pages or retire +|`instant-sync.yml` |dormant |`disabled_manually`, last run 2026-07-14 |#147 — record the decision or delete +|`push-email-notify.yml` |dormant |`disabled_manually`, last run 2026-07-14 |#147 — record the decision or delete +|=== + +`lint-workflows` itself is green: workflow-linter run +https://github.com/hyperpolymath/rpa-elysium/actions/runs/38608640962[38608640962] +on `main`. The repair is in the repository (SPDX-before-marker ordering, the +structured lock verifier, the governance pin), not a muted step — see +link:CI-CD-REPAIR.adoc[]. + +A *stale* state is the honest reading of `cflite_*`: the failure is older than the +last commit that touched the workflow file, so it measures an earlier revision. +The repairs are in the tree; nobody has re-measured them, and asserting they work +would be the same mistake in the opposite direction. + +== The ledger + +`docs/audits/ci-check-ledger.json` is the single place a determination is +written, and `scripts/ci-check-ledger.sh` is what reads it. + +Offline (`--check`, wired into `just ci-check` and into CI on every event): + +* the ledger must cover **exactly** the workflow files on disk — a new workflow + cannot be added without a recorded state, and a deleted one cannot leave a + ghost entry; +* `green` needs an actions-run URL from *this* repository, and its determination + may only be `green` or `fixed`; +* `red` is the strict tier: it needs a reason, an owner action, an issue URL and + a review date; +* `stale`, `superseded`, `dormant`, `never-run` and `unknown` are the light tier: + a reason, a review date, and at least one of owner action or issue; +* review dates more than 90 days out are rejected, so a determination cannot be + parked indefinitely. + +Online (`--verify`, run by `workflow-linter` on pushes to the default branch): + +* recorded states are compared with the latest runs; +* a workflow recorded as green that is now red fails the check — an unrecorded + red is precisely what this mechanism exists to prevent; +* benign transitions (a merged entry going green, a superseded run) are printed + as drift to record and do not fail, because recording them is bookkeeping + rather than a hazard; +* when the API cannot be read the ledger is reported as **not verified**. It is + never reported as verified on an unreadable observation set. + +`just ci-ledger-write` refreshes states, conclusions and evidence from the API and +deliberately leaves every human field untouched: a new red still has to be +determined by hand, which is the entire point. + +== The dependency-issue lifecycle + +hyperpolymath/rpa-elysium#143[#143] and its predecessor #113 are what the old +`cargo-audit.yml` produced: an issue whose whole body was "Run `cargo audit` +locally for details", no findings, no acceptance criteria, never closed. Two of +them, indistinguishable from a live vulnerability. + +The workflow now runs both audits (blocking, unchanged: `--deny warnings` and +`--deny unmaintained`), captures the output, and hands it to +`scripts/reconcile-audit-issue.sh`, which: + +* on a failing run, keeps **one** issue carrying a stable marker, the audit + output, the run that produced it and acceptance criteria; a new failing run is + commented so the finding history survives; +* on a passing run, comments the green run and closes the marked issue. Closing is + evidence — the gate that opened the issue is the gate that closes it; +* adopts an issue created by the old, markerless publisher (same title, same + labels) instead of opening a duplicate beside it; +* never touches issues on pull requests. + +The advisory that produced #143, RUSTSEC-2026-0316, was fixed by the +`wasmtime 36.0.16` / `async-trait 0.1.92` update in +link:CI-CD-REPAIR.adoc[the CI repair]; cargo-audit run +https://github.com/hyperpolymath/rpa-elysium/actions/runs/38606899195[38606899195] +on `main` is green and is the evidence the reconciler links when it closes #143. + +== The mirror configuration + +Seven mirror legs run against three declared mirrors. The declaration is +`.machine_readable/6a2/META.a2ml` (`forges = ["github", "gitlab", "bitbucket", +"codeberg"]`), and `README.adoc` §Multi-Forge Distribution and `ROADMAP.adoc` +agree with it. `gitea` and `disroot` are enabled but undeclared; `sourcehut` and +`radicle` are enabled, undeclared, and currently working. + +`scripts/check-mirror-config.sh` runs as the `mirror-preflight` job in +`mirror.yml` before the fan-out. It fails on an enabled-but-undeclared forge and +on an enabled-but-unconfigured one — including the specific shape that reddened +`main`: the gitea leg reads `vars.GITEA_HOST`, and an empty host makes +`ssh-keyscan` exit 1 before any push is attempted. The preflight reads repository +*variables* only: an empty forge key is already handled by the reusable (the job +prints a skip notice rather than mirroring), and a job that only compares sets +has no reason to hold an SSH key. The five working legs and the +two failing ones stay exactly as they are until the owner decides; what changes is +that the decision is now named in the run instead of left as an exit code. + +== What this does not claim + +* No Idris2, Zig or Rust toolchain is available in the environment where this was + written: nothing here compiles anything, and the ABI/FFI evidence rules in + link:STANDARDS-ALIGNMENT.adoc[] are unaffected. The RSR gaps measured there + remain open. +* The ledger records GitHub Actions state. It is not a claim that the 13 green + workflows test what they appear to test. +* Closing the dependency issue when the audit passes is not a waiver: the audit + steps are unchanged and blocking, and a waiver of a specific advisory still has + to be written into the change that needs it. +* Estate-wide rollout is not part of this: the mirror reusable's missing-host + behaviour and the templated `cargo-audit.yml` publisher are both standards-side + candidates, filed separately rather than patched per repository. + +== Updating the record + +[source,bash] +---- +just ci-ledger-check # offline: does the ledger still describe the workflows? +just ci-ledger-write # refresh states/evidence from the latest runs +$EDITOR docs/audits/ci-check-ledger.json # record the determination for any new red +just ci-ledger-check # the entry is rejected until reason/owner/issue/review date are real +---- + +Interpretation belongs in the ledger entry (reason and owner action) and in the +issue the entry points at. Evidence belongs in the entry's `evidence` list. diff --git a/docs/audits/ci-check-ledger.json b/docs/audits/ci-check-ledger.json new file mode 100644 index 0000000..37cb2a0 --- /dev/null +++ b/docs/audits/ci-check-ledger.json @@ -0,0 +1,333 @@ +{ + "default_branch": "main", + "description": "Recorded state and determination for every workflow in this repository. Green entries carry the run that proves it; every non-green entry carries the reason, the owner action that clears it, an issue, and a review date. Validate with scripts/ci-check-ledger.sh --check.", + "repo": "hyperpolymath/rpa-elysium", + "schema_version": "1.0", + "updated": "2026-10-14", + "workflows": [ + { + "conclusion": "success", + "determination": "green", + "evidence": [ + "https://github.com/hyperpolymath/rpa-elysium/actions/runs/38602621810" + ], + "file_updated": null, + "issue": null, + "name": "BoJ Server Build Trigger", + "observed": "2026-10-14", + "owner_action": null, + "path": ".github/workflows/boj-build.yml", + "reason": "", + "review_by": null, + "state": "green", + "workflow_state": "active" + }, + { + "conclusion": "success", + "determination": "green", + "evidence": [ + "https://github.com/hyperpolymath/rpa-elysium/actions/runs/38606899195" + ], + "file_updated": null, + "issue": null, + "name": "Cargo Audit", + "observed": "2026-10-14", + "owner_action": null, + "path": ".github/workflows/cargo-audit.yml", + "reason": "", + "review_by": null, + "state": "green", + "workflow_state": "active" + }, + { + "conclusion": "cancelled", + "determination": "in-triage", + "evidence": [ + "https://github.com/hyperpolymath/rpa-elysium/actions/runs/38608640928" + ], + "file_updated": "2026-10-14", + "issue": "https://github.com/hyperpolymath/rpa-elysium/issues/147", + "name": "GitHub Pages", + "observed": "2026-10-14", + "owner_action": "Enable GitHub Pages with source 'GitHub Actions', or retire the workflow and its actions.lock entry and drop docs-site from .machine_readable/rsr-profile.a2ml.", + "path": ".github/workflows/casket-pages.yml", + "reason": "Pages deploys alternate cancelled and startup_failure since 2026-09-22 with no successful deployment in the window; a Pages workflow cannot start when the repository site is not configured for GitHub Actions, which is the shape startup_failure takes here.", + "review_by": "2026-11-13", + "state": "superseded", + "workflow_state": "active" + }, + { + "conclusion": "startup_failure", + "determination": "in-triage", + "evidence": [ + "https://github.com/hyperpolymath/rpa-elysium/actions/runs/38097301623" + ], + "file_updated": "2026-10-14", + "issue": "https://github.com/hyperpolymath/rpa-elysium/issues/147", + "name": "ClusterFuzzLite Batch", + "observed": "2026-10-11", + "owner_action": "Run 'ClusterFuzzLite Batch' from the Actions UI (or wait for the Sunday schedule) and record the result; if it fails, fix the harness or retire both cflite workflows and their actions.lock entries in one change.", + "path": ".github/workflows/cflite_batch.yml", + "reason": "The weekly ClusterFuzzLite batch run of 2026-10-11 failed before the in-file build.sh and vulnerabilities.sarif repairs now on main, so the current file is unmeasured rather than known-red.", + "review_by": "2026-11-13", + "state": "stale", + "workflow_state": "active" + }, + { + "conclusion": "failure", + "determination": "in-triage", + "evidence": [ + "https://github.com/hyperpolymath/rpa-elysium/actions/runs/35542891981" + ], + "file_updated": "2026-10-14", + "issue": "https://github.com/hyperpolymath/rpa-elysium/issues/147", + "name": "ClusterFuzzLite PR", + "observed": "2026-09-20", + "owner_action": "Push a change under **/*.rs, or dispatch 'ClusterFuzzLite PR', and record the result; if it fails, fix the harness or retire both cflite workflows and their actions.lock entries in one change.", + "path": ".github/workflows/cflite_pr.yml", + "reason": "The last run (2026-09-20) predates the in-file build.sh and vulnerabilities.sarif repairs, and the workflow only runs on pull requests that touch **/*.rs, so the repaired file has never been measured.", + "review_by": "2026-11-13", + "state": "stale", + "workflow_state": "active" + }, + { + "conclusion": "success", + "determination": "green", + "evidence": [ + "https://github.com/hyperpolymath/rpa-elysium/actions/runs/38608640985" + ], + "file_updated": null, + "issue": null, + "name": "CodeQL Security Analysis", + "observed": "2026-10-14", + "owner_action": null, + "path": ".github/workflows/codeql.yml", + "reason": "", + "review_by": null, + "state": "green", + "workflow_state": "active" + }, + { + "conclusion": "success", + "determination": "green", + "evidence": [ + "https://github.com/hyperpolymath/rpa-elysium/actions/runs/38608641076" + ], + "file_updated": null, + "issue": null, + "name": "Dogfood Gate", + "observed": "2026-10-14", + "owner_action": null, + "path": ".github/workflows/dogfood-gate.yml", + "reason": "", + "review_by": null, + "state": "green", + "workflow_state": "active" + }, + { + "conclusion": "success", + "determination": "green", + "evidence": [ + "https://github.com/hyperpolymath/rpa-elysium/actions/runs/38608641802" + ], + "file_updated": null, + "issue": null, + "name": "Governance", + "observed": "2026-10-14", + "owner_action": null, + "path": ".github/workflows/governance.yml", + "reason": "", + "review_by": null, + "state": "green", + "workflow_state": "active" + }, + { + "conclusion": "success", + "determination": "green", + "evidence": [ + "https://github.com/hyperpolymath/rpa-elysium/actions/runs/38608641836" + ], + "file_updated": null, + "issue": null, + "name": "Hypatia Security Scan", + "observed": "2026-10-14", + "owner_action": null, + "path": ".github/workflows/hypatia-scan.yml", + "reason": "", + "review_by": null, + "state": "green", + "workflow_state": "active" + }, + { + "conclusion": "failure", + "determination": "exempt", + "evidence": [ + "https://github.com/hyperpolymath/rpa-elysium/actions/runs/29342839608" + ], + "file_updated": null, + "issue": "https://github.com/hyperpolymath/rpa-elysium/issues/147", + "name": "Instant Sync", + "observed": "2026-07-14", + "owner_action": "Confirm the dormancy is permanent and delete the workflow plus its actions.lock entry, or record what unblocks the farm dispatch token.", + "path": ".github/workflows/instant-sync.yml", + "reason": "disabled_manually in GitHub: the forge-propagation dispatch to .git-private-farm has not run since 2026-07-14. Nothing is broken and no run is expected while the workflow is off.", + "review_by": "2026-11-13", + "state": "dormant", + "workflow_state": "disabled_manually" + }, + { + "conclusion": "success", + "determination": "green", + "evidence": [ + "https://github.com/hyperpolymath/rpa-elysium/actions/runs/38613978647", + "https://github.com/hyperpolymath/rpa-elysium/actions/runs/35776613292" + ], + "file_updated": null, + "issue": null, + "name": "Label Triage", + "observed": "2026-10-14", + "owner_action": null, + "path": ".github/workflows/label-triage.yml", + "reason": "", + "review_by": null, + "state": "green", + "workflow_state": "active" + }, + { + "conclusion": "success", + "determination": "green", + "evidence": [ + "https://github.com/hyperpolymath/rpa-elysium/actions/runs/36816004282" + ], + "file_updated": null, + "issue": null, + "name": "Labels", + "observed": "2026-10-01", + "owner_action": null, + "path": ".github/workflows/labels.yml", + "reason": "", + "review_by": null, + "state": "green", + "workflow_state": "active" + }, + { + "conclusion": "success", + "determination": "green", + "evidence": [ + "https://github.com/hyperpolymath/rpa-elysium/actions/runs/38608640933" + ], + "file_updated": null, + "issue": null, + "name": "Lock Sync Gate", + "observed": "2026-10-14", + "owner_action": null, + "path": ".github/workflows/lock-sync-gate.yml", + "reason": "", + "review_by": null, + "state": "green", + "workflow_state": "active" + }, + { + "conclusion": "failure", + "determination": "in-triage", + "evidence": [ + "https://github.com/hyperpolymath/rpa-elysium/actions/runs/38608642035" + ], + "file_updated": "2026-10-14", + "issue": "https://github.com/hyperpolymath/rpa-elysium/issues/147", + "name": "Mirror to Git Forges", + "observed": "2026-10-14", + "owner_action": "Decide declare-or-disable for each enabled forge (gitea and disroot, plus the undeclared-but-working sourcehut and radicle), set or remove vars.GITEA_HOST accordingly, and diagnose or retire the Disroot leg.", + "path": ".github/workflows/mirror.yml", + "reason": "Two of seven mirror legs fail on main: mirror-gitea exits 1 (the ssh-keyscan step runs with an empty vars.GITEA_HOST) and mirror-disroot exits 128 (git push refused). Seven legs are enabled while META.a2ml, README.adoc and ROADMAP.adoc declare three mirrors.", + "review_by": "2026-11-13", + "state": "red", + "workflow_state": "active" + }, + { + "conclusion": "startup_failure", + "determination": "exempt", + "evidence": [ + "https://github.com/hyperpolymath/rpa-elysium/actions/runs/29342847990" + ], + "file_updated": null, + "issue": "https://github.com/hyperpolymath/rpa-elysium/issues/147", + "name": "Push email notification", + "observed": "2026-07-14", + "owner_action": "Confirm the freeze is permanent and delete the workflow plus its actions.lock entry, or re-enable it by setting the PUSH_EMAIL_ENABLED variable to true and re-running once.", + "path": ".github/workflows/push-email-notify.yml", + "reason": "disabled_manually in GitHub since the 2026-07-20 notification-storm freeze; the file itself documents PUSH_EMAIL_ENABLED=true as the single on/off switch and records why the concurrency group must stay per-run.", + "review_by": "2026-11-13", + "state": "dormant", + "workflow_state": "disabled_manually" + }, + { + "conclusion": "success", + "determination": "green", + "evidence": [ + "https://github.com/hyperpolymath/rpa-elysium/actions/runs/38608641840" + ], + "file_updated": null, + "issue": null, + "name": "Rust CI", + "observed": "2026-10-14", + "owner_action": null, + "path": ".github/workflows/rust-ci.yml", + "reason": "", + "review_by": null, + "state": "green", + "workflow_state": "active" + }, + { + "conclusion": "success", + "determination": "green", + "evidence": [ + "https://github.com/hyperpolymath/rpa-elysium/actions/runs/38510365624" + ], + "file_updated": null, + "issue": null, + "name": "OSSF Scorecard", + "observed": "2026-10-14", + "owner_action": null, + "path": ".github/workflows/scorecard.yml", + "reason": "", + "review_by": null, + "state": "green", + "workflow_state": "active" + }, + { + "conclusion": "success", + "determination": "green", + "evidence": [ + "https://github.com/hyperpolymath/rpa-elysium/actions/runs/38608641807" + ], + "file_updated": null, + "issue": null, + "name": "Secret Scanner", + "observed": "2026-10-14", + "owner_action": null, + "path": ".github/workflows/secret-scanner.yml", + "reason": "", + "review_by": null, + "state": "green", + "workflow_state": "active" + }, + { + "conclusion": "success", + "determination": "green", + "evidence": [ + "https://github.com/hyperpolymath/rpa-elysium/actions/runs/38608640962" + ], + "file_updated": null, + "issue": null, + "name": "Workflow Security Linter", + "observed": "2026-10-14", + "owner_action": null, + "path": ".github/workflows/workflow-linter.yml", + "reason": "", + "review_by": null, + "state": "green", + "workflow_state": "active" + } + ] +} diff --git a/scripts/check-mirror-config.sh b/scripts/check-mirror-config.sh new file mode 100644 index 0000000..649224a --- /dev/null +++ b/scripts/check-mirror-config.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# check-mirror-config.sh — the enabled mirror set must match the declared one, +# and every enabled forge must actually be configured. +# +# This reads repository *variables* only. Key secrets are deliberately not read: +# an empty key is already handled by the reusable (the job prints a skip notice +# rather than mirroring), and copying SSH keys into a job that only compares sets +# would buy nothing. What this catches is the variable-shaped failure the keys +# cannot explain — an empty vars.GITEA_HOST, and forges enabled without being +# declared. +# +# Why this exists: `mirror.yml` delegates to a reusable that fans out to one job +# per forge, each gated on a repository variable (`_MIRROR_ENABLED`) and +# skipping itself when its key is absent. When a *variable* is missing instead +# of a secret — the gitea job reads `vars.GITEA_HOST` — the job still starts and +# dies inside `ssh-keyscan`, leaving `main` red with an exit code and nothing +# else to go on. Both failures this repo hit on 2026-09-15 were of that shape: +# `mirror-gitea` exit 1, `mirror-disroot` exit 128. +# +# The declared set lives in .machine_readable/6a2/META.a2ml (`forges = [...]`), +# which is also what README.adoc §Multi-Forge Distribution and ROADMAP.adoc +# state. Enabling a forge there without declaring it — or declaring one that is +# not enabled — is drift, and drift is what this script fails on. +# +# Usage: scripts/check-mirror-config.sh [REPO_ROOT] +# Exit: 0 consistent | 1 drift or unconfigured enabled forge | 2 setup failure + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$SCRIPT_DIR/.." +if [ "$#" -gt 0 ] && [ -d "${1:-}" ] && [ "${1#-}" = "$1" ]; then + REPO_ROOT="$1" + shift +fi +REPO_ROOT="$(cd "$REPO_ROOT" && pwd)" +case "$#" in + 0) ;; + -h|--help) sed -n '2,22p' "${BASH_SOURCE[0]}"; exit 0 ;; + *) printf 'check-mirror-config: unknown argument: %s\n' "$1" >&2; exit 2 ;; +esac + +META="$REPO_ROOT/.machine_readable/6a2/META.a2ml" +[ -f "$META" ] || { printf 'check-mirror-config: %s not found\n' "$META" >&2; exit 2; } + +FAILED=0 +forges=(codeberg gitlab bitbucket sourcehut radicle gitea disroot) + +enabled_flag() { # forge -> true|false|"" (unset), by indirect expansion + local upper name + upper="$(printf '%s' "$1" | tr '[:lower:]' '[:upper:]')" + name="${upper}_MIRROR_ENABLED" + printf '%s' "${!name-}" +} + +host_value() { # forge -> the host the reusable targets + case "$1" in + gitea) printf '%s' "${GITEA_HOST:-}" ;; + codeberg) printf 'codeberg.org' ;; + gitlab) printf 'gitlab.com' ;; + bitbucket) printf 'bitbucket.org' ;; + sourcehut) printf 'git.sr.ht' ;; + disroot) printf 'git.disroot.org' ;; + radicle) printf 'p2p seed node' ;; + esac +} + +# Declared mirrors: the META.a2ml forges minus the origin itself. +declared="$(sed -n 's/^forges *= *\[\(.*\)\]$/\1/p' "$META" | tr -d '"' | tr ',' '\n' \ + | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' | grep -v '^github$' | grep -v '^$' || true)" +[ -n "$declared" ] || { printf 'check-mirror-config: no forges list found in %s\n' "$META" >&2; exit 2; } + +printf 'check-mirror-config: declared mirrors: %s\n' "$(printf '%s' "$declared" | tr '\n' ' ')" +printf '%-10s %-8s %-9s %-14s %s\n' forge enabled declared host verdict +for forge in "${forges[@]}"; do + flag="$(enabled_flag "$forge")" + host="$(host_value "$forge")" + if printf '%s\n' "$declared" | grep -qx "$forge"; then shown_declared=yes; else shown_declared=no; fi + if [ "$flag" = "true" ]; then shown_enabled=yes; else shown_enabled=no; fi + if [ -n "$host" ]; then shown_host="$host"; else shown_host="unset"; fi + + verdict="ok" + if [ "$shown_enabled" = yes ] && [ "$shown_declared" = no ]; then + verdict=undeclared + printf '::error::check-mirror-config: the %s mirror is enabled for this repository but %s does not declare it (README.adoc and ROADMAP.adoc agree with META.a2ml). Declare it or disable it: gh variable delete %s_MIRROR_ENABLED\n' \ + "$forge" ".machine_readable/6a2/META.a2ml" "$(printf '%s' "$forge" | tr '[:lower:]' '[:upper:]')" + FAILED=1 + fi + if [ "$shown_enabled" = yes ] && [ "$forge" = gitea ] && [ -z "$host" ]; then + verdict=no-host + printf '::error::check-mirror-config: the gitea mirror is enabled but vars.GITEA_HOST is empty, so the ssh-keyscan step fails with exit 1 before any push is attempted. Set it (gh variable set GITEA_HOST --body ) or delete GITEA_MIRROR_ENABLED.\n' + FAILED=1 + fi + if [ "$shown_enabled" = no ] && [ "$shown_declared" = yes ]; then + if [ "$verdict" = ok ]; then verdict="declared-off"; fi + printf '::warning::check-mirror-config: %s is declared in META.a2ml but not enabled, so the declaration overstates what happens: gh variable set %s_MIRROR_ENABLED --body true, or remove it from the declaration.\n' \ + "$forge" "$(printf '%s' "$forge" | tr '[:lower:]' '[:upper:]')" + fi + printf '%-10s %-8s %-9s %-14s %s\n' "$forge" "$shown_enabled" "$shown_declared" "$shown_host" "$verdict" +done + +if [ "$FAILED" -ne 0 ]; then + printf '\ncheck-mirror-config: FAIL — the enabled mirror set and the declared mirror set disagree.\n' >&2 + printf 'See docs/CI-RED-DETERMINATIONS.adoc and hyperpolymath/rpa-elysium#147.\n' >&2 + exit 1 +fi +printf 'check-mirror-config: OK — every enabled mirror is declared and configured.\n' diff --git a/scripts/ci-check-ledger.sh b/scripts/ci-check-ledger.sh new file mode 100644 index 0000000..2545fb6 --- /dev/null +++ b/scripts/ci-check-ledger.sh @@ -0,0 +1,415 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# ci-check-ledger.sh — every workflow in this repository has a recorded state, +# and every workflow that is not green has a written determination. +# +# This is the mechanised form of the estate's standing stopping rule: a red +# check is an issue with acceptance criteria, never something that stays +# undetermined (hyperpolymath/standards#138, #1070). Reds used to accumulate +# invisibly here — five workflows sat red or dormant on main while the only +# tracked triage issue named one check. +# +# The ledger (docs/audits/ci-check-ledger.json) is the single place where a +# determination is written. --check requires it to cover exactly the workflow +# files on disk, so neither a new workflow nor a deleted one can be missed. +# +# States, derived in this order: +# dormant the workflow is disabled in GitHub (a deliberate owner act) +# green the latest completed run on the default branch succeeded +# red the latest completed run failed +# stale the latest completed run failed/cancelled but predates the last +# commit that touched the workflow file, so it measures an older +# revision (repair is in the tree, awaiting a run) +# superseded the latest completed run was cancelled or skipped +# never-run no completed run on the default branch +# unknown the API reported something this script does not classify +# +# Tiering: green needs evidence and a green|fixed determination. red is the +# strict tier and needs reason, owner_action, an issue URL and review_by. +# Every other non-green state is the light tier and needs reason and review_by +# plus at least one of owner_action / issue. +# +# Modes: +# --check (default) offline, fail-closed structural validation. +# --verify compare recorded states with the latest runs on the default +# branch. Fails when a workflow recorded as green is not: an +# unrecorded red is exactly what this mechanism exists to prevent. +# Benign transitions warn; recording them is bookkeeping. +# --write update states, conclusions, evidence and staleness from the API. +# Human judgement (determination, reason, owner_action, issue, +# review_by) is never touched. +# +# Usage: +# scripts/ci-check-ledger.sh [--check|--verify|--write] [REPO_ROOT] +# [--ledger FILE] [--observations FILE] +# [--repo OWNER/NAME] [--branch NAME] [--quiet] +# Exit: 0 pass | 1 violation | 2 setup failure + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$SCRIPT_DIR/.." +# An optional positional argument (never a flag) names the repository root. +if [ "$#" -gt 0 ] && [ -d "${1:-}" ] && [ "${1#-}" = "$1" ]; then + REPO_ROOT="$1" + shift +fi +REPO_ROOT="$(cd "$REPO_ROOT" && pwd)" + +LEDGER="docs/audits/ci-check-ledger.json" +MODE="check" +OBSERVATIONS="" +REPO="hyperpolymath/rpa-elysium" +BRANCH="main" +WORKFLOW_REL=".github/workflows" +QUIET=0 + +while [ "$#" -gt 0 ]; do + case "$1" in + --check) MODE="check" ;; + --verify) MODE="verify" ;; + --write) MODE="write" ;; + --ledger) LEDGER="$2"; shift ;; + --observations) OBSERVATIONS="$2"; shift ;; + --repo) REPO="$2"; shift ;; + --branch) BRANCH="$2"; shift ;; + --quiet) QUIET=1 ;; + -h|--help) sed -n '2,45p' "${BASH_SOURCE[0]}"; exit 0 ;; + *) printf 'ci-check-ledger: unknown argument: %s\n' "$1" >&2; exit 2 ;; + esac + shift +done + +case "$LEDGER" in /*) ;; *) LEDGER="$REPO_ROOT/$LEDGER" ;; esac +case "$OBSERVATIONS" in ""|/*) ;; *) OBSERVATIONS="$REPO_ROOT/$OBSERVATIONS" ;; esac + +say() { [ "$QUIET" -eq 1 ] || printf 'ci-check-ledger: %s\n' "$*"; } +warn() { printf '::warning::ci-check-ledger: %s\n' "$*" >&2; } +bad() { printf 'ci-check-ledger: FAIL: %s\n' "$*" >&2; FAILED=1; } +note() { printf 'ci-check-ledger: %s\n' "$*"; } + +FAILED=0 +STATES='["green","red","stale","superseded","dormant","never-run","unknown"]' +DETERMINATIONS='["green","fixed","exempt","in-triage"]' +STRICT_STATES='["red"]' +LIGHT_STATES='["stale","superseded","dormant","never-run","unknown"]' + +# ------------------------------------------------------------------- setup +command -v jq >/dev/null 2>&1 || { printf 'ci-check-ledger: jq is required\n' >&2; exit 2; } +[ -f "$LEDGER" ] || { printf 'ci-check-ledger: ledger not found: %s\n' "$LEDGER" >&2; exit 2; } +jq -e . "$LEDGER" >/dev/null 2>&1 || { printf 'ci-check-ledger: ledger is not valid JSON: %s\n' "$LEDGER" >&2; exit 2; } + +mapfile -t FILES < <(cd "$REPO_ROOT" && ls "$WORKFLOW_REL"/*.yml "$WORKFLOW_REL"/*.yaml 2>/dev/null | sort) +[ "${#FILES[@]}" -gt 0 ] || { printf 'ci-check-ledger: no workflows found under %s\n' "$WORKFLOW_REL" >&2; exit 2; } + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +printf '%s\n' "${FILES[@]}" > "$TMP/disk-paths" +jq -r '.workflows[].path' "$LEDGER" | sort > "$TMP/ledger-paths" + +TODAY="$(date -u +%F)" +HORIZON="$(date -u -d '+90 days' +%F 2>/dev/null || date -u -v+90d +%F)" + +# ------------------------------------------------------- observations source +observed_states() { + # Emits {"": {"workflow_state", "conclusion", "run", "observed", + # "file_updated"}} for every workflow, or writes a reason to stdout and + # returns non-zero when observation is impossible. + if [ -n "$OBSERVATIONS" ]; then + jq -e . "$OBSERVATIONS" >/dev/null 2>&1 || { echo "observations file is not valid JSON: $OBSERVATIONS"; return 2; } + jq -c . "$OBSERVATIONS" > "$TMP/obs.final.json" + emit_observations + return $? + fi + command -v gh >/dev/null 2>&1 || { echo "gh is not installed"; return 2; } + + if ! gh api "repos/$REPO/actions/workflows?per_page=100" \ + > "$TMP/workflows.json" 2> "$TMP/err"; then + echo "GitHub API unavailable for the workflow list: $(head -1 "$TMP/err" | cut -c1-140)" + return 2 + fi + if ! gh api "repos/$REPO/actions/runs?branch=$BRANCH&per_page=100" --paginate \ + > "$TMP/runs.jsonl" 2> "$TMP/err"; then + echo "GitHub API unavailable for the run list: $(head -1 "$TMP/err" | cut -c1-140)" + return 2 + fi + [ -s "$TMP/runs.jsonl" ] || { echo "GitHub API returned no run pages for $REPO ($BRANCH)"; return 2; } + + # --paginate emits one JSON document per page; -s slurps them into an array. + # The newest COMPLETED run is used, so an in-flight run is never mistaken for + # a state change; a workflow disabled in GitHub is recorded as dormant. + jq -c -s --slurpfile wf "$TMP/workflows.json" ' + ($wf[0].workflows | map({key: (.path | sub("^/";"")), value: .state}) | from_entries) as $states + | [.[] | .workflow_runs[]?] + | map(select(.path != null)) + | group_by(.path) + | map( + (map(select(.conclusion != null)) | max_by(.created_at)) as $done + | {key: (.[0].path | sub("^/";"")), + value: (if $done == null + then {workflow_state: ($states[.[0].path] // "active"), conclusion: null, + run: "", observed: null, file_updated: null} + else {workflow_state: ($states[$done.path] // "active"), conclusion: $done.conclusion, + run: ($done.html_url // ""), observed: ($done.created_at | .[0:10]), + file_updated: null} + end)} + ) + | from_entries + ' "$TMP/runs.jsonl" > "$TMP/obs.json" + + # Staleness only matters for observations that are not green and not dormant: + # compare the run date with the last commit that touched the workflow file. + jq -r 'to_entries[] + | select(.value.workflow_state == "active") + | select(.value.conclusion != null and .value.conclusion != "success") + | select(.value.conclusion != "skipped") + | .key' "$TMP/obs.json" > "$TMP/needs-staleness" + : > "$TMP/staleness.jsonl" + while IFS= read -r path; do + [ -n "$path" ] || continue + when="$(gh api "repos/$REPO/commits?sha=$BRANCH&path=$path&per_page=1" \ + --jq 'if length > 0 then .[0].commit.committer.date else null end' 2>/dev/null || echo null)" + when="${when%\"}"; when="${when#\"}" + [ -n "$when" ] && [ "$when" != "null" ] || continue + jq -cn --arg p "$path" --arg d "${when:0:10}" '{key: $p, value: $d}' >> "$TMP/staleness.jsonl" + done < "$TMP/needs-staleness" + + if [ -s "$TMP/staleness.jsonl" ]; then + # No -s here: slurping the observations would turn the object into an array + # and with_entries would index it numerically, silently emptying every + # lookup. --slurpfile already handles the dates. + jq -c --slurpfile dates "$TMP/staleness.jsonl" ' + # --slurpfile already yields an array of the JSON values in the file. + ($dates | from_entries) as $d + | with_entries(.value.file_updated = ($d[.key] // null)) + ' "$TMP/obs.json" > "$TMP/obs.final.json" || { echo "could not merge staleness into the observations"; return 2; } + else + cp "$TMP/obs.json" "$TMP/obs.final.json" + fi + emit_observations +} + +# emit_observations is the single exit path: fail closed on an empty or +# malformed set, because every lookup would then read null and every workflow +# would be recorded as never-run — worse than a hard error. +emit_observations() { + if ! jq -e 'type == "object" and (length > 0) + and all(.[]; type == "object" and (.conclusion == null or (.conclusion | type == "string")))' \ + "$TMP/obs.final.json" >/dev/null 2>&1; then + echo "the observation set is empty or malformed" + return 2 + fi + cat "$TMP/obs.final.json" + return 0 +} + +# classify lives in one place so --check, --verify and --write cannot disagree. +read -r -d '' CLASSIFY <<'JQ' || true +def classify($o): + if $o == null then "never-run" + elif (($o.workflow_state // "active") | startswith("disabled")) then "dormant" + elif $o.conclusion == "success" then "green" + elif ($o.conclusion == "failure" or $o.conclusion == "startup_failure" + or $o.conclusion == "timed_out" or $o.conclusion == "action_required") then + (if ($o.file_updated != null and $o.observed != null and $o.file_updated > $o.observed) + then "stale" else "red" end) + elif ($o.conclusion == "cancelled" or $o.conclusion == "skipped") then + (if ($o.file_updated != null and $o.observed != null and $o.file_updated > $o.observed) + then "stale" else "superseded" end) + elif ($o.conclusion == null) then "never-run" + else "unknown" end; +JQ + +# -------------------------------------------------------------------- check +run_check() { + jq -e '(.schema_version == "1.0") and (.workflows | type == "array") and (.repo | type == "string")' \ + "$LEDGER" >/dev/null || bad "ledger header is invalid (need schema_version 1.0, repo, workflows[])" + + # The ledger must cover exactly the workflow files on disk. + while IFS= read -r p; do + [ -n "$p" ] || continue + grep -qxF "$p" "$TMP/ledger-paths" || { + bad "$p exists but has no ledger entry — every workflow needs a recorded state" + note " add it with: just ci-ledger-write (then fill in the determination fields)" + } + done < "$TMP/disk-paths" + while IFS= read -r p; do + [ -n "$p" ] || continue + grep -qxF "$p" "$TMP/disk-paths" || bad "ledger lists $p, which does not exist — remove the entry" + done < "$TMP/ledger-paths" + + # Per-entry validation, evaluated in jq so the rules stay in one place. + # shellcheck disable=SC2016 # the jq program is intentionally single-quoted + jq -r --argjson states "$STATES" --argjson dets "$DETERMINATIONS" \ + --argjson strict "$STRICT_STATES" --argjson light "$LIGHT_STATES" \ + --arg today "$TODAY" --arg horizon "$HORIZON" --arg repo "$REPO" ' + def url_ok: startswith("https://github.com/" + $repo + "/actions/runs/") + and test("/actions/runs/[0-9]+$"); + def issue_ok: type == "string" and test("^https://github\\.com/[^/]+/[^/]+/(issues|pull)/[0-9]+$"); + def date_ok: type == "string" and test("^[0-9]{4}-[0-9]{2}-[0-9]{2}$"); + .workflows[] + | . as $e + | [ + (if ($e.path | test("^\\.github/workflows/[A-Za-z0-9._-]+\\.ya?ml$") | not) + then "\($e.path): path is not a workflow path" else empty end), + (if (($e.name // "") | length == 0) then "\($e.path): name is empty" else empty end), + (if ($states | index($e.state) | not) + then "\($e.path): state \"\($e.state)\" is not one of \($states | join(", "))" else empty end), + (if ($dets | index($e.determination) | not) + then "\($e.path): determination \"\($e.determination)\" is not one of \($dets | join(", "))" else empty end), + (if ($e.evidence | type != "array") then "\($e.path): evidence must be an array" else empty end), + (($e.evidence // [])[] | if (url_ok | not) + then "\($e.path): evidence is not a \($repo) actions run URL: \(.)" else empty end), + # green: proven by a run, and its determination may only be green|fixed + (if ($e.state == "green" and (((($e.determination == "green") or ($e.determination == "fixed"))) | not)) + then "\($e.path): state green accepts determination green|fixed, not \"\($e.determination)\"" else empty end), + (if ($e.state == "green" and (($e.evidence | length) == 0)) + then "\($e.path): state green needs at least one actions-run evidence URL" else empty end), + # non-green: a written determination that cannot rot + (if (($e.state != "green") and (($e.determination == "green") or ($e.determination == "fixed"))) + then "\($e.path): state \($e.state) needs an exempt|in-triage determination, not \"\($e.determination)\"" else empty end), + (if (($e.state != "green") and (($e.reason // "") | length < 10)) + then "\($e.path): state \($e.state) needs a reason (at least 10 characters)" else empty end), + (if (($e.state != "green") and ($e.review_by | date_ok | not)) + then "\($e.path): state \($e.state) needs review_by as YYYY-MM-DD" else empty end), + (if (($e.state != "green") and ($e.review_by | date_ok) and $e.review_by < $today) + then "\($e.path): review_by \($e.review_by) has passed — re-determine or clear it" else empty end), + (if (($e.state != "green") and ($e.review_by | date_ok) and $e.review_by > $horizon) + then "\($e.path): review_by \($e.review_by) is more than 90 days out" else empty end), + # the strict tier: a red must have an owner and an issue + (if (($strict | index($e.state)) and (($e.owner_action // "") | length < 5)) + then "\($e.path): state red needs an owner_action (the exact step that clears it)" else empty end), + (if (($strict | index($e.state)) and (($e.issue // "") | issue_ok | not)) + then "\($e.path): state red needs an issue URL where the acceptance criteria live" else empty end), + # the light tier: one concrete pointer is enough + (if (($light | index($e.state)) and ((($e.owner_action // "") | length < 5) and (($e.issue // "") | issue_ok | not))) + then "\($e.path): state \($e.state) needs an owner_action or an issue URL" else empty end), + (if ($e.issue != null and ($e.issue | issue_ok | not)) + then "\($e.path): issue must be an issue or pull-request URL when present" else empty end), + (if ($e.determination == "exempt" and $e.state == "green") + then "\($e.path): exemption recorded for a green workflow — clear the exemption" else empty end) + ] + | .[] + ' "$LEDGER" > "$TMP/findings" + while IFS= read -r f; do + [ -n "$f" ] || continue + bad "$f" + done < "$TMP/findings" + + local total green determined + total="$(jq '.workflows | length' "$LEDGER")" + green="$(jq '[.workflows[] | select(.state == "green")] | length' "$LEDGER")" + determined="$(jq '[.workflows[] | select(.state != "green")] | length' "$LEDGER")" + say "ledger: $total workflow(s), $green green, $determined with a written determination" +} + +# ------------------------------------------------------------------- verify +run_verify() { + local obs + if ! obs="$(observed_states)"; then + warn "could not observe workflow states (${obs:-unknown reason})" + warn "the ledger was NOT verified; offline validation (--check) still applies" + return 0 + fi + printf '%s' "$obs" > "$TMP/observed.json" + + jq -n --slurpfile ledger "$LEDGER" --slurpfile obs "$TMP/observed.json" "$CLASSIFY"' + $obs[0] as $o + | $ledger[0].workflows[] + | . as $e + | classify($o[$e.path]) as $seen + | if $seen == $e.state then empty + else {path: $e.path, recorded: $e.state, observed: $seen, + run: ($o[$e.path].run // ""), severity: ( + if ($e.state == "green" and ($seen == "red" or $seen == "unknown")) + then "unrecorded-red" + elif ($e.state == "never-run") then "first-observation" + else "bookkeeping" end)} + end + ' > "$TMP/drift" + + if [ -s "$TMP/drift" ]; then + # Process substitution, not a pipe: `bad` sets FAILED, and a pipeline + # would run it in a subshell where the flag is lost. + while IFS= read -r line; do + [ -n "$line" ] || continue + bad "state drift:$line" + done < <(jq -r 'select(.severity == "unrecorded-red") + | " \(.path): recorded \(.recorded), observed \(.observed)\(if .run != "" then " — \(.run)" else "" end)"' \ + "$TMP/drift") + while IFS= read -r line; do + [ -n "$line" ] || continue + note "state drift (record it):$line" + done < <(jq -r 'select(.severity != "unrecorded-red") + | " \(.path): recorded \(.recorded), observed \(.observed)"' "$TMP/drift") + note "run: just ci-ledger-write then record a determination for any workflow that is" + note "no longer green, and commit the ledger in the same change." + fi + [ "$FAILED" -eq 0 ] && say "verify: recorded states agree with the latest runs on $BRANCH" + return 0 +} + +# -------------------------------------------------------------------- write +run_write() { + local obs + if ! obs="$(observed_states)"; then + printf 'ci-check-ledger: cannot write the ledger: %s\n' "${obs:-unknown reason}" >&2 + exit 2 + fi + printf '%s' "$obs" > "$TMP/observed.json" + + jq -S -n --slurpfile ledger "$LEDGER" --slurpfile obs "$TMP/observed.json" --arg today "$TODAY" "$CLASSIFY"' + $obs[0] as $o + | $ledger[0] + | .updated = $today + | .workflows = [ .workflows[] | . as $e + | ($o[$e.path] // null) as $run + | (classify($run)) as $seen + | $e + | .state = $seen + | .conclusion = (if $run == null then null else $run.conclusion end) + | .observed = (if $run == null then $e.observed else $run.observed end) + | .workflow_state = (if $run == null then $e.workflow_state else $run.workflow_state end) + | .file_updated = (if $run == null then $e.file_updated else $run.file_updated end) + | .evidence = ( + if $run == null or (($run.run // "") | length) == 0 then ($e.evidence // []) + else ([$run.run] + [($e.evidence // [])[] | select(. != $run.run)]) | .[0:3] + end + ) + ] + ' > "$TMP/ledger.new" + + jq -n --slurpfile old "$LEDGER" --slurpfile new "$TMP/ledger.new" ' + ($new[0].workflows | map({key: .path, value: .state}) | from_entries) as $n + | $old[0].workflows[] + | select($n[.path] != .state) + | "\(.path): \(.state) -> \($n[.path])" + ' > "$TMP/transitions" + + cp "$TMP/ledger.new" "$LEDGER" + say "write: updated $LEDGER" + if [ -s "$TMP/transitions" ]; then + note "state transitions recorded:" + while IFS= read -r t; do + [ -n "$t" ] || continue + note " $t" + done < "$TMP/transitions" + note "any workflow that is not green needs reason, review_by and an owner_action or issue;" + note "state red needs all four. Re-run with --check before committing." + else + say "write: no state changes" + fi +} + +# -------------------------------------------------------------------- drive +case "$MODE" in + check) run_check ;; + verify) run_check; run_verify ;; + write) run_write; run_check ;; +esac + +[ "$FAILED" -eq 0 ] || { + printf '\nci-check-ledger: FAIL — see docs/audits/ci-check-ledger.json and docs/CI-RED-DETERMINATIONS.adoc.\n' >&2 + exit 1 +} +exit 0 diff --git a/scripts/reconcile-audit-issue.sh b/scripts/reconcile-audit-issue.sh new file mode 100755 index 0000000..d204dd3 --- /dev/null +++ b/scripts/reconcile-audit-issue.sh @@ -0,0 +1,199 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# reconcile-audit-issue.sh — keep exactly one dependency-vulnerability issue, +# with the findings in it, and close it with evidence when the audit passes. +# +# Why this exists: the previous version of .github/workflows/cargo-audit.yml +# created an issue whose whole body was "Run `cargo audit` locally for details" +# and never closed it. rpa-elysium#143 and rpa-elysium#113 are that issue twice, +# with no findings, no acceptance criteria, and no way to tell from the issue +# itself whether it was already fixed. An issue nobody can act on is noise that +# hides real reds. +# +# Behaviour: +# audit failed -> adopt or create ONE issue carrying a stable marker, the +# cargo-audit output, the run that produced it, and +# acceptance criteria. A new failing run is commented, so +# the history of findings survives. +# audit passed -> comment the green run and close the marked issue. Closing +# is evidence, not muting: the gate that opened it proves it +# fixed on a run of the default branch. +# pull_request -> never touched. Issues are reconciled from the default +# branch and scheduled runs only. +# +# Usage: +# scripts/reconcile-audit-issue.sh --status N --run URL [--summary FILE] +# [--repo OWNER/NAME] [--event NAME] +# [--marker TEXT] [--title TEXT] +# [--label security] [--label dependencies] +# [--dry-run] +# Exit: 0 reconciled | 1 the reconciliation failed | 2 setup failure + +set -uo pipefail + +STATUS="" +RUN_URL="" +SUMMARY="" +REPO="${GITHUB_REPOSITORY:-hyperpolymath/rpa-elysium}" +EVENT="${GITHUB_EVENT_NAME:-push}" +MARKER="" +TITLE="Security: Dependency vulnerabilities detected" +LABELS=() +DRY_RUN=0 +FAILED=0 + +while [ "$#" -gt 0 ]; do + case "$1" in + --status) STATUS="$2"; shift ;; + --run) RUN_URL="$2"; shift ;; + --summary) SUMMARY="$2"; shift ;; + --repo) REPO="$2"; shift ;; + --event) EVENT="$2"; shift ;; + --marker) MARKER="$2"; shift ;; + --title) TITLE="$2"; shift ;; + --label) LABELS+=("$2"); shift ;; + --dry-run) DRY_RUN=1 ;; + -h|--help) sed -n '2,30p' "${BASH_SOURCE[0]}"; exit 0 ;; + *) printf 'reconcile-audit-issue: unknown argument: %s\n' "$1" >&2; exit 2 ;; + esac + shift +done + +say() { printf 'reconcile-audit-issue: %s\n' "$*"; } +bad() { printf 'reconcile-audit-issue: FAIL: %s\n' "$*" >&2; FAILED=1; } + +command -v jq >/dev/null 2>&1 || { printf 'reconcile-audit-issue: jq is required\n' >&2; exit 2; } +[ -n "$STATUS" ] || { printf 'reconcile-audit-issue: --status is required\n' >&2; exit 2; } +[ -n "$RUN_URL" ] || { printf 'reconcile-audit-issue: --run is required\n' >&2; exit 2; } +if [ "${#LABELS[@]}" -eq 0 ]; then LABELS=(security dependencies); fi + +if [ "$EVENT" = "pull_request" ]; then + say "pull_request event: issues are reconciled from default-branch and scheduled runs only" + exit 0 +fi + +if [ "$DRY_RUN" -eq 0 ]; then + command -v gh >/dev/null 2>&1 || { printf 'reconcile-audit-issue: gh is required\n' >&2; exit 2; } +fi + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +gh_issue() { + if [ "$DRY_RUN" -eq 1 ]; then + printf 'reconcile-audit-issue: would run: gh issue %s\n' "$*" + return 0 + fi + gh issue "$@" || bad "gh issue $1 failed" +} + +# ------------------------------------------------------------- find the issue +label_args=() +for label in "${LABELS[@]}"; do label_args+=(--label "$label"); done + +if [ "$DRY_RUN" -eq 0 ]; then + if ! gh issue list --repo "$REPO" --state open "${label_args[@]}" --limit 100 \ + --json number,title,body > "$TMP/open.json" 2> "$TMP/open.err"; then + printf 'reconcile-audit-issue: cannot list issues: %s\n' "$(head -1 "$TMP/open.err")" >&2 + exit 1 + fi +else + printf '%s' '[]' > "$TMP/open.json" +fi + +number="$(jq -r --arg m "$MARKER" '[.[] | select(.body | contains($m))][0].number // ""' "$TMP/open.json")" +adopted="" +if [ -z "$number" ]; then + # Adopt an issue created by the previous, markerless publisher: same title, + # same labels, no findings. Otherwise the old issue would never close. + number="$(jq -r --arg t "$TITLE" '[.[] | select(.title == $t)][0].number // ""' "$TMP/open.json")" + [ -n "$number" ] && adopted=yes +fi + +# ------------------------------------------------------------- body assembly +findings="" +if [ -n "$SUMMARY" ] && [ -f "$SUMMARY" ]; then + findings="$(tail -c 6000 "$SUMMARY")" +else + findings="(no cargo-audit output was captured — the audit failed before it produced any)" +fi + +write_body() { # failing | passing + local kind="$1" + { + printf '%s\n' "$MARKER" + printf '\n\n' "$RUN_URL" + if [ "$kind" = failing ]; then + printf '## Summary\n\n' + printf '`cargo audit --deny warnings` / `--deny unmaintained` failed on `%s`.\n\n' "$REPO" + printf 'Run: %s\n\n' "$RUN_URL" + printf '## Findings\n\n
cargo audit output\n\n```\n%s\n```\n\n
\n\n' "$findings" + cat <<'BODY' +## Acceptance criteria + +- [ ] Every advisory above is addressed: upgrade to a release that fixes it, or + remove or replace the dependency. +- [ ] If no fixed release exists upstream and the code path is not reachable, + a documented waiver is added in the same change that needs it — the + advisory ID, why it cannot be fixed here, and a review date. +- [ ] `cargo audit --deny warnings` exits 0 on a run of the default branch. + This issue closes itself with that run linked, so "verified fixed" is + evidence rather than an assertion. +BODY + printf '\nAutomation: this issue is maintained by `.github/workflows/cargo-audit.yml`\n' + printf 'through `scripts/reconcile-audit-issue.sh`. It carries the marker above so\n' + printf 'the workflow can find it again, and it is closed only after the audit passes.\n' + else + printf '## Resolved\n\n' + printf '`cargo audit --deny warnings` and `cargo audit --deny unmaintained` both\n' + printf 'exited 0 on a run of the default branch: %s\n\n' "$RUN_URL" + printf 'The gate that opened this issue closed it, so the fix is verified rather\n' + printf 'than asserted. If the audit fails again the workflow reopens the record by\n' + printf 'creating or updating this issue with the new findings.\n' + fi + } > "$TMP/body.md" +} + +if [ "$STATUS" -eq 0 ]; then + # ------------------------------------------------------------- green path + if [ -z "$number" ]; then + say "audit passed and no open vulnerability issue exists: nothing to reconcile" + exit 0 + fi + write_body passing + say "audit passed: closing #$number with $RUN_URL" + # The body is rewritten as well, so a future failure finds this issue by its + # marker rather than by its title, and the record of why it closed lives in + # the issue rather than only in a comment. + gh_issue edit "$number" --repo "$REPO" --body-file "$TMP/body.md" + gh_issue close "$number" --repo "$REPO" \ + --comment "Verified fixed: $RUN_URL — \`cargo audit --deny warnings\` and \`cargo audit --deny unmaintained\` both exited 0 on a run of the default branch." + [ "$FAILED" -eq 0 ] || exit 1 + say "closed #$number" + exit 0 +fi + +# ------------------------------------------------------------ failing path +write_body failing +if [ -z "$number" ]; then + say "audit failed: opening one vulnerability issue" + gh_issue create --repo "$REPO" --title "$TITLE" "${label_args[@]}" --body-file "$TMP/body.md" +else + if [ "$adopted" = yes ]; then + say "adopting markerless issue #$number created by the previous publisher" + fi + last_run="$(jq -r --arg m "$MARKER" '[.[] | select(.body | contains($m))][0].body // ""' "$TMP/open.json" \ + | sed -n 's/.*.*/\1/p' | head -1)" + if [ "$last_run" = "$RUN_URL" ]; then + say "audit failed: #$number already records this run; updating the report only" + gh_issue edit "$number" --repo "$REPO" --body-file "$TMP/body.md" + else + say "audit failed: updating #$number with the new findings" + gh_issue edit "$number" --repo "$REPO" --body-file "$TMP/body.md" + gh_issue comment "$number" --repo "$REPO" --body "New failing audit run: $RUN_URL" + fi +fi + +[ "$FAILED" -eq 0 ] || exit 1 +say "reconciled" +exit 0 diff --git a/scripts/tests/ci-check-ledger-test.sh b/scripts/tests/ci-check-ledger-test.sh new file mode 100755 index 0000000..7283da8 --- /dev/null +++ b/scripts/tests/ci-check-ledger-test.sh @@ -0,0 +1,232 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Controls for scripts/ci-check-ledger.sh. Every control must fail closed: a +# ledger that is missing an entry, an owner, an issue or a live review date is +# rejected, and a workflow recorded as green that has gone red must not pass +# verification. +set -euo pipefail + +PROJECT="$(cd "$(dirname "$0")/../.." && pwd)" +CHECK="$PROJECT/scripts/ci-check-ledger.sh" +scratch="$(mktemp -d)" +trap 'rm -rf "$scratch"' EXIT + +mkdir -p "$scratch/.github/workflows" "$scratch/docs/audits" +for wf in alpha beta gamma; do + printf '# SPDX-License-Identifier: MPL-2.0\nname: %s\non:\n push:\npermissions:\n contents: read\njobs:\n test:\n runs-on: ubuntu-latest\n timeout-minutes: 5\n steps:\n - run: "true"\n' \ + "${wf^}" > "$scratch/.github/workflows/$wf.yml" +done + +ENTRIES="$scratch/entries.jsonl" +: > "$ENTRIES" +ISSUE_URL="https://github.com/hyperpolymath/rpa-elysium/issues/147" +RUN_URL="https://github.com/hyperpolymath/rpa-elysium/actions/runs/1" + +run_date() { date -u -d "$1" +%F 2>/dev/null || date -u -v"$1" +%F; } +reset_entries() { : > "$ENTRIES"; } + +# entry appends a ledger entry for the given workflow path. +entry() { # path state determination [extra-json] + jq -cn --arg p "$1" --arg s "$2" --arg d "$3" --argjson extra "${4:-{\}}" ' + {path: $p, name: ($p | sub("^.*/"; "") | sub("\\.yml$"; "")), + state: $s, conclusion: null, observed: "2026-10-14", workflow_state: "active", + file_updated: null, determination: $d, reason: "", evidence: [], owner_action: null, + issue: null, review_by: null} + $extra' >> "$ENTRIES" +} + +green_entry() { # path + entry "$1" green green "{\"evidence\":[\"$RUN_URL\"]}" +} + +red_entry() { # path [review date expression] + entry .github/workflows/alpha.yml red in-triage \ + "{\"reason\":\"a red check needs a full determination\",\"owner_action\":\"fix the generator or retire the workflow\",\"issue\":\"$ISSUE_URL\",\"review_by\":\"$(run_date "${1:-+30 days}")\"}" +} + +# patch_last rewrites the most recent entry with a jq filter. +patch_last() { + local n tmp="$ENTRIES.tmp" + n=$(wc -l < "$ENTRIES" | tr -d ' ') + [ "$n" -ge 1 ] || { echo "FAIL: no entry to patch"; exit 1; } + head -n "$((n - 1))" "$ENTRIES" > "$tmp" + sed -n "${n}p" "$ENTRIES" | jq -c "$1" >> "$tmp" + mv "$tmp" "$ENTRIES" +} + +ledger() { + jq -cn --slurpfile w "$ENTRIES" '{schema_version: "1.0", repo: "hyperpolymath/rpa-elysium", + default_branch: "main", updated: "2026-10-14", description: "fixture", + workflows: $w}' > "$scratch/docs/audits/ci-check-ledger.json" +} + +three_green() { + reset_entries + green_entry .github/workflows/alpha.yml + green_entry .github/workflows/beta.yml + green_entry .github/workflows/gamma.yml + ledger +} + +two_green() { + reset_entries + green_entry .github/workflows/beta.yml + green_entry .github/workflows/gamma.yml +} + +expect_pass() { + if ! bash "$CHECK" "$scratch" --check --quiet > "$scratch/out" 2>&1; then + echo "FAIL: $1 was rejected"; sed -n '1,8p' "$scratch/out"; exit 1 + fi + echo "PASS: $1 accepted" +} + +expect_failure() { + if bash "$CHECK" "$scratch" --check --quiet > "$scratch/out" 2>&1; then + echo "FAIL: $1 was accepted"; exit 1 + fi + echo "PASS: $1 fails closed" +} + +# ---------------------------------------------------------------- structure +# 1 +three_green +expect_pass 'three green workflows with evidence' + +# 2 — a workflow on disk with no ledger entry +two_green; ledger +expect_failure 'a workflow missing from the ledger' + +# 3 — an entry whose workflow file does not exist +three_green +entry .github/workflows/deleted.yml green green "{\"evidence\":[\"$RUN_URL\"]}" +ledger +expect_failure 'a ledger entry with no workflow file' + +# 4 — an unknown state name +three_green +patch_last '.state = "probably-fine"' +ledger +expect_failure 'an unknown state name' + +# ------------------------------------------------------- the red strict tier +# 5 +two_green; red_entry; ledger +expect_pass 'a red workflow with a complete determination' + +# 6 — no owner +two_green; red_entry; patch_last '.owner_action = null'; ledger +expect_failure 'a red workflow with no owner_action' + +# 7 — no issue +two_green; red_entry; patch_last '.issue = null'; ledger +expect_failure 'a red workflow with no issue' + +# 8 — a review date that has already passed +two_green; red_entry '-1 day'; ledger +expect_failure 'a red workflow whose review date has passed' + +# 9 — a review date so far out that nothing is reviewed +two_green; red_entry '+200 days'; ledger +expect_failure 'a red workflow whose review date is more than 90 days out' + +# 10 — a reason of the "see above" variety is not a reason +two_green; red_entry; patch_last '.reason = "red"'; ledger +expect_failure 'a red workflow with a token reason' + +# -------------------------------------------------------- green entries +# 11 — green with no evidence +reset_entries +entry .github/workflows/alpha.yml green green '{}' +green_entry .github/workflows/beta.yml; green_entry .github/workflows/gamma.yml; ledger +expect_failure 'a green workflow with no evidence' + +# 12 — evidence pointing at another repository +reset_entries +entry .github/workflows/alpha.yml green green '{"evidence":["https://github.com/elsewhere/other/actions/runs/1"]}' +green_entry .github/workflows/beta.yml; green_entry .github/workflows/gamma.yml; ledger +expect_failure 'evidence pointing at another repository' + +# 13 — an exemption on a workflow that is green +reset_entries +entry .github/workflows/alpha.yml green exempt \ + "{\"evidence\":[\"$RUN_URL\"],\"reason\":\"exemptions belong on non-green states\",\"review_by\":\"$(run_date '+30 days')\"}" +green_entry .github/workflows/beta.yml; green_entry .github/workflows/gamma.yml; ledger +expect_failure 'an exemption attached to a green workflow' + +# --------------------------------------------------------- the light tier +# 14 — dormant: reason, issue and review date are enough +reset_entries +entry .github/workflows/alpha.yml dormant exempt \ + "{\"reason\":\"the owner disabled this workflow deliberately\",\"review_by\":\"$(run_date '+30 days')\",\"issue\":\"$ISSUE_URL\"}" +green_entry .github/workflows/beta.yml; green_entry .github/workflows/gamma.yml; ledger +expect_pass 'a dormant workflow with reason, issue and review date' + +# 15 — the light tier still needs a pointer +reset_entries +entry .github/workflows/alpha.yml stale in-triage \ + "{\"reason\":\"the last run predates the repair now on main\",\"review_by\":\"$(run_date '+30 days')\"}" +green_entry .github/workflows/beta.yml; green_entry .github/workflows/gamma.yml; ledger +expect_failure 'a stale workflow with no owner_action and no issue' + +# ------------------------------------------------------------- verification +OBS="$scratch/obs.json" +cat > "$OBS" < "$scratch/out" 2>&1; then + echo "FAIL: an unrecorded red was accepted"; exit 1 +fi +grep -q 'observed red' "$scratch/out" || { echo "FAIL: the drift message did not name the red"; cat "$scratch/out"; exit 1; } +echo "PASS: an unrecorded red fails verification" + +# 17 — the same red, recorded, passes +two_green; red_entry; ledger +if ! bash "$CHECK" "$scratch" --verify --observations "$OBS" --quiet > "$scratch/out" 2>&1; then + echo "FAIL: a recorded red was rejected by verification"; cat "$scratch/out"; exit 1 +fi +echo "PASS: a recorded red passes verification" + +# 18 — a benign transition is announced without failing. alpha is recorded red +# and matches the observation; gamma is recorded superseded while the +# observation says green, which is a transition to record rather than a hazard. +reset_entries +red_entry +green_entry .github/workflows/beta.yml +entry .github/workflows/gamma.yml superseded in-triage \ + "{\"reason\":\"the deploy was superseded by a newer push\",\"review_by\":\"$(run_date '+30 days')\",\"issue\":\"$ISSUE_URL\"}" +ledger +if ! bash "$CHECK" "$scratch" --verify --observations "$OBS" --quiet > "$scratch/out" 2>&1; then + echo "FAIL: a benign transition failed verification"; cat "$scratch/out"; exit 1 +fi +grep -q 'state drift (record it)' "$scratch/out" || { echo "FAIL: the benign transition was silent"; cat "$scratch/out"; exit 1; } +echo "PASS: a benign transition warns without failing" + +# 19 — an observation set that cannot be read is not treated as drift-free +three_green +printf 'not json at all' > "$scratch/bad.json" +if ! bash "$CHECK" "$scratch" --verify --observations "$scratch/bad.json" --quiet > "$scratch/out" 2>&1; then + echo "FAIL: an unreadable observation set failed verification instead of warning"; cat "$scratch/out"; exit 1 +fi +grep -q 'NOT verified' "$scratch/out" || { echo "FAIL: an unreadable observation set was silent"; cat "$scratch/out"; exit 1; } +echo "PASS: an unreadable observation set warns and is not counted as verified" + +# 20 — --write records the observation and preserves the human determination +two_green; red_entry; ledger +bash "$CHECK" "$scratch" --write --observations "$OBS" --quiet > "$scratch/out" 2>&1 || true +jq -e '.workflows[] | select(.path == ".github/workflows/alpha.yml") + | .determination == "in-triage" and .state == "red" and .conclusion == "failure" + and (.evidence | length) == 1 and (.evidence[0] | endswith("/1"))' \ + "$scratch/docs/audits/ci-check-ledger.json" > /dev/null || { + echo "FAIL: --write did not record the observation while preserving the determination"; cat "$scratch/out"; exit 1; } +echo "PASS: --write records observations and preserves determinations" + +# 21 — the committed ledger in this repository satisfies the same rules +bash "$CHECK" "$PROJECT" --check --quiet > /dev/null || { echo "FAIL: the committed ledger is invalid"; exit 1; } +echo "PASS: the committed ledger validates" + +echo '21 CI check ledger controls passed.' diff --git a/scripts/tests/mirror-config-test.sh b/scripts/tests/mirror-config-test.sh new file mode 100644 index 0000000..be1e21e --- /dev/null +++ b/scripts/tests/mirror-config-test.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Controls for scripts/check-mirror-config.sh. The checker reads repository +# variables and secrets from the environment (that is how GitHub Actions hands +# them to a workflow), so every control is a set of environment values. +set -euo pipefail + +PROJECT="$(cd "$(dirname "$0")/../.." && pwd)" +CHECK="$PROJECT/scripts/check-mirror-config.sh" +scratch="$(mktemp -d)" +trap 'rm -rf "$scratch"' EXIT + +mkdir -p "$scratch/.machine_readable/6a2" +cat > "$scratch/.machine_readable/6a2/META.a2ml" <<'META' +[development-practices] +test-coverage-target = "80%" +ci-pipelines = 17 +multi-forge = true +forges = ["github", "gitlab", "bitbucket", "codeberg"] +META + +# declared_and_enabled exports the three declared mirrors, keyed and hosted. +declared_and_enabled() { + unset GITEA_MIRROR_ENABLED DISROOT_MIRROR_ENABLED SOURCEHUT_MIRROR_ENABLED RADICLE_MIRROR_ENABLED + export GITLAB_MIRROR_ENABLED=true + export BITBUCKET_MIRROR_ENABLED=true + export CODEBERG_MIRROR_ENABLED=true + unset GITEA_HOST +} + +# Annotations (::error::/::warning::) go to stdout and the verdict to stderr, +# so every control reads the two streams together. +expect_pass() { + if ! bash "$CHECK" "$scratch" > "$scratch/out" 2>&1; then + echo "FAIL: $1 was rejected"; cat "$scratch/out"; exit 1 + fi + echo "PASS: $1 accepted" +} + +expect_failure() { + if bash "$CHECK" "$scratch" > "$scratch/out" 2>&1; then + echo "FAIL: $1 was accepted"; cat "$scratch/out"; exit 1 + fi + grep -q "$2" "$scratch/out" || { echo "FAIL: $1 did not name the cause"; cat "$scratch/out"; exit 1; } + echo "PASS: $1 fails closed" +} + +# 1 — the declared set, enabled and keyed. +declared_and_enabled +expect_pass 'the declared mirror set, enabled and keyed' + +# 2 — a forge enabled that the repository does not declare. +declared_and_enabled +export DISROOT_MIRROR_ENABLED=true +expect_failure 'an enabled but undeclared mirror' 'does not declare it' + +# 3 — the preflight reads variables only. An empty key secret is the reusable's +# business (it prints a skip notice); no job here holds a forge key. +grep -q 'secrets\.' <(sed -n '/mirror-preflight:/,/^ mirror:/p' "$PROJECT/.github/workflows/mirror.yml") && { + echo "FAIL: the preflight job references secrets"; exit 1; } +grep -q 'secrets\.' "$PROJECT/scripts/check-mirror-config.sh" && { + echo "FAIL: the checker reads secrets"; exit 1; } +grep -q 'eval ' "$PROJECT/scripts/check-mirror-config.sh" && { + echo "FAIL: the checker uses eval"; exit 1; } +echo "PASS: the preflight reads variables only, without eval" + +# 4 — the gitea leg specifically: enabled, keyed, but no host variable. +declared_and_enabled +export GITEA_MIRROR_ENABLED=true +unset GITEA_HOST +expect_failure 'an enabled gitea mirror with no host variable' 'vars.GITEA_HOST is empty' + +# 5 — declared but not enabled: a warning, not a failure. +declared_and_enabled +export GITLAB_MIRROR_ENABLED=false +expect_pass 'a declared mirror that is switched off' +grep -q 'declared in META.a2ml but not enabled' "$scratch/out" || { + echo "FAIL: a switched-off declared mirror was silent"; cat "$scratch/out"; exit 1; } +echo "PASS: a switched-off declared mirror warns without failing" + +# 6 — the configuration measured on this repository on 2026-10-14: gitea and +# disroot enabled alongside the three declared mirrors, sourcehut and radicle +# enabled but undeclared, and no GITEA_HOST. The checker must name each. +declared_and_enabled +export GITEA_MIRROR_ENABLED=true +export DISROOT_MIRROR_ENABLED=true +export SOURCEHUT_MIRROR_ENABLED=true +export RADICLE_MIRROR_ENABLED=true +unset GITEA_HOST +if bash "$CHECK" "$PROJECT" > "$scratch/repo.out" 2>&1; then + echo "FAIL: the measured mirror configuration reported as consistent" + cat "$scratch/repo.out"; exit 1 +fi +grep -q 'gitea mirror is enabled for this repository but' "$scratch/repo.out" || { + echo "FAIL: the undeclared gitea leg was not named"; cat "$scratch/repo.out"; exit 1; } +grep -q 'vars.GITEA_HOST is empty' "$scratch/repo.out" || { + echo "FAIL: the missing gitea host was not named"; cat "$scratch/repo.out"; exit 1; } +grep -q 'disroot mirror is enabled for this repository but' "$scratch/repo.out" || { + echo "FAIL: the undeclared disroot leg was not named"; cat "$scratch/repo.out"; exit 1; } +grep -q 'sourcehut mirror is enabled for this repository but' "$scratch/repo.out" || { + echo "FAIL: the undeclared sourcehut leg was not named"; cat "$scratch/repo.out"; exit 1; } +echo "PASS: the repository's measured mirror drift is named in full" + +# 7 — the workflow wires the checker into every push, before the fan-out. +WF="$PROJECT/.github/workflows/mirror.yml" +grep -q 'check-mirror-config.sh' "$WF" || { echo "FAIL: mirror.yml does not run the checker"; exit 1; } +grep -q 'mirror-preflight' "$WF" || { echo "FAIL: mirror.yml has no preflight job"; exit 1; } +echo "PASS: mirror.yml runs the preflight" + +echo '7 mirror configuration controls passed.' diff --git a/scripts/tests/reconcile-audit-issue-test.sh b/scripts/tests/reconcile-audit-issue-test.sh new file mode 100755 index 0000000..cfd3171 --- /dev/null +++ b/scripts/tests/reconcile-audit-issue-test.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Controls for scripts/reconcile-audit-issue.sh. A stub `gh` records every call +# so the tests can assert what the reconciler did — and, more importantly, what +# it refused to do. No network, no repository writes. +set -euo pipefail + +PROJECT="$(cd "$(dirname "$0")/../.." && pwd)" +SCRIPT="$PROJECT/scripts/reconcile-audit-issue.sh" +scratch="$(mktemp -d)" +trap 'rm -rf "$scratch"' EXIT + +mkdir -p "$scratch/bin" "$scratch/state" +cat > "$scratch/bin/gh" <<'STUB' +#!/usr/bin/env bash +# Stub gh: logs every call, answers `issue list` from $GH_STUB_ISSUES, and +# copies any --body-file it is handed so the tests can inspect the report. +printf '%s\n' "$*" >> "$GH_STUB_LOG" +if [ "$1 $2" = "issue list" ]; then + cat "$GH_STUB_ISSUES" + exit 0 +fi +prev="" +for arg in "$@"; do + if [ "$prev" = "--body-file" ]; then cp "$arg" "$GH_STUB_BODY" 2>/dev/null || true; fi + prev="$arg" +done +exit 0 +STUB +chmod +x "$scratch/bin/gh" +export PATH="$scratch/bin:$PATH" +export GH_STUB_LOG="$scratch/state/calls.log" +export GH_STUB_ISSUES="$scratch/state/issues.json" +export GH_STUB_BODY="$scratch/state/body.md" + +MARKER='' +RUN="https://github.com/hyperpolymath/rpa-elysium/actions/runs/123" +RUN2="https://github.com/hyperpolymath/rpa-elysium/actions/runs/456" + +reset_log() { : > "$GH_STUB_LOG"; } + +run_reconcile() { # status event + bash "$SCRIPT" --status "$1" --event "$2" --run "$RUN" \ + --summary "$scratch/summary.txt" --repo hyperpolymath/rpa-elysium +} + +expect_calls() { # description expected-substring... + local what="$1"; shift + local count + count="$(wc -l < "$GH_STUB_LOG" | tr -d ' ')" + [ "$count" -eq "$#" ] || { + echo "FAIL: $what expected $# gh call(s), saw $count:"; cat "$GH_STUB_LOG"; exit 1; } + local i=1 + for want in "$@"; do + local got; got="$(sed -n "${i}p" "$GH_STUB_LOG")" + case "$got" in *"$want"*) ;; *) echo "FAIL: $what call $i: expected '$want', got '$got'"; exit 1 ;; esac + i=$((i + 1)) + done + echo "PASS: $what" +} + +printf 'error: 1 vulnerability found\nID: RUSTSEC-2026-0316\nPackage: wasmtime\nVersion: 36.0.15\nSolution: upgrade to >=36.0.16\n' \ + > "$scratch/summary.txt" + +# 1. A passing audit with no open issue touches nothing. +printf '%s' '[]' > "$GH_STUB_ISSUES" +reset_log +run_reconcile 0 push > /dev/null +expect_calls 'a passing audit with no open issue lists but never writes' 'issue list' + +# 2. A passing audit closes the marked issue with the green run linked. +jq -n --arg m "$MARKER" '[{number: 143, title: "Security: Dependency vulnerabilities detected", body: ("body " + $m)}]' \ + > "$GH_STUB_ISSUES" +reset_log +run_reconcile 0 push > /dev/null +expect_calls 'a passing audit records the resolution and closes the marked issue' \ + 'issue list' 'issue edit 143' 'issue close 143' +grep -q 'Verified fixed' "$GH_STUB_LOG" || { + echo "FAIL: the close carries no verification evidence"; cat "$GH_STUB_LOG"; exit 1; } +grep -q 'exited 0 on a run of the default branch' "$GH_STUB_BODY" || { + echo "FAIL: the issue body does not record the green run"; exit 1; } + +# 3. A passing audit adopts a markerless legacy issue, then closes it. +jq -n '[{number: 113, title: "Security: Dependency vulnerabilities detected", body: "cargo audit found vulnerabilities. Run `cargo audit` locally for details."}]' \ + > "$GH_STUB_ISSUES" +reset_log +run_reconcile 0 push > /dev/null +expect_calls 'a passing audit adopts and closes a legacy markerless issue' \ + 'issue list' 'issue edit 113' 'issue close 113' + +# 4. A failing audit with no issue opens exactly one, with the findings and criteria. +printf '%s' '[]' > "$GH_STUB_ISSUES" +reset_log +run_reconcile 1 push > /dev/null +expect_calls 'a failing audit opens one issue' 'issue list' 'issue create' +grep -q 'RUSTSEC-2026-0316' "$GH_STUB_BODY" || { + echo "FAIL: the created issue does not carry the findings"; exit 1; } +grep -q 'Acceptance criteria' "$GH_STUB_BODY" || { + echo "FAIL: the created issue has no acceptance criteria"; exit 1; } +grep -q 'rpa-elysium:cargo-audit' "$GH_STUB_BODY" || { + echo "FAIL: the created issue has no marker for the next run to find"; exit 1; } + +# 5. A failing audit on a new run comments the finding and updates the issue. +jq -n --arg m "$MARKER" --arg r "$RUN2" \ + '[{number: 143, title: "Security: Dependency vulnerabilities detected", body: ("body " + $m + " ")}]' \ + > "$GH_STUB_ISSUES" +reset_log +run_reconcile 1 push > /dev/null +expect_calls 'a new failing run updates and comments' \ + 'issue list' 'issue edit 143' 'issue comment 143' + +# 6. The same failing run again updates the report without a duplicate comment. +jq -n --arg m "$MARKER" --arg r "$RUN" \ + '[{number: 143, title: "Security: Dependency vulnerabilities detected", body: ("body " + $m + " ")}]' \ + > "$GH_STUB_ISSUES" +reset_log +run_reconcile 1 push > /dev/null +expect_calls 'the same failing run does not duplicate the comment' 'issue list' 'issue edit 143' + +# 7. Pull-request events never touch issues. +printf '%s' '[]' > "$GH_STUB_ISSUES" +reset_log +run_reconcile 1 pull_request > /dev/null +expect_calls 'a pull request never reconciles issues' + +# 8. --dry-run performs no mutating call. +printf '%s' '[]' > "$GH_STUB_ISSUES" +reset_log +bash "$SCRIPT" --status 1 --event push --run "$RUN" --summary "$scratch/summary.txt" \ + --repo hyperpolymath/rpa-elysium --dry-run > "$scratch/dry.out" 2>&1 +[ ! -s "$GH_STUB_LOG" ] || { echo "FAIL: --dry-run called gh"; cat "$GH_STUB_LOG"; exit 1; } +grep -q 'would run: gh issue create' "$scratch/dry.out" || { + echo "FAIL: --dry-run did not report the action it would take"; cat "$scratch/dry.out"; exit 1; } +echo "PASS: --dry-run reports without writing" + +# 9. A missing summary is reported in the body rather than silently dropped. +printf '%s' '[]' > "$GH_STUB_ISSUES" +bash "$SCRIPT" --status 1 --event push --run "$RUN" --summary "$scratch/absent.txt" \ + --repo hyperpolymath/rpa-elysium > "$scratch/nosummary.out" 2>&1 +grep -q 'no cargo-audit output was captured' "$GH_STUB_BODY" || { + echo "FAIL: a missing summary was not recorded"; exit 1; } +echo "PASS: a missing summary is recorded in the issue body" + +# 10. The workflow really calls this script, and keeps the gate blocking. +WF="$PROJECT/.github/workflows/cargo-audit.yml" +grep -q 'reconcile-audit-issue.sh' "$WF" || { echo "FAIL: the workflow does not call the reconciler"; exit 1; } +grep -q 'cargo audit --deny warnings' "$WF" || { echo "FAIL: the workflow lost the blocking audit"; exit 1; } +grep -q 'exit "\$status"' "$WF" || { echo "FAIL: the audit step no longer propagates its failure"; exit 1; } +echo "PASS: the workflow calls the reconciler and stays blocking" + +echo '10 audit-issue reconciliation controls passed.'