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 "