From 4471ff5873d1dbe27abfbbdee6ee445c808d1d24 Mon Sep 17 00:00:00 2001 From: rldyourmnd Date: Mon, 7 Sep 2026 11:04:35 +0500 Subject: [PATCH] fix(ci): register trusted feedback and exact development pins Signed-off-by: rldyourmnd --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- .github/workflows/ci-feedback-events.yml | 25 +++++++++++ AGENTS.md | 57 +++++++++--------------- CHANGELOG.md | 5 +++ README.md | 14 +++++- catalog/tools.yml | 16 ++++++- docs/05-runners.md | 3 +- docs/13-external-tools.md | 2 +- docs/15-language-and-quality-packs.md | 2 +- docs/generated/workflow-inventory.md | 1 + scripts/_workflow_yaml.py | 1 + scripts/check_pinned_actions.py | 32 ++++++++++--- scripts/validate_catalog.py | 34 +++++++++++--- 13 files changed, 143 insertions(+), 51 deletions(-) create mode 100644 .github/workflows/ci-feedback-events.yml diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 1a170e3..218818d 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -40,7 +40,7 @@ that is more than `contents: read`. Example: ## Checklist - [ ] All third-party actions pinned to a **full 40-char commit SHA** with a - `# vX.Y.Z` version comment (no tags/branches). + release-version or matching `# commit:` comment (no tags/branches). - [ ] Least-privilege `permissions` (top-level `{}`, per-job minimal scopes). - [ ] `concurrency` and `timeout-minutes` present on new/changed workflows/jobs. - [ ] `persist-credentials: false` on all read-only `actions/checkout` steps. diff --git a/.github/workflows/ci-feedback-events.yml b/.github/workflows/ci-feedback-events.yml new file mode 100644 index 0000000..08f3eba --- /dev/null +++ b/.github/workflows/ci-feedback-events.yml @@ -0,0 +1,25 @@ +name: CI feedback + +on: + # Trusted publisher reads completed-run metadata only; source-run code and + # artifacts are never executed. Its code is fixed by the reviewed caller/ref. + workflow_run: # zizmor: ignore[dangerous-triggers] + workflows: ["ci", "codeql", "dependabot-catalog-convergence", "dependency-review", "gitleaks", "maintenance", "release", "runtime-fixtures-event-write", "runtime-fixtures-fuzzing", "runtime-fixtures-languages", "runtime-fixtures", "scorecard"] + types: [completed] + +permissions: {} + +concurrency: + group: ci-feedback-events-${{ github.repository_id }}-${{ github.run_id }} + cancel-in-progress: false + +jobs: + feedback: + if: ${{ contains(fromJSON('["failure","timed_out","action_required","stale","startup_failure","cancelled"]'), github.event.workflow_run.conclusion) }} + permissions: + actions: read # Read exact completed run and job metadata. + issues: write # Publish bounded failure evidence in this repository. + uses: NDDev-OpenNetwork/github-actions/.github/workflows/ci-feedback.yml@a0dc5592333dcfdf2a3d239ed1f2b128899e5482 # commit:a0dc5592333dcfdf2a3d239ed1f2b128899e5482 + with: + run-id: ${{ format('{0}', github.event.workflow_run.id) }} + run-attempt: ${{ format('{0}', github.event.workflow_run.run_attempt) }} diff --git a/AGENTS.md b/AGENTS.md index a49f676..f4e34ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,8 +9,8 @@ contracts below are strict. | | | | --- | --- | -| `.github/workflows/*.yml` | The product: every `on: workflow_call` file here, counted in `docs/generated/workflow-inventory.md`. Plus the self workflows, which are exactly `SELF_WORKFLOWS` in `scripts/_workflow_yaml.py` — `ci.yml`, `release.yml`, `maintenance.yml` (scheduled advisory sweep), `runtime-fixtures.yml`, `runtime-fixtures-languages.yml` and `runtime-fixtures-event-write.yml` (the evidence estate, split three ways), `codeql.yml`, `dependency-review.yml`, `gitleaks.yml`, `scorecard.yml`. | -| `catalog/*.yml` | Source of truth. One concern each: `capabilities` (what exists, per tier), `tools` (pins + `used_by`), `product-facts` (volatile external plan/price/quota facts, dated and expiring), `runtime-coverage` (what is actually proven to run), `profiles` (operating modes), `deprecations`. | +| `.github/workflows/*.yml` | Reusable product workflows plus self workflows enumerated by `SELF_WORKFLOWS` in `scripts/_workflow_yaml.py`. Self workflows are consumers and do not create catalog capabilities. | +| `catalog/*.yml` | Source of truth. One concern each: `capabilities` (what exists, per tier), `tools` (pins + `used_by`), `product-facts` (volatile external plan/price/quota facts, dated and expiring), `evidence-orchestration` (which evidence lanes apply), `profiles` (operating modes), `deprecations`. | | `scripts/` | The validators. `validate_all.py` aggregates them. | | `docs/generated/*` | Rendered from the catalog. Never hand-edit. | | `docs/NN-*.md`, `README.md` | Human prose. May reference the catalog, may not restate it. Where prose and a generated artifact disagree, the generated artifact wins. | @@ -52,17 +52,14 @@ unrelated bugfix. Touch this → also do this: -- **a workflow** → catalog entry, `tools.yml` `used_by`, an `examples/` caller, - regenerate docs, `CHANGELOG.md` under `[Unreleased]`. -- **a *proven* workflow** (`runtime-coverage.yml` says `runtime-proven`) → the - `proven_digest` no longer matches and the gate says so. Push a `fixtures/**` - branch: the estate — `runtime-fixtures.yml` for the tree-level lanes, - `runtime-fixtures-languages.yml` for the language and stack packs, and - `runtime-fixtures-event-write.yml` for the lanes that mutate real state — calls - the reusables as a consumer would, most against a minimal project under - `tests/fixtures/`, and its evidence job prints the run URL and each new digest - ready to paste back. Or drop the record to `static-only`. Never leave a stale - run masquerading as proof. +- **a reusable workflow** → catalog entry, `tools.yml` `used_by`, an + `examples/` caller, generated docs and `CHANGELOG.md` under `[Unreleased]`. +- **a self workflow** → `SELF_WORKFLOWS`, consumed tool pins, generated docs and + `CHANGELOG.md`; do not invent a reusable capability for a local consumer. +- **runtime claims** → `scripts/render_runtime_evidence.py` binds eligible + successful caller/guard results to exact workflow digests and a run URL. + Failed, cancelled, skipped and missing results prove nothing. Preserve the + scope of actual fixture execution; static validation is not runtime evidence. - **a gate's behaviour** → the `shell-gates` and `dockerfile-gate` jobs in `ci.yml` are inside `ci-gate`'s `needs`, so they **block the merge**. They lift each gate's real step out of its workflow with @@ -84,7 +81,7 @@ the named script — its fixtures say what the contract is. | Contract | Enforced by | | --- | --- | | No duplicate mapping keys in any canonical YAML | `_strict_yaml.py` | -| Full 40-char SHA pins with a version comment; `uvx`/`bunx` tools pinned | `check_pinned_actions.py`, `check_tool_pinning.py` | +| Full 40-char SHA pins with a release or matching `commit:` comment; `uvx`/`bunx` tools pinned | `check_pinned_actions.py`, `check_tool_pinning.py` | | `permissions: {}` top-level, least-privilege jobs, `timeout-minutes` everywhere | `check_permissions.py` | | Release writes sit behind the promotion gate **and** a protected environment | `check_release_graph.py` | | `gate.yml` is a report, not an authorization primitive | `check_gate_contract.py` | @@ -94,7 +91,7 @@ the named script — its fixtures say what the contract is. | Monorepo router is fail-closed: strict JSON filters, no wildcards, conservative all-true | `check_monorepo_routing.py` | | Runtime bundle ⊆ source archive | `check_release_supply_chain.py` | | Every valid repository shape resolves to a programme | `resolve_profile.py` | -| Blocking workflows owe runtime evidence | `validate_runtime_coverage.py` | +| Runtime evidence binds successful caller/guard results and exact workflow digests | `render_runtime_evidence.py` | | Every action is registered, and `used_by` matches the tree | `check_tool_registry.py` | | Every reusable has a caller example, which states its runner | `check_examples.py` | | No estate inventory or observed spend in public prose | `check_public_docs.py` | @@ -119,25 +116,14 @@ Two rules no validator can catch for you: ## Tier truth -Artifact Attestations: free on public repositories on any plan; private and -internal require **Enterprise Cloud** (Code Security does not unlock them). -Private-free repositories release with `release-supply-chain-free.yml`. - -Runners: **standard** hosted runners are unmetered on public repositories — all -three operating systems, macOS included, `macos-latest` is standard. The -reusables proven on all three carry `proven_os` in `runtime-coverage.yml`; the -fixture estate runs them there because it costs nothing and Windows is where -non-portable shell syntax surfaces. **Larger** -ones (`-N-cores`, `-large`, `-xlarge`) are billed from the first minute there too; -hosted is not the same as free. On **private** repositories the OS multiplier -bites: macOS is 10.33x Linux. A public repository must never route to -self-hosted hardware — a forked pull request there is remote code execution on it. -`docs/05` teaches the routing; the amounts live in `catalog/product-facts.yml`. - -Code Quality is a separate licence needing Team or Enterprise, billed per active -committer counted once per organization. **Its public per-committer rate is -disputed between GitHub's own sources**, so never compile a public cost from it -in either direction. It ships no Action, so it carries `workflow: null`. +Use `catalog/product-facts.yml` for dated plan, licence, price and quota facts; +use `catalog/profiles.yml` and the resolver for available workflow composition. +Do not duplicate volatile commercial values in agent instructions. Public jobs +use standard GitHub-hosted runners; private routing follows the caller contract. + +`catalog/evidence-orchestration.yml` declares evidence lanes and platforms. +`scripts/render_runtime_evidence.py` reports what a concrete fixture run proves; +a declared lane or green source check is not proof it ran on every platform. ## Git @@ -157,4 +143,5 @@ resolve → promotion → authorize → publish; see `docs/09`. `validate_all.py`, `actionlint`, and the pinned `zizmor` all pass, and you can name the evidence for anything you claim works. A green gate proves the contracts hold; it does not prove a workflow runs — that is what -`catalog/runtime-coverage.yml` records, honestly, including what is unproven. +the fixture summaries from `scripts/render_runtime_evidence.py` record, +including failed, skipped and missing caller results. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3553891..92d5bf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ The project follows Semantic Versioning. ## [Unreleased] +- Publish unsuccessful completed self-workflow attempts as unassigned, + repository-local CI evidence; preserve actual conclusions and exact attempts. +- Accept exact matching development-commit comments and correct nested action + pin validation and container whitespace rejection. Keep registrations scoped to their actual action paths. + - Declare both git-submodule and reusable-workflow consumption in the GDS module contract. Refresh its projection using the existing stable bundle. - Place the Docker publisher permission explanation inline so the pinned diff --git a/README.md b/README.md index 372cbd5..7ffdbde 100644 --- a/README.md +++ b/README.md @@ -358,7 +358,7 @@ examples/ copy-paste callers: per-tier + languages/ quality/ security/ testing ## Conventions -- Third-party actions pinned to full commit SHAs with version comments, and +- Third-party actions pinned to full commit SHAs with release or matching development-commit comments, and `check_transitive_action_pins.py` resolves each one to check what *it* calls — a pin is only one level deep, and the advisory sweep reports any action reaching a tag. @@ -375,3 +375,15 @@ examples/ copy-paste callers: per-tier + languages/ quality/ security/ testing - Security policy: [SECURITY.md](SECURITY.md) - Contributing: [CONTRIBUTING.md](CONTRIBUTING.md) - Releases: https://github.com/NDDev-OpenNetwork/ci-workflows/releases + + +## CI feedback + +The CI feedback workflow reports unsuccessful completed self-workflow attempts +as unassigned issues in this repository. It uses a pinned publisher and records +actual job conclusions and attempt identity without executing source-run code. +Issue publication does not launch a repair agent or authorize deployment. + +Unreleased dependencies use a `commit:` comment matching the immutable +ref and catalog version. Stable releases retain their release-version comments; +the validator rejects a shortened or mismatched development identity. diff --git a/catalog/tools.yml b/catalog/tools.yml index de06954..b7e0d01 100644 --- a/catalog/tools.yml +++ b/catalog/tools.yml @@ -521,12 +521,26 @@ tools: - .github/workflows/zizmor-sarif.yml last_verified: "2026-07-20" + - id: ci-feedback + name: NDDev-OpenNetwork/github-actions/.github/workflows/ci-feedback.yml + homepage: "https://github.com/NDDev-OpenNetwork/github-actions/blob/a0dc5592333dcfdf2a3d239ed1f2b128899e5482/.github/workflows/ci-feedback.yml" + kind: action + current_version: "commit:a0dc5592333dcfdf2a3d239ed1f2b128899e5482" + pin: "NDDev-OpenNetwork/github-actions/.github/workflows/ci-feedback.yml@a0dc5592333dcfdf2a3d239ed1f2b128899e5482" + used_by: + - .github/workflows/ci-feedback-events.yml + last_verified: "2026-09-07" + notes: >- + Published development commit, not a stable release claim. The trusted + publisher reads completed job metadata and writes unassigned issues in + the caller repository without executing source-run code. + - id: nddev-tool-cache name: NDDev-OpenNetwork/github-actions/actions/tool-cache homepage: "https://github.com/NDDev-OpenNetwork/github-actions/tree/main/actions/tool-cache" kind: action current_version: "v1.2.0" - pin: "NDDev-OpenNetwork/github-actions@468af475a08210020502f74588b2c98ba4c01696" + pin: "NDDev-OpenNetwork/github-actions/actions/tool-cache@468af475a08210020502f74588b2c98ba4c01696" used_by: - .github/workflows/private-security-bundle-free.yml last_verified: "2026-09-01" diff --git a/docs/05-runners.md b/docs/05-runners.md index b19d400..3681895 100644 --- a/docs/05-runners.md +++ b/docs/05-runners.md @@ -300,7 +300,8 @@ is checked against the mapping on every gate run. The generated [workflow routing matrix](generated/workflow-routing.md) keeps supported OS separate from runtime-proven OS. A static routing check is not a -live run: only `proven_os` in `catalog/runtime-coverage.yml` is runtime evidence. +live run: only successful scoped fixture results with an exact workflow digest +from `scripts/render_runtime_evidence.py` are runtime evidence. macOS and Windows stay hosted until a future native backend is independently reviewed and proves the same lifecycle contract. diff --git a/docs/13-external-tools.md b/docs/13-external-tools.md index 74e8029..efdd52a 100644 --- a/docs/13-external-tools.md +++ b/docs/13-external-tools.md @@ -46,7 +46,7 @@ container images, checksum for downloaded binaries). | Kind | Pin by | Bumped by | | --- | --- | --- | -| GitHub Action | full commit SHA + version comment | Dependabot | +| GitHub Action | full commit SHA + release or matching development-commit comment | Dependabot | | Container image | `name:tag@sha256:...` digest | Dependabot / manual | | Downloaded binary | version + SHA256 checksum verify | manual | diff --git a/docs/15-language-and-quality-packs.md b/docs/15-language-and-quality-packs.md index 6ec4f06..a4976ab 100644 --- a/docs/15-language-and-quality-packs.md +++ b/docs/15-language-and-quality-packs.md @@ -2,7 +2,7 @@ This page documents the reusable packs added in the July 2026 expansion. Every pack follows the library conventions (top-level `permissions: {}`, SHA-pinned -actions with version comments, env-indirected caller commands, `timeout-minutes`, +actions with release or matching development-commit comments, env-indirected caller commands, `timeout-minutes`, and an explicit private-free-safe action surface) and is validated by `scripts/validate_all.py`. The machine-readable source of truth is diff --git a/docs/generated/workflow-inventory.md b/docs/generated/workflow-inventory.md index 2e08721..66a8530 100644 --- a/docs/generated/workflow-inventory.md +++ b/docs/generated/workflow-inventory.md @@ -6,6 +6,7 @@ | `.github/workflows/actionlint.yml` | `actionlint` | ga | | `.github/workflows/benchmark-compare.yml` | `benchmark-compare` | ga | | `.github/workflows/benchmark.yml` | `benchmark` | ga | +| `.github/workflows/ci-feedback-events.yml` | internal | internal | | `.github/workflows/ci.yml` | internal | internal | | `.github/workflows/clusterfuzzlite.yml` | `clusterfuzzlite-fuzzing` | ga | | `.github/workflows/codeql.yml` | internal | internal | diff --git a/scripts/_workflow_yaml.py b/scripts/_workflow_yaml.py index f04e202..7813733 100644 --- a/scripts/_workflow_yaml.py +++ b/scripts/_workflow_yaml.py @@ -18,6 +18,7 @@ # this repository applying its own catalog to itself, so they carry no catalog, # runtime-coverage, or generated-inventory entry. SELF_WORKFLOWS = { + "ci-feedback-events.yml", "ci.yml", "maintenance.yml", "codeql.yml", diff --git a/scripts/check_pinned_actions.py b/scripts/check_pinned_actions.py index 9b8646e..5ec4293 100644 --- a/scripts/check_pinned_actions.py +++ b/scripts/check_pinned_actions.py @@ -16,12 +16,22 @@ # The comment must say *which release* the SHA is, because that is the only # human-readable half of the pin: a reviewer diffing a Dependabot bump reads the # comment, and a bare `#` or `# bumped` both satisfied the old presence-only -# test. Two accepted forms — a semantic version, or an ISO date for upstreams -# that publish no releases at all (`google/clusterfuzzlite`), where a date is -# the honest identifier rather than an invented version. +# test. A semantic release version or an ISO date for an untagged upstream +# identifies stable inputs. An unreleased development input must instead name +# its exact commit, equal to the immutable ref, rather than inventing a release. PIN_COMMENT_RE = re.compile(r"#\s*(v?\d+\.\d+(?:\.\d+)?[\w.+-]*|\d{4}-\d{2}-\d{2})\b") +DEVELOPMENT_COMMENT_RE = re.compile(r"^\s*#\s*commit:([0-9a-f]{40})(?:\s|$)") + + +def supported_pin_comment(ref: str, comment: str) -> bool: + if re.match(r"^\s*#\s*commit:", comment): + match = DEVELOPMENT_COMMENT_RE.search(comment) + return match is not None and match.group(1) == ref.rsplit("@", 1)[-1] + return PIN_COMMENT_RE.search(comment) is not None + + def check() -> list[str]: problems: list[str] = _selftest() for path in workflow_files(): @@ -46,11 +56,11 @@ def check() -> list[str]: continue if "#" not in rest: problems.append(f"{where}: SHA pin missing a `# vX.Y.Z` version comment: {ref}") - elif PIN_COMMENT_RE.search(rest) is None: + elif not supported_pin_comment(ref, rest): problems.append( f"{where}: SHA pin comment must name the release " f"(`# vX.Y.Z`, or `# YYYY-MM-DD` for an upstream that tags no " - f"releases), got {rest.strip()!r}: {ref}" + f"releases; or # commit:), got {rest.strip()!r}: {ref}" ) return problems @@ -65,6 +75,18 @@ def _selftest() -> list[str]: for bad in (" #", " # bumped", " # see PR", " # latest", " # "): if PIN_COMMENT_RE.search(bad) is not None: problems.append(f"check_pinned_actions self-test: accepted {bad.strip()!r}") + development_sha = "a" * 40 + development_ref = "example/action@" + development_sha + if not supported_pin_comment(development_ref, " # commit:" + development_sha): + problems.append("check_pinned_actions self-test: rejected exact development commit") + for comment in ( + " # commit:" + "b" * 40, + " # commit:" + "a" * 7, + " # commit:" + "a" * 41, + " # commit:wrong # v1.0.0", + ): + if supported_pin_comment(development_ref, comment): + problems.append("check_pinned_actions self-test: accepted mismatched development identity") return problems diff --git a/scripts/validate_catalog.py b/scripts/validate_catalog.py index d319563..a46018d 100644 --- a/scripts/validate_catalog.py +++ b/scripts/validate_catalog.py @@ -34,8 +34,8 @@ EXAMPLES_DIR = REPO_ROOT / "examples" SCHEMA_FILE = CATALOG_DIR / "schema" / "capability.schema.yaml" -PIN_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[^@\\s]+)?@[0-9a-f]{40}(?:@sha256:[0-9a-f]{64})?$") -CONTAINER_PIN_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+:[^\\s@]+@sha256:[0-9a-f]{64}$") +PIN_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[^@\s]+)?@[0-9a-f]{40}(?:@sha256:[0-9a-f]{64})?$") +CONTAINER_PIN_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+:[^\s@]+@sha256:[0-9a-f]{64}$") def _load(name: str, problems: list[str]): @@ -50,8 +50,32 @@ def _load(name: str, problems: list[str]): return None +def pin_selftest() -> list[str]: + sha = "a" * 40 + valid = ["example/actions@" + sha, + "example/actions/actions/tool-cache@" + sha, + "example/actions/.github/workflows/ci-feedback.yml@" + sha] + invalid = ["example/actions/actions/tool cache@" + sha, + "example/actions/actions/tool\tcache@" + sha, + "example/actions@" + sha + "\n", + "example/actions/actions/tool-cache@main"] + problems = [] + for value in valid: + if not PIN_RE.fullmatch(value): + problems.append("catalog pin self-test rejected a nested immutable reference") + for value in invalid: + if PIN_RE.fullmatch(value): + problems.append("catalog pin self-test accepted whitespace or a mutable reference") + digest = "a" * 64 + if not CONTAINER_PIN_RE.fullmatch("example/scanner:stable@sha256:" + digest): + problems.append("catalog pin self-test rejected an immutable container tag") + if CONTAINER_PIN_RE.fullmatch("example/scanner:bad tag@sha256:" + digest): + problems.append("catalog pin self-test accepted container tag whitespace") + return problems + + def check() -> list[str]: - problems: list[str] = [] + problems: list[str] = pin_selftest() if not CATALOG_DIR.is_dir(): return [f"missing catalog directory: {CATALOG_DIR}"] problems += _json_schema.selftest() @@ -151,9 +175,9 @@ def check() -> list[str]: if isinstance(pin, str): if "#" in pin: problems.append(f"tool `{tid}`: pin value must not include comments: {pin}") - if kind == "action" and not PIN_RE.match(pin): + if kind == "action" and not PIN_RE.fullmatch(pin): problems.append(f"tool `{tid}`: action pin is not a full-SHA ref: {pin}") - if kind == "container" and not CONTAINER_PIN_RE.match(pin): + if kind == "container" and not CONTAINER_PIN_RE.fullmatch(pin): problems.append(f"tool `{tid}`: container pin is not digest-pinned: {pin}") for used_by in tool.get("used_by", []): used_path = REPO_ROOT / used_by