From 29365f44010c7f5f9ebc93b74e1385be3d69a85a Mon Sep 17 00:00:00 2001 From: Omry Yadan Date: Tue, 20 Oct 2026 15:49:35 +0800 Subject: [PATCH] Support compatible workflow updates through v1 release tags Installations can use matching major release tags for the Node audit and publisher, while retaining full commit SHA pinning. Resolve release tags once to immutable repository snapshots for fleet checks, reject mixed references and unavailable tags, and preserve existing interface and permission validation. Recommend @v1 in maintenance and fleet setup guides. Document compatible release review, tag advancement, immutable opt-in and explicit adoption of breaking major releases. Validation: public regression suite, Ruff lint and formatting, runtime type checks, and release-reference concurrency/failure regressions. --- README.md | 5 +- docs/node-fleet-setup.md | 15 +++-- docs/node-maintenance.md | 33 ++++++----- docs/node-migration.md | 4 +- docs/shared-defaults.md | 4 +- docs/workflow-releases.md | 38 +++++++++++++ node/runtime/fleet.py | 14 +++-- node/tests/test_fleet_setup.py | 75 ++++++++++++++++++++++++- node/tests/test_integration_contract.py | 2 +- 9 files changed, 156 insertions(+), 34 deletions(-) create mode 100644 docs/workflow-releases.md diff --git a/README.md b/README.md index 6f91bb1..22cc41b 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Shared GitHub Actions Reusable workflows for organization-owned and personal repositories. Each -installation keeps its configuration and pins shared workflow code to a reviewed -commit. Supports GitHub.com. +installation keeps its configuration and uses `@v1` for automatic compatible +workflow updates. Full commit SHA pinning remains available. Supports GitHub.com. ## Available workflows @@ -59,6 +59,7 @@ repositories at a time in one runner, with one combined report and issue. - [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) +- [Workflow releases and immutable pins](docs/workflow-releases.md) - [Maintenance caller template](node/caller.yml) and [integration contract](node/contract.json) The Node implementation retains its BSD 3-Clause license. diff --git a/docs/node-fleet-setup.md b/docs/node-fleet-setup.md index 7aa9818..2e3057e 100644 --- a/docs/node-fleet-setup.md +++ b/docs/node-fleet-setup.md @@ -78,15 +78,16 @@ jobs: contents: read actions: read issues: write - uses: hydra-ecosystem/github-actions/.github/workflows/node-fleet-check.yml@REVIEWED_FULL_COMMIT_SHA + uses: hydra-ecosystem/github-actions/.github/workflows/node-fleet-check.yml@v1 with: inventory-repository: owner/settings inventory-path: defaults/node-repositories.json inventory-ref: main ``` -Replace `REVIEWED_FULL_COMMIT_SHA` with a reviewed full SHA that contains -`node-fleet-check.yml`. Use your inventory location. Enable issues in the host +Use `@v1` for automatic compatible checker updates, or replace it with a reviewed +full commit SHA containing `node-fleet-check.yml` for immutable pinning. See +[workflow releases](workflow-releases.md). Use your inventory location. Enable issues in the host repository. Only the host needs issue-write permission; listed repositories do not need a new workflow, extra write permission, or an issue opened in them. Reports have the host repository's visibility. Keep private-repository inventories @@ -175,8 +176,8 @@ workflow without opening an issue. a compatible `engines.node` range, and an existing validation script. - Lockfile format, mixed-lockfile restrictions, and pnpm release cooldown satisfy the resolved configuration. -- The maintenance caller pins audit and publisher workflows from one repository - at the same full SHA. That revision declares Node integration version 6 and +- The maintenance caller uses audit and publisher workflows from one repository + at the same major release tag (such as `v1`) or full commit SHA. That revision declares Node integration version 6 and contains both reusable workflows and supports the resolved toolchain. It does not have to be the latest revision. - Caller schedules match resolved policy, including bounds for the supported @@ -194,7 +195,9 @@ workflow without opening an issue. - The maintenance workflow is enabled in GitHub Actions. Each repository is read at one immutable default-branch commit. Shared defaults -reads are cached for the scan. Remote files are never executed or imported, and +reads are cached for the scan. Each workflow release tag is resolved once to an +immutable commit and tree before its files are checked. A missing or unreadable +tag cannot receive a passing verdict. Remote files are never executed or imported, and symlinks/submodules are rejected for checked files. Files are bounded: inventory and ordinary configuration files are at most 64 KiB, lockfiles at most 2 MB; truncated or oversized repository trees cannot receive a passing verdict. diff --git a/docs/node-maintenance.md b/docs/node-maintenance.md index a845bc9..da68234 100644 --- a/docs/node-maintenance.md +++ b/docs/node-maintenance.md @@ -165,15 +165,16 @@ so its caller needs both cron entries in step 3. ### 3. Add the caller workflow -Choose a reviewed commit from `hydra-ecosystem/github-actions`. On GitHub, open -that commit, confirm its [`node/contract.json`](../node/contract.json) declares -integration version **6**, and copy its full 40-character SHA. Use the contract -and caller from that same revision. - Create `.github/workflows/dependency-audit.yml` using the following caller. -Replace **both** `REVIEWED_FULL_COMMIT_SHA` placeholders with your chosen SHA. -The placeholders avoid prescribing a revision that becomes stale; installed -workflows must pin an immutable reviewed revision. +Both workflow calls use `@v1`, a moving release tag. Compatible fixes reach your +installation without a local workflow edit. Breaking changes use a new major +release and require an explicit update. + +To review every workflow update yourself, replace **both** `v1` references with +the same reviewed full 40-character commit SHA. That revision must declare Node +integration version **6** in `node/contract.json`; use its caller template. +See [workflow releases and immutable pins](workflow-releases.md) for the release +policy and tradeoff. ```yaml name: Node dependency audit @@ -203,7 +204,7 @@ jobs: permissions: contents: read issues: write - uses: hydra-ecosystem/github-actions/.github/workflows/node-audit.yml@REVIEWED_FULL_COMMIT_SHA + uses: hydra-ecosystem/github-actions/.github/workflows/node-audit.yml@v1 with: audit-kind: ${{ inputs.audit-kind || 'auto' }} @@ -212,7 +213,7 @@ jobs: permissions: contents: write pull-requests: write - uses: hydra-ecosystem/github-actions/.github/workflows/node-publish.yml@REVIEWED_FULL_COMMIT_SHA + uses: hydra-ecosystem/github-actions/.github/workflows/node-publish.yml@v1 with: validation-passed: ${{ needs.audit.outputs.validation_passed }} app-client-id: ${{ vars.NODE_AUDIT_APP_CLIENT_ID }} @@ -345,7 +346,7 @@ 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 +support. Use `@v1` or update both immutable pins together; older version-6 revisions do not accept `localPatches`. ## Update or migrate an installation @@ -353,10 +354,12 @@ accept `localPatches`. For an earlier Docusaurus installation, use the [migration checklist](node-migration.md). There are no aliases for its old configuration, script option, or workflow paths. -For later updates, compare the installed and proposed contract and caller template. -Update both workflow SHA references together, then run a manual security audit -from the default branch. Shared-defaults refs are independent of workflow pins; -review their changes separately. Verify toolchain changes locally first. +With `@v1`, compatible workflow updates need no installation PR. When adopting a +new major release, compare its contract and caller template, update both workflow +references together, and run a manual security audit from the default branch. +For immutable pins, follow those steps for each chosen commit. Shared-defaults +refs are independent of workflow references; review their changes separately. +Verify toolchain changes locally first. ## Troubleshooting diff --git a/docs/node-migration.md b/docs/node-migration.md index 0eb4e34..df88b84 100644 --- a/docs/node-migration.md +++ b/docs/node-migration.md @@ -23,7 +23,7 @@ are manual steps; the shared repository does not rewrite consumer installations. ## Consumer checklist -1. Choose a reviewed version-6 commit. Read `node/contract.json` and use +1. Choose `v1` for compatible updates, or a reviewed version-6 commit for immutable pinning. Read `node/contract.json` and use `node/caller.yml` from that same revision. 2. Rename `.github/docusaurus.json` to `.github/node-maintenance.json`. 3. Rename `buildScript` to `validationScript` if present. Keep `"build"` for an @@ -34,7 +34,7 @@ are manual steps; the shared repository does not rewrite consumer installations. inherited values and local overrides; following the recommended defaults enables quarterly routine upgrades as well as weekly security checks. 5. Replace both caller workflow references with `node-audit.yml` and - `node-publish.yml` at the same reviewed SHA. Use + `node-publish.yml` at the same `v1` tag or reviewed full SHA. Use `.github/workflows/dependency-audit.yml` as the caller filename. Match its cron entries to the resolved [schedule](audit-schedules.md), and include the current caller's permissions and manual `audit-kind` input forwarding. diff --git a/docs/shared-defaults.md b/docs/shared-defaults.md index f6fda01..84fd9d6 100644 --- a/docs/shared-defaults.md +++ b/docs/shared-defaults.md @@ -79,8 +79,8 @@ schedule: - cron: '0 2 1 */3 *' ``` -Place this under `on` in the caller. Keep both reusable workflow calls pinned -to the same reviewed version-6 SHA. Enable repository issues and publication as +Place this under `on` in the caller. Keep both reusable workflow calls at `@v1`, or use the same reviewed full +version-6 SHA for immutable pinning. Enable repository issues and publication as shown in [step 4](node-maintenance.md#4-enable-publication-and-run-the-first-audit), then run a manual security audit. Preflight checks the resolved tools, cooldown, and schedules before dependency changes. diff --git a/docs/workflow-releases.md b/docs/workflow-releases.md new file mode 100644 index 0000000..89f8d96 --- /dev/null +++ b/docs/workflow-releases.md @@ -0,0 +1,38 @@ +# Workflow releases + +Use `@v1` on the reusable workflow calls to receive compatible updates without +an installation PR for every fix. This applies to maintenance and fleet setup +checks. Keep the audit and publisher calls on the same reference. + +`v1` is a moving release tag maintained in this repository. Advancing it changes +the code that installations trust on their next run. Compatible fixes are +reviewed, tested, and merged before the tag advances. The tag does not track +every push to `main`. Changes that require an incompatible caller or +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. + +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 +the caller template at that commit. Both reference styles are supported by the +fleet checker; arbitrary branches and version strings are not. + +The fleet checker resolves a major tag once per scan, then inspects files from +that commit's immutable tree. Its static verdict describes that observed +release, not every future value of the tag. The audit's internal actions load +from the called workflow's revision. Avoid moving the tag while a release is +being validated across installations. + +## Publish a compatible release + +Maintainers first obtain approval and passing checks for the exact proposed +change, squash-merge it, and verify the resulting commit has the reviewed file +tree. Then create or advance `refs/tags/v1` through GitHub's Git references API +to that full merged commit SHA. Verify the tag's resolved commit afterward. +Do this once centrally, without editing each installation. Never move `v1` to +an unreviewed commit or publish an incompatible interface through it. Retain +the previous release SHA so a faulty compatible release can be rolled back. + +For the initial release, `v1` exposes the Node integration version 6 interface. +The major release number is independent of the integration contract's version. diff --git a/node/runtime/fleet.py b/node/runtime/fleet.py index 5e4a5ce..ccae869 100644 --- a/node/runtime/fleet.py +++ b/node/runtime/fleet.py @@ -27,6 +27,8 @@ CONFIG_PATH = ".github/node-maintenance.json" CALLER_PATH = ".github/workflows/dependency-audit.yml" SHA = r"[0-9a-f]{40}" +MAJOR_RELEASE = r"v[1-9][0-9]*" +WORKFLOW_REF = rf"(?:{SHA}|{MAJOR_RELEASE})" CRON_FIELDS = ( (0, 59, {}), (0, 23, {}), @@ -57,6 +59,10 @@ class Unverified(ValueError): """A required observation could not be obtained; this is not a pass.""" +def workflow_snapshot_ref(ref): + return "tags/" + ref if re.fullmatch(MAJOR_RELEASE, ref) else ref + + class GitHub: def __init__(self, token=""): self.token = token @@ -400,9 +406,7 @@ def check_caller(root, config, selected, github): raise ValueError( "Caller audit and publisher jobs must be unconditionally enabled" ) - pattern = ( - rf"([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)/\.github/workflows/node-audit\.yml@({SHA})" - ) + 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 @@ -410,7 +414,7 @@ def check_caller(root, config, selected, github): != f"{match[1]}/.github/workflows/node-publish.yml@{match[2]}" ): raise ValueError( - "Audit and publisher must pin matching Node workflows at the same full commit SHA" + "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"]): @@ -444,7 +448,7 @@ def check_caller(root, config, selected, github): raise ValueError( "Caller must serialize runs with concurrency and cancel-in-progress: false" ) - source = github.public_github.snapshot(match[1], match[2]) + source = github.public_github.snapshot(match[1], workflow_snapshot_ref(match[2])) contract = CONFIGURATION["json_object"]( source.read("node/contract.json"), "pinned Node contract" ) diff --git a/node/tests/test_fleet_setup.py b/node/tests/test_fleet_setup.py index b12fafd..9a57e2f 100644 --- a/node/tests/test_fleet_setup.py +++ b/node/tests/test_fleet_setup.py @@ -174,6 +174,79 @@ def forbidden(*args, **kwargs): assert all(call.startswith("repos/") for call in github.calls) +def test_inventory_major_release_branch_keeps_generic_ref_resolution(): + github = GitHub() + report = FLEET["check_fleet"]( + github, + {"repository": "owner/settings", "path": "defaults/repos.json", "ref": "v1"}, + ) + assert report["status"] == "ok" + assert report["inventory"]["ref"] == "v1" + assert [call for call in github.calls if "owner/settings/commits/" in call] == [ + "repos/owner/settings/commits/v1" + ] + + +@pytest.mark.parametrize("ref", ["v1", "v2", "v123", SHA]) +def test_matching_release_references_are_resolved_once_per_scan(ref): + github = GitHub() + files = project("npm") + path = ".github/workflows/dependency-audit.yml" + files[path] = files[path].replace(SHA.encode(), ref.encode()) + for name in ("owner/one", "owner/two"): + github.add(name, files) + with ThreadPoolExecutor(max_workers=2) as pool: + results = list( + pool.map( + lambda name: FLEET["check_repository"](github, name, SOURCE), + ["owner/one", "owner/two"], + ) + ) + assert [result["status"] for result in results] == ["ok", "ok"] + # Explicit tag namespace avoids inspecting a same-named branch instead. + resolved = "tags%2F" + ref if ref.startswith("v") else ref + calls = [call for call in github.calls if "owner/actions/commits/" in call] + assert calls == ["repos/owner/actions/commits/" + resolved] + + +@pytest.mark.parametrize("ref", ["main", "v0", "v01", "v1.2", "V1", "tags/v1"]) +def test_branch_and_malformed_release_references_fail_setup(ref): + github = GitHub() + path = ".github/workflows/dependency-audit.yml" + files = github.repos["owner/project"] + files[path] = files[path].replace(SHA.encode(), ref.encode()) + result = FLEET["check_repository"](github, "owner/project", SOURCE) + assert result["status"] == "failed" + assert "same major release tag" in result["diagnostic"] + + +def test_audit_and_publisher_cannot_mix_release_references(): + github = GitHub() + path = ".github/workflows/dependency-audit.yml" + files = github.repos["owner/project"] + files[path] = files[path].replace(("@" + SHA).encode(), b"@v1", 1) + result = FLEET["check_repository"](github, "owner/project", SOURCE) + assert result["status"] == "failed" + + +def test_unavailable_release_tag_is_unverified_without_branch_fallback(): + github = GitHub() + path = ".github/workflows/dependency-audit.yml" + files = github.repos["owner/project"] + files[path] = files[path].replace(SHA.encode(), b"v1") + original = github.get + + def get(path): + if path == "repos/owner/actions/commits/tags%2Fv1": + raise FLEET["Unverified"]("Release tag unavailable") + return original(path) + + github.get = get + result = FLEET["check_repository"](github, "owner/project", SOURCE) + assert result["status"] == "unverified" + assert "Release tag unavailable" in result["diagnostic"] + + @pytest.mark.parametrize("failure", [None, "old-pin"]) def test_fleet_verifies_compact_patch_assertions_and_pinned_support( monkeypatch, failure @@ -671,7 +744,7 @@ def test_workflow_splits_read_access_and_host_issue_write_access(): if "jobs:" in block ) job = caller["jobs"]["setup"] - assert job["uses"].endswith("/node-fleet-check.yml@REVIEWED_FULL_COMMIT_SHA") + assert job["uses"].endswith("/node-fleet-check.yml@v1") assert set(job["with"]) <= set(workflow["on"]["workflow_call"]["inputs"]) assert job["permissions"] == { "contents": "read", diff --git a/node/tests/test_integration_contract.py b/node/tests/test_integration_contract.py index e0c49b0..acd204a 100644 --- a/node/tests/test_integration_contract.py +++ b/node/tests/test_integration_contract.py @@ -91,7 +91,7 @@ def test_setup_guide_uses_the_current_caller_and_standalone_schedule(): (ROOT / "node/caller.yml") .read_text() .replace("__CENTRAL_REPOSITORY__", "hydra-ecosystem/github-actions") - .replace("__CENTRAL_REVISION__", "REVIEWED_FULL_COMMIT_SHA") + .replace("__CENTRAL_REVISION__", "v1") ) schedule = yaml.safe_dump({"schedule": [{"cron": "0 2 * * 1"}]}, sort_keys=False) expected = expected.replace(