From 6f52bd366c84dcd2e0c5c60c1173ea25f4e218c8 Mon Sep 17 00:00:00 2001 From: Omry Yadan Date: Wed, 21 Oct 2026 00:12:29 +0800 Subject: [PATCH] Add independent Node maintenance proposals and dependency push checks --- .github/workflows/node-audit.yml | 65 ++- .github/workflows/node-maintenance.yml | 154 +++++++ .github/workflows/node-publish.yml | 79 ++-- README.md | 12 +- docs/audit-schedules.md | 9 +- docs/node-coordination.md | 112 +++++ docs/node-maintenance.md | 4 + docs/node-migration.md | 4 + docs/workflow-releases.md | 6 + node/audit/action.yml | 91 +++-- node/contract.json | 18 +- node/coordinated-caller.yml | 41 ++ node/maintenance.md | 7 +- node/plan/action.yml | 47 +++ node/publish/publish.sh | 57 ++- node/runtime/coordination.py | 155 +++++++ node/runtime/fleet.py | 142 ++++--- node/runtime/project.py | 2 + node/runtime/scheduling.py | 8 +- node/runtime/templates/dependency_audit.py | 36 +- node/tests/test_configuration_issue.py | 6 +- node/tests/test_coordination.py | 381 ++++++++++++++++++ node/tests/test_dependency_audit_feedback.py | 6 +- node/tests/test_dependency_audit_publisher.py | 2 + node/tests/test_fleet_setup.py | 35 ++ node/tests/test_integration_contract.py | 5 +- node/tests/test_local_patches.py | 2 +- node/tests/test_node_standard.py | 2 +- node/tests/test_transferred_workflow.py | 4 +- node/tests/verify_node_project.py | 11 + 30 files changed, 1370 insertions(+), 133 deletions(-) create mode 100644 .github/workflows/node-maintenance.yml create mode 100644 docs/node-coordination.md create mode 100644 node/coordinated-caller.yml create mode 100644 node/plan/action.yml create mode 100644 node/runtime/coordination.py create mode 100644 node/tests/test_coordination.py diff --git a/.github/workflows/node-audit.yml b/.github/workflows/node-audit.yml index 6401160..2ad5275 100644 --- a/.github/workflows/node-audit.yml +++ b/.github/workflows/node-audit.yml @@ -6,10 +6,28 @@ name: Node dependency audit description: auto selects the configured cadence; security or upgrades overrides one manual run. type: string default: auto + audit-ref: + type: string + default: '' + artifact-prefix: + type: string + default: '' + refresh: + type: boolean + default: false + operation: + type: string + default: propose + proposal-kind: + type: string + default: legacy outputs: validation_passed: description: Whether candidate validation passed. value: ${{ jobs.audit.outputs.validation_passed }} + proposal_complete: + description: Whether proposal resolution and audit evidence completed. + value: ${{ jobs.audit.outputs.proposal_complete }} permissions: contents: read jobs: @@ -20,24 +38,31 @@ jobs: timeout-minutes: 60 outputs: validation_passed: ${{ steps.proposal.outputs.validation_passed }} + proposal_complete: ${{ steps.proposal.outputs.proposal_complete }} steps: - name: Validate manual dispatch ref env: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - run: "set -euo pipefail\nexpected_ref=\"refs/heads/$DEFAULT_BRANCH\"\nif [ \"\ - $GITHUB_EVENT_NAME\" = \"workflow_dispatch\" ] && [ \"$GITHUB_REF\" != \"\ - $expected_ref\" ]; then\n echo \"::error::Manual dispatch must target the\ - \ repository default branch ($expected_ref); got $GITHUB_REF.\" >&2\n exit\ - \ 1\nfi\n" + run: | + set -euo pipefail + expected_ref="refs/heads/$DEFAULT_BRANCH" + if { [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ] || [ "$GITHUB_EVENT_NAME" = "push" ]; } && [ "$GITHUB_REF" != "$expected_ref" ]; then + echo "::error::Manual dispatch must target the repository default branch ($expected_ref); got $GITHUB_REF." >&2 + exit 1 + fi - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: - ref: ${{ github.sha }} + ref: ${{ inputs.audit-ref || github.sha }} persist-credentials: false - name: Audit, propose upgrades, and validate id: proposal uses: $/node/audit with: audit-kind: ${{ inputs.audit-kind }} + artifact-prefix: ${{ inputs.artifact-prefix }} + refresh: ${{ inputs.refresh }} + operation: ${{ inputs.operation }} + proposal-kind: ${{ inputs.proposal-kind }} configuration-issue: needs: audit if: ${{ always() && !cancelled() }} @@ -45,32 +70,50 @@ jobs: timeout-minutes: 5 permissions: issues: write + contents: read concurrency: group: node-audit-configuration-issue-${{ github.repository }} cancel-in-progress: false + queue: max steps: + - name: Reject stale coordinated issue reports + if: ${{ inputs.audit-ref != '' }} + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + AUDIT_SHA: ${{ inputs.audit-ref }} + BASE_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + current=$(gh api "repos/$GH_REPO/commits/$BASE_BRANCH" --jq .sha) + if [ "$current" != "$AUDIT_SHA" ]; then + echo "::error::Default branch advanced; stale issue reports discarded." + exit 1 + fi + id: freshness - name: Read configuration status from this run id: report continue-on-error: true uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: - name: node-audit-configuration + name: ${{ inputs.artifact-prefix || 'node-audit' }}-configuration path: ${{ runner.temp }}/node-audit-configuration + if: ${{ success() && steps.freshness.outcome != 'failure' }} - name: Maintain the configuration issue - if: ${{ steps.report.outcome == 'success' }} + if: ${{ steps.report.outcome == 'success' && steps.freshness.outcome != 'failure' }} uses: $/node/configuration-issue env: GH_TOKEN: ${{ github.token }} - name: Read local patch status from this run - if: ${{ always() && !cancelled() }} + if: ${{ always() && !cancelled() && steps.freshness.outcome != 'failure' }} id: local-patches continue-on-error: true uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: - name: node-audit-local-patches + name: ${{ inputs.artifact-prefix || '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' }} + if: ${{ always() && !cancelled() && steps.local-patches.outcome == 'success' && steps.freshness.outcome != 'failure' }} uses: $/node/local-patch-issue env: GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/node-maintenance.yml b/.github/workflows/node-maintenance.yml new file mode 100644 index 0000000..69a1450 --- /dev/null +++ b/.github/workflows/node-maintenance.yml @@ -0,0 +1,154 @@ +name: Coordinated Node dependency maintenance +'on': + workflow_call: + inputs: + audit-kind: + type: string + default: auto + app-client-id: + type: string + default: '' + secrets: + app-private-key: + required: false +permissions: + contents: read +jobs: + plan: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + outputs: + sha: ${{ steps.revision.outputs.sha }} + security: ${{ steps.plan.outputs.security }} + upgrades: ${{ steps.plan.outputs.upgrades }} + security_pr: ${{ steps.plan.outputs.security_pr }} + upgrades_pr: ${{ steps.plan.outputs.upgrades_pr }} + refresh_only: ${{ steps.plan.outputs.refresh_only }} + validate_main: ${{ steps.plan.outputs.validate_main }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + fetch-depth: 0 + - id: revision + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - id: plan + uses: $/node/plan + with: + audit-kind: ${{ inputs.audit-kind }} + env: + GH_TOKEN: ${{ github.token }} + main: + needs: plan + if: ${{ needs.plan.outputs.validate_main == 'true' }} + permissions: + contents: read + issues: write + uses: ./.github/workflows/node-audit.yml + with: + audit-ref: ${{ needs.plan.outputs.sha }} + artifact-prefix: node-main + audit-kind: ${{ inputs.audit-kind }} + refresh: ${{ github.event_name == 'push' }} + operation: validate + proposal-kind: main + security: + needs: plan + if: ${{ needs.plan.outputs.security == 'true' }} + permissions: + contents: read + issues: write + uses: ./.github/workflows/node-audit.yml + with: + audit-ref: ${{ needs.plan.outputs.sha }} + artifact-prefix: node-security + audit-kind: ${{ github.event_name == 'push' && 'security' || inputs.audit-kind }} + refresh: ${{ github.event_name == 'push' }} + operation: propose + proposal-kind: security + security-pr: + needs: + - plan + - security + permissions: + contents: write + pull-requests: write + uses: ./.github/workflows/node-publish.yml + with: + validation-passed: ${{ needs.security.outputs.validation_passed }} + proposal-complete: ${{ needs.security.outputs.proposal_complete }} + audit-ref: ${{ needs.plan.outputs.sha }} + artifact-prefix: node-security + proposal-kind: security + refresh-only: ${{ needs.plan.outputs.refresh_only == 'true' }} + expected-pr: ${{ needs.plan.outputs.security_pr }} + app-client-id: ${{ inputs.app-client-id }} + secrets: + app-private-key: ${{ secrets.app-private-key }} + upgrades: + needs: plan + if: ${{ needs.plan.outputs.upgrades == 'true' }} + permissions: + contents: read + issues: write + uses: ./.github/workflows/node-audit.yml + with: + audit-ref: ${{ needs.plan.outputs.sha }} + artifact-prefix: node-upgrades + audit-kind: ${{ github.event_name == 'push' && 'upgrades' || inputs.audit-kind }} + refresh: ${{ github.event_name == 'push' }} + operation: propose + proposal-kind: upgrades + upgrades-pr: + needs: + - plan + - upgrades + permissions: + contents: write + pull-requests: write + uses: ./.github/workflows/node-publish.yml + with: + validation-passed: ${{ needs.upgrades.outputs.validation_passed }} + proposal-complete: ${{ needs.upgrades.outputs.proposal_complete }} + audit-ref: ${{ needs.plan.outputs.sha }} + artifact-prefix: node-upgrades + proposal-kind: upgrades + refresh-only: ${{ needs.plan.outputs.refresh_only == 'true' }} + expected-pr: ${{ needs.plan.outputs.upgrades_pr }} + app-client-id: ${{ inputs.app-client-id }} + secrets: + app-private-key: ${{ secrets.app-private-key }} + planning-issue: + needs: plan + if: ${{ always() && !cancelled() && needs.plan.result == 'failure' }} + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + issues: write + concurrency: + group: node-audit-configuration-issue-${{ github.repository }} + cancel-in-progress: false + queue: max + steps: + - name: Refuse stale configuration diagnostics + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + BASE_BRANCH: ${{ github.event.repository.default_branch }} + AUDIT_SHA: ${{ needs.plan.outputs.sha }} + run: | + set -euo pipefail + current=$(gh api "repos/$GH_REPO/commits/$BASE_BRANCH" --jq .sha) + test "$current" = "$AUDIT_SHA" + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: node-plan-configuration + path: ${{ runner.temp }}/node-audit-configuration + - uses: $/node/configuration-issue + env: + GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/node-publish.yml b/.github/workflows/node-publish.yml index 952e3c2..78398d0 100644 --- a/.github/workflows/node-publish.yml +++ b/.github/workflows/node-publish.yml @@ -9,6 +9,24 @@ name: Publish Node dependency proposal required: false type: string default: '' + audit-ref: + type: string + default: '' + artifact-prefix: + type: string + default: '' + proposal-kind: + type: string + default: legacy + refresh-only: + type: boolean + default: false + expected-pr: + type: string + default: '' + proposal-complete: + type: string + default: 'false' secrets: app-private-key: required: false @@ -38,46 +56,61 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: token: ${{ steps.app-token.outputs.token || github.token }} - ref: ${{ github.sha }} + ref: ${{ inputs.audit-ref || github.sha }} fetch-depth: 0 - name: Verify audit dependency state env: - AUDIT_SHA: ${{ github.sha }} + AUDIT_SHA: ${{ inputs.audit-ref || github.sha }} BASE_BRANCH: ${{ github.event.repository.default_branch }} - run: "set -euo pipefail\ngit fetch --no-tags origin \"$BASE_BRANCH:refs/remotes/origin/$BASE_BRANCH\"\ - \ngit checkout --detach \"refs/remotes/origin/$BASE_BRANCH\"\nif ! git cat-file\ - \ -e \"${AUDIT_SHA}^{commit}\" 2>/dev/null; then\n echo \"Triggering audit\ - \ revision ${AUDIT_SHA} is not available locally; fetching it.\"\n if ! git\ - \ fetch --no-tags origin \"$AUDIT_SHA\"; then\n echo \"::error::Unable\ - \ to fetch the triggering audit revision; refusing stale publication.\"\n\ - \ exit 1\n fi\nfi\nif ! git cat-file -e \"${AUDIT_SHA}^{commit}\" 2>/dev/null;\ - \ then\n echo \"::error::The triggering audit revision could not be resolved;\ - \ refusing publication.\"\n exit 1\nfi\nif git diff --quiet \"$AUDIT_SHA\"\ - \ HEAD -- .; then\n :\nelse\n comparison_status=$?\n if [ \"$comparison_status\"\ - \ -eq 1 ]; then\n echo \"::error::The default branch repository inputs\ - \ changed since ${AUDIT_SHA}; rerun the audit from the current default branch.\"\ - \n else\n echo \"::error::Unable to compare default-branch repository\ - \ inputs with ${AUDIT_SHA}; refusing publication.\"\n fi\n exit 1\nfi\n" + run: | + set -euo pipefail + git fetch --no-tags origin "$BASE_BRANCH:refs/remotes/origin/$BASE_BRANCH" + git checkout --detach "refs/remotes/origin/$BASE_BRANCH" + if ! git cat-file -e "${AUDIT_SHA}^{commit}" 2>/dev/null; then + echo "Triggering audit revision ${AUDIT_SHA} is not available locally; fetching it." + if ! git fetch --no-tags origin "$AUDIT_SHA"; then + echo "::error::Unable to fetch the triggering audit revision; refusing stale publication." + exit 1 + fi + fi + if ! git cat-file -e "${AUDIT_SHA}^{commit}" 2>/dev/null; then + echo "::error::The triggering audit revision could not be resolved; refusing publication." + exit 1 + fi + if git diff --quiet "$AUDIT_SHA" HEAD -- .; then + : + else + comparison_status=$? + if [ "$comparison_status" -eq 1 ]; then + echo "::error::The default branch repository inputs changed since ${AUDIT_SHA}; rerun the audit from the current default branch." + else + echo "::error::Unable to compare default-branch repository inputs with ${AUDIT_SHA}; refusing publication." + fi + exit 1 + fi - name: Read project configuration uses: $/node/project - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: - name: dependency-audit-dependencies + name: ${{ inputs.artifact-prefix || 'dependency-audit' }}-dependencies path: ${{ env.NPM_PROJECT }} - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: - name: dependency-audit-report + name: ${{ inputs.artifact-prefix || 'dependency-audit' }}-report path: ${{ runner.temp }} - - name: Open or update the umbrella PR + - name: Maintain the selected dependency PR env: GH_TOKEN: ${{ steps.app-token.outputs.token || github.token }} GH_REPO: ${{ github.repository }} BASE_BRANCH: ${{ github.event.repository.default_branch }} PR_BRANCH: maintenance/dependency-audit VALIDATION_PASSED: ${{ inputs.validation-passed }} - PR_FOOTER: '[Workflow run](${{ github.server_url }}/${{ github.repository - }}/actions/runs/${{ github.run_id }}) - - ' + PR_FOOTER: | + [Workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) + PROPOSAL_KIND: ${{ inputs.proposal-kind }} + REFRESH_ONLY: ${{ inputs.refresh-only }} + EXPECTED_PR: ${{ inputs.expected-pr }} + PROPOSAL_COMPLETE: ${{ inputs.proposal-complete }} + AUDIT_SHA: ${{ inputs.audit-ref || github.sha }} uses: $/node/publish timeout-minutes: 15 diff --git a/README.md b/README.md index 22cc41b..efeae7f 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ workflow updates. Full commit SHA pinning remains available. 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 dependency maintenance | Attempt compatible security fixes, optionally propose routine upgrades, validate the project, and maintain grouped dependency PRs. | [Setup guide](docs/node-maintenance.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 @@ -20,10 +20,17 @@ project scripts. Nothing merges or repairs configuration automatically. 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**; +checks. With the released two-job caller, 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. +For separate security and routine-upgrade PRs, see the opt-in +[coordinated caller](docs/node-coordination.md). It validates dependency-file +pushes and refreshes existing proposals after either PR merges. This new caller +requires a workflow revision containing that interface; existing callers keep +their combined-PR behavior. + | Maintenance setup | Automatic security checks | Automatic routine upgrades | | --- | --- | --- | | Basic setup in the guide | Weekly | Disabled; run upgrades manually | @@ -56,6 +63,7 @@ repositories at a time in one runner, with one combined report and issue. - [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) +- [Separate proposal channels and automatic refresh](docs/node-coordination.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) diff --git a/docs/audit-schedules.md b/docs/audit-schedules.md index 20fad60..591e32b 100644 --- a/docs/audit-schedules.md +++ b/docs/audit-schedules.md @@ -6,8 +6,13 @@ you change cadence.** Shared defaults do not rewrite workflow files. The [basic setup](node-maintenance.md#set-it-up) checks security weekly and leaves routine upgrades manual. The [recommended shared defaults](../defaults/node-maintenance.json) -check security weekly and propose routine upgrades every three months. Both use -integration version 6 and maintain the same dependency PR. +check security weekly and propose routine upgrades every three months. With the +released two-job caller, both kinds maintain the same dependency PR. + +The opt-in [coordinated caller](node-coordination.md) uses these same schedules, +but keeps a separate PR for each channel. Its dependency-file push trigger +validates the default branch and refreshes only existing proposals; it does not +enable extra automatic upgrades. ## Weekly security, quarterly upgrades diff --git a/docs/node-coordination.md b/docs/node-coordination.md new file mode 100644 index 0000000..ee487a8 --- /dev/null +++ b/docs/node-coordination.md @@ -0,0 +1,112 @@ +# Separate security and routine-upgrade proposals + +The coordinated caller keeps at most one open **security** PR and one open +**upgrades** PR. Each groups all qualifying changes for that channel. A security +run cannot overwrite the upgrades proposal. Upgrade runs still attempt security +fixes first, so the two proposals can overlap. + +Both start from the current default branch. Nothing merges automatically. +Existing drafts remain drafts after successful refreshes. + +## Install or adopt the coordinated caller + +Keep the project configuration and prerequisites from the +[setup guide](node-maintenance.md). This is an opt-in caller change; existing +two-job callers retain their combined-PR behavior. + +This interface is not in the currently published `v1` release. Until it is +released, select a reviewed full commit SHA containing +`.github/workflows/node-maintenance.yml`. After a compatible release, `@v1` can +be used. Do not point this new caller at an older release that lacks the workflow. + +1. Merge or close any existing PR from `maintenance/dependency-audit`. Review + that proposal before discarding it. The coordinator refuses to proceed while + a legacy combined proposal is open; it does not silently replace it. +2. Copy [node/coordinated-caller.yml](../node/coordinated-caller.yml) from the + selected revision to `.github/workflows/dependency-audit.yml` in your repository. + Replace `__CENTRAL_REPOSITORY__` with `hydra-ecosystem/github-actions`, + `__CENTRAL_REVISION__` with the selected revision, and `__DEFAULT_BRANCH__` + with your actual default branch, such as `main`. +3. Replace `schedule: []` with the unique entries from your resolved schedule. + For weekly security and quarterly upgrades, use: + + ```yaml + schedule: + - cron: '0 2 * * 1' + - cron: '0 2 1 */3 *' + ``` + + For manual-only routine upgrades, omit the quarterly entry. For no scheduled + work, remove `schedule`. Keep the manual input and push trigger in all cases. +4. Keep the existing PR-creation permission or optional App configuration. + Replace the previous caller rather than installing a second scheduled file. + Keep the `dependency-audit` concurrency group with `cancel-in-progress: false` + and `queue: max` so a new request does not cancel a pending push check. +5. Merge the caller into the default branch. Dispatch **security**, then + **upgrades** when wanted. Inspect the run summaries and the resulting proposals. + A run with no qualifying changes creates no PR. + +The configuration remains `.github/node-maintenance.json`; no new policy fields +or branch-protection settings are required. Both caller styles are accepted by +the fleet checker at a revision supporting this interface. Older fleet-checker +releases must be updated before they can recognize a coordinated caller. + +## What runs and when + +| Trigger | Work | +| --- | --- | +| Security schedule or manual security request | Audit and validate a security proposal; create or update `maintenance/node-security`. | +| Upgrades schedule or manual upgrades request | Audit and validate security fixes plus routine upgrades; create or update `maintenance/node-upgrades`. | +| Matching default-branch push | Validate committed dependencies and refresh whichever channel PRs remain open. Never create a PR from a push refresh. | + +The push trigger watches only these paths: + +```yaml +paths: +- '**/pnpm-lock.yaml' +- '**/package-lock.json' +- '.github/node-maintenance.json' +``` + +Stable lockfile patterns follow a project when its directory changes without +requiring the action to rewrite its own caller. A read-only precheck ignores +lockfile changes outside the selected project. A configuration change always +checks the newly selected project. Unrelated source and documentation pushes do +not trigger maintenance. Other project CI remains responsible for those changes. + +Every relevant push runs a frozen install, complete audit, and `validationScript` +against committed dependencies, even if no proposal remains open. This validation +does not run dependency repairs or propose upgrades. Failures make the Actions +run fail; details and reports remain in its summary and artifacts. Dependency +failures do not create configuration issues or repair `main` automatically. + +## Refreshes and merge order + +If security merges first, the routine proposal is regenerated from the new +default branch. If upgrades merge first, an unneeded security proposal closes. +When a complete, successful run finds no remaining changes in a channel, it +comments and closes that PR while preserving its description. Failed validation +or incomplete proposal resolution cannot close it. + +Runs are serialized, and each run snapshots the latest default branch when its +plan starts. All its jobs use that commit. Artifacts are isolated by channel; +project code runs without publisher credentials. Publication checks the default +branch again and refuses stale results. A refresh also checks the original PR +identity and open state, so closing or merging it during an audit cannot cause +that refresh to create a replacement. + +Pending triggers queue instead of replacing each other. GitHub permits up to +100 pending runs with `queue: max`; beyond that, additional runs are canceled. +Each run checks the latest default branch rather than its older event revision. +A stale run fails rather than claiming its result applies to newer code. Rerun +manually if a newer change did not match the push paths. + +There is no mandatory merge gate: someone can merge both PRs before a refresh +finishes. The push run detects installation or project-check failures afterward; +it cannot prevent broken code from landing. For prevention, optionally configure +required PR CI and require branches to be up to date. Those repository rules +affect all PRs targeting that branch. The maintenance report itself is not a +required PR status check. + +The [legacy caller](../node/caller.yml) remains supported and does not gain push +triggers or separate proposal channels by moving its workflow tag. diff --git a/docs/node-maintenance.md b/docs/node-maintenance.md index da68234..5c4ed7a 100644 --- a/docs/node-maintenance.md +++ b/docs/node-maintenance.md @@ -10,6 +10,10 @@ 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. +For separate security and routine-upgrade proposals with dependency-file-triggered +validation and refresh, use the [coordinated caller](node-coordination.md). +The released two-job caller below retains one combined maintenance PR. + ## Supported projects - One project directory, at the repository root (`"directory": "."`) or in a diff --git a/docs/node-migration.md b/docs/node-migration.md index df88b84..730383f 100644 --- a/docs/node-migration.md +++ b/docs/node-migration.md @@ -1,5 +1,9 @@ # Migrate from Docusaurus maintenance +Already using Node maintenance and want separate security and upgrades PRs? +Use the [coordinated-caller adoption steps](node-coordination.md#install-or-adopt-the-coordinated-caller). +The checklist below covers the earlier Docusaurus interface change. + Integration version 6 replaces the Docusaurus-specific interface with standalone Node maintenance. Use this checklist for an installation pinned to the earlier workflows. New installations should use the [setup guide](node-maintenance.md). diff --git a/docs/workflow-releases.md b/docs/workflow-releases.md index 89f8d96..3419bd6 100644 --- a/docs/workflow-releases.md +++ b/docs/workflow-releases.md @@ -12,6 +12,12 @@ configuration use a new major tag, such as `v2`, and require a local adoption PR Defaults files and local schedules are separate; a workflow release does not rewrite them. +The [coordinated caller](node-coordination.md) adds an opt-in interface while +retaining the existing audit/publisher calls. Its push triggers and separate +proposal channels require a caller adoption PR; advancing `v1` alone does not +install them. Release the additive interface before installing a caller that +references it through `@v1`. + For immutable code, replace both `@v1` references with the same reviewed full 40-character commit SHA. This gives you control over each code update and requires an installation PR to adopt each fix. Read `node/contract.json` and diff --git a/node/audit/action.yml b/node/audit/action.yml index 87e5149..d39027e 100644 --- a/node/audit/action.yml +++ b/node/audit/action.yml @@ -4,10 +4,25 @@ inputs: audit-kind: description: auto, security, or upgrades (manual dispatch only). default: auto + artifact-prefix: + description: Isolate artifacts for coordinated jobs; empty retains the legacy names. + default: '' + refresh: + description: Select an existing proposal channel on a default-branch push. + default: 'false' + operation: + description: propose or validate (read-only dependency validation). + default: propose + proposal-kind: + description: Verify resolved policy agrees with the selected channel. + default: legacy outputs: validation_passed: description: Whether frozen installation and project checks passed. value: ${{ steps.proposal.outputs.validation_passed }} + proposal_complete: + description: Whether proposal resolution and audit evidence completed. + value: ${{ steps.proposal.outputs.proposal_complete }} runs: using: composite steps: @@ -20,11 +35,12 @@ runs: - name: Read project configuration env: AUDIT_KIND: ${{ inputs.audit-kind }} - run: >- - python "$GITHUB_ACTION_PATH/../runtime/project.py" --github-env "$GITHUB_ENV" - --resolved-config "$RUNNER_TEMP/node-audit-resolved-config.json" - --configuration-report "$RUNNER_TEMP/node-audit-configuration.json" - --audit-kind "$AUDIT_KIND" + REFRESH: ${{ inputs.refresh }} + run: | + set -euo pipefail + refresh_args=() + if [ "${REFRESH:-false}" = true ]; then refresh_args=(--refresh); fi + python "$GITHUB_ACTION_PATH/../runtime/project.py" --github-env "$GITHUB_ENV" --resolved-config "$RUNNER_TEMP/node-audit-resolved-config.json" --configuration-report "$RUNNER_TEMP/node-audit-configuration.json" --audit-kind "$AUDIT_KIND" "${refresh_args[@]}" shell: bash - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 with: @@ -44,32 +60,45 @@ runs: - name: Verify the declared toolchain before dependency changes env: AUDIT_KIND: ${{ inputs.audit-kind }} - run: >- - python "$GITHUB_ACTION_PATH/../runtime/project.py" --verify-runtime - --resolved-config "$RUNNER_TEMP/node-audit-resolved-config.json" - --configuration-report "$RUNNER_TEMP/node-audit-configuration.json" - --audit-kind "$AUDIT_KIND" + REFRESH: ${{ inputs.refresh }} + run: | + set -euo pipefail + refresh_args=() + if [ "${REFRESH:-false}" = true ]; then refresh_args=(--refresh); fi + python "$GITHUB_ACTION_PATH/../runtime/project.py" --verify-runtime --resolved-config "$RUNNER_TEMP/node-audit-resolved-config.json" --configuration-report "$RUNNER_TEMP/node-audit-configuration.json" --audit-kind "$AUDIT_KIND" "${refresh_args[@]}" shell: bash - name: Retain configuration status even when preflight fails if: ${{ always() && !cancelled() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: - name: node-audit-configuration + name: ${{ inputs.artifact-prefix || 'node-audit' }}-configuration if-no-files-found: ignore path: ${{ runner.temp }}/node-audit-configuration.json + - name: Verify the selected proposal channel + env: + PROPOSAL_KIND: ${{ inputs.proposal-kind }} + MODE: ${{ env.NODE_AUDIT_MAINTENANCE_MODE }} + run: | + set -euo pipefail + case "$PROPOSAL_KIND:$MODE" in + legacy:*|main:*|security:security-only|upgrades:security-and-maintenance) ;; + *) echo "::error::Resolved policy differs from the planned proposal channel; rerun maintenance."; exit 1 ;; + esac + shell: bash - name: Audit, propose upgrades, and validate id: proposal - run: 'python "$GITHUB_ACTION_PATH/../runtime/templates/dependency_audit.py" --directory - "$NPM_PROJECT" --report "$RUNNER_TEMP/dependency-audit-pr.md" --github-output - "$GITHUB_OUTPUT" - --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" - --resolved-config "$RUNNER_TEMP/node-audit-resolved-config.json" - --local-patches-report "$RUNNER_TEMP/node-audit-local-patches.json" - - ' + run: | + set -euo pipefail + operation_args=() + case "${OPERATION:-propose}" in + propose) ;; + validate) operation_args=(--validate-only) ;; + *) echo "::error::Unknown maintenance operation"; exit 1 ;; + esac + python "$GITHUB_ACTION_PATH/../runtime/templates/dependency_audit.py" --directory "$NPM_PROJECT" --report "$RUNNER_TEMP/dependency-audit-pr.md" --github-output "$GITHUB_OUTPUT" --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" --resolved-config "$RUNNER_TEMP/node-audit-resolved-config.json" --local-patches-report "$RUNNER_TEMP/node-audit-local-patches.json" "${operation_args[@]}" shell: bash + env: + OPERATION: ${{ inputs.operation }} - name: Retain the audit summary even when the audit fails if: ${{ always() && !cancelled() }} run: | @@ -79,27 +108,29 @@ runs: shell: bash - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: - name: dependency-audit-dependencies + name: ${{ inputs.artifact-prefix || 'dependency-audit' }}-dependencies if-no-files-found: error - path: '${{ env.NPM_PROJECT }}/package.json - + path: | + ${{ env.NPM_PROJECT }}/package.json ${{ env.NPM_PROJECT }}/package-lock.json - ${{ env.NPM_PROJECT }}/pnpm-lock.yaml - ${{ env.NPM_PROJECT }}/pnpm-workspace.yaml - - ' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a if: ${{ always() && !cancelled() }} with: - name: dependency-audit-report + name: ${{ inputs.artifact-prefix || 'dependency-audit' }}-report 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 + name: ${{ inputs.artifact-prefix || 'node-audit' }}-local-patches if-no-files-found: ignore path: ${{ runner.temp }}/node-audit-local-patches.json + - name: Fail committed-state validation when project checks fail + if: ${{ inputs.operation == 'validate' && steps.proposal.outputs.validation_passed != 'true' }} + run: | + echo "::error::Default-branch validation failed; see the retained report." + exit 1 + shell: bash diff --git a/node/contract.json b/node/contract.json index 3dc8d0e..39268d6 100644 --- a/node/contract.json +++ b/node/contract.json @@ -80,5 +80,21 @@ "old_script_option": "buildScript", "compatibility_layer": false }, - "cron_list_maintenance_mode_default": "security-and-maintenance" + "cron_list_maintenance_mode_default": "security-and-maintenance", + "coordinated_maintenance": { + "caller_template": "node/coordinated-caller.yml", + "workflow": ".github/workflows/node-maintenance.yml", + "branches": { + "security": "maintenance/node-security", + "upgrades": "maintenance/node-upgrades" + }, + "push_paths": [ + "**/pnpm-lock.yaml", + "**/package-lock.json", + ".github/node-maintenance.json" + ], + "default_branch_placeholder": "__DEFAULT_BRANCH__", + "refresh_creates_prs": false, + "legacy_caller_supported": true + } } diff --git a/node/coordinated-caller.yml b/node/coordinated-caller.yml new file mode 100644 index 0000000..04664e6 --- /dev/null +++ b/node/coordinated-caller.yml @@ -0,0 +1,41 @@ +name: Node dependency maintenance + +'on': + schedule: [] # Rendered from the consumer project configuration. + workflow_dispatch: + inputs: + audit-kind: + description: Security fixes only, or security plus routine upgrades for this run. + type: choice + default: security + options: + - security + - upgrades + push: + branches: + - __DEFAULT_BRANCH__ + paths: + - '**/pnpm-lock.yaml' + - '**/package-lock.json' + - '.github/node-maintenance.json' + +permissions: + contents: read + +concurrency: + group: dependency-audit + cancel-in-progress: false + queue: max + +jobs: + maintenance: + permissions: + contents: write + pull-requests: write + issues: write + uses: __CENTRAL_REPOSITORY__/.github/workflows/node-maintenance.yml@__CENTRAL_REVISION__ + with: + audit-kind: ${{ inputs.audit-kind || 'auto' }} + app-client-id: ${{ vars.NODE_AUDIT_APP_CLIENT_ID }} + secrets: + app-private-key: ${{ secrets.NODE_AUDIT_APP_PRIVATE_KEY }} diff --git a/node/maintenance.md b/node/maintenance.md index 8abbe87..bd3d5c7 100644 --- a/node/maintenance.md +++ b/node/maintenance.md @@ -69,6 +69,10 @@ versions are not downgraded just because they are younger than the cutoff. ## Reports and PRs +This section describes the legacy two-job caller. The opt-in +[coordinated caller](../docs/node-coordination.md) uses separate security and +upgrade branches and can close obsolete proposals after successful refreshes. + All audit kinds use the same `maintenance/dependency-audit` branch and open PR. Only the project manifest, lockfile, and required pnpm security exceptions enter that PR. Reports remain in the PR body, run summary, and artifacts; they are not @@ -155,7 +159,8 @@ failures do not create configuration issues. No automatic repair PR is generated ## Publication and credentials The dependency-execution job has only `contents: read` and checkout credentials -are not persisted. A separate serialized reporter has only `issues: write`, +are not persisted. A separate serialized reporter has `issues: write` and +`contents: read` to reject stale coordinated reports, reads the current run's configuration artifact, and never checks out project code. The publisher has Contents and Pull requests write access. Only it can push the maintenance branch and open the PR. diff --git a/node/plan/action.yml b/node/plan/action.yml new file mode 100644 index 0000000..4e035fa --- /dev/null +++ b/node/plan/action.yml @@ -0,0 +1,47 @@ +name: Plan Node maintenance +description: Select proposal channels and snapshot the default-branch revision without running project code. +inputs: + audit-kind: + default: auto + description: Select the configured cadence or one manual channel. +outputs: + validate_main: + value: ${{ steps.plan.outputs.validate_main }} + description: Whether to validate committed dependencies. + sha: + value: ${{ steps.plan.outputs.sha }} + description: Observed default-branch commit. + security: + value: ${{ steps.plan.outputs.security }} + description: Whether to audit the security channel. + upgrades: + value: ${{ steps.plan.outputs.upgrades }} + description: Whether to audit the upgrades channel. + security_pr: + value: ${{ steps.plan.outputs.security_pr }} + description: Original open security PR number, if any. + upgrades_pr: + value: ${{ steps.plan.outputs.upgrades_pr }} + description: Original open upgrades PR number, if any. + refresh_only: + value: ${{ steps.plan.outputs.refresh_only }} + description: Whether publication must only update existing proposals. +runs: + using: composite + steps: + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + with: + python-version: '3.13' + - run: python -m pip install PyYAML==6.0.3 + shell: bash + - id: plan + env: + AUDIT_KIND: ${{ inputs.audit-kind }} + run: python "$GITHUB_ACTION_PATH/../runtime/coordination.py" --github-output "$GITHUB_OUTPUT" --configuration-report "$RUNNER_TEMP/node-audit-configuration.json" + shell: bash + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + if: ${{ always() && !cancelled() }} + with: + name: node-plan-configuration + if-no-files-found: ignore + path: ${{ runner.temp }}/node-audit-configuration.json diff --git a/node/publish/publish.sh b/node/publish/publish.sh index f0df50b..0183054 100644 --- a/node/publish/publish.sh +++ b/node/publish/publish.sh @@ -1,4 +1,33 @@ set -euo pipefail +PROPOSAL_KIND=${PROPOSAL_KIND:-legacy} +REFRESH_ONLY=${REFRESH_ONLY:-false} +case "$PROPOSAL_KIND" in + legacy) ;; + security) PR_BRANCH=maintenance/node-security ;; + upgrades) PR_BRANCH=maintenance/node-upgrades ;; + *) echo '::error::Unknown proposal kind'; exit 1 ;; +esac +current_base() { + if [ "$PROPOSAL_KIND" != legacy ]; then + git fetch --no-tags origin "$BASE_BRANCH:refs/remotes/origin/$BASE_BRANCH" + if [ "$(git rev-parse "refs/remotes/origin/$BASE_BRANCH")" != "$AUDIT_SHA" ]; then + echo '::error::Default branch advanced; discard this stale result and run again.' >&2 + return 1 + fi + fi +} +still_open() { + local state + state=$(gh pr view "$pr_number" --json state --jq .state) || { + echo '::error::Unable to verify proposal state; refusing publication.' >&2 + exit 1 + } + case "$state" in + OPEN) return 0 ;; + CLOSED|MERGED) return 1 ;; + *) echo '::error::Unrecognized proposal state; refusing publication.' >&2; exit 1 ;; + esac +} validation_status() { if [ "$VALIDATION_PASSED" != true ]; then echo '::error::Candidate frozen installation or project checks failed; see the retained audit report.' @@ -37,11 +66,19 @@ proposal_title() { } git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' +current_base expected_head=$(git rev-parse --verify "refs/remotes/origin/$PR_BRANCH" 2>/dev/null || true) body_file="$RUNNER_TEMP/dependency-audit-pr.md" pr_title=$(proposal_title "$body_file") +if [ "$PROPOSAL_KIND" != legacy ]; then + pr_title="${pr_title/Node:/Node $PROPOSAL_KIND:}" +fi printf '\n%s\n' "$PR_FOOTER" >> "$body_file" pr_number=$(gh pr list --state open --head "$PR_BRANCH" --json number,isCrossRepository --jq '[.[] | select(.isCrossRepository == false)][0].number // empty') +if [ "$REFRESH_ONLY" = true ] && { [ -z "${EXPECTED_PR:-}" ] || [ "$pr_number" != "$EXPECTED_PR" ]; }; then + echo 'The original proposal is no longer open; refresh will not create or replace a PR.' + exit 0 +fi git checkout -B "$PR_BRANCH" git add -- "$NPM_PROJECT/package.json" for file in package-lock.json pnpm-lock.yaml pnpm-workspace.yaml; do @@ -50,6 +87,14 @@ for file in package-lock.json pnpm-lock.yaml pnpm-workspace.yaml; do fi done if git diff --cached --quiet; then + if [ "$PROPOSAL_KIND" != legacy ] && [ -n "$pr_number" ] && [ "$VALIDATION_PASSED" = true ] && [ "${PROPOSAL_COMPLETE:-false}" = true ]; then + current_base + if still_open; then + gh pr comment "$pr_number" --body "No dependency changes remain in this channel after auditing and validating the current default branch. Closing this proposal. $PR_FOOTER" + gh pr close "$pr_number" + fi + exit 0 + fi echo 'No dependency changes; existing PR branch and report retained. See the Actions run summary for this audit report.' validation_status exit 0 @@ -61,16 +106,20 @@ else fi fi if [ -n "$pr_number" ] && [ -n "$expected_head" ]; then - if git diff --cached --quiet "$expected_head" -- .; then + if git diff --cached --quiet "$expected_head" -- . && { [ "$PROPOSAL_KIND" = legacy ] || git merge-base --is-ancestor "$AUDIT_SHA" "$expected_head"; }; then echo 'The open dependency PR already contains this proposal; no dependency push needed. See the current run summary for the audit report.' pr_base=$(gh pr view "$pr_number" --json baseRefName --jq .baseRefName) existing_body="$RUNNER_TEMP/dependency-audit-existing-pr.md" gh pr view "$pr_number" --json body --jq .body > "$existing_body" if [ "$VALIDATION_PASSED" != true ] || [ "$pr_base" != "$BASE_BRANCH" ] || ! diff -q <(report_content "$existing_body") <(report_content "$body_file") >/dev/null; then + current_base + if [ "$PROPOSAL_KIND" != legacy ] && ! still_open; then exit 0; fi gh pr edit "$pr_number" --base "$BASE_BRANCH" --title "$pr_title" --body-file "$body_file" else existing_title=$(gh pr view "$pr_number" --json title --jq .title) if [ "$existing_title" != "$pr_title" ]; then + current_base + if [ "$PROPOSAL_KIND" != legacy ] && ! still_open; then exit 0; fi gh pr edit "$pr_number" --title "$pr_title" fi fi @@ -85,9 +134,15 @@ else fi fi git commit -m 'Node dependency audit and upgrades' + current_base + if [ "$PROPOSAL_KIND" != legacy ] && [ -n "$pr_number" ] && ! still_open; then + echo 'The proposal was closed or merged during the audit; no branch update published.' + exit 0 + fi git push --force-with-lease="refs/heads/$PR_BRANCH:$expected_head" origin "HEAD:refs/heads/$PR_BRANCH" fi if [ -n "$pr_number" ]; then + if [ "$PROPOSAL_KIND" != legacy ] && ! still_open; then exit 0; fi gh pr edit "$pr_number" --base "$BASE_BRANCH" --title "$pr_title" --body-file "$body_file" else draft_args=() diff --git a/node/runtime/coordination.py b/node/runtime/coordination.py new file mode 100644 index 0000000..c1a4d72 --- /dev/null +++ b/node/runtime/coordination.py @@ -0,0 +1,155 @@ +"""Plan independent proposals from one observed default-branch revision.""" + +import argparse +import json +import os +import runpy +import subprocess +from pathlib import Path + +PROJECT = runpy.run_path(str(Path(__file__).with_name("project.py"))) +BRANCHES = { + "security": "maintenance/node-security", + "upgrades": "maintenance/node-upgrades", +} +PUSH_PATHS = [ + "**/pnpm-lock.yaml", + "**/package-lock.json", + ".github/node-maintenance.json", +] + + +def plan( + root, + config, + selected, + event_name, + event, + requested, + pull_requests, + *, + changed_paths=None, + directory=".", +): + if event_name not in ("push", "schedule", "workflow_dispatch"): + raise ValueError("Unsupported maintenance event") + default = event["repository"]["default_branch"] + if event_name != "schedule" and event.get("ref") != f"refs/heads/{default}": + raise ValueError("Maintenance must target the default branch") + mode = PROJECT["SCHEDULING"]["select_mode"]( + root, config, selected["maintenance"]["mode"], requested, event_name, event + ) + relevant = { + ".github/node-maintenance.json", + *[ + (Path(directory) / name).as_posix() + for name in ("pnpm-lock.yaml", "package-lock.json") + ], + } + validate_main = event_name == "push" and ( + changed_paths is None or bool(relevant.intersection(changed_paths)) + ) + result = {"refresh_only": event_name == "push", "validate_main": validate_main} + for kind, branch in BRANCHES.items(): + matches = [ + pr + for pr in pull_requests + if pr["headRefName"] == branch and not pr["isCrossRepository"] + ] + if len(matches) > 1: + raise ValueError( + f"Multiple open proposals on {branch}; reconcile them first" + ) + result[kind + "_pr"] = str(matches[0]["number"]) if matches else "" + result[kind] = ( + bool(matches) and validate_main + if event_name == "push" + else ((kind == "security") == (mode == "security-only")) + ) + # The old combined branch needs an explicit disposition before adoption. + if any( + pr["headRefName"] == "maintenance/dependency-audit" + and not pr["isCrossRepository"] + for pr in pull_requests + ): + raise ValueError( + "Merge or close the legacy maintenance/dependency-audit PR before adopting coordinated maintenance" + ) + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--github-output", type=Path, required=True) + parser.add_argument("--configuration-report", type=Path) + args = parser.parse_args() + root = Path.cwd().resolve() + sources = [] + selected = None + try: + config = PROJECT["CONFIGURATION"]["load_configuration"](root, sources) + directory, selected = PROJECT["project"](root, config=config) + event = PROJECT["SCHEDULING"]["event_payload"](os.environ["GITHUB_EVENT_PATH"]) + event["ref"] = os.environ.get("GITHUB_REF", event.get("ref")) + repo = event["repository"]["full_name"] + # Do not execute project code in this credentialed planning job. + prs = json.loads( + subprocess.check_output( + [ + "gh", + "pr", + "list", + "--repo", + repo, + "--state", + "open", + "--limit", + "1000", + "--json", + "number,headRefName,isCrossRepository", + ], + text=True, + ) + ) + before = event.get("before") + changed_paths = None + if os.environ["GITHUB_EVENT_NAME"] == "push" and before and before != "0" * 40: + changed_paths = subprocess.check_output( + [ + "git", + "diff", + "--name-only", + "-z", + before, + "HEAD", + ], + text=True, + ).split("\0") + result = plan( + root, + config, + selected, + os.environ["GITHUB_EVENT_NAME"], + event, + os.environ.get("AUDIT_KIND", "auto"), + prs, + changed_paths=changed_paths, + directory=directory.relative_to(root).as_posix(), + ) + result["sha"] = subprocess.check_output( + ["git", "rev-parse", "HEAD"], text=True + ).strip() + except (OSError, ValueError, KeyError, subprocess.SubprocessError) as error: + PROJECT["write_configuration_report"]( + args.configuration_report, "error", str(error), sources, selected + ) + parser.exit(2, str(error) + "\n") + with args.github_output.open("a") as output: + for key, value in result.items(): + output.write( + f"{key}={str(value).lower() if isinstance(value, bool) else value}\n" + ) + + +if __name__ == "__main__": + main() diff --git a/node/runtime/fleet.py b/node/runtime/fleet.py index ccae869..fa690e8 100644 --- a/node/runtime/fleet.py +++ b/node/runtime/fleet.py @@ -375,7 +375,7 @@ def _validate_call_interface(job, call, path): ) -def check_caller(root, config, selected, github): +def check_caller(root, config, selected, github, default_branch="main"): workflow = yaml.safe_load((root / CALLER_PATH).read_bytes()) if not isinstance(workflow, dict): raise ValueError("Caller must be a workflow mapping") @@ -399,46 +399,95 @@ def check_caller(root, config, selected, github): 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@({WORKFLOW_REF})" - 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 use matching Node workflows at the same major release tag (such as v1) or 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" - ) + coordinated = "maintenance" in jobs + if coordinated: + job = jobs["maintenance"] + pattern = rf"([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)/\.github/workflows/node-maintenance\.yml@({WORKFLOW_REF})" + match = re.fullmatch(pattern, job.get("uses", "")) + if set(jobs) != {"maintenance"} or not match or "if" in job or "needs" in job: + raise ValueError( + "Coordinated caller requires one unconditional maintenance job" + ) + if job.get("permissions") != { + "contents": "write", + "pull-requests": "write", + "issues": "write", + }: + raise ValueError( + "Coordinated caller permissions differ from the supported contract" + ) + if ( + job.get("with", {}).get("audit-kind") + != "${{ inputs.audit-kind || 'auto' }}" + ): + raise ValueError("Coordinated caller must forward audit-kind") + expected_push = { + "branches": [default_branch], + "paths": [ + "**/pnpm-lock.yaml", + "**/package-lock.json", + ".github/node-maintenance.json", + ], + } + if events.get("push") != expected_push: + raise ValueError( + "Coordinated caller requires the default-branch dependency path filters" + ) + calls = [(job, ".github/workflows/node-maintenance.yml")] + if workflow.get("concurrency") != { + "group": "dependency-audit", + "cancel-in-progress": False, + "queue": "max", + }: + raise ValueError( + "Coordinated caller must retain pending runs with queue: max" + ) + else: + 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@({WORKFLOW_REF})" + 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 use matching Node workflows at the same major release tag (such as v1) or 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" + ) + calls = [ + (audit, ".github/workflows/node-audit.yml"), + (publisher, ".github/workflows/node-publish.yml"), + ] concurrency = workflow.get("concurrency", {}) if ( not isinstance(concurrency, dict) @@ -473,10 +522,13 @@ def check_caller(root, config, selected, github): 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"), + if ( + coordinated + and contract.get("coordinated_maintenance", {}).get("workflow") + != ".github/workflows/node-maintenance.yml" ): + raise ValueError("Pinned workflows do not support coordinated maintenance") + for job, path in calls: reusable = yaml.safe_load(source.read(path)) triggers = ( reusable.get("on", reusable.get(True, {})) @@ -550,7 +602,7 @@ def check_repository(github, name, expected): directory, selected = PROJECT["project"](root, config=config) PROJECT["verify_package_files"](directory, selected) check_engines(directory, selected) - check_caller(root, config, selected, github) + check_caller(root, config, selected, github, repository.ref) state = github.get( f"repos/{name}/actions/workflows/{PurePosixPath(CALLER_PATH).name}" ) diff --git a/node/runtime/project.py b/node/runtime/project.py index 30fd795..ccbb7ae 100644 --- a/node/runtime/project.py +++ b/node/runtime/project.py @@ -533,6 +533,7 @@ def main() -> None: parser.add_argument("--resolved-config", type=Path) parser.add_argument("--configuration-report", type=Path) parser.add_argument("--audit-kind", choices=("auto", "security", "upgrades")) + parser.add_argument("--refresh", action="store_true") args = parser.parse_args() sources = [] selected = None @@ -563,6 +564,7 @@ def main() -> None: args.audit_kind, os.environ.get("GITHUB_EVENT_NAME", ""), SCHEDULING["event_payload"](os.environ.get("GITHUB_EVENT_PATH")), + refresh=args.refresh, ) if args.verify_runtime: verify_runtime(directory, selected) diff --git a/node/runtime/scheduling.py b/node/runtime/scheduling.py index 29b3ab1..9c90a98 100644 --- a/node/runtime/scheduling.py +++ b/node/runtime/scheduling.py @@ -44,7 +44,9 @@ def cron_entries(schedule): ) -def select_mode(root, config, configured_mode, requested, event_name, event): +def select_mode( + root, config, configured_mode, requested, event_name, event, *, refresh=False +): if requested not in ("auto", "security", "upgrades"): raise ValueError("audit-kind must be auto, security or upgrades") schedule = config.get("schedule") @@ -83,7 +85,9 @@ def select_mode(root, config, configured_mode, requested, event_name, event): raise ValueError( "schedule.upgrades requires maintenance.mode=security-and-maintenance; use an empty upgrades list for manual-only upgrades" ) - if event_name == "workflow_dispatch" and requested != "auto": + if refresh and event_name != "push": + raise ValueError("Proposal refresh is supported only for default-branch pushes") + if (event_name == "workflow_dispatch" or refresh) and requested != "auto": return ( "security-only" if requested == "security" else "security-and-maintenance" ) diff --git a/node/runtime/templates/dependency_audit.py b/node/runtime/templates/dependency_audit.py index 8b1c563..9024c10 100644 --- a/node/runtime/templates/dependency_audit.py +++ b/node/runtime/templates/dependency_audit.py @@ -2926,6 +2926,7 @@ def prepare( maintenance_mode="security-and-maintenance", upgrade_scope="major", local_patches=None, + validate_only=False, ) -> str: # Leave time in the 60-minute job to write and upload a partial report. deadline = time.monotonic() + 45 * 60 @@ -2985,11 +2986,13 @@ def prepare( changes = [] routine_targets = set() cache: dict = {} + proposal_complete = True # Give compatible security fixes the first use of the shared time budget. fixed = ( subprocess.CompletedProcess([], 0, "", "") - if maintenance_mode == "security-only" + if validate_only + or maintenance_mode == "security-only" and isinstance(before, dict) and not affected else fix_security( @@ -3001,10 +3004,12 @@ def prepare( release_before=release_before, ) ) - restore_invalid_state( + if restore_invalid_state( directory, last_valid_snapshot, notes, "Initial security fix", security_fix=True - ) + ): + proposal_complete = False if fixed.returncode: + proposal_complete = False notes.append( f"Initial security fix returned exit {fixed.returncode}; remaining findings " "or a tool error require manual review." @@ -3033,6 +3038,7 @@ def prepare( source=package_manager, ) if not confirmation or direct_violation: + proposal_complete = False restore_snapshot(directory, initial_snapshot) security_snapshot = initial_snapshot notes.append( @@ -3045,7 +3051,7 @@ def prepare( + " Routine upgrades require an upgrades run." ) last_valid_snapshot = security_snapshot - if maintenance_mode == "security-and-maintenance": + if maintenance_mode == "security-and-maintenance" and not validate_only: manifest = json.loads(security_snapshot[0]) # Preserve overrides and peer constraints for a maintainer to review. @@ -3109,6 +3115,7 @@ def prepare( or not isinstance(latest, str) or not re.fullmatch(r"\d+\.\d+\.\d+", latest) ): + proposal_complete = False notes.append(f"Could not look up {name}; retained `{current}`.") continue if latest != current.lstrip("~^") and ( @@ -3130,6 +3137,7 @@ def prepare( ), ) if latest is None: + proposal_complete = False notes.append( f"No eligible stable release verified for {name}; retained `{current}`." ) @@ -3173,6 +3181,7 @@ def prepare( f"Rejected routine proposal: maintenance.upgradeScope={upgrade_scope}; {violation}. Restored accepted security fixes." ) if lock.returncode or invalid_stable_state: + proposal_complete = False restore_snapshot(directory, security_snapshot) notes.append( f"Stable upgrade resolution failed (exit {lock.returncode}); restored " @@ -3205,14 +3214,16 @@ def prepare( before=stable_audit, release_before=release_before, ) - restore_invalid_state( + if restore_invalid_state( directory, stable_snapshot, notes, "Follow-up security fix", security_fix=True, - ) + ): + proposal_complete = False if fixed.returncode: + proposal_complete = False notes.append( f"Automatic audit fix returned exit {fixed.returncode}; remaining findings " "or a tool error require manual review." @@ -3287,6 +3298,7 @@ def prepare( ) ): restore_snapshot(directory, initial_snapshot) + proposal_complete = False after_snapshot = initial_snapshot after = audit(directory, deadline=deadline) if restore_invalid_state( @@ -3382,6 +3394,7 @@ def published_patch_versions(specifier, *, all_versions=False): if validation is not None: validation["passed"] = check_status == "Passed." validation["audit_errors"] = audit_errors + validation["proposal_complete"] = proposal_complete and not audit_errors validation["local_patches"] = patch_report routine_rows = routine_upgrade_rows( initial_snapshot, security_snapshot, after_snapshot, routine_targets @@ -3666,6 +3679,12 @@ def published_patch_versions(specifier, *, all_versions=False): ) + f"### Candidate dependency files (SHA-256)\n\n{fingerprints}\n\n\n" ) + if validate_only: + report = ( + f"**Committed dependency validation · {'Passed' if check_status == 'Passed.' else 'Failed'}**\n\n" + "Checked committed dependencies without attempting repairs or routine upgrades.\n\n" + + report.partition("\n")[2] + ) return report @@ -3692,6 +3711,7 @@ def main() -> None: ) parser.add_argument("--resolved-config", type=Path) parser.add_argument("--local-patches-report", type=Path) + parser.add_argument("--validate-only", action="store_true") args = parser.parse_args() directory = Path(args.directory) if ( @@ -3726,6 +3746,7 @@ def main() -> None: maintenance_mode=args.maintenance_mode, upgrade_scope=args.upgrade_scope, local_patches=patches, + validate_only=args.validate_only, ) except (OSError, ValueError) as error: parser.error(str(error)) @@ -3744,6 +3765,9 @@ def main() -> None: if args.github_output is not None: with args.github_output.open("a") as output: output.write(f"validation_passed={str(validation['passed']).lower()}\n") + output.write( + f"proposal_complete={str(validation['proposal_complete']).lower()}\n" + ) print(report) if validation["audit_errors"]: print( diff --git a/node/tests/test_configuration_issue.py b/node/tests/test_configuration_issue.py index 8aad4f3..83a4177 100644 --- a/node/tests/test_configuration_issue.py +++ b/node/tests/test_configuration_issue.py @@ -219,11 +219,12 @@ def test_reporting_job_is_serialized_and_has_no_consumer_checkout_or_app_key(): audit = workflow["jobs"]["audit"] issue = workflow["jobs"]["configuration-issue"] assert audit["permissions"] == {"contents": "read"} - assert issue["permissions"] == {"issues": "write"} + assert issue["permissions"] == {"issues": "write", "contents": "read"} assert issue["needs"] == "audit" and "always()" in issue["if"] assert issue["concurrency"] == { "group": "node-audit-configuration-issue-${{ github.repository }}", "cancel-in-progress": False, + "queue": "max", } assert not any("checkout@" in step.get("uses", "") for step in issue["steps"]) assert "secrets." not in json.dumps(issue) @@ -233,7 +234,8 @@ def test_reporting_job_is_serialized_and_has_no_consumer_checkout_or_app_key(): uploads = [ step for step in steps - if step.get("with", {}).get("name") == "node-audit-configuration" + if step.get("with", {}).get("name") + == "${{ inputs.artifact-prefix || 'node-audit' }}-configuration" ] assert len(uploads) == 1 and "always()" in uploads[0]["if"] upload_index = steps.index(uploads[0]) diff --git a/node/tests/test_coordination.py b/node/tests/test_coordination.py new file mode 100644 index 0000000..7c5be18 --- /dev/null +++ b/node/tests/test_coordination.py @@ -0,0 +1,381 @@ +"""Exercise channel selection, main validation, and real branch lifecycles.""" + +import json +import os +import runpy +import subprocess +import sys +from pathlib import Path + +import pytest +import yaml +from test_audit_schedules import setup +from test_node_standard import audit_module, fake_npm +from test_node_standard import npm_project as npm_project +from test_node_standard import pnpm_project as pnpm_project + +ROOT = Path(__file__).parents[2] +COORDINATION = runpy.run_path(str(ROOT / "node/runtime/coordination.py")) +BRANCHES = COORDINATION["BRANCHES"] + + +@pytest.mark.parametrize( + "security,upgrades", [(False, False), (True, False), (False, True), (True, True)] +) +@pytest.mark.parametrize("changed", [True, False]) +def test_push_validates_main_and_refreshes_only_relevant_open_channels( + tmp_path, security, upgrades, changed +): + config = setup( + tmp_path, {"security": ["0 2 * * 1"], "upgrades": []}, "security-only" + ) + prs = [ + {"number": i + 1, "headRefName": BRANCHES[kind], "isCrossRepository": False} + for i, (kind, enabled) in enumerate( + (("security", security), ("upgrades", upgrades)) + ) + if enabled + ] + result = COORDINATION["plan"]( + tmp_path, + config, + {"maintenance": {"mode": "security-only"}}, + "push", + {"repository": {"default_branch": "main"}, "ref": "refs/heads/main"}, + "auto", + prs, + changed_paths=["app/pnpm-lock.yaml" if changed else "other/pnpm-lock.yaml"], + directory="app", + ) + assert result["validate_main"] == changed + assert result["security"] == (security and changed) + assert result["upgrades"] == (upgrades and changed) + assert result["refresh_only"] is True + + +@pytest.mark.parametrize("kind", ["security", "upgrades"]) +def test_manual_requests_create_only_their_own_channel(tmp_path, kind): + config = setup(tmp_path, {"security": [], "upgrades": []}, "security-only") + result = COORDINATION["plan"]( + tmp_path, + config, + {"maintenance": {"mode": "security-only"}}, + "workflow_dispatch", + {"repository": {"default_branch": "trunk"}, "ref": "refs/heads/trunk"}, + kind, + [], + ) + assert result[kind] is True + assert result["upgrades" if kind == "security" else "security"] is False + assert not result["refresh_only"] and not result["validate_main"] + + +def test_adoption_rejects_an_unresolved_legacy_proposal(tmp_path): + config = setup(tmp_path, {"security": [], "upgrades": []}) + with pytest.raises(ValueError, match="Merge or close the legacy"): + COORDINATION["plan"]( + tmp_path, + config, + {"maintenance": {"mode": "security-and-maintenance"}}, + "push", + {"repository": {"default_branch": "main"}, "ref": "refs/heads/main"}, + "auto", + [ + { + "number": 1, + "headRefName": "maintenance/dependency-audit", + "isCrossRepository": False, + } + ], + ) + + +@pytest.mark.parametrize( + "kind,mode", + [("security", "security-only"), ("upgrades", "security-and-maintenance")], +) +@pytest.mark.parametrize("drifted", [False, True]) +def test_resolved_policy_cannot_publish_into_the_wrong_channel( + tmp_path, kind, mode, drifted +): + action = yaml.safe_load((ROOT / "node/audit/action.yml").read_text()) + step = next( + step + for step in action["runs"]["steps"] + if step.get("name") == "Verify the selected proposal channel" + ) + if drifted: + mode = ( + "security-and-maintenance" if mode == "security-only" else "security-only" + ) + result = subprocess.run( + ["bash", "-c", step["run"]], + env={**os.environ, "PROPOSAL_KIND": kind, "MODE": mode}, + capture_output=True, + ) + assert result.returncode == int(drifted) + + +@pytest.mark.parametrize("manager", ["npm", "pnpm"]) +@pytest.mark.parametrize("failed", [False, True]) +def test_validate_only_checks_committed_files_without_attempting_repairs( + request, monkeypatch, manager, failed +): + directory = request.getfixturevalue(manager + "_project") + fake_npm(monkeypatch) + module = audit_module["prepare"].__globals__ + before = audit_module["valid_snapshot"](directory) + monkeypatch.setitem( + module, + "fix_security", + lambda *a, **kw: pytest.fail("Read-only validation attempted a repair"), + ) + commands = [] + + def run(path, *args, **kwargs): + commands.append(args) + assert args[0] in ("ci", "install", "run", "view") + if args[0] == "install": + assert "--frozen-lockfile" in args + return subprocess.CompletedProcess( + args, int(failed and args[0] == "run"), '"1.0.0"', "" + ) + + monkeypatch.setitem(module, "run", run) + monkeypatch.setitem( + module, + "audit", + lambda *a, **kw: { + "vulnerabilities": {}, + "metadata": {"vulnerabilities": {"total": 0}}, + }, + ) + validation = {} + audit_module["prepare"](directory, validation=validation, validate_only=True) + assert validation["passed"] is not failed + assert audit_module["valid_snapshot"](directory) == before + assert any(command[0] == "run" for command in commands) + + +class Publisher: + def __init__(self, tmp_path): + self.root = tmp_path + self.remote = tmp_path / "remote.git" + self.work = tmp_path / "work" + self.git("init", "--bare", str(self.remote), cwd=tmp_path) + self.git("clone", str(self.remote), str(self.work), cwd=tmp_path) + self.git("config", "user.name", "Test") + self.git("config", "user.email", "test@example.org") + self.git("checkout", "-b", "main") + (self.work / "app").mkdir() + self.files("1.0.0") + self.git("add", ".") + self.git("commit", "-m", "baseline") + self.git("push", "origin", "main") + self.state = tmp_path / "state.json" + self.state.write_text("{}") + self.log = tmp_path / "commands.jsonl" + binary = tmp_path / "bin" + binary.mkdir() + gh = binary / "gh" + gh.write_text(f"#!{sys.executable}\n" + MOCK_GH) + gh.chmod(0o755) + self.env = { + **os.environ, + "PATH": str(binary) + os.pathsep + os.environ["PATH"], + "STATE": str(self.state), + "LOG": str(self.log), + "NPM_PROJECT": "app", + "BASE_BRANCH": "main", + "RUNNER_TEMP": str(tmp_path), + "PR_FOOTER": "[Workflow run](https://example.org/run)", + } + + def git(self, *args, cwd=None): + return subprocess.check_output( + ["git", *args], cwd=cwd or self.work, text=True, stderr=subprocess.DEVNULL + ).strip() + + def files(self, version): + for name in ("package.json", "pnpm-lock.yaml"): + (self.work / "app" / name).write_text( + json.dumps({"version": version}) + "\n" + ) + + def publish( + self, + kind, + version, + *, + refresh=False, + passed=True, + complete=True, + stale=False, + closed=False, + api_error=False, + ): + state = json.loads(self.state.read_text()) + expected = str(state.get(BRANCHES[kind], {}).get("number", "")) + self.git("reset", "--hard", "origin/main") + self.git("checkout", "-B", "audit-input", "origin/main") + sha = self.git("rev-parse", "HEAD") + if stale: + (self.work / "unrelated.txt").write_text("new main\n") + self.git("add", ".") + self.git("commit", "-m", "advance main") + self.git("push", "origin", "HEAD:main") + self.git("reset", "--hard", sha) + self.files(version) + (self.root / "dependency-audit-pr.md").write_text( + "**1 dependency update · 1 security fix · Validation passed**\n\nOriginal report.\n" + ) + return subprocess.run( + ["bash", str(ROOT / "node/publish/publish.sh")], + cwd=self.work, + env={ + **self.env, + "PROPOSAL_KIND": kind, + "VALIDATION_PASSED": str(passed).lower(), + "PROPOSAL_COMPLETE": str(complete).lower(), + "AUDIT_SHA": sha, + "REFRESH_ONLY": str(refresh).lower(), + "EXPECTED_PR": expected, + "CLOSE_ON_VIEW": str(closed).lower(), + "API_ERROR": str(api_error).lower(), + }, + capture_output=True, + text=True, + ) + + def merge(self, kind): + branch = BRANCHES[kind] + self.git("reset", "--hard", "origin/main") + self.git("checkout", "-B", "main", "origin/main") + self.git("merge", "--squash", "origin/" + branch) + self.git("commit", "-m", "merge " + kind) + self.git("push", "origin", "main") + state = json.loads(self.state.read_text()) + state[branch]["state"] = "MERGED" + self.state.write_text(json.dumps(state)) + + +MOCK_GH = """import json, os, sys +from pathlib import Path +args = sys.argv[1:] +path = Path(os.environ['STATE']) +state = json.loads(path.read_text()) +with open(os.environ['LOG'], 'a') as output: output.write(json.dumps(args) + '\\n') +command = args[1] +if command == 'list': + pr = state.get(args[args.index('--head') + 1], {}) + print(pr.get('number', '') if pr.get('state') == 'OPEN' else '') +elif command == 'create': + branch = args[args.index('--head') + 1] + assert branch not in state or state[branch]['state'] != 'OPEN' + state[branch] = {'number': len(state) + 1, 'state': 'OPEN', 'isDraft': '--draft' in args, + 'body': Path(args[args.index('--body-file') + 1]).read_text(), + 'title': args[args.index('--title') + 1], 'baseRefName': 'main'} +else: + pr = next(pr for pr in state.values() if str(pr['number']) == args[2]) + if command == 'view': + key = args[args.index('--json') + 1] + if key == 'state' and os.environ.get('API_ERROR') == 'true': sys.exit(1) + if key == 'state' and os.environ.get('CLOSE_ON_VIEW') == 'true': pr['state'] = 'CLOSED' + value = pr[key] + print(str(value).lower() if isinstance(value, bool) else value) + elif command == 'edit': + if '--body-file' in args: pr['body'] = Path(args[args.index('--body-file') + 1]).read_text() + if '--title' in args: pr['title'] = args[args.index('--title') + 1] + elif command == 'ready': + assert '--undo' in args + pr['isDraft'] = True + elif command == 'comment': pr.setdefault('comments', []).append(args[args.index('--body') + 1]) + elif command == 'close': pr['state'] = 'CLOSED' + else: raise AssertionError(args) +path.write_text(json.dumps(state)) +""" + + +@pytest.fixture +def publisher(tmp_path): + return Publisher(tmp_path) + + +@pytest.mark.parametrize("first", ["security", "upgrades"]) +def test_both_merge_orders_refresh_same_pr_or_close_obsolete_proposal(publisher, first): + for kind, version in (("security", "1.0.1"), ("upgrades", "1.1.0")): + result = publisher.publish(kind, version) + assert result.returncode == 0, result.stderr + publisher.merge(first) + remaining = "upgrades" if first == "security" else "security" + result = publisher.publish(remaining, "1.1.0", refresh=True) + assert result.returncode == 0, result.stderr + state = json.loads(publisher.state.read_text()) + pr = state[BRANCHES[remaining]] + assert pr["state"] == ("OPEN" if first == "security" else "CLOSED") + assert pr["title"].startswith("Node " + remaining + ":") + if pr["state"] == "OPEN": + assert publisher.git( + "merge-base", "origin/main", "origin/" + BRANCHES[remaining] + ) == publisher.git("rev-parse", "origin/main") + else: + assert "Original report." in pr["body"] and len(pr["comments"]) == 1 + commands = [json.loads(line) for line in publisher.log.read_text().splitlines()] + assert len([c for c in commands if c[:2] == ["pr", "create"]]) == 2 + + +@pytest.mark.parametrize("passed,complete", [(False, True), (True, False)]) +def test_empty_failed_or_incomplete_proposals_do_not_close_existing_pr( + publisher, passed, complete +): + assert publisher.publish("security", "1.0.1").returncode == 0 + result = publisher.publish( + "security", "1.0.0", refresh=True, passed=passed, complete=complete + ) + assert result.returncode == (0 if passed else 1) + assert ( + json.loads(publisher.state.read_text())[BRANCHES["security"]]["state"] == "OPEN" + ) + + +def test_refresh_never_creates_a_missing_pr(publisher): + result = publisher.publish("security", "1.0.1", refresh=True) + assert result.returncode == 0 + assert json.loads(publisher.state.read_text()) == {} + assert not publisher.git("ls-remote", "--heads", "origin", BRANCHES["security"]) + + +@pytest.mark.parametrize("race", ["stale", "closed", "api_error"]) +def test_stale_or_closed_proposals_do_not_publish(publisher, race): + assert publisher.publish("upgrades", "1.1.0").returncode == 0 + before = publisher.git("rev-parse", "origin/" + BRANCHES["upgrades"]) + result = publisher.publish("upgrades", "1.2.0", refresh=True, **{race: True}) + assert result.returncode == (0 if race == "closed" else 1), result.stderr + assert publisher.git("rev-parse", "origin/" + BRANCHES["upgrades"]) == before + + +def test_successful_refresh_does_not_promote_an_existing_draft(publisher): + assert publisher.publish("security", "1.0.1", passed=False).returncode == 1 + assert publisher.publish("security", "1.0.2", refresh=True).returncode == 0 + assert ( + json.loads(publisher.state.read_text())[BRANCHES["security"]]["isDraft"] is True + ) + + +def test_coordinator_separates_project_execution_from_write_credentials(): + workflow = yaml.safe_load( + (ROOT / ".github/workflows/node-maintenance.yml").read_text() + ) + for kind in ("main", "security", "upgrades"): + job = workflow["jobs"][kind] + assert job["permissions"] == {"contents": "read", "issues": "write"} + assert "secrets" not in job + assert job["with"]["artifact-prefix"] == "node-" + kind + assert job["with"]["audit-ref"] == "${{ needs.plan.outputs.sha }}" + for kind in ("security", "upgrades"): + job = workflow["jobs"][kind + "-pr"] + assert job["needs"] == ["plan", kind] + assert job["with"]["proposal-kind"] == kind + caller = yaml.safe_load((ROOT / "node/coordinated-caller.yml").read_text()) + assert caller["on"]["push"]["paths"] == COORDINATION["PUSH_PATHS"] + assert caller["concurrency"]["cancel-in-progress"] is False diff --git a/node/tests/test_dependency_audit_feedback.py b/node/tests/test_dependency_audit_feedback.py index 063694a..992128f 100644 --- a/node/tests/test_dependency_audit_feedback.py +++ b/node/tests/test_dependency_audit_feedback.py @@ -66,6 +66,7 @@ def test_cli_exports_validation_status_without_preventing_report_publication( assert report.exists() assert output.read_text() == ( f"existing=value\nvalidation_passed={'true' if build == 0 else 'false'}\n" + "proposal_complete=true\n" ) @@ -110,7 +111,7 @@ def evidence(*args, **kwargs): assert calls == 2 assert "- Project checks: passed." in report.read_text() assert "Security audit incomplete" in report.read_text() - assert output.read_text() == "validation_passed=true\n" + assert output.read_text() == "validation_passed=true\nproposal_complete=false\n" assert "registry response is incomplete" in capsys.readouterr().err @@ -168,7 +169,8 @@ def test_action_retains_incomplete_audit_summary_and_artifact(tmp_path, report_e artifact = next( step for step in steps - if step.get("with", {}).get("name") == "dependency-audit-report" + if step.get("with", {}).get("name") + == "${{ inputs.artifact-prefix || 'dependency-audit' }}-report" ) for step in (summary, artifact): assert step["if"] == "${{ always() && !cancelled() }}" diff --git a/node/tests/test_dependency_audit_publisher.py b/node/tests/test_dependency_audit_publisher.py index caddec4..e058193 100644 --- a/node/tests/test_dependency_audit_publisher.py +++ b/node/tests/test_dependency_audit_publisher.py @@ -214,6 +214,8 @@ def test_publication_preserves_reports_and_signals_failed_validation( env={ **os.environ, **PUBLISH["env"], + "PROPOSAL_KIND": "legacy", + "REFRESH_ONLY": "false", "PATH": f"{tmp_path}:{os.environ['PATH']}", "NPM_PROJECT": "website", "BASE_BRANCH": "main", diff --git a/node/tests/test_fleet_setup.py b/node/tests/test_fleet_setup.py index 9a57e2f..c0cd0cf 100644 --- a/node/tests/test_fleet_setup.py +++ b/node/tests/test_fleet_setup.py @@ -58,6 +58,9 @@ def __init__(self, manager="pnpm"): ".github/workflows/node-publish.yml": ( ROOT / ".github/workflows/node-publish.yml" ).read_bytes(), + ".github/workflows/node-maintenance.yml": ( + ROOT / ".github/workflows/node-maintenance.yml" + ).read_bytes(), }, ) self.add("owner/project", project(manager)) @@ -157,6 +160,38 @@ def project(manager): return files +@pytest.mark.parametrize( + "problem", [None, "branch", "paths", "permissions", "condition", "unsupported"] +) +def test_fleet_accepts_coordinated_installation_and_checks_trigger_contract(problem): + github = GitHub() + caller = yaml.safe_load( + (ROOT / "node/coordinated-caller.yml") + .read_text() + .replace("__CENTRAL_REPOSITORY__", "owner/actions") + .replace("__CENTRAL_REVISION__", SHA) + .replace("__DEFAULT_BRANCH__", "main") + ) + caller["on"]["schedule"] = [{"cron": cron} for cron in ["0 2 * * 1", "0 2 1 */3 *"]] + if problem == "branch": + caller["on"]["push"]["branches"] = ["wrong"] + if problem == "paths": + caller["on"]["push"]["paths"] = ["**"] + if problem == "permissions": + caller["jobs"]["maintenance"]["permissions"].pop("issues") + if problem == "condition": + caller["jobs"]["maintenance"]["if"] = "false" + if problem == "unsupported": + contract = json.loads(github.repos["owner/actions"]["node/contract.json"]) + del contract["coordinated_maintenance"] + github.repos["owner/actions"]["node/contract.json"] = data(contract) + github.repos["owner/project"][".github/workflows/dependency-audit.yml"] = ( + yaml.safe_dump(caller).encode() + ) + result = FLEET["check_repository"](github, "owner/project", SOURCE) + assert result["status"] == ("ok" if problem is None else "failed"), result + + @pytest.mark.parametrize("manager", ["npm", "pnpm"]) def test_healthy_fleet_without_running_project_commands(monkeypatch, manager): def forbidden(*args, **kwargs): diff --git a/node/tests/test_integration_contract.py b/node/tests/test_integration_contract.py index acd204a..6193dd2 100644 --- a/node/tests/test_integration_contract.py +++ b/node/tests/test_integration_contract.py @@ -15,7 +15,10 @@ def test_caller_separates_audit_and_publisher_authority(): assert audit["permissions"] == {"contents": "read", "issues": "write"} shared = yaml.safe_load((ROOT / ".github/workflows/node-audit.yml").read_text()) assert shared["jobs"]["audit"]["permissions"] == {"contents": "read"} - assert shared["jobs"]["configuration-issue"]["permissions"] == {"issues": "write"} + assert shared["jobs"]["configuration-issue"]["permissions"] == { + "issues": "write", + "contents": "read", + } assert "secrets" not in audit assert publisher["permissions"] == {"contents": "write", "pull-requests": "write"} assert set(publisher["secrets"]) == {"app-private-key"} diff --git a/node/tests/test_local_patches.py b/node/tests/test_local_patches.py index 235be29..e20fac2 100644 --- a/node/tests/test_local_patches.py +++ b/node/tests/test_local_patches.py @@ -690,5 +690,5 @@ def test_declarations_are_local_and_reporter_has_no_project_execution_authority( assert PROJECT["CONFIGURATION"]["merge_shared"]({}, {"localPatches": [ID]}) == {} workflow = yaml.safe_load((ROOT / ".github/workflows/node-audit.yml").read_text()) job = workflow["jobs"]["configuration-issue"] - assert job["permissions"] == {"issues": "write"} + assert job["permissions"] == {"issues": "write", "contents": "read"} assert not any("checkout@" in step.get("uses", "") for step in job["steps"]) diff --git a/node/tests/test_node_standard.py b/node/tests/test_node_standard.py index fb4b881..b0b62af 100644 --- a/node/tests/test_node_standard.py +++ b/node/tests/test_node_standard.py @@ -325,7 +325,7 @@ def test_publisher_uses_the_audited_revision_and_default_branch_base(): for step in workflow["jobs"][job]["steps"] if step.get("uses", "").startswith("actions/checkout@") ) - assert checkout["with"]["ref"] == "${{ github.sha }}" + assert checkout["with"]["ref"] == "${{ inputs.audit-ref || github.sha }}" publisher = workflow["jobs"]["pull-request"]["steps"][-1] assert ( publisher["env"]["BASE_BRANCH"] diff --git a/node/tests/test_transferred_workflow.py b/node/tests/test_transferred_workflow.py index dccff51..139a42d 100644 --- a/node/tests/test_transferred_workflow.py +++ b/node/tests/test_transferred_workflow.py @@ -317,7 +317,7 @@ def test_both_jobs_start_at_the_same_immutable_revision(): for step in WORKFLOW["jobs"][job]["steps"] if step.get("uses", "").startswith("actions/checkout@") ) - assert checkout["with"]["ref"] == "${{ github.sha }}" + assert checkout["with"]["ref"] == "${{ inputs.audit-ref || github.sha }}" assert ( VERIFY["env"]["BASE_BRANCH"] == "${{ github.event.repository.default_branch }}" ) @@ -357,5 +357,5 @@ def test_dependency_state_check_precedes_artifact_download(): if step.get("uses", "").startswith("actions/download-artifact@") ) assert verify_index < download_index - assert VERIFY["env"]["AUDIT_SHA"] == "${{ github.sha }}" + assert VERIFY["env"]["AUDIT_SHA"] == "${{ inputs.audit-ref || github.sha }}" assert "git add" not in VERIFY["run"] diff --git a/node/tests/verify_node_project.py b/node/tests/verify_node_project.py index 5cf30ca..6bb0191 100644 --- a/node/tests/verify_node_project.py +++ b/node/tests/verify_node_project.py @@ -184,6 +184,16 @@ def check(node, manager, version): assert all( (project / name).read_bytes() == data for name, data in before.items() ) + report = audit["prepare"]( + directory, + validation=validation, + validation_script="check", + validate_only=True, + ) + assert validation["passed"] and not validation["audit_errors"], report + assert all( + (project / name).read_bytes() == data for name, data in before.items() + ), report # A library with no build script also exercises the routine path, including # release-time lookup and the native npm publication cutoff. report = audit["prepare"]( @@ -206,6 +216,7 @@ def check(node, manager, version): "standalone_library": "passed", "security_and_upgrades": "passed", "frozen_install_and_tests": "passed", + "committed_state_validation": "passed without dependency changes", } if manager == "npm": result["inherited_npm_security_policy"] = check_npm_policy(version)