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
29 changes: 23 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ jobs:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# The preflight reconciles CHANGELOG headings against SemVer tags, and
# that check fails closed when it can see none -- a shallow checkout
# cannot tell "no tags" from "no tags fetched". A release of all things
# must not be the run that cannot see its own ledger.
fetch-depth: 0
fetch-tags: true
persist-credentials: false
- name: Set up Python
id: python
Expand All @@ -88,14 +94,25 @@ jobs:
"$PYTHON_PATH" -I -B -m venv --copies .venv
uv pip install --python .venv/bin/python --require-hashes -r requirements-ci.txt

# A release is the one place where the full sweep is the right gate: an
# immutable artifact must not ship carrying an expired external fact or a
# runtime-coverage waiver that is already past due. Pull requests get the
# scoped tiers; a release gets everything.
- name: Full validation sweep
# An immutable artifact must not ship carrying an expired external fact or
# a runtime-coverage waiver already past due, so the release keeps the
# calendar checks. What it cannot keep is the rest of the advisory tier:
# `transitive-action-pins` and `anchor-contexts` fail closed without a
# GitHub token, and `flutter-pin` and `qt-pin` need two hosts this job's
# egress policy blocks. Running `validate_all.py --` here meant every
# release stopped in preflight on four capability failures -- before the
# promotion gate, and long before the missing evidence manifest (#157) that
# is the *known* blocker. The graph had never been exercised: at 0.13.3
# `resolve` did not run validate_all at all.
#
# `--tier release` is core plus the calendar checks, and reaches no further
# than this checkout. `catalog/validation-tiers.yml` records why, and
# `check_validation_tier_contract.py` fails if this job ever invokes a tier
# it does not grant the capabilities for.
- name: Release validation preflight
run: |
.venv/bin/python -I -B scripts/check_python_syntax.py
.venv/bin/python -I -B scripts/check_python_execution_contract.py --launch validate_all.py --
.venv/bin/python -I -B scripts/check_python_execution_contract.py --launch validate_all.py -- --tier release

- name: Resolve and validate version
id: v
Expand Down
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,34 @@

## [Unreleased]

- Make a tier's requirements data, and hold every caller to them. `--tier
scheduled` needs a GitHub token, two external hosts and the tag refs. That was
written down once, as comments beside `maintenance.yml`'s egress allow-list, and
enforced nowhere. Three jobs invoked the tier and one granted what it needs.

`release.yml` ran **every** tier with no token, no tags and an allow-list
omitting both SDK hosts, so a release stopped in preflight on four capability
failures — before its own promotion gate, and long before the missing evidence
manifest that is the *known* release blocker. Nothing had noticed because the
graph had never been exercised: at 0.13.3 `resolve` did not run `validate_all`
at all, and the step that does arrived ten days after the last release.

The advisory tier splits by what a check reaches for. `CALENDAR` —
product-fact expiry, runtime-coverage waivers, the release ledger against tags,
documentation links — reaches no further than the checkout. `EXTERNAL` — the two
SDK pins and the two GitHub-API checks — does. A new `--tier release` is core
plus `CALENDAR`, so a release still refuses to ship an expired external fact
without a publishing graph reaching the network to find out. It passes with no
token and no reachable host, which is the point.

`catalog/validation-tiers.yml` records what each check needs and which job may
run which tier. `check_validation_tier_contract.py` reads tier membership from
`validate_all` itself, the requirement from the catalog, and the grant from the
job — allow-list, step and job `env`, checkout inputs. Discovery is fail-closed
in both directions: an undeclared caller is a finding, and so is a declared
caller that no longer exists. Six mutations were each caught, including
`release.yml` reverting to every tier.

- Let the advisory sweep survive finding something. The first real run of
`maintenance.yml` failed, and the reason was only visible from the run: GitHub
runs `run:` steps as `bash -e {0}`, and `-e` arrives on the shell's own command
Expand Down
5 changes: 4 additions & 1 deletion catalog/python-execution.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"schema_version": 1,
"python": {
"major_minor": "3.13",
"subject_count": 58,
"subject_count": 59,
"launcher": "scripts/check_python_execution_contract.py",
"launcher_prefix": [".venv/bin/python", "-I", "-B", "scripts/check_python_execution_contract.py", "--launch"],
"syntax_gate_prefix": [".venv/bin/python", "-I", "-B", "scripts/check_python_syntax.py"],
Expand Down Expand Up @@ -122,6 +122,7 @@
"check_benchmark_contract.py", "check_cache_contract.py",
"check_ci_tier_selection.py",
"check_maintenance_report_contract.py",
"check_validation_tier_contract.py",
"check_docs_links.py", "check_documented_commands.py",
"check_flutter_pin.py", "check_qt_pin.py",
"check_examples.py",
Expand Down Expand Up @@ -162,6 +163,7 @@
"check_cache_contract.py": ["_strict_yaml", "_workflow_yaml"],
"check_ci_tier_selection.py": ["_workflow_yaml", "check_python_execution_contract"],
"check_maintenance_report_contract.py": ["_workflow_yaml", "check_python_execution_contract"],
"check_validation_tier_contract.py": ["_strict_yaml", "_workflow_yaml", "validate_all"],
"check_documented_commands.py": ["_workflow_yaml"],
"check_flutter_pin.py": ["_strict_yaml"],
"check_qt_pin.py": ["_strict_yaml"],
Expand Down Expand Up @@ -202,6 +204,7 @@
"check_benchmark_contract", "check_cache_contract",
"check_ci_tier_selection",
"check_maintenance_report_contract",
"check_validation_tier_contract",
"check_docs_links", "check_documented_commands", "check_examples",
"check_flutter_pin", "check_qt_pin",
"check_gate_contract",
Expand Down
84 changes: 84 additions & 0 deletions catalog/validation-tiers.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# What each validator needs beyond the tree, and which job may invoke which tier.
#
# The tier split was written down in prose and enforced nowhere. `--tier
# scheduled` needs a GitHub token, two external hosts and the tag refs, and that
# requirement lived only in comments beside `maintenance.yml`'s egress
# allow-list. Three jobs invoked the tier; one of them granted what it needs.
#
# `release.yml` ran every tier with none of the four, so a release stopped in
# preflight on capability failures before its promotion gate -- and before the
# missing evidence manifest that is the known blocker. `ci.yml` fell back to the
# advisory tier when it could not resolve a change base, putting calendar and
# network work inside a required gate while skipping the blocking checks it was
# supposed to run. Both were invisible because neither path had ever executed.
#
# So the requirement becomes data, and `check_validation_tier_contract.py`
# holds every caller to it. Discovery is fail-closed: a job invoking
# `validate_all.py` that is not declared below is a finding, because a closed
# allowlist that only checks what it was told is exactly how the first version
# of this problem stayed invisible.
schema_version: 1

capabilities:
git_tags: >-
The repository's SemVer tag refs. `actions/checkout` fetches no tags by
default, and `fetch-depth: 0` alone still fetches none -- `fetch-tags: true`
is a separate input. A check that reconciles against tags fails closed
without them, because it cannot tell "no tags" from "no tags fetched".
github_token: >-
A GitHub API token in `GH_TOKEN` or `GITHUB_TOKEN`. Unauthenticated the API
allows 60 requests an hour, which these checks exhaust, so they report
themselves unverified rather than silently pass on a partial answer.
network:api.github.com: >-
The GitHub REST API, for resolving what a pinned third-party action itself
calls and for reading the live branch ruleset.
network:storage.googleapis.com: >-
Google's published Flutter release manifest, which the SDK pin is resolved
against.
network:download.qt.io: >-
The published Qt repository index, which the Qt pin is resolved against.

# Checks needing something the checkout alone does not provide. A check absent
# from this map needs nothing. Every name here must be a registered check.
requirements:
release-ledger-tags:
- git_tags
flutter-pin:
- network:storage.googleapis.com
qt-pin:
- network:download.qt.io
transitive-action-pins:
- github_token
- network:api.github.com
anchor-contexts:
- github_token
- network:api.github.com

# Every job that runs `validate_all.py`, and the tier it runs. An invocation not
# listed here fails the contract rather than inheriting whatever the job happens
# to grant.
invocations:
- workflow: .github/workflows/ci.yml
job: validate
tier: core
note: The blocking gate. Properties of the tree in hand, and nothing else.
- workflow: .github/workflows/ci.yml
job: validate-touched
tier: touched
note: >-
Blocking and scoped to the change. Must never invoke the advisory tier:
it does not contain these checks, and it does contain checks this job
grants nothing for.
- workflow: .github/workflows/maintenance.yml
job: sweep
tier: scheduled
note: >-
The only caller that grants the full advisory set, and the only one that
should. Failures here are maintenance debt, filed as one tracking issue.
- workflow: .github/workflows/release.yml
job: resolve
tier: release
note: >-
Core plus the calendar checks. An immutable artifact must not ship
carrying an expired external fact, but a publishing graph must not be
reaching the network to find out.
Loading
Loading