Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/node-fleet-check.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
name: Node fleet setup
name: Node maintenance setup check
'on':
schedule:
- cron: '0 3 * * 1'
workflow_call:
inputs:
inventory-repository:
Expand Down Expand Up @@ -51,7 +53,7 @@ jobs:
GH_TOKEN: ${{ secrets.read-token || github.token }}
with:
inventory-repository: ${{ inputs.inventory-repository }}
inventory-path: ${{ inputs.inventory-path }}
inventory-path: ${{ inputs.inventory-path || 'defaults/node-repositories.json' }}
inventory-ref: ${{ inputs.inventory-ref }}
- name: Retain the setup report
if: ${{ always() && !cancelled() }}
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ commit. Supports GitHub.com.
| 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) |
| Node maintenance setup check | 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) |

The workflows are independent. Install maintenance in each Node repository you
want to maintain. Run the fleet checker from one repository to monitor those
Expand Down Expand Up @@ -46,7 +46,10 @@ that should follow it. You do not need to register either file here.
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.
reopens it on recurrence. This repository checks ten public repositories weekly
on Mondays at 03:00 UTC. Keep private inventories and reports in a private host;
other hosts choose their own caller schedule. Checks run five repositories at a time in one
runner, with one combined report and issue.

## Documentation

Expand Down
10 changes: 9 additions & 1 deletion defaults/node-repositories.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@
},
"repositories": [
"hydra-ecosystem/hydra",
"hydra-ecosystem/omegaconf"
"hydra-ecosystem/omegaconf",
"omry/arbiter",
"omry/cli.dev",
"omry/hoodlefinance",
"omry/omegaflow",
"omry/reploy",
"omry/yadan.net",
"omry/airport-time-space",
"omry/stale-buffer-guard"
]
}
45 changes: 31 additions & 14 deletions docs/node-fleet-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,15 @@ 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.
This repository's [inventory](../defaults/node-repositories.json) lists ten public
repositories: Hydra, OmegaConf, and eight personal Node projects. Repositories
that are not installed yet remain listed so their missing setup stays visible.
Other maintainers can keep their own inventories 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
To check this repository's inventory, use **Actions → Node maintenance setup check → 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.
Expand All @@ -63,8 +65,10 @@ To host the check elsewhere, add `.github/workflows/node-fleet-setup.yml` to tha
repository's default branch:

```yaml
name: Node fleet setup
name: Node maintenance setup check
'on':
schedule:
- cron: '0 3 * * 1'
workflow_dispatch:
permissions:
contents: read
Expand All @@ -85,7 +89,11 @@ 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.
Reports have the host repository's visibility. Keep private-repository inventories
and their checker in a host that will remain private if names, checked commits,
and diagnostics must stay private. A private inventory alone does not make
issues or run summaries in a public host private. Changing the host to public
also requires reviewing its inventory history and existing reports.

For public repositories, the built-in token is sufficient. To read private
listed repositories or a private inventory in another repository, add a host
Expand All @@ -103,7 +111,7 @@ 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
Open **Actions → Node maintenance setup check → Run workflow** in the host. From a checkout
of that repository, you can also run:

```sh
Expand All @@ -122,13 +130,19 @@ its exact checked default-branch commit, and an actionable diagnostic:
| `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 scanner checks up to five repositories concurrently in one runner, while
keeping results in inventory order. Each repository has isolated temporary files.
Shared defaults and pinned repository snapshots are reused across checks.
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
result fails the scanner job and opens or updates one **Node maintenance setup check** 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.

The issue starts with a results table and links to each repository. Exact commit
identities are in the expandable **Checked revisions** section.

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
Expand Down Expand Up @@ -173,10 +187,11 @@ 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
## Change the weekly schedule

There is no automatic fleet-check schedule by default. To check weekly, add a
schedule alongside `workflow_dispatch` in the host caller:
This repository checks its inventory every Monday at 03:00 UTC (11:00 in
Singapore). The caller example above uses the same weekly schedule.
To change it, edit the `schedule` block in the host workflow:

```yaml
'on':
Expand All @@ -185,6 +200,8 @@ schedule alongside `workflow_dispatch` in the host caller:
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.
Remove the `schedule` block to run only manually. Keep `workflow_dispatch` for
on-demand checks. A reusable workflow uses its caller's schedule; it does not
inherit this repository's cron. This schedule is independent of the security
and upgrade cadences in each maintenance installation; do not add the fleet
cron to those installations' maintenance configuration.
2 changes: 1 addition & 1 deletion node/fleet-check/action.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Check Node fleet setup
name: Check Node maintenance setup
description: Inspect listed installations as data without running project commands.
inputs:
inventory-repository:
Expand Down
2 changes: 1 addition & 1 deletion node/fleet-report/action.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Report Node fleet setup
name: Report Node maintenance setup
description: Maintain one setup issue per inventory in the calling repository.
runs:
using: composite
Expand Down
37 changes: 28 additions & 9 deletions node/runtime/fleet.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,17 @@
import urllib.error
import urllib.parse
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path, PurePosixPath
from tempfile import TemporaryDirectory
from threading import Lock

import yaml

PROJECT = runpy.run_path(str(Path(__file__).with_name("project.py")))
CONFIGURATION = PROJECT["CONFIGURATION"]
MAX_REPOSITORIES = 25
MAX_CONCURRENT_REPOSITORIES = 5
MAX_FILE_BYTES = 2_000_000
CONFIG_PATH = ".github/node-maintenance.json"
CALLER_PATH = ".github/workflows/dependency-audit.yml"
Expand Down Expand Up @@ -58,8 +61,16 @@ class GitHub:
def __init__(self, token=""):
self.token = token
self.snapshots = {}
self.snapshot_locks = {}
self.snapshot_locks_guard = Lock()
self.defaults_lock = Lock()
# Defaults remain public and unauthenticated, as in the individual audit.
self.read_defaults = functools.cache(CONFIGURATION["public_read"])
self.cached_defaults = functools.cache(CONFIGURATION["public_read"])

def read_defaults(self, url, accept="application/json"):
# Resolve each shared ref once, even when several repositories need it.
with self.defaults_lock:
return self.cached_defaults(url, accept)

@functools.cache
def get(self, path):
Expand Down Expand Up @@ -99,9 +110,12 @@ def get(self, path):
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]
with self.snapshot_locks_guard:
lock = self.snapshot_locks.setdefault(key, Lock())
with lock:
if key not in self.snapshots:
self.snapshots[key] = Repository(self, repository, ref)
return self.snapshots[key]


class Repository:
Expand Down Expand Up @@ -564,9 +578,14 @@ def check_fleet(github, source):
repository.read(source["path"]), "fleet inventory"
)
)
for name in settings["repositories"]:
report["results"].append(
check_repository(github, name, settings["defaults"])
with ThreadPoolExecutor(
max_workers=min(MAX_CONCURRENT_REPOSITORIES, len(settings["repositories"]))
) as executor:
report["results"] = list(
executor.map(
lambda name: check_repository(github, name, settings["defaults"]),
settings["repositories"],
)
)
report["status"] = (
"ok"
Expand All @@ -585,7 +604,7 @@ def escape(value):
return html.escape(str(value)).replace("|", "|").replace("\n", " ")

lines = [
"## Node fleet setup",
"## Node maintenance setup check",
"",
"| Repository | Static setup | Detail |",
"| --- | --- | --- |",
Expand Down Expand Up @@ -627,7 +646,7 @@ def main():
output.write(text)
print(text)
if report["status"] != "ok":
parser.exit(1, "Fleet setup needs attention; inspect the report.\n")
parser.exit(1, "Node maintenance setup needs attention; inspect the report.\n")


if __name__ == "__main__":
Expand Down
70 changes: 58 additions & 12 deletions node/runtime/fleet_issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,27 +115,73 @@ def diagnostic(value):
truncated = True
return html.escape(value[:limit] + TRUNCATION_SUFFIX)

def cell(value):
return (
re.sub(
r"[\\`*_\[\]~!|]",
lambda match: f"&#{ord(match[0])};",
value,
)
.replace("\r\n", "\n")
.replace("\r", "\n")
.replace("\n", "<br>")
)

counts = {
status: sum(item["status"] == status for item in report["results"])
for status in ("ok", "failed", "unverified")
}
statuses = {
"ok": "✅ Passed",
"failed": "⚠️ Needs attention",
"unverified": "❔ Unverified",
}
headline = (
" · ".join(
f"{counts[status]} {label}"
for status, label in (
("failed", "need attention"),
("ok", "passed"),
("unverified", "unverified"),
)
if counts[status]
)
or "No repositories verified"
)
lines = [
marker(source),
"",
"## Node fleet setup",
"## Node maintenance setup check",
"",
f"**{headline}**",
"",
f"Inventory: <code>{text(source['repository'])}/{text(source['path'])}</code>",
f"Revision: <code>{text(source['ref'])} @ {text(source['commit'] or 'unverified')}</code>",
"",
]
if report["diagnostic"]:
lines.append("<pre>" + diagnostic(report["diagnostic"]) + "</pre>")
lines.extend(["<pre>" + diagnostic(report["diagnostic"]) + "</pre>", ""])
lines.extend(["| Repository | Setup | Detail |", "| --- | --- | --- |"])
for item in report["results"]:
lines.extend(
[
f"### {text(item['repository'])}: {text(item['status'])}",
"",
"<pre>" + diagnostic(item["diagnostic"]) + "</pre>",
f"Checked commit: <code>{text(item['commit'] or 'unverified')}</code>",
"",
]
lines.append(
f"| [{cell(text(item['repository']))}](https://github.com/{item['repository']}) | {statuses[item['status']]} | {cell(diagnostic(item['diagnostic']))} |"
)
lines.extend(
[
"",
"<details>",
"<summary>Checked revisions</summary>",
"",
f"Inventory: <code>{text(source['ref'])} @ {text(source['commit'] or 'unverified')}</code>",
"",
"| Repository | Commit |",
"| --- | --- |",
]
)
for item in report["results"]:
lines.append(
f"| {cell(text(item['repository']))} | <code>{text(item['commit'] or 'unverified')}</code> |"
)
lines.extend(["", "</details>", ""])
if truncated:
lines.extend(
[
Expand Down Expand Up @@ -174,7 +220,7 @@ def diagnostic(value):
return ISSUES["maintain_issue"](
repository,
marker=marker(source),
title="Node fleet setup: " + source["path"][:180],
title="Node maintenance setup check: " + source["path"][:180],
body=body,
failing=report["status"] != "ok",
request=request,
Expand Down
Loading
Loading