From e3c4a6cdccffa1370e9ed95545a91f27f97d41ee Mon Sep 17 00:00:00 2001 From: Omry Yadan Date: Mon, 19 Oct 2026 01:22:05 +0800 Subject: [PATCH] Check Node maintenance setup across a repository fleet Add a manual and reusable fleet workflow with an inventory beside shared defaults. Read each listed default branch as immutable data, verify configuration, toolchain declarations, caller pins and schedules, and maintain one central setup issue per inventory. Reuse that issue across failures, closure and recurrence. Keep optional private-repository read credentials in the scanner job and host issue writes in a separate reporter. Reuse existing configuration, static project validation and bot-owned issue lifecycle helpers. The checker does not install dependencies, execute project code, audit dependencies, or change consumers. Document both workflows, inventories, local overrides, optional scheduling and static-check limits. Include Hydra and OmegaConf in the example inventory. Validation: 1,073 public tests; Ruff lint/format; runtime type checks; 54 local links and anchors; 19 parsed JSON/YAML examples; four modeled npm/pnpm configurations; read-only live setup checks passed for Hydra and OmegaConf. Hosted fleet execution remains a post-merge check. --- .github/workflows/node-fleet-check.yml | 83 +++ README.md | 67 ++- defaults/node-repositories.json | 11 + docs/audit-schedules.md | 2 +- docs/node-fleet-setup.md | 190 +++++++ docs/node-maintenance.md | 3 + docs/shared-defaults.md | 4 + node/fleet-check/action.yml | 31 ++ node/fleet-report/action.yml | 10 + node/runtime/configuration.py | 7 +- node/runtime/configuration_issue.py | 33 +- node/runtime/fleet.py | 634 +++++++++++++++++++++++ node/runtime/fleet_issue.py | 211 ++++++++ node/runtime/project.py | 10 +- node/tests/test_fleet_setup.py | 677 +++++++++++++++++++++++++ 15 files changed, 1929 insertions(+), 44 deletions(-) create mode 100644 .github/workflows/node-fleet-check.yml create mode 100644 defaults/node-repositories.json create mode 100644 docs/node-fleet-setup.md create mode 100644 node/fleet-check/action.yml create mode 100644 node/fleet-report/action.yml create mode 100644 node/runtime/fleet.py create mode 100644 node/runtime/fleet_issue.py create mode 100644 node/tests/test_fleet_setup.py diff --git a/.github/workflows/node-fleet-check.yml b/.github/workflows/node-fleet-check.yml new file mode 100644 index 0000000..15cbed8 --- /dev/null +++ b/.github/workflows/node-fleet-check.yml @@ -0,0 +1,83 @@ +name: Node fleet setup +'on': + workflow_call: + inputs: + inventory-repository: + type: string + default: '' + description: Repository containing the inventory; empty selects the caller. + inventory-path: + type: string + default: defaults/node-repositories.json + description: JSON inventory next to the shared defaults. + inventory-ref: + type: string + default: '' + description: Branch, tag or SHA; empty selects the default branch. + secrets: + read-token: + required: false + description: Optional token with Contents and Actions read access to private listed repositories. + workflow_dispatch: + inputs: + inventory-repository: + type: string + default: '' + description: Repository containing the inventory; empty selects this repository. + inventory-path: + type: string + default: defaults/node-repositories.json + description: JSON inventory path. + inventory-ref: + type: string + default: '' + description: Branch, tag or SHA; empty selects the default branch. +permissions: + contents: read +concurrency: + group: node-fleet-setup-${{ github.repository }} + cancel-in-progress: false +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + actions: read + steps: + - name: Check listed installations + uses: $/node/fleet-check + env: + GH_TOKEN: ${{ secrets.read-token || github.token }} + with: + inventory-repository: ${{ inputs.inventory-repository }} + inventory-path: ${{ inputs.inventory-path }} + inventory-ref: ${{ inputs.inventory-ref }} + - name: Retain the setup report + if: ${{ always() && !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: node-fleet-report + path: ${{ runner.temp }}/node-fleet-report.json + if-no-files-found: warn + report: + needs: check + if: ${{ always() && !cancelled() }} + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + actions: read + issues: write + steps: + - name: Read the report + id: report + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: node-fleet-report + path: ${{ runner.temp }}/node-fleet-report + - name: Maintain the central setup issue + if: ${{ steps.report.outcome == 'success' }} + uses: $/node/fleet-report + env: + GH_TOKEN: ${{ github.token }} diff --git a/README.md b/README.md index 94b15e8..f257a99 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,61 @@ # Shared GitHub Actions -Reusable GitHub workflows for organization-owned and personal repositories. -Each repository keeps its project configuration and pins the shared workflows -to a reviewed commit. +Reusable workflows for organization-owned and personal repositories. Each +installation keeps its configuration and pins shared workflow code to a reviewed +commit. Supports GitHub.com. -## Node dependency maintenance +## Available workflows -Maintain dependencies for a standalone Node application, library, or website -using npm or pnpm. The workflow attempts compatible security fixes, optionally -proposes routine upgrades, and validates the result with a frozen install and -your project's checks. +| Workflow | Use it to | Get started | +| --- | --- | --- | +| Node dependency maintenance | Attempt compatible security fixes, optionally propose routine upgrades, validate the project, and maintain one dependency PR. | [Setup guide](docs/node-maintenance.md) | +| Node fleet setup | Check a list of maintenance installations for broken setup or configuration drift, and maintain one central issue per list. | [Fleet setup guide](docs/node-fleet-setup.md) | -All audit kinds maintain **one open dependency PR per repository**. Reruns update -that PR rather than opening a PR for each dependency. Reports show the proposed -versions, validation results, and any blocked findings. Nothing merges automatically. +The workflows are independent. Install maintenance in each Node repository you +want to maintain. Run the fleet checker from one repository to monitor those +installations. Fleet checks do not audit dependencies, install packages, or run +project scripts. Nothing merges or repairs configuration automatically. -**[Set up Node maintenance](docs/node-maintenance.md)** for supported versions, -complete npm and pnpm instructions, and your first audit. +## Node dependency maintenance -## Choose your maintenance policy +Supports standalone Node applications, libraries, and websites using npm or pnpm. +A maintenance run validates its proposal with a frozen install and your project's +checks. All audit kinds maintain **one open dependency PR per repository**; +reruns update that PR rather than opening one PR per dependency. Reports show +proposed versions, validation, and blocked findings. -| Setup | Automatic security checks | Automatic routine upgrades | +| Maintenance setup | Automatic security checks | Automatic routine upgrades | | --- | --- | --- | | Basic setup in the guide | Weekly | Disabled; run upgrades manually | | Follow this repository's shared defaults | Weekly | Every three months | -Both setups use a ten-day release cooldown. The basic setup follows your +Both examples use a ten-day release cooldown. The basic setup follows your supported project toolchain. The [shared defaults](defaults/node-maintenance.json) -require Node 24 and pnpm 11.21.0 or npm 11.9.0. Local settings can override -inherited defaults; conflicts with project declarations fail preflight and -update one reusable configuration issue. +require Node 24 and pnpm 11.21.0 or npm 11.9.0. Local overrides remain valid; +conflicting declarations fail preflight and update the repository's reusable +configuration issue. See the [setup guide](docs/node-maintenance.md) and +[scheduling guide](docs/audit-schedules.md). + +## Manage several installations + +Keep an authoritative [defaults file](docs/shared-defaults.md) in any public +GitHub repository. Optionally keep a [repository inventory](defaults/node-repositories.json) +beside it. The inventory names the expected defaults source and the repositories +that should follow it. You do not need to register either file here. -For several installations, [share defaults from a public GitHub JSON file](docs/shared-defaults.md). -To change cadence, disable automatic upgrades, or run an audit on demand, use -the [scheduling guide](docs/audit-schedules.md). GitHub cron entries remain in -each repository's caller workflow and must match its resolved configuration. +The [fleet checker](docs/node-fleet-setup.md) inspects each default branch, honors +local overrides, and reports failed or unverifiable setup in the host repository. +It reuses one issue per inventory, closes it when every static check passes, and +reopens it on recurrence. Run it manually or add your own caller schedule. ## Documentation -- [Setup, first run, updates, and troubleshooting](docs/node-maintenance.md) -- [Schedules and manual audits](docs/audit-schedules.md) +- [Node maintenance: setup, first run, updates, and troubleshooting](docs/node-maintenance.md) +- [Fleet setup checks: inventory, caller, results, and limits](docs/node-fleet-setup.md) +- [Maintenance schedules and manual audits](docs/audit-schedules.md) - [Shared defaults for multiple installations](docs/shared-defaults.md) - [Maintenance behavior, reports, and permissions](node/maintenance.md) - [Migrate an earlier Docusaurus installation](docs/node-migration.md) -- [Caller template](node/caller.yml) and [integration contract](node/contract.json) +- [Maintenance caller template](node/caller.yml) and [integration contract](node/contract.json) -Supports GitHub.com. The Node implementation retains its BSD 3-Clause license. +The Node implementation retains its BSD 3-Clause license. diff --git a/defaults/node-repositories.json b/defaults/node-repositories.json new file mode 100644 index 0000000..30f0bbf --- /dev/null +++ b/defaults/node-repositories.json @@ -0,0 +1,11 @@ +{ + "defaults": { + "repository": "hydra-ecosystem/github-actions", + "path": "defaults/node-maintenance.json", + "ref": "main" + }, + "repositories": [ + "hydra-ecosystem/hydra", + "hydra-ecosystem/omegaconf" + ] +} diff --git a/docs/audit-schedules.md b/docs/audit-schedules.md index 58bf8b2..68f072d 100644 --- a/docs/audit-schedules.md +++ b/docs/audit-schedules.md @@ -1,4 +1,4 @@ -# Schedules and manual audits +# Node maintenance schedules and manual audits Each installation keeps its policy in `.github/node-maintenance.json` and its GitHub triggers in `.github/workflows/dependency-audit.yml`. **Update both when diff --git a/docs/node-fleet-setup.md b/docs/node-fleet-setup.md new file mode 100644 index 0000000..88d5ef5 --- /dev/null +++ b/docs/node-fleet-setup.md @@ -0,0 +1,190 @@ +# Check Node maintenance setup across repositories + +Run one workflow to check whether several Node maintenance installations still +have a valid setup. It reads configuration and workflow files as data. It never +installs dependencies, executes project code, audits vulnerabilities, opens +repair PRs, or changes the listed repositories. + +Use this alongside [Node dependency maintenance](node-maintenance.md). The two +workflows have separate callers and schedules. A fleet check creates or updates +one central issue in the repository running the checker. + +## 1. Keep an inventory beside your defaults + +For example, keep these files in your configuration repository: + +```text +defaults/node-maintenance.json +defaults/node-repositories.json +``` + +The inventory contains the expected defaults source and 1 to 25 distinct +`owner/repository` names: + +```json +{ + "defaults": { + "repository": "owner/settings", + "path": "defaults/node-maintenance.json", + "ref": "main" + }, + "repositories": [ + "owner/application", + "owner/library", + "owner/website" + ] +} +``` + +Change the source to your authoritative public defaults file. A listed +installation must inherit that repository/path/ref, either directly or through +another defaults source. Explicit local overrides remain valid and are checked +against that installation's project declarations. A repository that stops +following the expected source fails even if its current versions happen to match. + +Use a branch ref for live defaults or a full commit SHA for fixed policy. +The inventory ref used to fetch this list is independent of the defaults ref +inside it. See [shared defaults](shared-defaults.md) for inheritance rules. + +This repository's [inventory](../defaults/node-repositories.json) lists Hydra +and OmegaConf. Other maintainers can own their own files in their own repositories; +no registration here is required. + +## 2. Choose where the checker and issue run + +To check this repository's inventory, use **Actions → Node fleet setup → Run +workflow** after the workflow is merged. Its default inputs select +`defaults/node-repositories.json` on this repository's default branch. Set +`inventory-repository`, `inventory-path`, and `inventory-ref` to read another list. +An empty repository selects the host; an empty ref selects the inventory +repository's default branch. + +To host the check elsewhere, add `.github/workflows/node-fleet-setup.yml` to that +repository's default branch: + +```yaml +name: Node fleet setup +'on': + workflow_dispatch: +permissions: + contents: read +jobs: + setup: + permissions: + contents: read + actions: read + issues: write + uses: hydra-ecosystem/github-actions/.github/workflows/node-fleet-check.yml@REVIEWED_FULL_COMMIT_SHA + with: + inventory-repository: owner/settings + inventory-path: defaults/node-repositories.json + inventory-ref: main +``` + +Replace `REVIEWED_FULL_COMMIT_SHA` with a reviewed full SHA that contains +`node-fleet-check.yml`. Use your inventory location. Enable issues in the host +repository. Only the host needs issue-write permission; listed repositories do +not need a new workflow, extra write permission, or an issue opened in them. +Reports have the host repository's visibility. + +For public repositories, the built-in token is sufficient. To read private +listed repositories or a private inventory in another repository, add a host +Actions secret `NODE_FLEET_READ_TOKEN` with **Contents: read** and **Actions: read** +access to the required repositories, then add this to the calling job: + +```yaml +secrets: + read-token: ${{ secrets.NODE_FLEET_READ_TOKEN }} +``` + +The optional token goes only to the scanner. The reporter uses the host's +built-in token. Shared defaults sources remain public, as required by the +maintenance resolver. No private key or secret content is inspected. + +## 3. Run and inspect results + +Open **Actions → Node fleet setup → Run workflow** in the host. From a checkout +of that repository, you can also run: + +```sh +gh workflow run node-fleet-setup.yml --ref main +``` + +Use your host's default branch name. For a direct run of this repository's +workflow, the filename is `node-fleet-check.yml` instead. + +The run summary and `node-fleet-report` artifact contain one result per repository, +its exact checked default-branch commit, and an actionable diagnostic: + +| Result | Meaning | +| --- | --- | +| `ok` | All supported static setup checks passed. | +| `failed` | Setup violates a checked requirement, such as mismatched Node policy or caller schedules. | +| `unverified` | A required observation was unavailable, such as inaccessible repository data, API limits, or an incomplete tree. It is not a pass. | + +The scan continues after an individual repository fails. Any failed or unverified +result fails the scanner job and opens or updates one **Node fleet setup** issue +in the host. The reporter still runs after scan failures. A fully passing scan +closes that issue; recurrence reopens it. Repeated failures update the body +without per-run comments. An invalid or unreadable inventory also reports against +that list when its repository/path identity is valid. + +Issue identity uses the inventory repository and path, so changing its contents +or ref keeps the same issue. Different paths have separate issues. Runs are +serialized in the host to avoid competing issue creation. Existing ambiguous +duplicate marked issues cause a reporting error instead of another issue. +Invalid inventory location inputs that cannot identify a safe list fail the +workflow without opening an issue. + +## What the checker verifies + +- Config exists at `.github/node-maintenance.json`; shared defaults resolve and + include the inventory's expected source. +- The project is standalone, has a supported Node and exact npm/pnpm declaration, + a compatible `engines.node` range, and an existing validation script. +- Lockfile format, mixed-lockfile restrictions, and pnpm release cooldown satisfy + the resolved configuration. +- The maintenance caller pins audit and publisher workflows from one repository + at the same full SHA. That revision declares Node integration version 6 and + contains both reusable workflows and supports the resolved toolchain. It does + not have to be the latest revision. +- Caller schedules match resolved policy, including bounds for the supported + numeric, range, list, step, and month/day name forms. The original cron text + is retained for event matching. Required job permissions, audit input + forwarding, publisher dependency/validation forwarding, manual choices, and + concurrency are present. +- The caller's audit and publisher jobs are unconditionally enabled. The + checker does not evaluate arbitrary GitHub expressions; a conditional job is + outside the supported caller contract and fails static setup. + The audit has no prerequisite jobs; the publisher depends only on the audit. +- Pinned reusable workflows have callable jobs, and every required input and + secret has an explicit caller mapping. Secret contents and effective + publication access are not tested. +- The maintenance workflow is enabled in GitHub Actions. + +Each repository is read at one immutable default-branch commit. Shared defaults +reads are cached for the scan. Remote files are never executed or imported, and +symlinks/submodules are rejected for checked files. Files are bounded: inventory +and ordinary configuration files are at most 64 KiB, lockfiles at most 2 MB; +truncated or oversized repository trees cannot receive a passing verdict. + +A static pass is not an end-to-end audit run. Installed Node/package-manager +versions, credential contents, effective PR-creation permissions, dependency +health, and project builds are labeled **not tested**. Use a manual maintenance +run to exercise installation, project checks, and publication. + +## Optional: schedule the fleet check + +There is no automatic fleet-check schedule by default. To check weekly, add a +schedule alongside `workflow_dispatch` in the host caller: + +```yaml +'on': + schedule: + - cron: '0 3 * * 1' + workflow_dispatch: +``` + +This runs Mondays at 03:00 UTC. It is independent of the security and upgrade +cadences in each maintenance installation; do not add the fleet cron to those +installations' maintenance configuration. diff --git a/docs/node-maintenance.md b/docs/node-maintenance.md index bf7e16f..32a84a9 100644 --- a/docs/node-maintenance.md +++ b/docs/node-maintenance.md @@ -4,6 +4,9 @@ Use these workflows for a standalone Node application, library, or website. They attempt compatible security fixes, optionally propose routine upgrades, and validate the result before opening or updating one maintenance PR. +This guide installs maintenance in one repository. To monitor setup across several +installations, use the separate [fleet setup checker](node-fleet-setup.md). + Already using the earlier Docusaurus workflows? Follow the [migration checklist](node-migration.md) instead. diff --git a/docs/shared-defaults.md b/docs/shared-defaults.md index 44d4fc8..1898344 100644 --- a/docs/shared-defaults.md +++ b/docs/shared-defaults.md @@ -4,6 +4,10 @@ Use one JSON file as the authoritative policy for several repositories. Each installation inherits toolchain constraints, release cooldown, maintenance options, and schedules, while keeping its own project settings and credentials. +To monitor the installations following that policy, keep an optional repository +inventory beside the defaults and run the separate [fleet setup checker](node-fleet-setup.md). +It reports broken setup centrally without running dependency maintenance. + A source can be a regular file in **any public GitHub.com repository**. It needs no service, App, or registration here. The consuming repository may be private. Use integration version **6** and the [setup guide](node-maintenance.md#set-it-up) diff --git a/node/fleet-check/action.yml b/node/fleet-check/action.yml new file mode 100644 index 0000000..16c7b23 --- /dev/null +++ b/node/fleet-check/action.yml @@ -0,0 +1,31 @@ +name: Check Node fleet setup +description: Inspect listed installations as data without running project commands. +inputs: + inventory-repository: + default: '' + description: Repository containing the inventory; empty selects the calling repository. + inventory-path: + default: defaults/node-repositories.json + description: Repository-relative JSON inventory path. + inventory-ref: + default: '' + description: Branch, tag or SHA; empty selects the inventory repository default branch. +runs: + using: composite + steps: + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + with: + python-version: '3.13' + - shell: bash + run: python -m pip install PyYAML==6.0.3 + - shell: bash + env: + INVENTORY_REPOSITORY: ${{ inputs.inventory-repository || github.repository }} + INVENTORY_PATH: ${{ inputs.inventory-path }} + INVENTORY_REF: ${{ inputs.inventory-ref }} + run: >- + python "$GITHUB_ACTION_PATH/../runtime/fleet.py" + --inventory-repository "$INVENTORY_REPOSITORY" + --inventory-path "$INVENTORY_PATH" + --inventory-ref "$INVENTORY_REF" + --report "$RUNNER_TEMP/node-fleet-report.json" diff --git a/node/fleet-report/action.yml b/node/fleet-report/action.yml new file mode 100644 index 0000000..95df3da --- /dev/null +++ b/node/fleet-report/action.yml @@ -0,0 +1,10 @@ +name: Report Node fleet setup +description: Maintain one setup issue per inventory in the calling repository. +runs: + using: composite + steps: + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + with: + python-version: '3.13' + - shell: bash + run: python "$GITHUB_ACTION_PATH/../runtime/fleet_issue.py" --report "$RUNNER_TEMP/node-fleet-report/node-fleet-report.json" diff --git a/node/runtime/configuration.py b/node/runtime/configuration.py index d592712..9416e8f 100644 --- a/node/runtime/configuration.py +++ b/node/runtime/configuration.py @@ -174,7 +174,8 @@ def local_configuration(root): return config -def load_configuration(root, sources=None, *, inherit_schedule=True): +def load_configuration(root, sources=None, *, inherit_schedule=True, read=None): + read = public_read if read is None else read local = local_configuration(root) sources = sources if sources is not None else [] commits = {} @@ -191,7 +192,7 @@ def inherit(source, stack): f"https://api.github.com/repos/{repository}/commits/" + urllib.parse.quote(ref, safe="") ) - commit = public_read(url, "application/vnd.github.sha").decode().strip() + commit = read(url, "application/vnd.github.sha").decode().strip() if not re.fullmatch(r"[0-9a-f]{40}", commit): raise ValueError( f"{repository}@{ref}: GitHub did not return a full commit" @@ -214,7 +215,7 @@ def inherit(source, stack): "commit": commit, } sources.append(source_info) - config = json_object(public_read(url), f"{repository}/{path}@{commit}") + config = json_object(read(url), f"{repository}/{path}@{commit}") validate_settings(config, f"{repository}/{path}@{commit}") inherited = ( inherit(config["defaults"], (*stack, identity)) diff --git a/node/runtime/configuration_issue.py b/node/runtime/configuration_issue.py index 8d2efae..e1c339e 100644 --- a/node/runtime/configuration_issue.py +++ b/node/runtime/configuration_issue.py @@ -120,7 +120,7 @@ def github_request(method, path, payload=None): return json.loads(data) -def existing_issue(repository, request): +def existing_issue(repository, request, marker=MARKER): matches = [] for page in range(1, 101): query = urllib.parse.urlencode( @@ -138,7 +138,7 @@ def existing_issue(repository, request): and author.get("login") == BOT and author.get("type") == "Bot" and isinstance(issue.get("body"), str) - and issue["body"].startswith(MARKER + "\n") + and issue["body"].startswith(marker + "\n") ): if ( type(issue.get("number")) is not int @@ -150,12 +150,10 @@ def existing_issue(repository, request): if len(issues) < 100: break else: - raise ValueError( - "Cannot prove complete configuration issue history; no issue created" - ) + raise ValueError("Cannot prove complete marked issue history; no issue created") if len(matches) > 1: raise ValueError( - "Multiple bot-owned configuration issues found; reconcile duplicates before rerunning" + "Multiple bot-owned marked issues found; reconcile duplicates before rerunning" ) return matches[0] if matches else None @@ -164,17 +162,30 @@ def update_issue(repository, report, run_url, request=github_request): validate_report(report) if report["status"] == "pending": return {"operation": "skipped", "reason": "preflight did not finish"} - issue = existing_issue(repository, request) - if report["status"] == "ok" and (issue is None or issue["state"] == "closed"): + return maintain_issue( + repository, + marker=MARKER, + title=TITLE, + body=issue_body(report, run_url), + failing=report["status"] == "error", + request=request, + ) + + +def maintain_issue(repository, *, marker, title, body, failing, request=github_request): + """Reuse one bot-owned marked issue, including after closure.""" + if not body.startswith(marker + "\n") or len(body.encode()) > MAX_ISSUE_BYTES: + raise ValueError("invalid marked issue body") + issue = existing_issue(repository, request, marker) + if not failing and (issue is None or issue["state"] == "closed"): return {"operation": "unchanged", "number": issue["number"] if issue else None} - body = issue_body(report, run_url) if issue is None: result = request( - "POST", f"repos/{repository}/issues", {"title": TITLE, "body": body} + "POST", f"repos/{repository}/issues", {"title": title, "body": body} ) operation = "created" else: - state = "open" if report["status"] == "error" else "closed" + state = "open" if failing else "closed" if issue["state"] == state and issue["body"] == body: return {"operation": "unchanged", "number": issue["number"]} result = request( diff --git a/node/runtime/fleet.py b/node/runtime/fleet.py new file mode 100644 index 0000000..4b9bd67 --- /dev/null +++ b/node/runtime/fleet.py @@ -0,0 +1,634 @@ +"""Check Node maintenance installations from immutable repository data only.""" + +import argparse +import base64 +import functools +import hashlib +import html +import json +import os +import re +import runpy +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path, PurePosixPath +from tempfile import TemporaryDirectory + +import yaml + +PROJECT = runpy.run_path(str(Path(__file__).with_name("project.py"))) +CONFIGURATION = PROJECT["CONFIGURATION"] +MAX_REPOSITORIES = 25 +MAX_FILE_BYTES = 2_000_000 +CONFIG_PATH = ".github/node-maintenance.json" +CALLER_PATH = ".github/workflows/dependency-audit.yml" +SHA = r"[0-9a-f]{40}" +CRON_FIELDS = ( + (0, 59, {}), + (0, 23, {}), + (1, 31, {}), + ( + 1, + 12, + { + "JAN": 1, + "FEB": 2, + "MAR": 3, + "APR": 4, + "MAY": 5, + "JUN": 6, + "JUL": 7, + "AUG": 8, + "SEP": 9, + "OCT": 10, + "NOV": 11, + "DEC": 12, + }, + ), + (0, 6, {"SUN": 0, "MON": 1, "TUE": 2, "WED": 3, "THU": 4, "FRI": 5, "SAT": 6}), +) + + +class Unverified(ValueError): + """A required observation could not be obtained; this is not a pass.""" + + +class GitHub: + def __init__(self, token=""): + self.token = token + self.snapshots = {} + # Defaults remain public and unauthenticated, as in the individual audit. + self.read_defaults = functools.cache(CONFIGURATION["public_read"]) + + @functools.cache + def get(self, path): + headers = { + "Accept": "application/vnd.github+json", + "User-Agent": "node-fleet-setup", + } + if self.token: + headers["Authorization"] = "Bearer " + self.token + request = urllib.request.Request( + "https://api.github.com/" + path, headers=headers + ) + try: + # A redirect must never carry the token to another origin. + with urllib.request.build_opener(CONFIGURATION["NoRedirect"]).open( + request, timeout=20 + ) as response: + data = response.read(8_000_001) + except urllib.error.HTTPError as error: + raise Unverified( + f"GitHub read failed (HTTP {error.code}); check access and API limits" + ) from error + except (OSError, urllib.error.URLError) as error: + raise Unverified( + "GitHub read unavailable; rerun when access is restored" + ) from error + if len(data) > 8_000_000: + raise Unverified("GitHub response exceeds the checker size limit") + try: + value = json.loads(data) + except (ValueError, RecursionError) as error: + raise Unverified("GitHub returned invalid JSON") from error + if not isinstance(value, dict): + raise Unverified("GitHub did not return a response object") + return value + + def snapshot(self, repository, ref=""): + repository = repository.lower() + key = (repository, ref) + if key not in self.snapshots: + self.snapshots[key] = Repository(self, repository, ref) + return self.snapshots[key] + + +class Repository: + def __init__(self, github, repository, ref): + self.github = github + self.name = repository + metadata = github.get(f"repos/{repository}") + if not isinstance(metadata.get("default_branch"), str): + raise Unverified("GitHub did not identify the default branch") + self.ref = ref or metadata["default_branch"] + commit = github.get( + f"repos/{repository}/commits/{urllib.parse.quote(self.ref, safe='')}" + ) + try: + self.commit = commit["sha"] + tree_sha = commit["commit"]["tree"]["sha"] + except (KeyError, TypeError) as error: + raise Unverified( + "GitHub did not identify the pinned commit tree" + ) from error + if not all( + isinstance(value, str) and re.fullmatch(SHA, value) + for value in (self.commit, tree_sha) + ): + raise Unverified( + "GitHub did not return immutable commit and tree identities" + ) + tree = github.get(f"repos/{repository}/git/trees/{tree_sha}?recursive=1") + if tree.get("truncated") is not False or not isinstance(tree.get("tree"), list): + raise Unverified( + "Repository tree is incomplete; no setup verdict is possible" + ) + if any( + not isinstance(entry, dict) or not isinstance(entry.get("path"), str) + for entry in tree["tree"] + ): + raise Unverified("GitHub returned an invalid repository tree") + self.files = {entry["path"]: entry for entry in tree["tree"]} + if len(self.files) != len(tree["tree"]): + raise Unverified("GitHub returned duplicate repository paths") + + def read(self, path, *, required=True, limit=65536): + entry = self.files.get(path) + if entry is None: + if required: + raise ValueError(f"Missing {path}") + return None + if entry.get("type") != "blob" or entry.get("mode") not in ("100644", "100755"): + raise ValueError( + f"{path} must be a regular file; symlinks and submodules are unsupported" + ) + if type(entry.get("size")) is not int or not 0 <= entry["size"] <= limit: + raise ValueError(f"{path} exceeds the supported file size ({limit} bytes)") + blob_sha = entry.get("sha", "") + if not re.fullmatch(SHA, blob_sha): + raise Unverified(f"Invalid Git blob identity for {path}") + blob = self.github.get(f"repos/{self.name}/git/blobs/{blob_sha}") + try: + if blob.get("encoding") != "base64" or not isinstance( + blob.get("content"), str + ): + raise ValueError("unsupported encoding") + data = base64.b64decode("".join(blob["content"].split()), validate=True) + except (KeyError, TypeError, ValueError) as error: + raise Unverified(f"Cannot decode {path}") from error + if len(data) > limit or len(data) != entry["size"]: + raise Unverified(f"File size differs from the pinned tree: {path}") + actual = hashlib.sha1(f"blob {len(data)}\0".encode() + data).hexdigest() + if actual != blob_sha: + raise Unverified(f"File content differs from the pinned tree: {path}") + return data + + +def inventory(value): + if not isinstance(value, dict) or set(value) != {"defaults", "repositories"}: + raise ValueError("Inventory requires exactly defaults and repositories") + CONFIGURATION["validate_source"](value["defaults"]) + repos = value["repositories"] + if not isinstance(repos, list) or not 1 <= len(repos) <= MAX_REPOSITORIES: + raise ValueError( + f"repositories must contain 1 to {MAX_REPOSITORIES} owner/repository names" + ) + names = [] + for name in repos: + repository, _, _ = CONFIGURATION["validate_source"]( + {"repository": name, "path": "inventory.json", "ref": "main"} + ) + if repository in names: + raise ValueError(f"Duplicate repository: {repository}") + names.append(repository) + return {"defaults": value["defaults"], "repositories": names} + + +def materialize(repository, root): + """Copy only required data into a private temporary directory. Execute nothing.""" + + def copy(path, *, required=False, limit=65536): + data = repository.read(path, required=required, limit=limit) + if data is not None: + destination = root / path + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(data) + return data + + raw = copy(CONFIG_PATH, required=True) + config = CONFIGURATION["json_object"](raw, CONFIG_PATH) + CONFIGURATION["validate_settings"](config, CONFIG_PATH) + name = config.get("directory") + if ( + not isinstance(name, str) + or not name + or PROJECT["DIRECTORY_PATTERN"].fullmatch(name) is None + or PurePosixPath(name).is_absolute() + or ".." in PurePosixPath(name).parts + ): + raise ValueError("directory must select a project inside the repository") + directory = PurePosixPath(name) + found_node = False + for parent in (directory, *directory.parents): + prefix = "" if parent == PurePosixPath(".") else str(parent) + "/" + copy(prefix + "package.json", required=parent == directory) + copy(prefix + "pnpm-workspace.yaml") + declarations = [prefix + filename for filename in (".nvmrc", ".node-version")] + if not found_node and any(path in repository.files for path in declarations): + for path in declarations: + copy(path) + found_node = True + prefix = "" if directory == PurePosixPath(".") else str(directory) + "/" + for filename in ("package-lock.json", "pnpm-lock.yaml", "npm-shrinkwrap.json"): + copy(prefix + filename, limit=MAX_FILE_BYTES) + copy(CALLER_PATH, required=True) + + +def check_engines(directory, selected): + """Find a compatible stable version in a declared major using range boundaries.""" + expression = ( + json.loads((directory / "package.json").read_bytes()) + .get("engines", {}) + .get("node") + ) + if expression is None or "." in selected["node"]: + return # Exact versions are checked by project(). + major = int(selected["node"]) + candidates = {(major, 13 if major == 22 else 0, 0)} + for match in re.finditer(r"v?(\d+(?:\.\d+){0,2})", expression): + parts = tuple(map(int, match[1].split("."))) + version = parts + (0,) * (3 - len(parts)) + candidates.update( + (version, (*version[:2], version[2] + 1), (version[0], version[1] + 1, 0)) + ) + if not any( + version[0] == major + and version >= (22, 13, 0) + and PROJECT["node_satisfies"](version, expression) + for version in candidates + ): + raise ValueError( + f"Node {major} has no supported stable version matching engines.node={expression!r}" + ) + + +def _cron_number(value, minimum, maximum, names): + upper = value.upper() + if upper in names: + number = names[upper] + elif value.isascii() and value.isdecimal(): + number = int(value) + else: + raise ValueError(f"Unsupported cron value {value!r}") + if not minimum <= number <= maximum: + raise ValueError(f"Cron value {value!r} is outside {minimum}-{maximum}") + return number + + +def _cron_part(value, minimum, maximum, names): + if not value: + raise ValueError("Cron fields cannot be empty") + if "/" in value: + if value.count("/") != 1: + raise ValueError(f"Unsupported cron step {value!r}") + base, step = value.split("/") + if not step.isascii() or not step.isdecimal() or int(step) <= 0: + raise ValueError(f"Invalid cron step {value!r}") + if base != "*": + _cron_part(base, minimum, maximum, names) + return + if value == "*": + return + if "," in value: + parts = value.split(",") + if any(not part for part in parts): + raise ValueError(f"Invalid cron list {value!r}") + for part in parts: + _cron_part(part, minimum, maximum, names) + return + if "-" in value: + if value.count("-") != 1: + raise ValueError(f"Unsupported cron range {value!r}") + start, end = value.split("-") + if _cron_number(start, minimum, maximum, names) > _cron_number( + end, minimum, maximum, names + ): + raise ValueError(f"Cron range is reversed {value!r}") + return + _cron_number(value, minimum, maximum, names) + + +def validate_cron(value, location="schedule"): + """Validate the bounded cron forms used by fleet setup comparisons.""" + if not isinstance(value, str) or len(value.split()) != 5: + raise ValueError(f"{location} requires a five-field cron expression") + for field, (minimum, maximum, names) in zip(value.split(), CRON_FIELDS): + _cron_part(field, minimum, maximum, names) + + +def validate_crons(values, location="schedule"): + if not isinstance(values, list): + raise ValueError(f"{location} must be a list of cron expressions") + for value in values: + validate_cron(value, location) + + +def _validate_call_interface(job, call, path): + supplied_inputs = job.get("with", {}) + supplied_secrets = job.get("secrets", {}) + declared_inputs = call.get("inputs", {}) + declared_secrets = call.get("secrets", {}) + if not isinstance(supplied_inputs, dict) or not isinstance(supplied_secrets, dict): + raise ValueError( + f"Caller must use explicit input and secret mappings for {path}" + ) + if not isinstance(declared_inputs, dict) or not isinstance(declared_secrets, dict): + raise ValueError(f"Pinned {path} has invalid workflow_call mappings") + if not set(supplied_inputs) <= set(declared_inputs): + raise ValueError(f"Caller supplies undeclared inputs to pinned {path}") + if not set(supplied_secrets) <= set(declared_secrets): + raise ValueError(f"Caller supplies undeclared secrets to pinned {path}") + for name, declaration in declared_inputs.items(): + if not isinstance(declaration, dict): + raise ValueError(f"Pinned {path} has an invalid input declaration") + if declaration.get("required") is True and name not in supplied_inputs: + raise ValueError(f"Caller omits required input {name!r} for pinned {path}") + for name, declaration in declared_secrets.items(): + if not isinstance(declaration, dict): + raise ValueError(f"Pinned {path} has an invalid secret declaration") + if declaration.get("required") is True and name not in supplied_secrets: + raise ValueError( + f"Caller omits required secret mapping {name!r} for pinned {path}" + ) + + +def check_caller(root, config, selected, github): + workflow = yaml.safe_load((root / CALLER_PATH).read_bytes()) + if not isinstance(workflow, dict): + raise ValueError("Caller must be a workflow mapping") + events = workflow.get("on", workflow.get(True, {})) + if not isinstance(events, dict): + raise ValueError("Caller requires schedules and workflow_dispatch") + dispatch = events.get("workflow_dispatch") + choice = ( + dispatch.get("inputs", {}).get("audit-kind", {}) + if isinstance(dispatch, dict) + else {} + ) + if ( + choice.get("type") != "choice" + or set(choice.get("options", [])) != {"security", "upgrades"} + or choice.get("default") != "security" + ): + raise ValueError( + "Caller requires the security/upgrades manual choice with default security" + ) + jobs = workflow.get("jobs", {}) + if not isinstance(jobs, dict) or not jobs: + raise ValueError("Caller requires a nonempty jobs mapping") + audit, publisher = jobs.get("audit", {}), jobs.get("pull-request", {}) + if not isinstance(audit, dict) or not isinstance(publisher, dict): + raise ValueError("Caller requires audit and pull-request job mappings") + if "if" in audit or "if" in publisher: + raise ValueError( + "Caller audit and publisher jobs must be unconditionally enabled" + ) + pattern = ( + rf"([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)/\.github/workflows/node-audit\.yml@({SHA})" + ) + match = re.fullmatch(pattern, audit.get("uses", "")) + if ( + not match + or publisher.get("uses") + != f"{match[1]}/.github/workflows/node-publish.yml@{match[2]}" + ): + raise ValueError( + "Audit and publisher must pin matching Node workflows at the same full commit SHA" + ) + needs = publisher.get("needs", []) + if "needs" in audit or needs not in ("audit", ["audit"]): + raise ValueError( + "Audit must have no prerequisites; publisher must depend only on audit" + ) + if audit.get("permissions") != { + "contents": "read", + "issues": "write", + } or publisher.get("permissions") != { + "contents": "write", + "pull-requests": "write", + }: + raise ValueError( + "Caller audit/publisher permissions differ from the supported caller contract" + ) + if ( + audit.get("with", {}).get("audit-kind") != "${{ inputs.audit-kind || 'auto' }}" + or publisher.get("with", {}).get("validation-passed") + != "${{ needs.audit.outputs.validation_passed }}" + ): + raise ValueError( + "Caller must forward audit-kind and the audit validation result" + ) + concurrency = workflow.get("concurrency", {}) + if ( + not isinstance(concurrency, dict) + or concurrency.get("cancel-in-progress") is not False + or not concurrency.get("group") + ): + raise ValueError( + "Caller must serialize runs with concurrency and cancel-in-progress: false" + ) + source = github.snapshot(match[1], match[2]) + contract = CONFIGURATION["json_object"]( + source.read("node/contract.json"), "pinned Node contract" + ) + if contract.get("component") != "node" or contract.get("integration_version") != 6: + raise ValueError( + "Pinned workflows must declare the supported Node integration version 6" + ) + toolchain = CONFIGURATION["json_object"]( + source.read("node/runtime/toolchain.json"), "pinned toolchain support" + ).get("supported", {}) + manager = selected["manager"] + if selected["node"].split(".")[0] not in toolchain.get("node", []) or selected[ + manager + ] not in toolchain.get(manager, []): + raise ValueError( + "Pinned workflows do not support the resolved toolchain; update both workflow pins or align project policy" + ) + for job, path in ( + (audit, ".github/workflows/node-audit.yml"), + (publisher, ".github/workflows/node-publish.yml"), + ): + reusable = yaml.safe_load(source.read(path)) + triggers = ( + reusable.get("on", reusable.get(True, {})) + if isinstance(reusable, dict) + else {} + ) + call = triggers.get("workflow_call") if isinstance(triggers, dict) else None + if ( + not isinstance(call, dict) + or not isinstance(reusable.get("jobs"), dict) + or not reusable["jobs"] + ): + raise ValueError(f"Pinned {path} is not a reusable workflow") + _validate_call_interface(job, call, path) + if path.endswith("node-audit.yml") and "validation_passed" not in call.get( + "outputs", {} + ): + raise ValueError("Pinned audit workflow does not expose validation_passed") + configured = config.get("schedule", ["0 2 30 1 *"]) + validate_crons( + PROJECT["SCHEDULING"]["cron_entries"](configured), "resolved schedule" + ) + if isinstance(config.get("schedule"), dict): + PROJECT["SCHEDULING"]["select_mode"]( + root, + config, + selected["maintenance"]["mode"], + "auto", + "workflow_dispatch", + {}, + ) + else: + actual = events.get("schedule", []) + actual_crons = ( + [item.get("cron") for item in actual] if isinstance(actual, list) else [] + ) + validate_crons(actual_crons, "caller schedule") + if ( + not isinstance(actual, list) + or any(not isinstance(item, dict) for item in actual) + or sorted(actual_crons) != sorted(configured) + ): + raise ValueError("Caller cron entries differ from the resolved schedule") + + +def check_repository(github, name, expected): + result = { + "repository": name, + "commit": None, + "status": "unverified", + "diagnostic": "", + } + try: + repository = github.snapshot(name) + result["commit"] = repository.commit + with TemporaryDirectory(prefix="node-fleet-") as temporary: + root = Path(temporary) + materialize(repository, root) + sources = [] + config = CONFIGURATION["load_configuration"]( + root, sources, read=github.read_defaults + ) + wanted = CONFIGURATION["validate_source"](expected) + if not any( + (source["repository"], source["path"], source["ref"]) == wanted + for source in sources + ): + raise ValueError( + "Installation no longer follows the inventory's expected defaults repository/path/ref" + ) + directory, selected = PROJECT["project"](root, config=config) + PROJECT["verify_package_files"](directory, selected) + check_engines(directory, selected) + check_caller(root, config, selected, github) + state = github.get( + f"repos/{name}/actions/workflows/{PurePosixPath(CALLER_PATH).name}" + ) + if state.get("state") != "active": + raise ValueError( + "Node dependency audit workflow is disabled; enable it in Actions" + ) + result.update(status="ok", diagnostic="Static setup checks passed") + except Unverified as error: + result["diagnostic"] = str(error) + except ( + OSError, + ValueError, + TypeError, + KeyError, + AttributeError, + RecursionError, + yaml.YAMLError, + ) as error: + result.update(status="failed", diagnostic=str(error)[:4000]) + return result + + +def check_fleet(github, source): + CONFIGURATION["validate_source"]({**source, "ref": source["ref"] or "HEAD"}) + report = { + "schema_version": 1, + "inventory": {**source, "commit": None}, + "results": [], + "status": "unverified", + "diagnostic": "", + } + try: + repository = github.snapshot(source["repository"], source["ref"]) + report["inventory"].update(ref=repository.ref, commit=repository.commit) + settings = inventory( + CONFIGURATION["json_object"]( + repository.read(source["path"]), "fleet inventory" + ) + ) + for name in settings["repositories"]: + report["results"].append( + check_repository(github, name, settings["defaults"]) + ) + report["status"] = ( + "ok" + if all(item["status"] == "ok" for item in report["results"]) + else "failed" + ) + except Unverified as error: + report["diagnostic"] = str(error) + except (OSError, ValueError, TypeError, KeyError, RecursionError) as error: + report.update(status="failed", diagnostic=str(error)[:4000]) + return report + + +def summary(report): + def escape(value): + return html.escape(str(value)).replace("|", "|").replace("\n", " ") + + lines = [ + "## Node fleet setup", + "", + "| Repository | Static setup | Detail |", + "| --- | --- | --- |", + ] + for item in report["results"]: + lines.append( + f"| {escape(item['repository'])} | {escape(item['status'])} | {escape(item['diagnostic'])} |" + ) + if report["diagnostic"]: + lines.extend(["", escape(report["diagnostic"])]) + lines.extend( + [ + "", + "Installed tool versions, credential contents, publication access, dependency health, and project builds were not tested.", + ] + ) + return "\n".join(lines) + "\n" + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--inventory-repository", default=os.environ.get("GITHUB_REPOSITORY", "") + ) + parser.add_argument("--inventory-path", default="defaults/node-repositories.json") + parser.add_argument("--inventory-ref", default="") + parser.add_argument("--report", required=True, type=Path) + args = parser.parse_args() + source = { + "repository": args.inventory_repository, + "path": args.inventory_path, + "ref": args.inventory_ref, + } + report = check_fleet(GitHub(os.environ.get("GH_TOKEN", "")), source) + args.report.write_text(json.dumps(report, indent=2) + "\n") + text = summary(report) + if os.environ.get("GITHUB_STEP_SUMMARY"): + with Path(os.environ["GITHUB_STEP_SUMMARY"]).open("a") as output: + output.write(text) + print(text) + if report["status"] != "ok": + parser.exit(1, "Fleet setup needs attention; inspect the report.\n") + + +if __name__ == "__main__": + main() diff --git a/node/runtime/fleet_issue.py b/node/runtime/fleet_issue.py new file mode 100644 index 0000000..76c7a2a --- /dev/null +++ b/node/runtime/fleet_issue.py @@ -0,0 +1,211 @@ +"""Maintain one central setup issue per inventory, without touching consumers.""" + +import argparse +import hashlib +import html +import json +import os +import re +import runpy +from pathlib import Path + +ISSUES = runpy.run_path(str(Path(__file__).with_name("configuration_issue.py"))) +CONFIGURATION = runpy.run_path(str(Path(__file__).with_name("configuration.py"))) +MAX_ISSUE_BYTES = ISSUES["MAX_ISSUE_BYTES"] +DIAGNOSTIC_LIMIT = 1000 +TRUNCATION_SUFFIX = "… [truncated]" + + +def validate_report(report): + if ( + not isinstance(report, dict) + or set(report) + != {"schema_version", "inventory", "results", "status", "diagnostic"} + or report["schema_version"] != 1 + ): + raise ValueError("Invalid fleet report schema") + source = report["inventory"] + if not isinstance(source, dict) or set(source) != { + "repository", + "path", + "ref", + "commit", + }: + raise ValueError("Invalid inventory identity") + CONFIGURATION["validate_source"]( + { + key: source[key] or "HEAD" if key == "ref" else source[key] + for key in ("repository", "path", "ref") + } + ) + if source["commit"] is not None and ( + not isinstance(source["commit"], str) + or not re.fullmatch(r"[0-9a-f]{40}", source["commit"]) + ): + raise ValueError("Invalid inventory commit") + results = report["results"] + if ( + not isinstance(results, list) + or len(results) > 25 + or report["status"] not in ("ok", "failed", "unverified") + ): + raise ValueError("Invalid fleet results") + if not isinstance(report["diagnostic"], str) or len(report["diagnostic"]) > 4000: + raise ValueError("Invalid fleet diagnostic") + names = set() + for item in results: + if not isinstance(item, dict) or set(item) != { + "repository", + "commit", + "status", + "diagnostic", + }: + raise ValueError("Invalid repository result") + repository, _, _ = CONFIGURATION["validate_source"]( + {"repository": item["repository"], "path": "inventory.json", "ref": "main"} + ) + if ( + repository in names + or item["status"] not in ("ok", "failed", "unverified") + or not isinstance(item["diagnostic"], str) + or len(item["diagnostic"]) > 4000 + ): + raise ValueError("Invalid repository status") + names.add(repository) + if item["commit"] is not None and ( + not isinstance(item["commit"], str) + or not re.fullmatch(r"[0-9a-f]{40}", item["commit"]) + ): + raise ValueError("Invalid repository commit") + if item["status"] == "ok" and item["commit"] is None: + raise ValueError("A healthy repository requires its checked commit") + if report["status"] == "ok" and ( + not results + or source["commit"] is None + or report["diagnostic"] + or any(item["status"] != "ok" for item in results) + ): + raise ValueError("A healthy report must verify every listed repository") + + +def marker(source): + identity = source["repository"].lower() + "/" + source["path"] + return ( + "" + ) + + +def update_issue(repository, report, run_url, request=ISSUES["github_request"]): + validate_report(report) + source = report["inventory"] + + def render(limit): + truncated = False + + def text(value): + return html.escape(str(value)) + + def diagnostic(value): + nonlocal truncated + value = str(value) + if len(value) <= limit: + return html.escape(value) + truncated = True + return html.escape(value[:limit] + TRUNCATION_SUFFIX) + + lines = [ + marker(source), + "", + "## Node fleet setup", + "", + f"Inventory: {text(source['repository'])}/{text(source['path'])}", + f"Revision: {text(source['ref'])} @ {text(source['commit'] or 'unverified')}", + "", + ] + if report["diagnostic"]: + lines.append("
" + diagnostic(report["diagnostic"]) + "
") + for item in report["results"]: + lines.extend( + [ + f"### {text(item['repository'])}: {text(item['status'])}", + "", + "
" + diagnostic(item["diagnostic"]) + "
", + f"Checked commit: {text(item['commit'] or 'unverified')}", + "", + ] + ) + if truncated: + lines.extend( + [ + "", + f"Some diagnostic text was truncated to fit the {MAX_ISSUE_BYTES}-byte issue limit. Full diagnostics remain in the workflow run's node-fleet-report artifact.", + ] + ) + lines.extend( + [ + "Installed tools, credential contents, publication access, dependencies, and builds were not tested.", + "", + f"[Workflow run]({run_url})", + "", + "This issue is reused for this inventory. A fully passing scan closes it; a later failure or unverifiable setup reopens it.", + ] + ) + return "\n".join(lines) + + body = render(DIAGNOSTIC_LIMIT) + if len(body.encode()) > MAX_ISSUE_BYTES: + low, high = 0, DIAGNOSTIC_LIMIT - 1 + best = None + while low <= high: + limit = (low + high) // 2 + candidate = render(limit) + if len(candidate.encode()) <= MAX_ISSUE_BYTES: + best = candidate + low = limit + 1 + else: + high = limit - 1 + if best is None: + raise ValueError( + "fleet issue body exceeds its fixed identity and status budget" + ) + body = best + return ISSUES["maintain_issue"]( + repository, + marker=marker(source), + title="Node fleet setup: " + source["path"][:180], + body=body, + failing=report["status"] != "ok", + request=request, + ) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--report", required=True, type=Path) + args = parser.parse_args() + repository = os.environ.get("GITHUB_REPOSITORY", "") + run = os.environ.get("GITHUB_RUN_ID", "") + if ( + not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository) + or not run.isdecimal() + ): + parser.error("Fleet reporting requires the host GitHub repository and run ID") + with args.report.open("rb") as stream: + data = stream.read(1_000_001) + if len(data) > 1_000_000: + parser.error("Fleet report exceeds its size limit") + print( + json.dumps( + update_issue( + repository, + json.loads(data), + f"https://github.com/{repository}/actions/runs/{run}", + ) + ) + ) + + +if __name__ == "__main__": + main() diff --git a/node/runtime/project.py b/node/runtime/project.py index a4d35b6..30fd795 100644 --- a/node/runtime/project.py +++ b/node/runtime/project.py @@ -319,8 +319,6 @@ def verify_release_age(directory, selected): def verify_runtime(directory, selected): - import yaml - def output(command): result = subprocess.run( command, @@ -357,6 +355,14 @@ def output(command): raise ValueError( f"Installed {kind} {actual_manager} differs from package.json/toolchain {kind} {selected[kind]}" ) + verify_package_files(directory, selected) + + +def verify_package_files(directory, selected): + """Verify package declarations and lockfiles without invoking project tools.""" + import yaml + + kind = selected["manager"] shrinkwrap = directory / "npm-shrinkwrap.json" if shrinkwrap.is_symlink() or shrinkwrap.exists(): raise ValueError( diff --git a/node/tests/test_fleet_setup.py b/node/tests/test_fleet_setup.py new file mode 100644 index 0000000..9d7fecf --- /dev/null +++ b/node/tests/test_fleet_setup.py @@ -0,0 +1,677 @@ +"""Fleet setup checks inspect immutable data and never run project commands.""" + +import base64 +import copy +import hashlib +import json +import runpy +import subprocess +from pathlib import Path + +import pytest +import yaml + +ROOT = Path(__file__).parents[2] +FLEET = runpy.run_path(str(ROOT / "node/runtime/fleet.py")) +REPORTER = runpy.run_path(str(ROOT / "node/runtime/fleet_issue.py")) +SHA = "a" * 40 +SOURCE = {"repository": "owner/settings", "path": "defaults/policy.json", "ref": "main"} +DEFAULTS = json.loads((ROOT / "defaults/node-maintenance.json").read_text()) + + +def data(value): + return value.encode() if isinstance(value, str) else json.dumps(value).encode() + + +class GitHub(FLEET["GitHub"]): + def __init__(self, manager="pnpm"): + super().__init__() + self.repos = {} + self.calls = [] + self.states = {} + self.read_defaults = self.public + self.add( + "owner/settings", + { + "defaults/policy.json": data(DEFAULTS), + "defaults/repos.json": data( + {"defaults": SOURCE, "repositories": ["owner/project"]} + ), + }, + ) + self.add( + "owner/actions", + { + "node/contract.json": (ROOT / "node/contract.json").read_bytes(), + "node/runtime/toolchain.json": ( + ROOT / "node/runtime/toolchain.json" + ).read_bytes(), + ".github/workflows/node-audit.yml": ( + ROOT / ".github/workflows/node-audit.yml" + ).read_bytes(), + ".github/workflows/node-publish.yml": ( + ROOT / ".github/workflows/node-publish.yml" + ).read_bytes(), + }, + ) + self.add("owner/project", project(manager)) + + def add(self, name, files): + self.repos[name] = copy.deepcopy(files) + self.states[name] = "active" + self.snapshots.clear() + + def public(self, url, accept="application/json"): + if url.startswith("https://api.github.com/"): + return SHA.encode() + prefix = "https://raw.githubusercontent.com/" + owner, repo, revision, path = url[len(prefix) :].split("/", 3) + assert revision == SHA + return self.repos[f"{owner}/{repo}"][path] + + def get(self, path): + self.calls.append(path) + parts = path.split("/") + name = "/".join(parts[1:3]) + if name not in self.repos: + raise FLEET["Unverified"]( + "GitHub read failed (HTTP 404); access unverified" + ) + files = self.repos[name] + if len(parts) == 3: + return {"default_branch": "main"} + if parts[3] == "commits": + return {"sha": SHA, "commit": {"tree": {"sha": "b" * 40}}} + if parts[3:5] == ["git", "trees"]: + return { + "truncated": False, + "tree": [ + { + "path": path, + "mode": "100644", + "type": "blob", + "size": len(body), + "sha": hashlib.sha1( + f"blob {len(body)}\0".encode() + body + ).hexdigest(), + } + for path, body in files.items() + ], + } + if parts[3:5] == ["git", "blobs"]: + for body in files.values(): + if ( + hashlib.sha1(f"blob {len(body)}\0".encode() + body).hexdigest() + == parts[5] + ): + return { + "encoding": "base64", + "content": base64.b64encode(body).decode(), + } + raise AssertionError("unknown blob") + if parts[3:5] == ["actions", "workflows"]: + return {"state": self.states[name]} + raise AssertionError(path) + + +def project(manager): + manifest = { + "name": "plain-project", + "version": "1.0.0", + "packageManager": "pnpm@11.21.0" if manager == "pnpm" else "npm@11.9.0", + "engines": {"node": ">=24"}, + "scripts": {"check": "this command must never execute"}, + } + caller = yaml.safe_load( + (ROOT / "node/caller.yml") + .read_text() + .replace("__CENTRAL_REPOSITORY__", "owner/actions") + .replace("__CENTRAL_REVISION__", SHA) + ) + caller["on"]["schedule"] = [{"cron": cron} for cron in ["0 2 * * 1", "0 2 1 */3 *"]] + files = { + ".github/node-maintenance.json": data( + {"directory": "app", "validationScript": "check", "defaults": SOURCE} + ), + ".github/workflows/dependency-audit.yml": yaml.safe_dump(caller).encode(), + "app/package.json": data(manifest), + "app/.nvmrc": b"24", + } + if manager == "pnpm": + files["app/pnpm-lock.yaml"] = ( + b"lockfileVersion: '9.0'\nimporters: {'.': {}}\npackages: {}\n" + ) + files["app/pnpm-workspace.yaml"] = ( + b"packages: ['.']\nminimumReleaseAge: 14400\n" + ) + else: + files["app/package-lock.json"] = data( + {"lockfileVersion": 3, "packages": {"": manifest}} + ) + return files + + +@pytest.mark.parametrize("manager", ["npm", "pnpm"]) +def test_healthy_fleet_without_running_project_commands(monkeypatch, manager): + def forbidden(*args, **kwargs): + pytest.fail("A setup check must never run a project command") + + monkeypatch.setattr(subprocess, "run", forbidden) + github = GitHub(manager) + report = FLEET["check_fleet"]( + github, + {"repository": "owner/settings", "path": "defaults/repos.json", "ref": ""}, + ) + assert report["status"] == "ok" + assert report["inventory"]["commit"] == SHA + assert report["results"][0]["status"] == "ok" + assert all(call.startswith("repos/") for call in github.calls) + + +@pytest.mark.parametrize( + "case", + [ + "node", + "cooldown", + "lock", + "script", + "source", + "schedule", + "pin", + "permissions", + "dispatch", + "forwarding", + "disabled", + "ancestor-workspace", + "shrinkwrap", + "engines", + ], +) +def test_setup_drift_is_reported(case): + github = GitHub() + files = github.repos["owner/project"] + if case == "node": + files["app/.nvmrc"] = b"22" + elif case == "cooldown": + files["app/pnpm-workspace.yaml"] = b"packages: ['.']\nminimumReleaseAge: 1\n" + elif case == "lock": + files["app/pnpm-lock.yaml"] = b"lockfileVersion: '8.0'\n" + elif case == "script": + manifest = json.loads(files["app/package.json"]) + manifest["scripts"] = {} + files["app/package.json"] = data(manifest) + elif case == "source": + config = json.loads(files[".github/node-maintenance.json"]) + config.pop("defaults") + files[".github/node-maintenance.json"] = data(config) + elif case == "disabled": + github.states["owner/project"] = "disabled_manually" + elif case == "ancestor-workspace": + files["package.json"] = data({"workspaces": ["app"]}) + elif case == "shrinkwrap": + files["app/npm-shrinkwrap.json"] = b"{}" + elif case == "engines": + manifest = json.loads(files["app/package.json"]) + manifest["engines"]["node"] = "<24" + files["app/package.json"] = data(manifest) + else: + caller = yaml.safe_load(files[".github/workflows/dependency-audit.yml"]) + if case == "schedule": + caller["on"]["schedule"] = [] + elif case == "pin": + caller["jobs"]["audit"]["uses"] = caller["jobs"]["audit"]["uses"].replace( + SHA, "main" + ) + elif case == "permissions": + caller["jobs"]["audit"]["permissions"]["contents"] = "write" + elif case == "dispatch": + caller["on"]["workflow_dispatch"]["inputs"]["audit-kind"]["options"] = [ + "security" + ] + elif case == "forwarding": + caller["jobs"]["audit"]["with"] = {} + files[".github/workflows/dependency-audit.yml"] = yaml.safe_dump( + caller + ).encode() + result = FLEET["check_repository"](github, "owner/project", SOURCE) + assert result["status"] == "failed", result + assert result["diagnostic"] + + +def test_explicit_local_overrides_remain_valid(): + github = GitHub() + files = github.repos["owner/project"] + config = json.loads(files[".github/node-maintenance.json"]) + config["toolchain"] = {"node": "22"} + files[".github/node-maintenance.json"] = data(config) + files["app/.nvmrc"] = b"22.14.0" + manifest = json.loads(files["app/package.json"]) + manifest["engines"]["node"] = ">=22.13" + files["app/package.json"] = data(manifest) + assert FLEET["check_repository"](github, "owner/project", SOURCE)["status"] == "ok" + + +def test_inaccessible_repo_does_not_hide_other_results(): + github = GitHub() + github.repos["owner/settings"]["defaults/repos.json"] = data( + {"defaults": SOURCE, "repositories": ["owner/missing", "owner/project"]} + ) + report = FLEET["check_fleet"]( + github, + {"repository": "owner/settings", "path": "defaults/repos.json", "ref": "main"}, + ) + assert report["status"] == "failed" + assert [item["status"] for item in report["results"]] == ["unverified", "ok"] + + +def test_missing_inventory_is_a_reportable_failure(): + report = FLEET["check_fleet"]( + GitHub(), + {"repository": "owner/settings", "path": "missing.json", "ref": "main"}, + ) + assert report["status"] == "failed" and report["inventory"]["commit"] == SHA + + +@pytest.mark.parametrize( + "repos", [[], ["owner/repo", "OWNER/REPO"], ["../repo"], ["owner/repo"] * 26] +) +def test_inventory_rejects_ambiguous_or_unbounded_lists(repos): + with pytest.raises(ValueError): + FLEET["inventory"]({"defaults": SOURCE, "repositories": repos}) + + +def test_symlink_and_changed_blob_are_rejected(): + github = GitHub() + repo = github.snapshot("owner/project") + repo.files["app/.nvmrc"]["mode"] = "120000" + with pytest.raises(ValueError, match="regular file"): + repo.read("app/.nvmrc") + github = GitHub() + repo = github.snapshot("owner/project") + repo.files["app/.nvmrc"]["size"] += 1 + with pytest.raises(FLEET["Unverified"], match="size differs"): + repo.read("app/.nvmrc") + + +class Issues: + def __init__(self): + self.items = [] + + def request(self, method, path, payload=None): + if method == "GET": + return copy.deepcopy(self.items) + if method == "POST": + result = { + **payload, + "number": len(self.items) + 1, + "state": "open", + "user": {"login": "github-actions[bot]", "type": "Bot"}, + } + self.items.append(result) + return result + number = int(path.rsplit("/", 1)[1]) + self.items[number - 1].update(payload) + return self.items[number - 1] + + +def test_one_issue_per_inventory_updates_closes_and_reopens(): + github = GitHub() + issues = Issues() + report = FLEET["check_fleet"]( + github, + {"repository": "owner/settings", "path": "defaults/repos.json", "ref": "main"}, + ) + report["status"] = "failed" + report["results"][0].update( + status="failed", diagnostic="Node mismatch " + ) + + def update(): + return REPORTER["update_issue"]( + "owner/host", + report, + "https://github.com/owner/host/actions/runs/1", + issues.request, + ) + + assert update()["operation"] == "created" + assert update()["operation"] == "unchanged" + assert "