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(