diff --git a/.github/scripts/minimum_pages.py b/.github/scripts/minimum_pages.py new file mode 100644 index 0000000..78f2be2 --- /dev/null +++ b/.github/scripts/minimum_pages.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""Build the GitHub Pages copy of the minimum version table. + +The `minimum-versions-publish` workflow runs this script. The script reads the +committed table `cmax/scripts/1-audit/minimum-versions.json` and writes one +file, `/minimum-versions.json`. The workflow deploys that directory to +the GitHub Pages site of this repository. The site carries no other file. + +The published copy is the committed table plus two top-level stamps: + + * `refreshed`: when the daily `minimum-versions-refresh` job last completed + successfully. The job reads every upstream feed, so this stamp moves every + day, also when no minimum changed. The command line compares this stamp + against the current time to decide if its table is stale. + * `changed`: when a minimum value last changed. The script reads the Git + history of the committed table and finds the oldest consecutive revision + whose minimum values equal the current values. The `generated` stamp of + that revision is the moment the generator recorded the change. Metadata + such as bulletin sources and the `generated` stamp itself do not move + `changed`. + +Every component block is copied byte for byte. The command line and the +website ignore keys they do not read. + +The script is standard library only. + +Usage:: + + python3 .github/scripts/minimum_pages.py \\ + --table cmax/scripts/1-audit/minimum-versions.json \\ + --out "$RUNNER_TEMP/pages" \\ + --refreshed 2026-10-24T07:28:02Z +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import urllib.error +import urllib.request +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +OUTPUT_FILE = "minimum-versions.json" +SCHEMA_VERSION = 1 + +# Each kind keeps its minimums under its own key. Name the metadata keys +# instead, so a new kind with a new value key needs no edit here. This list +# matches the gate in `minimum-versions-refresh`. +METADATA_KEYS = frozenset( + { + "advisory", "advisories", "advisoryRanges", "branchSources", + "cves", "floorSources", "fixAvailability", "floorAvailability", + "kind", "policy", "programCves", "programSources", "release", + "source", "sources", + } +) + + +class PublishError(RuntimeError): + """The table must not reach the public site.""" + + +def parse_stamp(value: Any) -> datetime | None: + if not value: + return None + try: + parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed + + +def format_stamp(value: datetime) -> str: + return value.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def minimums(table: dict[str, Any]) -> dict[str, Any]: + """Return the minimum values alone, without metadata and timestamps.""" + return { + name: {key: value for key, value in block.items() if key not in METADATA_KEYS} + for name, block in (table.get("components") or {}).items() + if isinstance(block, dict) + } + + +def check_complete(table: Any) -> None: + """Raise `PublishError` for a table that must not be published. + + The public copy feeds an audit that grades a provider. An empty or + partial table must never reach the site. This repeats the gate in + `minimum-versions-refresh`, because a merge can also arrive by hand. + """ + if not isinstance(table, dict): + raise PublishError("the table is not a JSON object") + if table.get("schemaVersion") != SCHEMA_VERSION: + raise PublishError( + f"the table declares schema version {table.get('schemaVersion')!r}. " + f"The reader understands {SCHEMA_VERSION}." + ) + components = table.get("components") + if not isinstance(components, dict) or not components: + raise PublishError("the table has no components") + if parse_stamp(table.get("generated")) is None: + raise PublishError( + f"the table carries no usable generated timestamp: {table.get('generated')!r}" + ) + empty = sorted( + name for name, values in minimums(table).items() if not any(values.values()) + ) + if empty: + raise PublishError(f"these components carry no minimum value: {', '.join(empty)}") + + +def _git(*args: str, cwd: Path) -> str: + return subprocess.run( + ["git", *args], cwd=cwd, check=True, capture_output=True, text=True + ).stdout + + +def last_changed(table: dict[str, Any], path: Path, *, repo: Path) -> str: + """Return the `generated` stamp of the revision that set the current minimums. + + The history walk goes from the newest revision of `path` to the oldest. It + stops at the first revision whose minimum values differ from `table`. The + oldest revision before that stop set the current minimums. + + A missing or shallow history, or any Git error, returns the `generated` + stamp of `table`. That is never older than the real value, so the + website can show a `changed` time that is late, never one that is early. + """ + current = minimums(table) + fallback = str(table["generated"]) + try: + relative = path.resolve().relative_to(repo.resolve()).as_posix() + revisions = _git("log", "--format=%H", "--", relative, cwd=repo).split() + except (OSError, ValueError, subprocess.CalledProcessError): + return fallback + stamp: str | None = None + for revision in revisions: + try: + older = json.loads(_git("show", f"{revision}:{relative}", cwd=repo)) + except (subprocess.CalledProcessError, ValueError): + break + if not isinstance(older, dict) or minimums(older) != current: + break + if parse_stamp(older.get("generated")) is not None: + stamp = str(older["generated"]) + return stamp or fallback + + +def previous_refreshed(url: str | None, *, timeout: float = 15) -> str | None: + """Return the `refreshed` stamp the public site serves now, or None.""" + if not url: + return None + request = urllib.request.Request( + url, headers={"Accept": "application/json", "Cache-Control": "no-cache"} + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + data = json.loads(response.read(4 * 1024 * 1024).decode("utf-8")) + except (urllib.error.URLError, OSError, ValueError): + return None + if isinstance(data, dict) and parse_stamp(data.get("refreshed")) is not None: + return str(data["refreshed"]) + return None + + +def build( + table: dict[str, Any], + *, + refreshed: str | None, + changed: str, + previous: str | None = None, +) -> dict[str, Any]: + """Return the published copy of `table`. + + `refreshed` comes from the latest successful refresh run. When that lookup + failed, the stamp the site serves now (`previous`) is kept, and the + `generated` stamp is the last fallback. The result is never older than + `generated` or `changed`, because the table cannot be newer than the + last time the feeds were read. + """ + generated = parse_stamp(table["generated"]) + assert generated is not None + candidates = [generated] + for value in (refreshed or previous, changed): + stamp = parse_stamp(value) + if stamp is not None: + candidates.append(stamp) + published = dict(table) + published["refreshed"] = format_stamp(max(candidates)) + published["changed"] = changed + return published + + +def render(table: dict[str, Any]) -> str: + # Same rendering as the generator: sorted keys, two-space indent, trailing + # newline. + return json.dumps(table, indent=2, sort_keys=True) + "\n" + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--table", required=True, type=Path) + parser.add_argument("--out", required=True, type=Path) + parser.add_argument( + "--refreshed", + default="", + help="Completion time of the latest successful refresh run.", + ) + parser.add_argument( + "--previous-url", + default="", + help="Public address of the published copy, read when --refreshed is empty.", + ) + parser.add_argument("--repo", type=Path, default=Path.cwd()) + args = parser.parse_args(argv) + + try: + table = json.loads(args.table.read_text(encoding="utf-8")) + check_complete(table) + except (OSError, ValueError, PublishError) as exc: + print(f"::error::{args.table}: {exc}. Refuse to publish it.", file=sys.stderr) + return 1 + + changed = last_changed(table, args.table, repo=args.repo) + previous = None + if parse_stamp(args.refreshed) is None: + previous = previous_refreshed(args.previous_url or None) + print( + "::warning::no successful refresh run was found. " + + ( + f"Keeping the published refreshed stamp {previous}." + if previous + else "Using the generated stamp of the table." + ) + ) + published = build(table, refreshed=args.refreshed or None, changed=changed, previous=previous) + + args.out.mkdir(parents=True, exist_ok=True) + target = args.out / OUTPUT_FILE + target.write_text(render(published), encoding="utf-8") + print( + f"wrote {target}: generated={published['generated']} " + f"changed={published['changed']} refreshed={published['refreshed']}" + ) + output = os.environ.get("GITHUB_OUTPUT") + if output: + with open(output, "a", encoding="utf-8") as handle: + for key in ("generated", "changed", "refreshed"): + handle.write(f"{key}={published[key]}\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/select_tests.py b/.github/scripts/select_tests.py index d84e62b..98fc1fe 100644 --- a/.github/scripts/select_tests.py +++ b/.github/scripts/select_tests.py @@ -39,7 +39,6 @@ ".github/workflows/codeql*.yml", ".github/workflows/python-nightly.yml", ".github/requirements/release.*", - ".github/workflows/minimum-versions-publish.yml", ) # Changes here select the full suite. @@ -59,8 +58,10 @@ # Non-source files that tests read by name. EXTRA_SOURCES = ( ".github/workflows/minimum-versions-refresh.yml", + ".github/workflows/minimum-versions-publish.yml", ".github/scripts/nightly_version.py", ".github/scripts/stable_release.py", + ".github/scripts/minimum_pages.py", "tools/check_public_data.py", ".github/scripts/select_tests.py", ) diff --git a/.github/workflows/minimum-versions-publish.yml b/.github/workflows/minimum-versions-publish.yml index 35cb040..2c1dd89 100644 --- a/.github/workflows/minimum-versions-publish.yml +++ b/.github/workflows/minimum-versions-publish.yml @@ -1,68 +1,71 @@ name: minimum-versions-publish # Publish the audit minimum version table -# (cmax/scripts/1-audit/minimum-versions.json) to the public website. -# A "minimum" is the lowest version of a component that has no known applicable -# vulnerability. +# (cmax/scripts/1-audit/minimum-versions.json) to the GitHub Pages site of this +# repository. A "minimum" is the lowest version of a component that has no +# known applicable vulnerability. # -# The website repository is SemiAnalysisAI/clustermax-web-oss. This job writes the -# table to `data/minimum-versions.json` there. The site serves those bytes at -# https://www.clustermax.ai/minimum-versions.json, and its /minimum-versions -# page refetches that address on load. +# The site carries exactly one file: +# +# https://semianalysisai.github.io/ClusterMAX/minimum-versions.json +# +# Do not add other files to the Pages site. The deploy replaces the whole site +# with the one-file directory that this job builds. +# +# Why GitHub Pages: +# - The job needs no secret. The deploy authenticates with this run's own +# OIDC token (`pages: write`, `id-token: write`). Nothing outside this +# repository holds a token that can write the public table. +# - The job creates no commit, tag, or release, so the release list stays +# the list of command line releases. +# - The address is stable, served from a CDN, and readable without +# authentication, so `cmax audit security` and the website can both read it. # # Why this job exists: # - `cmax audit security` fetches that public address on startup. A pip # installation carries a read-only table inside the installed package, so # the public copy is the one way it gets current minimums. +# - The website (SemiAnalysisAI/ClusterMAX-web) reads the same address +# and renders it at https://www.clustermax.ai/minimum-versions. # - The table in this repository stays the single source of truth. This job # copies it. It never edits a minimum and it never runs the generator. # - The publish happens after a merge, never before review. The public copy # is therefore always equal to a reviewed state of master. # -# What the published copy adds, and why: +# What the published copy adds (see .github/scripts/minimum_pages.py): # - `refreshed`: when the daily `minimum-versions-refresh` job last completed -# successfully, so the website can say when the upstream feeds were last -# checked. The table's own `generated` stamp moves only when a minimum -# changes or the table nears its `maxAgeDays` limit, so it cannot say this. -# - `updated`: when a minimum value last changed in the published copy. The -# job compares the minimum values (not the metadata) against the copy the -# website already carries and moves this stamp only on a real change. -# Both are top-level metadata. Every component block is copied byte for byte, -# and the command line ignores keys it does not read. -# -# What this job does not do: -# - It does not open a pull request on the website repository. The table is -# generated data with its own review gate in this repository, and a daily -# manual merge on the website would hold the public copy behind. -# - It does not touch any other file in the website repository. +# successfully. It moves every day, also when no minimum changed. The +# command line warns when a failed fetch leaves it on a table whose +# `refreshed` stamp is more than 7 days old. +# - `changed`: when a minimum value last changed, from the Git history of +# the committed table. +# Every component block is copied byte for byte. on: push: branches: - master paths: - # Only a change to the table can change the published copy. - cmax/scripts/1-audit/minimum-versions.json - # A change to this job must be able to publish with the new logic. - .github/workflows/minimum-versions-publish.yml + - .github/scripts/minimum_pages.py + workflow_run: + # Publish right after each refresh, so `refreshed` moves the same day. + workflows: + - minimum-versions-refresh + types: + - completed schedule: - # 08:43 UTC each day, after the 07:17 UTC refresh. - # - The push event above publishes each merged table on the same day. - # - The daily run carries the `refreshed` stamp forward after a refresh - # that moved no minimum, and repairs a missed publish: a failed website - # deploy, a revoked token, or a job cancelled mid-push each leave the - # public copy behind master. + # 08:43 UTC each day, after the 07:17 UTC refresh. A safety net for a + # missed `workflow_run` event or a failed deploy. - cron: '43 8 * * *' workflow_dispatch: -# Least privilege. This job reads this repository and its workflow runs, and -# writes to the website repository with a separate token. +# Least privilege. Each job asks for what it needs. permissions: contents: read - actions: read -# One publish at a time. A scheduled run must not race a push run, because both -# push the same branch of the website repository. +# One publish at a time. A scheduled run must not race a push run. concurrency: group: minimum-versions-publish cancel-in-progress: false @@ -70,90 +73,30 @@ concurrency: env: MINIMUMS_PATH: cmax/scripts/1-audit/minimum-versions.json REFRESH_WORKFLOW: minimum-versions-refresh.yml - WEB_REPO: SemiAnalysisAI/clustermax-web-oss - WEB_PATH: data/minimum-versions.json - # The route file that serves the public path. The publish stops when it is - # absent, because the address would answer 404 and every fetch would fail. - WEB_ROUTE: app/minimum-versions.json/route.ts - PUBLIC_URL: https://www.clustermax.ai/minimum-versions.json + PUBLIC_URL: https://semianalysisai.github.io/ClusterMAX/minimum-versions.json jobs: - publish: + build: + # Only master is reviewed. The `github-pages` environment also rejects a + # deploy from another branch. + if: github.ref == 'refs/heads/master' runs-on: ubuntu-latest - # The work is one shallow clone, one file write, and one push. 10 minutes - # covers the deployment check and still kills a hung connection quickly. timeout-minutes: 10 + permissions: + contents: read + # Read the runs of `minimum-versions-refresh`. + actions: read + # Sign the published file with a build provenance attestation. + id-token: write + attestations: write + outputs: + refreshed: ${{ steps.build.outputs.refreshed }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: - # This job never pushes to this repository. Keep no token in Git - # configuration. persist-credentials: false - - - name: Check that the publish token is present - env: - WEB_TOKEN: ${{ secrets.CLUSTERMAX_WEB_TOKEN }} - # Fail loudly. A silent skip lets the public table age out, and the - # command line then reads minimums that no longer match this repository. - run: | - set -uo pipefail - if [ -z "$WEB_TOKEN" ]; then - echo "::error::the repository secret CLUSTERMAX_WEB_TOKEN is empty. Add a token with write access to $WEB_REPO, then run this job again." - exit 1 - fi - echo "the publish token is present" - - - name: Check that the table is complete - # The public copy feeds an audit that grades a provider. An empty or - # partial table must never reach the website. This repeats the gate in - # `minimum-versions-refresh`, because a merge can also arrive by hand. - run: | - python3 - <<'PY' - import json - import os - import sys - from datetime import datetime - - path = os.environ["MINIMUMS_PATH"] - with open(path, encoding="utf-8") as handle: - data = json.load(handle) - - if data.get("schemaVersion") != 1: - sys.exit(f"::error::{path} declares schema version {data.get('schemaVersion')}. The reader understands 1.") - - components = data.get("components") or {} - if not components: - sys.exit(f"::error::{path} has no components. Refuse to publish it.") - - stamp = str(data.get("generated") or "") - try: - datetime.fromisoformat(stamp.replace("Z", "+00:00")) - except ValueError: - sys.exit(f"::error::{path} carries no usable generated timestamp: {stamp!r}") - - # Each kind keeps its minimums under its own key. Name the metadata keys - # instead, so a new kind with a new value key needs no edit here. This - # list matches the gate in `minimum-versions-refresh`. - metadata_keys = { - "advisory", "advisories", "advisoryRanges", "branchSources", - "cves", "floorSources", "fixAvailability", "floorAvailability", - "kind", "policy", "programCves", "programSources", "release", - "source", "sources", - } - empty = sorted( - name for name, component in components.items() - if not any( - value for key, value in component.items() - if key not in metadata_keys - ) - ) - if empty: - sys.exit(f"::error::these components carry no minimum value: {', '.join(empty)}") - - print(f"{len(components)} components carry minimum values, generated {stamp}") - with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as out: - out.write(f"MINIMUMS_GENERATED={stamp}\n") - PY + # The `changed` stamp comes from the history of the table. + fetch-depth: 0 - name: Find when the upstream feeds were last checked id: refreshed @@ -162,165 +105,70 @@ jobs: # The latest successful `minimum-versions-refresh` run is the last time # the feeds were read end to end. A run that opened a pull request and a # run that found nothing both count; a failed run does not, so a broken - # refresh shows on the website as an aging stamp rather than a fresh one. - # This step never fails the job: the stamp is informational, and the - # publish below keeps the previous value when this lookup fails. + # refresh shows as an aging stamp rather than a fresh one. This step + # never fails the job. The build step keeps the published value when + # this lookup fails. continue-on-error: true run: | set -uo pipefail stamp="$(gh api \ "repos/${GITHUB_REPOSITORY}/actions/workflows/${REFRESH_WORKFLOW}/runs?status=success&per_page=1" \ --jq '.workflow_runs[0].updated_at // empty')" - if [ -z "$stamp" ]; then - echo "::warning::no successful ${REFRESH_WORKFLOW} run found. The published refreshed stamp keeps its previous value." - else - echo "the upstream feeds were last checked at $stamp" - fi + echo "the upstream feeds were last checked at ${stamp:-an unknown time}" echo "stamp=$stamp" >> "$GITHUB_OUTPUT" - - name: Publish the table to the website repository - id: publish + - name: Build the Pages site + id: build env: - WEB_TOKEN: ${{ secrets.CLUSTERMAX_WEB_TOKEN }} - SOURCE_REPO: ${{ github.repository }} - SOURCE_SHA: ${{ github.sha }} REFRESHED: ${{ steps.refreshed.outputs.stamp }} + run: >- + python3 .github/scripts/minimum_pages.py + --table "$MINIMUMS_PATH" + --out "$RUNNER_TEMP/pages" + --refreshed "$REFRESHED" + --previous-url "$PUBLIC_URL" + + - name: Check that the site carries one file run: | - set -uo pipefail - # Keep the token out of the log and out of the remote URL that Git - # writes into the clone configuration. - checkout="$RUNNER_TEMP/clustermax-web" - git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic $(printf 'x-access-token:%s' "$WEB_TOKEN" | base64 -w0)" \ - clone --depth 1 --no-tags "https://github.com/${WEB_REPO}.git" "$checkout" || { - echo "::error::the clone of $WEB_REPO failed. Check that CLUSTERMAX_WEB_TOKEN has read access." - exit 1 - } - - if [ ! -f "$checkout/$WEB_ROUTE" ]; then - echo "::error::$WEB_REPO carries no $WEB_ROUTE, so $PUBLIC_URL would answer 404. Merge the website pull request that adds the route, then run this job again." + set -euo pipefail + files="$(find "$RUNNER_TEMP/pages" -mindepth 1 -printf '%P\n')" + if [ "$files" != "minimum-versions.json" ]; then + echo "::error::the Pages site must carry only minimum-versions.json. It carries: $files" exit 1 fi - # Write the published copy: this repository's table plus the two - # website-only stamps. The component blocks are copied unchanged. - WEB_FILE="$checkout/$WEB_PATH" python3 - <<'PY' - import json - import os - - metadata_keys = { - "advisory", "advisories", "advisoryRanges", "branchSources", - "cves", "floorSources", "fixAvailability", "floorAvailability", - "kind", "policy", "programCves", "programSources", "release", - "source", "sources", - } - - def minimums(table): - # The minimum values alone, without bulletin metadata and without - # the timestamps, so `updated` moves only when a version moves. - return { - name: {key: value for key, value in block.items() if key not in metadata_keys} - for name, block in (table.get("components") or {}).items() - } - - with open(os.environ["MINIMUMS_PATH"], encoding="utf-8") as handle: - upstream = json.load(handle) - - web_file = os.environ["WEB_FILE"] - previous = {} - if os.path.exists(web_file): - with open(web_file, encoding="utf-8") as handle: - try: - previous = json.load(handle) - except ValueError: - previous = {} - - published = dict(upstream) - - if minimums(previous) != minimums(upstream) or not previous.get("updated"): - # A minimum moved, or the website carries no `updated` stamp yet. - # The table's own timestamp is the moment the generator recorded - # the change, so use it rather than the publish time. - published["updated"] = upstream["generated"] - changed = True - else: - published["updated"] = previous["updated"] - changed = False - - refreshed = os.environ.get("REFRESHED", "") or previous.get("refreshed") or upstream["generated"] - published["refreshed"] = refreshed - - os.makedirs(os.path.dirname(web_file), exist_ok=True) - with open(web_file, "w", encoding="utf-8") as handle: - # Same rendering as the generator: sorted keys, two-space indent, - # trailing newline, so the diff on the website is only what moved. - handle.write(json.dumps(published, indent=2, sort_keys=True) + "\n") - - summary = ( - f"minimums changed, updated={published['updated']}" if changed - else f"no minimum moved, updated stays {published['updated']}" - ) - print(f"{summary}; refreshed={refreshed}") - # GITHUB_ENV reaches later steps only, so hand the stamps to the rest - # of this step through a file as well. - with open(os.path.join(os.environ["RUNNER_TEMP"], "stamps.sh"), "w", encoding="utf-8") as out: - out.write(f"MINIMUMS_UPDATED='{published['updated']}'\n") - out.write(f"MINIMUMS_REFRESHED='{refreshed}'\n") - with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as out: - out.write(f"MINIMUMS_UPDATED={published['updated']}\n") - out.write(f"MINIMUMS_REFRESHED={refreshed}\n") - PY - # shellcheck disable=SC1091 - . "$RUNNER_TEMP/stamps.sh" - - cd "$checkout" - # Stage first, then compare the index against HEAD. `git diff` alone - # reports no change for a file the website repository does not carry - # yet, because an untracked file is in neither the index nor HEAD, so - # the first publish would skip and the address would stay a 404. - git add -- "$WEB_PATH" - if git diff --cached --quiet -- "$WEB_PATH"; then - echo "the published table already matches this repository. Nothing to push." - echo "pushed=false" >> "$GITHUB_OUTPUT" - exit 0 - fi + - name: Attest the published file + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: ${{ runner.temp }}/pages/minimum-versions.json - git config user.name 'clustermax-minimums[bot]' - git config user.email 'clustermax-minimums@users.noreply.github.com' - # Build the message in a file. A quoted multi-line argument would - # carry this file's indentation into the commit message. - message="$RUNNER_TEMP/commit-message.txt" - { - echo 'chore(security): publish the minimum version table' - echo - echo "Generated ${MINIMUMS_GENERATED}." - echo "Minimums last changed ${MINIMUMS_UPDATED}; feeds last checked ${MINIMUMS_REFRESHED}." - echo "Source: ${SOURCE_REPO}@${SOURCE_SHA}." - echo - echo 'This file is generated. Do not hand-edit it.' - } > "$message" - git commit -q -F "$message" || { - echo "::error::the commit failed." - exit 1 - } + - name: Upload the Pages artifact + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: ${{ runner.temp }}/pages - git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic $(printf 'x-access-token:%s' "$WEB_TOKEN" | base64 -w0)" \ - push origin HEAD || { - echo "::error::the push to $WEB_REPO failed. A branch protection rule can reject it, and CLUSTERMAX_WEB_TOKEN needs write access to the default branch." - exit 1 - } - echo "pushed=true" >> "$GITHUB_OUTPUT" - echo "published the table generated ${MINIMUMS_GENERATED}" + deploy: + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }}minimum-versions.json + steps: + - name: Deploy to GitHub Pages + id: deploy + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 - name: Check the public address - # The website deploys on its own queue, so a fresh copy takes a few - # minutes to serve. This step reports what the address serves now. It - # never fails the job: - # - A slow deployment is normal and is not a defect. - # - A stale public copy cannot produce a wrong verdict. The command - # line refuses a fetched table that is older than the installed - # table, so a stale copy degrades to no update. - # - The scheduled run checks the address again the next day. - if: steps.publish.outcome == 'success' && steps.publish.outputs.pushed == 'true' + # The CDN can take a minute to serve a new deploy. This step reports + # what the address serves now and never fails the job. A stale public + # copy cannot produce a wrong verdict: the command line refuses a + # fetched table that is older than the installed table. + env: + WANT_REFRESHED: ${{ needs.build.outputs.refreshed }} run: | python3 - <<'PY' import json @@ -330,40 +178,27 @@ jobs: import urllib.request url = os.environ["PUBLIC_URL"] - want = os.environ.get("MINIMUMS_GENERATED", "") - want_refreshed = os.environ.get("MINIMUMS_REFRESHED", "") + want = os.environ.get("WANT_REFRESHED", "") served = None - served_refreshed = None for attempt in range(1, 7): try: - # Ask the CDN for a fresh copy, not the cached response. request = urllib.request.Request(url, headers={"Cache-Control": "no-cache"}) with urllib.request.urlopen(request, timeout=15) as answer: - table = json.loads(answer.read(4_000_000).decode("utf-8")) - served = table.get("generated") - served_refreshed = table.get("refreshed") + served = json.loads(answer.read(4_000_000).decode("utf-8")).get("refreshed") except (urllib.error.URLError, ValueError, OSError) as exc: print(f"attempt {attempt}: {url} could not be read ({exc})") else: - print(f"attempt {attempt}: {url} serves the table generated {served}, refreshed {served_refreshed}") - if served == want and served_refreshed == want_refreshed: + print(f"attempt {attempt}: {url} serves refreshed {served}") + if served == want: break if attempt < 6: - time.sleep(30) + time.sleep(20) - if served == want and served_refreshed == want_refreshed: - summary = f"`{url}` serves the table generated {want}, refreshed {want_refreshed}." - elif served: - summary = ( - f"`{url}` still serves the table generated {served} (refreshed {served_refreshed}), " - f"and this run published {want} (refreshed {want_refreshed}). The website " - "deployment is probably still in its queue." - ) - print(f"::warning::{summary}") + if served == want: + summary = f"`{url}` serves the table refreshed {want}." else: - summary = f"`{url}` could not be read. Check the website deployment." + summary = f"`{url}` serves refreshed {served}, and this run published {want}. The CDN is probably still updating." print(f"::warning::{summary}") - with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as out: out.write(f"### Minimum versions publish\n\n{summary}\n") PY diff --git a/README.md b/README.md index 179fc22..cab2e0f 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ The [ClusterMAX website](https://clustermax.semianalysis.com/) explains the rati ## Run a security audit -The current [minimum-version table](https://www.clustermax.ai/minimum-versions) lists the software and firmware versions used for grading, with vendor advisories behind each value. Its [machine-readable form](https://www.clustermax.ai/minimum-versions.json) is the table `cmax audit security` fetches on startup (skip with `--no-fetch-minimums`). +The current [minimum-version table](https://www.clustermax.ai/minimum-versions) lists the software and firmware versions used for grading, with vendor advisories behind each value. Its [machine-readable form](https://semianalysisai.github.io/ClusterMAX/minimum-versions.json) is the table `cmax audit security` fetches on startup (skip with `--no-fetch-minimums`). The `minimum-versions-publish` workflow deploys that one file to the GitHub Pages site of this repository. Its `refreshed` stamp moves every day after a successful refresh, and its `changed` stamp moves only when a minimum value changes. The command line keeps the last fetched table in `~/.cache/clustermax/`, so an offline run grades against that copy instead of the older table inside the package. When the fetch fails and the table in use was last refreshed more than 7 days ago, the audit gives a warning. The read-only Bad Epoll check, `security.badEpoll.status`, checks the running kernel against [CVE-2026-46242](https://ubuntu.com/security/CVE-2026-46242). diff --git a/cmax/cli.py b/cmax/cli.py index 8384607..39cdcc9 100644 --- a/cmax/cli.py +++ b/cmax/cli.py @@ -261,11 +261,14 @@ def build_parser() -> argparse.ArgumentParser: dest="fetch_minimums", help=( "Do not read the published minimum version table from " - "https://www.clustermax.ai/minimum-versions.json before the audit " + "https://semianalysisai.github.io/ClusterMAX/minimum-versions.json " + "before the audit " "runs. By default the security audit fetches that table on " "startup, stores it in a user cache directory, and grades against " "it; a fetch failure prints a warning and the audit continues " - "against the installed table. This flag skips the fetch, which " + "against the last fetched table or the installed table, with a " + "second warning when that table was last refreshed more than 7 " + "days ago. This flag skips the fetch, which " "keeps a committed audit result reproducible from the installed " "table on a host with no outbound network access." ), diff --git a/cmax/minimum_links.py b/cmax/minimum_links.py index 9dde0df..25b0740 100644 --- a/cmax/minimum_links.py +++ b/cmax/minimum_links.py @@ -6,7 +6,7 @@ MINIMUM_VERSIONS_URL = "https://www.clustermax.ai/minimum-versions" -MINIMUM_VERSIONS_JSON_URL = "https://www.clustermax.ai/minimum-versions.json" +MINIMUM_VERSIONS_JSON_URL = "https://semianalysisai.github.io/ClusterMAX/minimum-versions.json" REFERENCE_LABEL = "ClusterMAX minimum versions" _COMPONENT_ANCHORS = frozenset( diff --git a/cmax/minimum_sync.py b/cmax/minimum_sync.py index c0a510a..4fd4af4 100644 --- a/cmax/minimum_sync.py +++ b/cmax/minimum_sync.py @@ -3,11 +3,22 @@ `cmax/scripts/1-audit/minimum-versions.json` is the source of truth for every minimum version the audit grades against. The daily -`minimum-versions-refresh` workflow regenerates that file, and a publish job -copies the merged file to the ClusterMAX website. The website serves the same -bytes at one stable address: +`minimum-versions-refresh` workflow regenerates that file, and the +`minimum-versions-publish` workflow deploys the merged file to the GitHub Pages +site of this repository. The Pages site serves that one file at one stable +address: - https://www.clustermax.ai/minimum-versions.json + https://semianalysisai.github.io/ClusterMAX/minimum-versions.json + +The published copy adds two top-level stamps to the table: + + * `refreshed` is when the daily refresh last read the upstream feeds + successfully. It moves every day, also when no minimum changed. + * `changed` is when a minimum value last changed. + +The command line compares `refreshed` against the current time to decide if +the table it grades against is stale. A table without `refreshed`, for +example the table that ships inside the package, uses `generated`. This module reads that address and stores the result in a user cache directory. A pip installation freezes the minimum table inside the wheel, and @@ -38,11 +49,14 @@ `cached_table()` hands that copy to the audit, so one launch with a working network keeps every later offline run on the newest table this host has seen. + * When the fetch fails and the table in use was last refreshed more than + `STALE_AFTER_DAYS` days ago, `stale_warning()` gives a warning. The audit + still runs. Command line:: python3 -m cmax.minimum_sync - python3 -m cmax.minimum_sync --url https://www.clustermax.ai/minimum-versions.json + python3 -m cmax.minimum_sync --url https://semianalysisai.github.io/ClusterMAX/minimum-versions.json """ from __future__ import annotations @@ -55,11 +69,11 @@ import urllib.error import urllib.request from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any -DEFAULT_URL = "https://www.clustermax.ai/minimum-versions.json" +DEFAULT_URL = "https://semianalysisai.github.io/ClusterMAX/minimum-versions.json" URL_ENV = "CLUSTERMAX_MINIMUMS_URL" USER_AGENT = "clustermax-minimum-sync/1.0" DEFAULT_TIMEOUT = 15 @@ -70,6 +84,10 @@ SCHEMA_VERSION = 1 CACHE_FILE = "minimum-versions.json" STATE_FILE = "minimum-versions.http.json" +# A failed fetch with a table in use that was last refreshed more than this many +# days ago gives a warning. The publish job moves `refreshed` every day, so 7 +# days is a week of missed refreshes or a week without network access. +STALE_AFTER_DAYS = 7 class MinimumSyncError(RuntimeError): @@ -157,6 +175,60 @@ def parse_stamp(value: Any) -> datetime | None: return parsed +def refreshed_stamp(table: Any) -> str | None: + """Return the stamp that tells when the table was last refreshed, or None. + + The published copy carries `refreshed`, which moves every day. A table + without a usable `refreshed` value, for example the table inside the + package, was last refreshed when it was generated. + """ + if not isinstance(table, dict): + return None + for key in ("refreshed", "generated"): + if parse_stamp(table.get(key)) is not None: + return str(table.get(key)) + return None + + +def table_refreshed(path: Path) -> str | None: + """Return `refreshed_stamp()` of the table file at `path`, or None.""" + try: + return refreshed_stamp(json.loads(path.read_text())) + except (OSError, ValueError): + return None + + +def stale_warning( + refreshed: str | None, + *, + now: datetime | None = None, + max_days: int = STALE_AFTER_DAYS, +) -> str | None: + """Return a warning when the table in use is stale, or None. + + The caller gives this warning only after a failed fetch. A table with no + usable stamp is stale. + """ + stamp = parse_stamp(refreshed) + if stamp is None: + return ( + "minimum fetch warning: the minimum table in use has no usable " + "refreshed or generated time, and the fetch of the published " + "table failed. Its minimums can be out of date." + ) + current = now or datetime.now(timezone.utc) + age = current - stamp + if age <= timedelta(days=max_days): + return None + return ( + f"minimum fetch warning: the minimum table in use was last refreshed " + f"{refreshed} ({age.days} days ago), which is more than {max_days} " + f"days, and the fetch of the published table failed. Its minimums can " + f"miss recent vendor advisories. Let this host reach " + f"{published_url()}, or update clustermax." + ) + + def validate(payload: bytes) -> dict[str, Any]: """Return the parsed table, or raise `MinimumSyncError`. diff --git a/cmax/security.py b/cmax/security.py index 000e01c..6b062d9 100644 --- a/cmax/security.py +++ b/cmax/security.py @@ -2407,6 +2407,11 @@ def supports_release_checks(path: Path) -> bool: f"(generated {cached_generated}) from {cache_file}.", flush=True, ) + stale = minimum_sync.stale_warning( + minimum_sync.table_refreshed(cache_file) + ) + if stale: + print(stale, flush=True) return True suffix = ( f" (generated {installed_generated})" if installed_generated else "" @@ -2416,6 +2421,14 @@ def supports_release_checks(path: Path) -> bool: f"Grading against the installed table{suffix}.", flush=True, ) + if result.generated is None: + # The fetch failed. A rejected older publication is a decision, + # not a failure, so it gives no staleness warning. + stale = minimum_sync.stale_warning( + minimum_sync.refreshed_stamp(installed_document) + ) + if stale: + print(stale, flush=True) return False if not supports_release_checks(result.path): return False diff --git a/tests/audit/test_minimum_sync.py b/tests/audit/test_minimum_sync.py index 6ce588d..8d00580 100644 --- a/tests/audit/test_minimum_sync.py +++ b/tests/audit/test_minimum_sync.py @@ -316,6 +316,90 @@ def test_a_cached_table_older_than_the_installed_table_is_not_used(self) -> None self.assertIn("older than the installed table", output) +class StalenessTests(MinimumSyncTestCase): + def _installed(self, data: dict) -> Path: + path = Path(self.tmp.name) / "installed" / "minimum-versions.json" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data)) + return path + + def _sync(self, now: datetime, *answers: object) -> tuple[bool, str]: + output = io.StringIO() + real = minimum_sync.stale_warning + + def at_now(refreshed, **kwargs): # noqa: ANN001, ANN003 + return real(refreshed, now=now, **kwargs) + + with self.server(*answers), mock.patch.object( + minimum_sync, "stale_warning", at_now + ), contextlib.redirect_stdout(output): + changed = security.sync_minimum_table() + return changed, output.getvalue() + + def test_refreshed_is_preferred_over_generated(self) -> None: + data = {**table(NOW - timedelta(days=30)), "refreshed": "2026-08-19T07:00:00Z"} + self.assertEqual(minimum_sync.refreshed_stamp(data), "2026-08-19T07:00:00Z") + self.assertEqual( + minimum_sync.refreshed_stamp(table(NOW)), "2026-08-20T07:32:09Z" + ) + + def test_the_warning_starts_after_seven_days(self) -> None: + stamp = "2026-08-20T07:32:09Z" + self.assertIsNone(minimum_sync.stale_warning(stamp, now=NOW + timedelta(days=7))) + warning = minimum_sync.stale_warning(stamp, now=NOW + timedelta(days=8)) + self.assertIsNotNone(warning) + self.assertIn("8 days ago", warning) + self.assertIsNotNone(minimum_sync.stale_warning(None, now=NOW)) + + def test_a_failed_fetch_warns_for_a_stale_installed_table(self) -> None: + installed = self._installed(table(NOW - timedelta(days=9))) + with mock.patch.object(security, "minimum_table_path", return_value=installed): + _, output = self._sync(NOW, urllib.error.URLError("no outbound network")) + self.assertIn("Grading against the installed table", output) + self.assertIn("last refreshed 2026-08-11T07:32:09Z (9 days ago)", output) + + def test_a_failed_fetch_does_not_warn_for_a_fresh_installed_table(self) -> None: + installed = self._installed(table(NOW - timedelta(days=2))) + with mock.patch.object(security, "minimum_table_path", return_value=installed): + _, output = self._sync(NOW, urllib.error.URLError("no outbound network")) + self.assertNotIn("last refreshed", output) + + def test_the_cached_refreshed_stamp_decides_and_not_generated(self) -> None: + # The content is old, but the daily publish refreshed it yesterday. + published = {**table(NOW - timedelta(days=20)), "refreshed": "2026-08-19T07:30:00Z"} + with self.server(FakeResponse(body(published))): + minimum_sync.sync() + installed = self._installed(table(NOW - timedelta(days=30))) + with mock.patch.object(security, "minimum_table_path", return_value=installed): + changed, output = self._sync(NOW, urllib.error.URLError("no outbound network")) + self.assertTrue(changed) + self.assertIn("Grading against the last fetched table", output) + self.assertNotIn("last refreshed", output) + # Ten days later the same cache is stale. + os.environ[_MINIMUM_ENV] = "" + with mock.patch.object(security, "minimum_table_path", return_value=installed): + _, output = self._sync( + NOW + timedelta(days=10), urllib.error.URLError("no outbound network") + ) + self.assertIn("last refreshed 2026-08-19T07:30:00Z", output) + + def test_a_successful_fetch_does_not_warn(self) -> None: + installed = self._installed(table(NOW - timedelta(days=30))) + with mock.patch.object(security, "minimum_table_path", return_value=installed): + _, output = self._sync( + NOW + timedelta(days=30), FakeResponse(body(table(NOW))) + ) + self.assertIn("minimum table in use:", output) + self.assertNotIn("last refreshed", output) + + def test_a_rejected_older_publication_does_not_warn(self) -> None: + installed = self._installed(table(NOW - timedelta(days=10))) + with mock.patch.object(security, "minimum_table_path", return_value=installed): + _, output = self._sync(NOW, FakeResponse(body(table(NOW - timedelta(days=30))))) + self.assertIn("Keeping the installed table", output) + self.assertNotIn("last refreshed", output) + + class CommandLineTests(MinimumSyncTestCase): def test_the_opt_out_flag_skips_the_fetch(self) -> None: target = security.SecurityTarget("standalone", "vm", True) diff --git a/tests/test_minimum_pages.py b/tests/test_minimum_pages.py new file mode 100644 index 0000000..6f75092 --- /dev/null +++ b/tests/test_minimum_pages.py @@ -0,0 +1,145 @@ +"""Tests for the GitHub Pages copy of the minimum version table.""" + +from __future__ import annotations + +import importlib.util +import json +import subprocess +from pathlib import Path + +import pytest +import yaml + +ROOT = Path(__file__).parents[1] +SCRIPT_PATH = ROOT / ".github" / "scripts" / "minimum_pages.py" +WORKFLOW_PATH = ROOT / ".github" / "workflows" / "minimum-versions-publish.yml" +SPEC = importlib.util.spec_from_file_location("minimum_pages", SCRIPT_PATH) +assert SPEC and SPEC.loader +PAGES = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(PAGES) + + +def table(generated: str, docker: str = "29.7.0", advisory: str = "a") -> dict: + return { + "schemaVersion": 1, + "generated": generated, + "maxAgeDays": 10, + "gracePeriodDays": 3, + "components": { + "docker": { + "kind": "minimum", + "minimum": docker, + "source": {"feed": "docker-release-notes", "id": advisory}, + } + }, + } + + +def git(repo: Path, *args: str) -> None: + subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True) + + +@pytest.fixture +def repo(tmp_path: Path) -> Path: + git(tmp_path, "init", "-q") + git(tmp_path, "config", "user.name", "test") + git(tmp_path, "config", "user.email", "test@example.test") + return tmp_path + + +def commit(repo: Path, data: dict) -> Path: + path = repo / "minimum-versions.json" + path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n") + git(repo, "add", path.name) + git(repo, "commit", "-q", "-m", data["generated"]) + return path + + +def test_changed_is_the_revision_that_set_the_current_minimums(repo: Path) -> None: + commit(repo, table("2026-10-01T07:00:00Z", docker="29.6.0")) + commit(repo, table("2026-10-05T07:00:00Z", docker="29.7.0")) + # A timestamp-only refresh and a metadata-only change do not move `changed`. + commit(repo, table("2026-10-13T07:00:00Z", docker="29.7.0")) + path = commit(repo, table("2026-10-20T07:00:00Z", docker="29.7.0", advisory="b")) + current = json.loads(path.read_text()) + assert PAGES.last_changed(current, path, repo=repo) == "2026-10-05T07:00:00Z" + + +def test_changed_follows_a_new_minimum(repo: Path) -> None: + commit(repo, table("2026-10-05T07:00:00Z", docker="29.7.0")) + path = commit(repo, table("2026-10-20T07:00:00Z", docker="29.8.0")) + current = json.loads(path.read_text()) + assert PAGES.last_changed(current, path, repo=repo) == "2026-10-20T07:00:00Z" + + +def test_changed_falls_back_to_generated_without_history(tmp_path: Path) -> None: + path = tmp_path / "minimum-versions.json" + data = table("2026-10-20T07:00:00Z") + path.write_text(json.dumps(data)) + assert PAGES.last_changed(data, path, repo=tmp_path) == "2026-10-20T07:00:00Z" + + +def test_refreshed_moves_without_a_content_change() -> None: + data = table("2026-10-13T07:00:00Z") + first = PAGES.build(data, refreshed="2026-10-20T07:28:00Z", changed="2026-10-05T07:00:00Z") + second = PAGES.build(data, refreshed="2026-10-21T07:28:00Z", changed="2026-10-05T07:00:00Z") + assert first["components"] == second["components"] == data["components"] + assert first["changed"] == second["changed"] == "2026-10-05T07:00:00Z" + assert (first["refreshed"], second["refreshed"]) == ( + "2026-10-20T07:28:00Z", + "2026-10-21T07:28:00Z", + ) + + +def test_refreshed_falls_back_to_the_published_stamp_then_generated() -> None: + data = table("2026-10-13T07:00:00Z") + kept = PAGES.build(data, refreshed=None, changed="2026-10-13T07:00:00Z", + previous="2026-10-19T07:28:00Z") + assert kept["refreshed"] == "2026-10-19T07:28:00Z" + bare = PAGES.build(data, refreshed=None, changed="2026-10-13T07:00:00Z") + assert bare["refreshed"] == "2026-10-13T07:00:00Z" + + +def test_refreshed_is_never_older_than_generated() -> None: + data = table("2026-10-13T07:00:00Z") + published = PAGES.build(data, refreshed="2026-10-01T00:00:00Z", changed="2026-10-13T07:00:00Z") + assert published["refreshed"] == "2026-10-13T07:00:00Z" + + +@pytest.mark.parametrize( + "broken", + [ + {"schemaVersion": 2}, + {"components": {}}, + {"generated": "not a time"}, + {"components": {"docker": {"kind": "minimum", "source": {}}}}, + ], +) +def test_an_incomplete_table_is_refused(broken: dict) -> None: + data = {**table("2026-10-13T07:00:00Z"), **broken} + with pytest.raises(PAGES.PublishError): + PAGES.check_complete(data) + + +def test_main_writes_only_the_table(repo: Path, tmp_path: Path) -> None: + path = commit(repo, table("2026-10-13T07:00:00Z")) + out = tmp_path / "site" + assert PAGES.main([ + "--table", str(path), "--out", str(out), + "--refreshed", "2026-10-24T07:28:02Z", "--repo", str(repo), + ]) == 0 + assert sorted(p.name for p in out.iterdir()) == ["minimum-versions.json"] + published = json.loads((out / "minimum-versions.json").read_text()) + assert published["refreshed"] == "2026-10-24T07:28:02Z" + assert published["changed"] == "2026-10-13T07:00:00Z" + + +def test_the_workflow_uses_no_secret_and_deploys_with_oidc() -> None: + text = WORKFLOW_PATH.read_text() + assert "secrets." not in text + workflow = yaml.safe_load(text) + assert workflow["permissions"] == {"contents": "read"} + deploy = workflow["jobs"]["deploy"] + assert deploy["permissions"] == {"pages": "write", "id-token": "write"} + assert deploy["environment"]["name"] == "github-pages" + assert "contents" not in deploy["permissions"]