From f441f1208a78af750137f06427ab7b75ae5bc2f8 Mon Sep 17 00:00:00 2001 From: Job Cespedes Date: Sat, 17 Oct 2026 12:46:51 +0000 Subject: [PATCH 1/4] feat(build): add merge or head checkout for pull requests Add the pr-checkout input. merge, the default, keeps building GitHub's merge of the pull request into its base branch; head builds the pull request head commit instead. head applies only to pull_request events, so pull_request_target never checks out pull request code with the base repository's secrets. --- .github/workflows/README.md | 2 + .github/workflows/build.yml | 30 ++++++++++++ Makefile | 4 ++ README.md | 2 + tests/test_build_checkout.py | 91 ++++++++++++++++++++++++++++++++++++ 5 files changed, 129 insertions(+) create mode 100644 tests/test_build_checkout.py diff --git a/.github/workflows/README.md b/.github/workflows/README.md index ad021e5..036f28d 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -24,6 +24,8 @@ File: `build.yml` Calculates a build version, optionally updates version files, runs configurable pre/post commands, builds Docker images with Buildx Bake, and can publish multi-platform images to a registry. Pull request image SHA tags use the PR head commit rather than GitHub's synthetic merge commit. +`pr-checkout` selects the commit built for `pull_request` events: `merge` (default) builds GitHub's merge of the PR into its base branch, and `head` builds the PR head commit. Other events, including `pull_request_target`, keep the default checkout. In `head` mode, the SemVer bump counts only commits reachable from the PR head. + `version-strategy` accepts `semver` (default) or `calver`. CalVer uses UTC `YYYY.M.PATCH` from Git release tags and preserves this workflow's default `-build` output suffix. `version-semver-previous` and `version-semver-next` apply only to SemVer; `version-output-format` and `version-tag-prefix` apply to both. Strategy-specific inputs use `version-semver-` or `version-calver-` prefixes; there are currently no CalVer-only inputs. Keep the default full-history checkout so all release tags are available. See the [CalVer action](../../release/version/calver/README.md) for the calculation and supported template fields. Set `version-galaxy: path/to/galaxy.yml` to update Ansible collection metadata included in the build. See [Version File](../../version-file/README.md) for accepted versions and file formats. Use a release containing this input. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a6794c6..c8030bb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -928,6 +928,11 @@ on: required: false type: boolean default: true + pr-checkout: + description: 'Commit to build for pull_request events: merge (the PR merged into its base branch) or head (the PR head commit)' + required: false + type: string + default: 'merge' jobs: prepare: @@ -957,6 +962,30 @@ jobs: *) echo '::error::version-strategy must be semver or calver' >&2; exit 1 ;; esac + - name: Resolve pull request checkout + id: pr_checkout + shell: bash + env: + PR_CHECKOUT: ${{ inputs.pr-checkout }} + EVENT_NAME: ${{ github.event_name }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + ref='' + case "${PR_CHECKOUT}" in + merge) ;; + head) + # Only pull_request: pull_request_target must not build PR code with base-repository secrets. + if [ "${EVENT_NAME}" = 'pull_request' ]; then + ref="${PR_HEAD_SHA}" + elif [ -n "${PR_HEAD_SHA}" ]; then + echo "::notice::pr-checkout: head applies only to pull_request events; using the default checkout for ${EVENT_NAME}" + fi + ;; + *) echo '::error::pr-checkout must be merge or head' >&2; exit 1 ;; + esac + printf 'ref=%s\n' "${ref}" >> "${GITHUB_OUTPUT}" + - name: Prepare build inputs id: prepare_report uses: $/build/report/prepare @@ -967,6 +996,7 @@ jobs: uses: actions/checkout@v6 id: checkout with: + ref: ${{ steps.pr_checkout.outputs.ref }} fetch-depth: ${{ inputs.fetch-depth }} fetch-tags: ${{ inputs.fetch-tags }} lfs: ${{ inputs.lfs }} diff --git a/Makefile b/Makefile index a5af09c..63afe0b 100644 --- a/Makefile +++ b/Makefile @@ -49,3 +49,7 @@ test-report: .PHONY: test-build-steps test-build-steps: python3 -B -m unittest discover -s tests -p 'test_build_steps.py' -v + +.PHONY: test-build-checkout +test-build-checkout: + python3 -B -m unittest discover -s tests -p 'test_build_checkout.py' -v diff --git a/README.md b/README.md index 5273478..29b5698 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,8 @@ Run `make test-report` for offline report aggregation and publishing tests. Thes Run `make test-build-steps` for pre/post matrix naming and configuration tests. These require Python 3.10 or newer and run offline. +Run `make test-build-checkout` for the Build workflow's pull request checkout and commit SHA tests. These require Python 3.10 or newer, Bash, and Git, and run offline. + For workflow lint, actionlint currently [does not recognize GitHub's `$/` references](https://github.com/rhysd/actionlint/issues/711). Lint a temporary copy with `uses: $/` rewritten to `uses: ./`, preserving the repository layout so local action paths and reusable-workflow inputs are checked. Keep the committed references as `$/`. The `Build cache` PR check calls the Bake composites from the workflow’s commit with a small two-target fixture on native AMD64 and ARM64 runners. It checks separate target/platform scopes, caller overrides, two warm imports on fresh builders, and source/dependency invalidation after all cold exports complete. It publishes only Actions cache entries and temporary test artifacts. Per-run cache prefixes prevent previous PR runs from warming the cold comparison. Measurements appear in job summaries and the `cache-measurements-*` artifacts; these synthetic timings are not consumer build benchmarks. diff --git a/tests/test_build_checkout.py b/tests/test_build_checkout.py new file mode 100644 index 0000000..2d3be89 --- /dev/null +++ b/tests/test_build_checkout.py @@ -0,0 +1,91 @@ +"""Check which commit the Build prepare job checks out and how it names it.""" + +import os +from pathlib import Path +import re +import subprocess +import tempfile +import textwrap +import unittest + + +WORKFLOW = Path(__file__).resolve().parents[1] / ".github/workflows/build.yml" +HEAD_SHA = "0123456789abcdef0123456789abcdef01234567" + + +def step(name): + block = WORKFLOW.read_text().split(f"\n - name: {name}\n", 1)[1] + return re.split(r"\n - name: |\n (?=\S)", block, maxsplit=1)[0] + + +def run(block, env, cwd=None): + script = textwrap.dedent(block.split(" run: |\n", 1)[1]) + with tempfile.TemporaryDirectory() as temp: + output = Path(temp) / "output" + output.touch() + result = subprocess.run( + ["bash", "-c", script], + cwd=cwd, + env={"PATH": os.environ["PATH"], "GITHUB_OUTPUT": str(output), **env}, + capture_output=True, + text=True, + ) + return result, dict(line.split("=", 1) for line in output.read_text().splitlines()) + + +class PullRequestCheckoutTests(unittest.TestCase): + def resolve(self, mode, event, head=HEAD_SHA): + return run(step("Resolve pull request checkout"), {"PR_CHECKOUT": mode, "EVENT_NAME": event, "PR_HEAD_SHA": head}) + + def test_head_checks_out_the_pull_request_head(self): + result, outputs = self.resolve("head", "pull_request") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(outputs, {"ref": HEAD_SHA}) + + def test_merge_and_events_without_a_pull_request_keep_the_default_checkout(self): + for mode, event, head in ( + ("merge", "pull_request", HEAD_SHA), + ("merge", "pull_request_target", HEAD_SHA), + ("merge", "push", ""), + ("head", "push", ""), + ("head", "workflow_dispatch", ""), + ): + with self.subTest(mode=mode, event=event): + result, outputs = self.resolve(mode, event, head) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(outputs, {"ref": ""}) + self.assertNotIn("::notice::", result.stdout) + + def test_head_keeps_the_default_checkout_for_pull_request_target(self): + result, outputs = self.resolve("head", "pull_request_target") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(outputs, {"ref": ""}) + self.assertIn("::notice::pr-checkout: head applies only to pull_request events", result.stdout) + + def test_unknown_modes_fail(self): + for mode in ("", "Head", "base"): + with self.subTest(mode=mode): + result, outputs = self.resolve(mode, "pull_request") + self.assertNotEqual(result.returncode, 0) + self.assertIn("pr-checkout must be merge or head", result.stderr) + self.assertEqual(outputs, {}) + + def test_checkout_follows_the_resolved_ref(self): + workflow = WORKFLOW.read_text() + self.assertLess( + workflow.index(" - name: Resolve pull request checkout"), + workflow.index(" - name: Checkout repository"), + ) + resolve = step("Resolve pull request checkout") + for line in ( + " id: pr_checkout\n", + " PR_CHECKOUT: ${{ inputs.pr-checkout }}\n", + " EVENT_NAME: ${{ github.event_name }}\n", + " PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}\n", + ): + self.assertIn(line, resolve) + self.assertIn(" ref: ${{ steps.pr_checkout.outputs.ref }}\n", step("Checkout repository")) + + +if __name__ == "__main__": + unittest.main() From 8898d4e7e25cb40b782d06f106c5242602f64c78 Mon Sep 17 00:00:00 2001 From: Job Cespedes Date: Sat, 17 Oct 2026 12:46:59 +0000 Subject: [PATCH 2/4] fix(build)!: tag and label PR images with the commit actually built Take the commit SHA from the checkout instead of the pull request head, and let docker/metadata-action use the head SHA only when the head was checked out. Drop event=push from the type=sha tag rules: the action ignores it for type=sha, so those rules already ran on every event. BREAKING CHANGE: In merge mode, the default, pull request images and the commit-sha output name the merge commit that was built. They no longer get the pull request head SHA tag or revision label. Callers that pull PR images by head SHA should set pr-checkout: head. --- .github/workflows/README.md | 4 ++-- .github/workflows/build.yml | 19 ++++++++----------- tests/test_build_checkout.py | 33 ++++++++++++++++++++++++++++++++- 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 036f28d..f4c5616 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -22,9 +22,9 @@ jobs: File: `build.yml` -Calculates a build version, optionally updates version files, runs configurable pre/post commands, builds Docker images with Buildx Bake, and can publish multi-platform images to a registry. Pull request image SHA tags use the PR head commit rather than GitHub's synthetic merge commit. +Calculates a build version, optionally updates version files, runs configurable pre/post commands, builds Docker images with Buildx Bake, and can publish multi-platform images to a registry. -`pr-checkout` selects the commit built for `pull_request` events: `merge` (default) builds GitHub's merge of the PR into its base branch, and `head` builds the PR head commit. Other events, including `pull_request_target`, keep the default checkout. In `head` mode, the SemVer bump counts only commits reachable from the PR head. +`pr-checkout` selects the commit built for `pull_request` events: `merge` (default) builds GitHub's merge of the PR into its base branch, and `head` builds the PR head commit. Other events, including `pull_request_target`, keep the default checkout. The full-SHA tags, the `sha-` tag, the `org.opencontainers.image.revision` label and the `commit-sha` output name the commit that was built. Earlier releases tagged and labelled merge builds with the PR head SHA as well; callers that pull PR images by head SHA should set `pr-checkout: head`. In `head` mode, the SemVer bump counts only commits reachable from the PR head. `version-strategy` accepts `semver` (default) or `calver`. CalVer uses UTC `YYYY.M.PATCH` from Git release tags and preserves this workflow's default `-build` output suffix. `version-semver-previous` and `version-semver-next` apply only to SemVer; `version-output-format` and `version-tag-prefix` apply to both. Strategy-specific inputs use `version-semver-` or `version-calver-` prefixes; there are currently no CalVer-only inputs. Keep the default full-history checkout so all release tags are available. See the [CalVer action](../../release/version/calver/README.md) for the calculation and supported template fields. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c8030bb..81e82df 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,7 +14,7 @@ on: description: 'Detected or calculated version' value: ${{ jobs.prepare.outputs.version }} commit-sha: - description: 'The commit SHA being built' + description: 'The checked-out commit SHA the images are built from' value: ${{ jobs.prepare.outputs.commit-sha }} image-digests: description: 'JSON with all merged image manifest digests' @@ -1029,13 +1029,9 @@ jobs: id: determine_sha shell: bash run: | - if [ -n "${{ github.event.pull_request.head.sha }}" ]; then - SHA="${{ github.event.pull_request.head.sha }}" - echo "Using PR HEAD SHA: ${SHA}" - else - SHA=$(git rev-parse HEAD) - echo "Using current HEAD SHA: ${SHA}" - fi + set -euo pipefail + SHA=$(git rev-parse HEAD) + echo "Using checked-out commit: ${SHA}" echo "sha=${SHA}" >> "$GITHUB_OUTPUT" - name: Next semantic version @@ -1088,7 +1084,8 @@ jobs: id: prepare_image uses: $/buildx-bake/prepare env: - DOCKER_METADATA_PR_HEAD_SHA: true + # Label and tag the PR head only when it is the commit checked out above. + DOCKER_METADATA_PR_HEAD_SHA: ${{ steps.pr_checkout.outputs.ref != '' }} with: bake-file: ${{ inputs.bake-file }} bake-target: ${{ inputs.bake-target }} @@ -1098,8 +1095,8 @@ jobs: meta-labels: ${{ inputs.meta-labels }} meta-tags: | - type=sha,prefix=,format=long,event=push - type=sha,event=push + type=sha,prefix=,format=long + type=sha type=ref,event=branch type=ref,event=pr type=ref,event=tag diff --git a/tests/test_build_checkout.py b/tests/test_build_checkout.py index 2d3be89..7a4c8f6 100644 --- a/tests/test_build_checkout.py +++ b/tests/test_build_checkout.py @@ -70,7 +70,7 @@ def test_unknown_modes_fail(self): self.assertIn("pr-checkout must be merge or head", result.stderr) self.assertEqual(outputs, {}) - def test_checkout_follows_the_resolved_ref(self): + def test_checkout_and_metadata_follow_the_resolved_ref(self): workflow = WORKFLOW.read_text() self.assertLess( workflow.index(" - name: Resolve pull request checkout"), @@ -85,6 +85,37 @@ def test_checkout_follows_the_resolved_ref(self): ): self.assertIn(line, resolve) self.assertIn(" ref: ${{ steps.pr_checkout.outputs.ref }}\n", step("Checkout repository")) + self.assertIn( + " DOCKER_METADATA_PR_HEAD_SHA: ${{ steps.pr_checkout.outputs.ref != '' }}\n", + step("Set image metadata and platform matrix"), + ) + + +class CommitIdentityTests(unittest.TestCase): + def test_commit_sha_is_the_checked_out_commit(self): + block = step("Determine commit SHA") + self.assertNotIn("pull_request", block) + env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + env.update({"GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_NOSYSTEM": "1", "GIT_TERMINAL_PROMPT": "0"}) + with tempfile.TemporaryDirectory() as repo: + def git(*args): + return subprocess.run( + ["git", *args], cwd=repo, env=env, check=True, capture_output=True, text=True, + ).stdout.strip() + + git("init", "--quiet") + git( + "-c", "user.name=Checkout Test", "-c", "user.email=test@example.invalid", + "-c", "commit.gpgsign=false", "commit", "--quiet", "--allow-empty", "-m", "Test fixture", + ) + result, outputs = run(block, {}, cwd=repo) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(outputs, {"sha": git("rev-parse", "HEAD")}) + + def test_sha_tag_rules_have_no_ignored_event_attribute(self): + tags = step("Set image metadata and platform matrix").split(" meta-tags: |\n", 1)[1] + sha_rules = [line.strip() for line in tags.splitlines() if line.strip().startswith("type=sha")] + self.assertEqual(sha_rules, ["type=sha,prefix=,format=long", "type=sha"]) if __name__ == "__main__": From 676ad220cb75af4a961548a70bf36dc6e0de2168 Mon Sep 17 00:00:00 2001 From: Job Cespedes Date: Sat, 17 Oct 2026 12:47:04 +0000 Subject: [PATCH 3/4] feat(build): pass the built commit to step commands Export BUILD_COMMIT_SHA, the commit-sha output, to the prepare command and to pre-step and post-step commands, so they can address the image built in the run without recomputing the SHA. It can differ from GITHUB_SHA. --- .github/workflows/README.md | 4 +++- .github/workflows/build.yml | 4 ++++ tests/test_build_steps.py | 11 +++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/README.md b/.github/workflows/README.md index f4c5616..6dabda6 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -24,7 +24,7 @@ File: `build.yml` Calculates a build version, optionally updates version files, runs configurable pre/post commands, builds Docker images with Buildx Bake, and can publish multi-platform images to a registry. -`pr-checkout` selects the commit built for `pull_request` events: `merge` (default) builds GitHub's merge of the PR into its base branch, and `head` builds the PR head commit. Other events, including `pull_request_target`, keep the default checkout. The full-SHA tags, the `sha-` tag, the `org.opencontainers.image.revision` label and the `commit-sha` output name the commit that was built. Earlier releases tagged and labelled merge builds with the PR head SHA as well; callers that pull PR images by head SHA should set `pr-checkout: head`. In `head` mode, the SemVer bump counts only commits reachable from the PR head. +`pr-checkout` selects the commit built for `pull_request` events: `merge` (default) builds GitHub's merge of the PR into its base branch, and `head` builds the PR head commit. Other events, including `pull_request_target`, keep the default checkout. The full-SHA tags, the `sha-` tag, the `org.opencontainers.image.revision` label and the `commit-sha` output name the commit that was built. Earlier releases tagged and labelled merge builds with the PR head SHA as well; callers that pull PR images by head SHA should set `pr-checkout: head` or use `BUILD_COMMIT_SHA`. In `head` mode, the SemVer bump counts only commits reachable from the PR head. `version-strategy` accepts `semver` (default) or `calver`. CalVer uses UTC `YYYY.M.PATCH` from Git release tags and preserves this workflow's default `-build` output suffix. `version-semver-previous` and `version-semver-next` apply only to SemVer; `version-output-format` and `version-tag-prefix` apply to both. Strategy-specific inputs use `version-semver-` or `version-calver-` prefixes; there are currently no CalVer-only inputs. Keep the default full-history checkout so all release tags are available. See the [CalVer action](../../release/version/calver/README.md) for the calculation and supported template fields. @@ -56,6 +56,8 @@ append their configured `*-name` as a matrix suffix, such as `Post Step (System for `post-test-name: System Tests`. Empty phases keep the fixed name and appear as skipped. Command steps and report sections retain the configured name. +Prepare, pre and post commands receive `BUILD_COMMIT_SHA`, the commit the images are built from (the `commit-sha` output). It can differ from `GITHUB_SHA`. Reference it as a shell variable inside the command, for example `post-test-command: ./scripts/test ghcr.io/example/app:$BUILD_COMMIT_SHA`. + The Build workflow enables Git LFS downloads during checkout by default. Set `lfs: false` when the caller does not need Git LFS files. When `lfs` is enabled, the workflow prints the tracked LFS files and fails before image build if any checked-out file is still an unresolved LFS pointer. Image builds use the uploaded source artifact as a local path context, so the hydrated checkout is what gets baked into the image. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 81e82df..458c93a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1113,6 +1113,8 @@ jobs: if: inputs.prepare-command != '' id: prepare_command shell: bash + env: + BUILD_COMMIT_SHA: ${{ steps.determine_sha.outputs.sha }} run: ${{ inputs.prepare-command }} - name: Verify Git LFS checkout @@ -1171,6 +1173,7 @@ jobs: name: ${{ fromJson(needs.prepare.outputs.pre-step-names) }} env: BUILD_STEP_CONFIG: ${{ toJSON(fromJSON(needs.prepare.outputs.pre-step-matrix)[strategy.job-index]) }} + BUILD_COMMIT_SHA: ${{ needs.prepare.outputs.commit-sha }} steps: - name: Download updated source uses: atos-actions/download-artifact@v2 @@ -1305,6 +1308,7 @@ jobs: name: ${{ fromJson(needs.prepare.outputs.post-step-names) }} env: BUILD_STEP_CONFIG: ${{ toJSON(fromJSON(needs.prepare.outputs.post-step-matrix)[strategy.job-index]) }} + BUILD_COMMIT_SHA: ${{ needs.prepare.outputs.commit-sha }} steps: - name: Download updated source uses: atos-actions/download-artifact@v2 diff --git a/tests/test_build_steps.py b/tests/test_build_steps.py index 9b2126c..f85ab05 100644 --- a/tests/test_build_steps.py +++ b/tests/test_build_steps.py @@ -98,6 +98,17 @@ def test_phase_names_are_static_and_only_names_enter_the_matrix(self): self.assertIn(f"name: ${{{{ fromJson(needs.prepare.outputs.{phase}-step-names) }}}}", job) self.assertIn(f"toJSON(fromJSON(needs.prepare.outputs.{phase}-step-matrix)[strategy.job-index])", job) + def test_commands_receive_the_built_commit(self): + workflow = WORKFLOW.read_text() + self.assertIn(" commit-sha: ${{ steps.determine_sha.outputs.sha }}\n", workflow) + for phase in ("pre", "post"): + with self.subTest(phase=phase): + job = re.split(r"\n (?=\S)", workflow.split(f"\n {phase}-steps:\n", 1)[1], maxsplit=1)[0] + self.assertIn(" BUILD_COMMIT_SHA: ${{ needs.prepare.outputs.commit-sha }}\n", job) + prepare_command = workflow.split("\n - name: Run prepare command\n", 1)[1].split("\n - name: ", 1)[0] + self.assertIn(" BUILD_COMMIT_SHA: ${{ steps.determine_sha.outputs.sha }}\n", prepare_command) + self.assertLess(workflow.index(" - name: Determine commit SHA"), workflow.index(" - name: Run prepare command")) + if __name__ == "__main__": unittest.main() From ed94eb1d103cc4aa3738a861b6a73afa81305c32 Mon Sep 17 00:00:00 2001 From: Job Cespedes Date: Sat, 17 Oct 2026 12:47:36 +0000 Subject: [PATCH 4/4] feat(all-green): make the evaluated checks configurable Add checks-include, checks-exclude and fail-on-missing-checks to the action and the reusable workflow. checks-exclude defaults to the advisory Copilot review check, which would otherwise fail the gate while still in progress. allcheckspassed rejects include and exclude together, so a checks-include other than empty or -1 wins. --- .github/workflows/README.md | 2 ++ .github/workflows/all-green.yml | 20 ++++++++++++ Makefile | 4 +++ README.md | 2 ++ all-green/README.md | 17 +++++++++- all-green/action.yml | 18 +++++++++++ tests/test_all_green.js | 55 +++++++++++++++++++++++++++++++++ 7 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 tests/test_all_green.js diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 6dabda6..b185f1f 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -12,6 +12,8 @@ File: `all-green.yml` Wraps the `all-green` composite action as a reusable workflow. Use it when a repository wants one final required check that confirms all other required PR checks have passed. +It accepts the action's `checks-include`, `checks-exclude` and `fail-on-missing-checks` inputs with the same defaults; see [All Green](../../all-green/README.md). + ```yaml jobs: all-green: diff --git a/.github/workflows/all-green.yml b/.github/workflows/all-green.yml index d915d93..dfc5924 100644 --- a/.github/workflows/all-green.yml +++ b/.github/workflows/all-green.yml @@ -2,6 +2,22 @@ name: All Green on: workflow_call: + inputs: + checks-include: + description: 'Comma-separated regexes of the check names to evaluate. Empty or -1 evaluates every check; any other value makes checks-exclude ignored' + required: false + type: string + default: '' + checks-exclude: + description: 'Comma-separated regexes of the check names to ignore; -1 ignores none. The default skips the advisory Copilot review check' + required: false + type: string + default: '^copilot-pull-request-reviewer$' + fail-on-missing-checks: + description: 'Whether to fail when a checks-include pattern matches no check' + required: false + type: boolean + default: false jobs: all-green: @@ -12,3 +28,7 @@ jobs: - name: All Green uses: $/all-green + with: + checks-include: ${{ inputs.checks-include }} + checks-exclude: ${{ inputs.checks-exclude }} + fail-on-missing-checks: ${{ inputs.fail-on-missing-checks }} diff --git a/Makefile b/Makefile index 63afe0b..f6dbc3b 100644 --- a/Makefile +++ b/Makefile @@ -53,3 +53,7 @@ test-build-steps: .PHONY: test-build-checkout test-build-checkout: python3 -B -m unittest discover -s tests -p 'test_build_checkout.py' -v + +.PHONY: test-all-green +test-all-green: + node --test tests/test_all_green.js diff --git a/README.md b/README.md index 29b5698..6fe2cf0 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,8 @@ Run `make test-build-steps` for pre/post matrix naming and configuration tests. Run `make test-build-checkout` for the Build workflow's pull request checkout and commit SHA tests. These require Python 3.10 or newer, Bash, and Git, and run offline. +Run `make test-all-green` for All Green input mapping tests. These require Node.js with `node:test` support and run offline. + For workflow lint, actionlint currently [does not recognize GitHub's `$/` references](https://github.com/rhysd/actionlint/issues/711). Lint a temporary copy with `uses: $/` rewritten to `uses: ./`, preserving the repository layout so local action paths and reusable-workflow inputs are checked. Keep the committed references as `$/`. The `Build cache` PR check calls the Bake composites from the workflow’s commit with a small two-target fixture on native AMD64 and ARM64 runners. It checks separate target/platform scopes, caller overrides, two warm imports on fresh builders, and source/dependency invalidation after all cold exports complete. It publishes only Actions cache entries and temporary test artifacts. Per-run cache prefixes prevent previous PR runs from warming the cold comparison. Measurements appear in job summaries and the `cache-measurements-*` artifacts; these synthetic timings are not consumer build benchmarks. diff --git a/all-green/README.md b/all-green/README.md index ea6365e..9d0ccc1 100644 --- a/all-green/README.md +++ b/all-green/README.md @@ -2,10 +2,25 @@ Checks that all required pull-request checks have passed. -Use this action as a final PR gate after the repository has configured its required checks. It wraps `wechuli/allcheckspassed` with polling disabled, so it reports the current state instead of waiting. +Use this action as a final PR gate after the repository has configured its required checks. It wraps `wechuli/allcheckspassed` with polling disabled, so it reports the current state instead of waiting: a check that has not completed (queued, waiting or running) fails the gate, and a check that has not been created yet is not seen. By default it evaluates every check on the commit except the advisory `copilot-pull-request-reviewer` check, because a Copilot review still in progress would otherwise fail the gate. + +Inputs take comma-separated JavaScript regexes, unanchored and case-sensitive; use `^...$` for exact names. Patterns cannot contain commas, and a value starting with `[` or `{` is read as allcheckspassed's JSON form, so start such a pattern with `^`: + +- `checks-include`: evaluate only matching checks. Empty or `-1` evaluates every check; any other value, even whitespace, makes `checks-exclude` ignored. +- `checks-exclude`: ignore matching checks. Set `-1` to ignore none. +- `fail-on-missing-checks`: fail when a `checks-include` pattern matches no check. Enable it with `checks-include`, since a check that has not been created yet would otherwise pass with only a warning. ```yaml steps: - uses: actions/checkout@v6 - uses: getdevopspro/github-actions/all-green@v8.3.7 ``` + +```yaml +steps: + - uses: actions/checkout@v6 + - uses: getdevopspro/github-actions/all-green@v12.0.0 + with: + checks-include: '^Lint$,^Unit Tests$' + fail-on-missing-checks: true +``` diff --git a/all-green/action.yml b/all-green/action.yml index db8ea1b..06107cb 100644 --- a/all-green/action.yml +++ b/all-green/action.yml @@ -1,6 +1,20 @@ name: "All Green" description: "Checks that all required checks have passed and the PR is green." +inputs: + checks-include: + description: 'Comma-separated regexes of the check names to evaluate. Empty or -1 evaluates every check; any other value makes checks-exclude ignored' + required: false + default: '' + checks-exclude: + description: 'Comma-separated regexes of the check names to ignore; -1 ignores none. The default skips the advisory Copilot review, which would otherwise fail the gate while still in progress' + required: false + default: '^copilot-pull-request-reviewer$' + fail-on-missing-checks: + description: 'Whether to fail when a checks-include pattern matches no check' + required: false + default: 'false' + runs: using: "composite" steps: @@ -10,3 +24,7 @@ runs: poll: false delay: 0 retries: 0 + # allcheckspassed rejects include and exclude together, so include wins. + checks_include: ${{ inputs.checks-include || '-1' }} + checks_exclude: ${{ ((inputs.checks-include == '' || inputs.checks-include == '-1') && inputs.checks-exclude) || '-1' }} + fail_on_missing_checks: ${{ inputs.fail-on-missing-checks }} diff --git a/tests/test_all_green.js b/tests/test_all_green.js new file mode 100644 index 0000000..8aafd9e --- /dev/null +++ b/tests/test_all_green.js @@ -0,0 +1,55 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const root = path.join(__dirname, '..'); +const action = fs.readFileSync(path.join(root, 'all-green/action.yml'), 'utf8'); +const workflow = fs.readFileSync(path.join(root, '.github/workflows/all-green.yml'), 'utf8'); +const COPILOT = '^copilot-pull-request-reviewer$'; + +// Evaluates an allcheckspassed `with:` expression from the action for the given inputs. +function expression(name) { + const source = action.match(new RegExp(`^ ${name}: \\$\\{\\{ (.*) \\}\\}$`, 'm'))[1] + .replace(/\.([\w]+(?:-[\w]+)+)/g, "['$1']"); + return new Function('inputs', `return (${source})`); +} + +function inputBlock(source, name, indent) { + const lines = source.split('\n'); + const start = lines.indexOf(`${' '.repeat(indent)}${name}:`); + assert.notEqual(start, -1, name); + const rest = lines.slice(start + 1); + const end = rest.findIndex(line => !line.startsWith(' '.repeat(indent + 2))); + return rest.slice(0, end).join('\n'); +} + +test('include wins over exclude, so allcheckspassed never receives both', () => { + const include = expression('checks_include'); + const exclude = expression('checks_exclude'); + for (const [inputs, expected] of [ + [{'checks-include': '', 'checks-exclude': COPILOT}, ['-1', COPILOT]], + [{'checks-include': '', 'checks-exclude': ''}, ['-1', '-1']], + [{'checks-include': '', 'checks-exclude': '-1'}, ['-1', '-1']], + [{'checks-include': '-1', 'checks-exclude': COPILOT}, ['-1', COPILOT]], + [{'checks-include': '^Lint$', 'checks-exclude': COPILOT}, ['^Lint$', '-1']], + [{'checks-include': '^Lint$,^Unit Tests$', 'checks-exclude': '^Other$'}, ['^Lint$,^Unit Tests$', '-1']], + ]) { + assert.deepEqual([include(inputs), exclude(inputs)], expected, JSON.stringify(inputs)); + } +}); + +test('the reusable workflow repeats the action defaults and forwards every input', () => { + assert.match(inputBlock(action, 'checks-include', 2), /^ default: ''$/m); + assert.match(inputBlock(workflow, 'checks-include', 6), /^ default: ''$/m); + assert.match(inputBlock(action, 'checks-exclude', 2), /^ default: '\^copilot-pull-request-reviewer\$'$/m); + assert.match(inputBlock(workflow, 'checks-exclude', 6), /^ default: '\^copilot-pull-request-reviewer\$'$/m); + assert.match(inputBlock(action, 'fail-on-missing-checks', 2), /^ default: 'false'$/m); + assert.match(inputBlock(workflow, 'fail-on-missing-checks', 6), /^ default: false$/m); + for (const name of ['checks-include', 'checks-exclude', 'fail-on-missing-checks']) { + assert.ok(workflow.includes(` ${name}: \${{ inputs.${name} }}\n`), name); + } + assert.match(action, /^ fail_on_missing_checks: \$\{\{ inputs\.fail-on-missing-checks \}\}$/m); +});