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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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.
15 changes: 9 additions & 6 deletions docs/node-fleet-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
33 changes: 18 additions & 15 deletions docs/node-maintenance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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' }}

Expand All @@ -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 }}
Expand Down Expand Up @@ -345,18 +346,20 @@ 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

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

Expand Down
4 changes: 2 additions & 2 deletions docs/node-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions docs/shared-defaults.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
38 changes: 38 additions & 0 deletions docs/workflow-releases.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 9 additions & 5 deletions node/runtime/fleet.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, {}),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -400,17 +406,15 @@ 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
or publisher.get("uses")
!= 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"]):
Expand Down Expand Up @@ -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"
)
Expand Down
75 changes: 74 additions & 1 deletion node/tests/test_fleet_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion node/tests/test_integration_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading