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
13 changes: 13 additions & 0 deletions .github/workflows/node-audit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,16 @@ jobs:
uses: $/node/configuration-issue
env:
GH_TOKEN: ${{ github.token }}
- name: Read local patch status from this run
if: ${{ always() && !cancelled() }}
id: local-patches
continue-on-error: true
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
with:
name: node-audit-local-patches
path: ${{ runner.temp }}/node-audit-local-patches
- name: Maintain the local patch notification issue
if: ${{ always() && !cancelled() && steps.local-patches.outcome == 'success' }}
uses: $/node/local-patch-issue
env:
GH_TOKEN: ${{ github.token }}
40 changes: 40 additions & 0 deletions docs/node-maintenance.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ installation, validation, and the audit result:
| --- | --- |
| Validated dependency changes | Opens or updates the single maintenance PR. |
| Dependency changes with failed installation or checks | Retains a draft proposal when possible and fails the workflow. |
| Incomplete or unavailable security audit | Fails the run and skips publication, even if project checks pass; retains the diagnostic summary and report artifact. |
| No dependency changes | Creates no PR and leaves any existing proposal untouched; read the current run summary or report artifact. |
| Invalid configuration | Stops before dependency changes and updates one reusable configuration issue. Successful preflight closes it. |

Expand Down Expand Up @@ -277,6 +278,7 @@ See the [tested versions](../node/runtime/toolchain.json).
| Setting | Behavior |
| --- | --- |
| `validationScript` | Existing nonempty package script; default `"build"`. Names start with a letter or digit and contain only letters, digits, `:`, `_`, `.`, or `-`. |
| `localPatches` | Project-local declarations identifying advisories mitigated by local patches. These are maintainer assertions, never inherited or counted as independently confirmed security fixes. See [local patches](#local-security-patches). |
| `minimumReleaseAgeDays` | Minimum release cooldown in days; nonnegative integer, default `10`. pnpm workspace policy must be at least this strict. |
| `maintenance.mode` | `"security-only"` or `"security-and-maintenance"`. A schedule object defaults to security-only unless a mode is supplied locally or inherited. |
| `maintenance.upgradeScope` | Maximum routine direct upgrade level: `"patch"`, `"minor"` including patches, or `"major"` including both (default). Security fixes are handled separately. |
Expand Down Expand Up @@ -306,6 +308,44 @@ subject to the repository's other policies. The maintenance run already performs
the frozen install and selected project checks; neither authentication mode waits
for additional PR CI. See [GitHub's token behavior](https://docs.github.com/en/actions/concepts/security/github_token).

## Local security patches

Record a maintainer assertion in `.github/node-maintenance.json` using an advisory
ID alone, or `[advisory, comment]` when you want to add context:

```json
{
"localPatches": [
"GHSA-5p2g-fcmc-qvqq",
["GHSA-w3rx-r6r6-pgpr", "Covered by regression tests."]
]
}
```

Merge this field into the existing configuration. The optional comment is a
single-line string of at most 2048 characters. Up to 32 distinct advisories are
supported. Assertions stay local to this repository; they are not inherited.
Package names and affected versions come from audit and lockfile evidence.

This records your assertion; the Action does not inspect, apply, or certify
arbitrary patches. Raw advisory findings stay visible and assertions never count
as confirmed security fixes. pnpm ignore rules stay unchanged while audits collect
the complete findings. An assertion whose advisory is absent from a complete
audit remains recorded as **not reported**, rather than being certified fixed.

When a newer stable official fix is verified, one **Node local patches: official
fixes available** issue is created, updated, or reopened for the repository.
Availability does not prove compatibility or release-cooldown eligibility. Review
the upstream fix and everything your patch changes before upgrading, removing the
patch and obsolete assertion, and rerunning. **Patches are never removed automatically.**
A complete check with no remaining notifications closes the issue; unavailable
evidence does not close it. The reporter uses the existing `issues: write`
permission without checking out or executing project code.

Use a workflow revision whose `node/contract.json` includes `local_patches`
support. Update both caller pins together; older version-6 revisions do not
accept `localPatches`.

## Update or migrate an installation

For an earlier Docusaurus installation, use the [migration checklist](node-migration.md).
Expand Down
2 changes: 2 additions & 0 deletions docs/shared-defaults.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ policy, if used, must satisfy those settings.
Directory, validation script, dependency declarations, security exceptions,
deployment, and credentials remain local. They are not inherited. Shared
constraints are checked against declarations; they never rewrite them.
`localPatches` declarations are also project-local and never inherited; see
[local security patches](node-maintenance.md#local-security-patches).

## Use another project as the authoritative source

Expand Down
21 changes: 18 additions & 3 deletions node/audit/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,18 @@ runs:
--validation-script "$NODE_AUDIT_VALIDATION_SCRIPT" --maintenance-mode "$NODE_AUDIT_MAINTENANCE_MODE"
--upgrade-scope "$NODE_AUDIT_UPGRADE_SCOPE"
--minimum-release-age-days "$NODE_AUDIT_MINIMUM_RELEASE_AGE_DAYS"

cat "$RUNNER_TEMP/dependency-audit-pr.md" >> "$GITHUB_STEP_SUMMARY"
--resolved-config "$RUNNER_TEMP/node-audit-resolved-config.json"
--local-patches-report "$RUNNER_TEMP/node-audit-local-patches.json"

'
shell: bash
- name: Retain the audit summary even when the audit fails
if: ${{ always() && !cancelled() }}
run: |
if [ -f "$RUNNER_TEMP/dependency-audit-pr.md" ]; then
cat "$RUNNER_TEMP/dependency-audit-pr.md" >> "$GITHUB_STEP_SUMMARY"
fi
shell: bash
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: dependency-audit-dependencies
Expand All @@ -84,7 +91,15 @@ runs:

'
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
if: ${{ always() && !cancelled() }}
with:
name: dependency-audit-report
if-no-files-found: error
if-no-files-found: ignore
path: ${{ runner.temp }}/dependency-audit-pr.md
- name: Retain local patch status for the issue reporter
if: ${{ always() && !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: node-audit-local-patches
if-no-files-found: ignore
path: ${{ runner.temp }}/node-audit-local-patches.json
7 changes: 7 additions & 0 deletions node/contract.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@
],
"project_scope": "one standalone package directory and lockfile",
"validation_script_default": "build",
"local_patches": {
"config_key": "localPatches",
"inherited": false,
"assertion": "maintainer-declared, not independently certified",
"automatic_patch_removal": false,
"notification": "one issue per repository for published official fixes"
},
"breaking_changes": {
"previous_component": "docusaurus",
"old_configuration": ".github/docusaurus.json",
Expand Down
7 changes: 7 additions & 0 deletions node/local-patch-issue/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
name: Report official fixes for local patches
description: Maintain one issue without modifying patches or executing project code.
runs:
using: composite
steps:
- shell: bash
run: python "$GITHUB_ACTION_PATH/../runtime/local_patch_issue.py" --report "$RUNNER_TEMP/node-audit-local-patches/node-audit-local-patches.json"
21 changes: 20 additions & 1 deletion node/maintenance.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ type. Security repairs explicitly disable force mode, including inherited
`force=true` settings. These overrides apply to audit and repair commands;
project validation still uses the project's install settings.

For pnpm, audit commands override advisory-ignore lists without rewriting them.
This keeps registry evidence complete while preserving project policy. A
`localPatches` entry records a maintainer's assertion about an advisory, with
an optional comment. Affected packages and versions come from audit and lockfile
evidence. It does not prove patch application or security effectiveness, remove
raw findings, or count as a confirmed security fix. See
[declaring local patches](../docs/node-maintenance.md#local-security-patches).

Upgrade audits attempt security fixes first, then routine stable upgrades.
`maintenance.upgradeScope` limits routine **direct** upgrades to `patch`, `minor`
(including patches), or `major` (the default, including both). Restricted scopes
Expand Down Expand Up @@ -69,7 +77,10 @@ the next PR. Nothing merges automatically.

The title describes dependency updates and confirmed security fixes. For example,
**Node: 9 routine dependency updates, no security fixes** identifies routine work.
If audit evidence is unavailable, the title says **security fixes unconfirmed**.
If initial or final audit evidence is unavailable or incomplete, the run fails
and skips publication. The report identifies the cause and retains the separate
frozen-install and project-check results. Its summary and report artifact remain
available after failure; passing project checks do not make the audit complete.
The report leads with proposed versions and validation results. Routine-only
headlines omit unrelated security counts. Existing blocked findings with unchanged
audit evidence appear as neutral background, with severity and constraints in
Expand All @@ -79,13 +90,21 @@ Reports retain unresolved findings, pin decisions, validation failures, changed
locked version sets, and candidate dependency-file SHA-256 hashes. Grouped logs
retain successful and failed command output, including warnings.

For declared local patches, reports also retain the advisory, inferred package/version,
optional maintainer comment, and verified official-fix availability in the
`node-audit-local-patches` artifact. A separate reporter updates one bot-owned
**Node local patches: official fixes available** issue when a newer stable fixed
release is verified. It never removes a patch or claims the release is compatible
with project constraints. Missing evidence cannot close that notification.

| Rerun result | Publisher behavior |
| --- | --- |
| Changed dependency proposal | Updates the existing PR and branch. |
| Same files, report, base branch, validation, and title | No commit, push, or PR edit. Run timestamps and workflow links alone do not count as changes. |
| Same proposal with new report findings | Updates the body without another dependency commit. |
| Same proposal with an outdated title | Corrects the title without rewriting the report or pushing a commit. |
| Retained proposal with failed installation or checks | Publishes or updates a draft and fails the workflow. |
| Initial or final security audit unavailable or incomplete | Fails the audit and skips publication; retains the diagnostic summary and report artifact. |
| Successful rerun of an existing draft | Leaves it as a draft for human review. |
| No qualifying dependency changes | Creates no PR and leaves an existing branch and report unchanged. Read the new run summary or artifacts. |

Expand Down
5 changes: 4 additions & 1 deletion node/runtime/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@
from pathlib import Path

SCHEDULING = runpy.run_path(str(Path(__file__).with_name("scheduling.py")))
LOCAL_PATCHES = runpy.run_path(str(Path(__file__).with_name("local_patches.py")))

MAX_BYTES = 65536
MAX_SOURCES = 8
SHARED_KEYS = {"toolchain", "maintenance", "minimumReleaseAgeDays", "schedule"}
LOCAL_KEYS = {"directory", "validationScript"}
LOCAL_KEYS = {"directory", "validationScript", "localPatches"}


def json_object(data, location, max_bytes=MAX_BYTES):
Expand Down Expand Up @@ -74,6 +75,8 @@ def validate_settings(config, location):
unknown = set(config) - SHARED_KEYS - LOCAL_KEYS - {"defaults"}
if unknown:
raise ValueError(f"{location}: unknown configuration fields: {sorted(unknown)}")
if "localPatches" in config:
LOCAL_PATCHES["validate_declarations"](config["localPatches"])
if "schedule" in config:
SCHEDULING["validate_schedule"](config["schedule"])
if "defaults" in config:
Expand Down
7 changes: 7 additions & 0 deletions node/runtime/fleet.py
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,13 @@ def check_caller(root, config, selected, github):
raise ValueError(
"Pinned workflows must declare the supported Node integration version 6"
)
if (
"localPatches" in config
and contract.get("local_patches", {}).get("config_key") != "localPatches"
):
raise ValueError(
"Pinned workflows do not support localPatches; update both workflow pins"
)
toolchain = CONFIGURATION["json_object"](
source.read("node/runtime/toolchain.json"), "pinned toolchain support"
).get("supported", {})
Expand Down
91 changes: 91 additions & 0 deletions node/runtime/local_patch_issue.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""Notify about published fixes for declared local patches using data only."""

import argparse
import json
import os
import re
import runpy
import urllib.error
from pathlib import Path

ISSUES = runpy.run_path(str(Path(__file__).with_name("configuration_issue.py")))
PATCHES = runpy.run_path(str(Path(__file__).with_name("local_patches.py")))
MARKER = "<!-- node-audit-local-patches:v1 -->"
TITLE = "Node local patches: official fixes available"


def update_issue(repository, report, run_url, request=ISSUES["github_request"]):
PATCHES["validate_report"](report)
available = any(item["status"] == "fix-available" for item in report["patches"])
if not available and (
not report["complete"]
or any(item["status"] == "unverified" for item in report["patches"])
):
return {"operation": "skipped", "reason": "official fix evidence is incomplete"}
preview = PATCHES["render"](report)
if len(preview.encode()) > 48_000:
preview = preview.encode()[:48_000].decode(errors="ignore").rsplit("\n", 1)[0]
preview += (
"\n\nTable preview truncated. The workflow run's "
"`node-audit-local-patches` artifact contains all entries and comments.\n\n"
)
body = (
MARKER
+ "\n\n"
+ (
"Published official fixes are available for maintainer-declared local patches.\n\n"
if available
else "No declared local patch currently has a verified newer official fix requiring this notification.\n\n"
)
+ preview
+ (
"The full security audit did not complete. This notification records verified release availability only.\n\n"
if not report["complete"]
else ""
)
+ "Review the upstream fix, project constraints, and release cooldown. Upgrade manually and remove a patch only after checking everything it changes. Remove obsolete localPatches declarations and any corresponding audit-ignore entries, then rerun maintenance.\n\n"
+ "Availability does not prove compatibility or certify the local patch. This reporter does not change patch files or project policy.\n\n"
+ f"[Workflow run]({run_url})\n\n"
+ "This repository reuses one issue for local-patch notifications. Unavailable audit or registry evidence does not close it."
)
return ISSUES["maintain_issue"](
repository,
marker=MARKER,
title=TITLE,
body=body,
failing=available,
request=request,
)


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--report", required=True, type=Path)
args = parser.parse_args()
try:
repository = os.environ.get("GITHUB_REPOSITORY", "")
run_id = os.environ.get("GITHUB_RUN_ID", "")
if (
not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository)
or any(part in {".", ".."} for part in repository.split("/"))
or not run_id.isdecimal()
):
raise ValueError(
"Local patch reporter requires the current repository and run ID"
)
with args.report.open("rb") as stream:
raw = stream.read(PATCHES["MAX_REPORT_BYTES"] + 1)
if len(raw) > PATCHES["MAX_REPORT_BYTES"]:
raise ValueError("Local patch report exceeds its size limit")
result = update_issue(
repository,
json.loads(raw),
f"https://github.com/{repository}/actions/runs/{run_id}",
)
print(json.dumps(result))
except (OSError, ValueError, urllib.error.URLError) as error:
parser.exit(1, f"Local patch issue: {error}\n")


if __name__ == "__main__":
main()
Loading
Loading