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 "