Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<full SHA>` 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.
Expand Down
25 changes: 25 additions & 0 deletions .github/workflows/ci-feedback-events.yml
Original file line number Diff line number Diff line change
@@ -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) }}
57 changes: 22 additions & 35 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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
Expand All @@ -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:<SHA>` 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` |
Expand All @@ -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` |
Expand All @@ -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

Expand All @@ -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.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:<full SHA>` comment matching the immutable
ref and catalog version. Stable releases retain their release-version comments;
the validator rejects a shortened or mismatched development identity.
16 changes: 15 additions & 1 deletion catalog/tools.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion docs/05-runners.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/13-external-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
2 changes: 1 addition & 1 deletion docs/15-language-and-quality-packs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/generated/workflow-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions scripts/_workflow_yaml.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
32 changes: 27 additions & 5 deletions scripts/check_pinned_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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:<same full SHA>), got {rest.strip()!r}: {ref}"
)
return problems

Expand All @@ -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


Expand Down
Loading
Loading