diff --git a/.github/workflows/README.md b/.github/workflows/README.md index ad021e5..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: @@ -22,7 +24,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. 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. @@ -54,6 +58,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/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/.github/workflows/build.yml b/.github/workflows/build.yml index a6794c6..458c93a 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' @@ -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 }} @@ -999,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 @@ -1058,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 }} @@ -1068,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 @@ -1086,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 @@ -1144,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 @@ -1278,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/Makefile b/Makefile index a5af09c..f6dbc3b 100644 --- a/Makefile +++ b/Makefile @@ -49,3 +49,11 @@ 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 + +.PHONY: test-all-green +test-all-green: + node --test tests/test_all_green.js diff --git a/README.md b/README.md index 5273478..6fe2cf0 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,10 @@ 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. + +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); +}); diff --git a/tests/test_build_checkout.py b/tests/test_build_checkout.py new file mode 100644 index 0000000..7a4c8f6 --- /dev/null +++ b/tests/test_build_checkout.py @@ -0,0 +1,122 @@ +"""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_and_metadata_follow_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")) + 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__": + unittest.main() 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()