From aa94f3d5f374a6c37c4a1b4db01e5df346c80a33 Mon Sep 17 00:00:00 2001 From: Job Cespedes Date: Sat, 17 Oct 2026 13:36:21 +0000 Subject: [PATCH 1/3] fix(release): export the checked-out commit as commit-sha The workflow-level commit-sha output returned the version instead of the job's commit SHA, and Determine commit SHA still preferred the pull request head, which build.yml dropped in v12.0.0. Take the SHA from the checkout and export it. It names the commit the release is made from, before any version-bump commit. --- .github/workflows/README.md | 2 +- .github/workflows/release.yml | 14 +++---- Makefile | 4 ++ README.md | 2 + tests/test_release_workflow.py | 71 ++++++++++++++++++++++++++++++++++ 5 files changed, 83 insertions(+), 10 deletions(-) create mode 100644 tests/test_release_workflow.py diff --git a/.github/workflows/README.md b/.github/workflows/README.md index b185f1f..9f38833 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -271,7 +271,7 @@ File: `release.yml` Calculates the release version, updates supported version files, optionally downloads build artifacts, can promote container image manifests, optionally generates a changelog, pushes release commits and tags, and can create or update a GitHub release. -With changelog generation and prepend enabled, a missing changelog is initialized from the history reachable from `HEAD`. The workflow's `changelog` output and GitHub release body contain only the current release notes; historical sections are kept in the changelog file. See the [changelog action](../../release/changelog/README.md) for initial-release and range behavior. +With changelog generation and prepend enabled, a missing changelog is initialized from the history reachable from `HEAD`. The workflow's `changelog` output and GitHub release body contain only the current release notes; historical sections are kept in the changelog file. The `commit-sha` output is the checked-out commit the release is made from. When the release has changes to commit (version files, the changelog or other `git-add-files`), the tag points to the version-bump commit made on top of it. See the [changelog action](../../release/changelog/README.md) for initial-release and range behavior. `version-strategy` accepts `semver` (default) or `calver`. Both strategies feed the same version-file, image promotion, changelog, tag, and release steps. CalVer defaults to UTC `YYYY.M.PATCH`; `version-semver-previous` and `version-semver-next` are SemVer-only inputs. Keep `fetch-depth: 0` and `fetch-tags: true` for a complete tag history, or provide all tags when disabling checkout. Calculation reads repository contents; this workflow retains its existing `contents: write` and `packages: write` permissions for release and image publication. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 052e323..4912113 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,8 +14,8 @@ on: description: 'Detected or calculated version' value: ${{ jobs.release.outputs.version }} commit-sha: - description: 'The commit SHA being built' - value: ${{ jobs.release.outputs.version }} + description: 'The checked-out commit SHA the release is made from' + value: ${{ jobs.release.outputs.commit-sha }} changelog: description: 'The changelog for the released version' value: ${{ jobs.release.outputs.changelog }} @@ -304,13 +304,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" - id: release_version_semver diff --git a/Makefile b/Makefile index db3bc9a..1fa7280 100644 --- a/Makefile +++ b/Makefile @@ -57,3 +57,7 @@ test-build-checkout: .PHONY: test-all-green test-all-green: node --test tests/test_all_green.js + +.PHONY: test-release-workflow +test-release-workflow: + python3 -B -m unittest discover -s tests -p 'test_release_workflow.py' -v diff --git a/README.md b/README.md index 764294d..66afabf 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,8 @@ Run `make test-build-checkout` for the Build workflow's pull request checkout an Run `make test-all-green` for All Green input mapping tests. These require Node.js with `node:test` support and run offline. +Run `make test-release-workflow` for Release workflow and release helper 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_release_workflow.py b/tests/test_release_workflow.py new file mode 100644 index 0000000..6c7dc67 --- /dev/null +++ b/tests/test_release_workflow.py @@ -0,0 +1,71 @@ +"""Check the Release workflow and the release helpers it calls.""" + +import os +from pathlib import Path +import re +import subprocess +import tempfile +import textwrap +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +RELEASE = ROOT / ".github/workflows/release.yml" + + +def step(path, name, indent): + block = path.read_text().split(f"\n{' ' * indent}- name: {name}\n", 1)[1] + return re.split(rf"\n {{{indent}}}- |\n(?=\S)", block, maxsplit=1)[0] + + +def script(block): + return textwrap.dedent(re.split(r"run: \|\n", block, maxsplit=1)[1]) + + +def git_env(**extra): + 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", **extra}) + return env + + +def run(command, cwd, env=None, check=True): + return subprocess.run(command, cwd=cwd, env=env or git_env(), check=check, capture_output=True, text=True) + + +def commit(repo, message): + run([ + "git", "-c", "user.name=Release Test", "-c", "user.email=test@example.invalid", + "-c", "commit.gpgsign=false", "commit", "--quiet", "--allow-empty", "-m", message, + ], repo) + + +class CommitShaTests(unittest.TestCase): + def test_commit_sha_is_the_checked_out_commit(self): + block = step(RELEASE, "Determine commit SHA", 6) + self.assertNotIn("pull_request", block) + with tempfile.TemporaryDirectory() as temp: + repo = Path(temp) + output = repo / "output" + output.touch() + run(["git", "init", "--quiet"], repo) + commit(repo, "Test fixture") + result = run(["bash", "-c", script(block)], repo, git_env(GITHUB_OUTPUT=str(output)), check=False) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(output.read_text(), f"sha={run(['git', 'rev-parse', 'HEAD'], repo).stdout.strip()}\n") + + def test_commit_sha_output_exports_the_job_output(self): + workflow = RELEASE.read_text() + outputs = workflow.split("\n outputs:\n", 1)[1].split("\n inputs:\n", 1)[0] + self.assertRegex(outputs, r" commit-sha:\n description: .*\n value: \$\{\{ jobs\.release\.outputs\.commit-sha \}\}\n") + self.assertIn(" commit-sha: ${{ steps.determine_sha.outputs.sha }}\n", workflow) + + def test_commit_sha_is_taken_before_the_release_commits(self): + workflow = RELEASE.read_text() + sha = workflow.index(" - name: Determine commit SHA\n") + self.assertLess(workflow.index(" - name: checkout repository\n"), sha) + for later in (" - name: run make target before push\n", " - name: push git version\n"): + self.assertLess(sha, workflow.index(later)) + + +if __name__ == "__main__": + unittest.main() From 36665cdb93b49789c1dba49899e41f1fc76a3d1c Mon Sep 17 00:00:00 2001 From: Job Cespedes Date: Sat, 17 Oct 2026 13:36:22 +0000 Subject: [PATCH 2/3] fix(version-file): pass the version-file input from both workflows Build and Release declared version-file but never passed it to the version-file action, so setting it wrote nothing. Callers that leave it unset see no change. --- .github/workflows/README.md | 4 +++- .github/workflows/build.yml | 1 + .github/workflows/release.yml | 1 + tests/test_release_workflow.py | 9 +++++++++ 4 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 9f38833..da377a1 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -30,7 +30,7 @@ Calculates a build version, optionally updates version files, runs configurable `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. +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. Set `version-file: path/to/VERSION` to write the bare version into a plain file; the Release workflow accepts the same input. ```yaml jobs: @@ -290,3 +290,5 @@ jobs: To opt into CalVer, add `version-strategy: calver` to `with:` above. The same input selects CalVer in the Build workflow. Serialize publication in the calling workflow to prevent concurrent releases from choosing the same version. For an Ansible collection, use `version-galaxy: galaxy.yml` instead of `version-makefile` and include `galaxy.yml` in `git-add-files`. The workflow updates the collection version before creating the release commit and tag; collection packaging and publishing remain separate. See [Version File](../../version-file/README.md) for accepted versions and file formats, and use a release containing this input. + +To commit a plain version file with the release, set `version-file: VERSION` and include `VERSION` in `git-add-files`. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 458c93a..284743b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1066,6 +1066,7 @@ jobs: uses: $/version-file with: version: ${{ steps.release_version.outputs.version }} + version-file: ${{ inputs.version-file }} version-galaxy: ${{ inputs.version-galaxy }} version-makefile: ${{ inputs.version-makefile }} version-justfile: ${{ inputs.version-justfile }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4912113..3dfd501 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -341,6 +341,7 @@ jobs: uses: $/version-file with: version: ${{ steps.release_version.outputs.version }} + version-file: ${{ inputs.version-file }} version-galaxy: ${{ inputs.version-galaxy }} version-makefile: ${{ inputs.version-makefile }} version-justfile: ${{ inputs.version-justfile }} diff --git a/tests/test_release_workflow.py b/tests/test_release_workflow.py index 6c7dc67..ad6469a 100644 --- a/tests/test_release_workflow.py +++ b/tests/test_release_workflow.py @@ -11,6 +11,7 @@ ROOT = Path(__file__).resolve().parents[1] RELEASE = ROOT / ".github/workflows/release.yml" +BUILD = ROOT / ".github/workflows/build.yml" def step(path, name, indent): @@ -67,5 +68,13 @@ def test_commit_sha_is_taken_before_the_release_commits(self): self.assertLess(sha, workflow.index(later)) +class VersionFileTests(unittest.TestCase): + def test_workflows_pass_version_file_to_the_action(self): + for workflow, name in ((BUILD, "Set version in file"), (RELEASE, "set version in file")): + with self.subTest(workflow=workflow.name): + self.assertIn("\n version-file:\n", workflow.read_text()) + self.assertIn(" version-file: ${{ inputs.version-file }}\n", step(workflow, name, 6)) + + if __name__ == "__main__": unittest.main() From ee3d1382b7d287a6e11497052acdafb74fe4248a Mon Sep 17 00:00:00 2001 From: Job Cespedes Date: Sat, 17 Oct 2026 13:36:22 +0000 Subject: [PATCH 3/3] fix(release): use version-tag-prefix for the tag git-push creates release/git-push always created and pushed v, while the version and release steps use version-tag-prefix. Add a version-tag-prefix input (default v) for the tag and the bump commit message, and pass it from the Release workflow. The default prefix keeps the same tags and messages. --- .github/workflows/release.yml | 1 + release/git-push/README.md | 2 +- release/git-push/action.yml | 22 ++++++----- tests/test_release_workflow.py | 72 ++++++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3dfd501..a3a6b6c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -390,6 +390,7 @@ jobs: id: git_push with: version: ${{ steps.release_version.outputs.version }} + version-tag-prefix: ${{ inputs.version-tag-prefix }} git-user-name: ${{ inputs.git-user-name }} git-user-email: ${{ inputs.git-user-email }} git-add-files: ${{ inputs.git-add-files }} diff --git a/release/git-push/README.md b/release/git-push/README.md index 2be1953..781f462 100644 --- a/release/git-push/README.md +++ b/release/git-push/README.md @@ -2,7 +2,7 @@ Commits release changes, creates the release tag, and pushes both to the repository. -Use this action near the end of a release workflow after version files or changelog files have been updated. It can also create and push a tag when there are no file changes to commit. +Use this action near the end of a release workflow after version files or changelog files have been updated. It can also create and push a tag when there are no file changes to commit. The tag is `version-tag-prefix` (default `v`) followed by `version`; use the same prefix for the version calculation, the changelog and the GitHub release. In the Release workflow, `version-tag-prefix` sets it for every step except the changelog, which uses `changelog-version-prefix`. ```yaml steps: diff --git a/release/git-push/action.yml b/release/git-push/action.yml index 77d5556..f8f079c 100644 --- a/release/git-push/action.yml +++ b/release/git-push/action.yml @@ -4,6 +4,10 @@ inputs: version: description: 'The version to push' required: true + version-tag-prefix: + description: 'The prefix for the release tag; the tag is this prefix followed by version' + required: false + default: 'v' git-user-name: description: 'The git user name' required: false @@ -21,7 +25,7 @@ runs: - name: Push git version shell: bash env: - TAG: ${{ inputs.version }} + TAG: ${{ inputs.version-tag-prefix }}${{ inputs.version }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: | set -euo pipefail @@ -40,16 +44,16 @@ runs: echo "No changes to commit" # Check if the target release tag already exists - if [ -z "$(git tag -l "v${TAG}")" ]; then - echo "Creating and pushing tag v${TAG}" - git tag "v${TAG}" - git push --atomic origin "v${TAG}" + if [ -z "$(git tag -l "${TAG}")" ]; then + echo "Creating and pushing tag ${TAG}" + git tag "${TAG}" + git push --atomic origin "${TAG}" else - echo "Tag v${TAG} already exists" + echo "Tag ${TAG} already exists" fi else echo "Committing changes and pushing" - git commit -m "chore: bump version to v${TAG}" -m "[skip ci]" - git tag "v${TAG}" - git push --atomic origin "${push_target}" "v${TAG}" + git commit -m "chore: bump version to ${TAG}" -m "[skip ci]" + git tag "${TAG}" + git push --atomic origin "${push_target}" "${TAG}" fi diff --git a/tests/test_release_workflow.py b/tests/test_release_workflow.py index ad6469a..32e9f8d 100644 --- a/tests/test_release_workflow.py +++ b/tests/test_release_workflow.py @@ -12,6 +12,7 @@ ROOT = Path(__file__).resolve().parents[1] RELEASE = ROOT / ".github/workflows/release.yml" BUILD = ROOT / ".github/workflows/build.yml" +GIT_PUSH = ROOT / "release/git-push/action.yml" def step(path, name, indent): @@ -76,5 +77,76 @@ def test_workflows_pass_version_file_to_the_action(self): self.assertIn(" version-file: ${{ inputs.version-file }}\n", step(workflow, name, 6)) +class GitPushTests(unittest.TestCase): + def setUp(self): + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + self.root = Path(temp.name) + self.origin = self.root / "origin.git" + self.work = self.root / "work" + run(["git", "init", "--quiet", "--bare", "--initial-branch=main", str(self.origin)], self.root) + run(["git", "clone", "--quiet", str(self.origin), str(self.work)], self.root) + run(["git", "checkout", "--quiet", "-b", "main"], self.work) + (self.work / "Makefile").write_text("VERSION ?= 1.2.2\n") + run(["git", "add", "Makefile"], self.work) + commit(self.work, "Test fixture") + run(["git", "push", "--quiet", "origin", "main"], self.work) + + def push(self, version, add_files, prefix=None): + block = step(GIT_PUSH, "Push git version", 4) + default = re.search(r"\n version-tag-prefix:\n(?: .*\n)*? default: '(.*)'\n", GIT_PUSH.read_text()) + inputs = { + "version": version, + "version-tag-prefix": default.group(1) if prefix is None else prefix, + "git-user-name": "Release Bot", + "git-user-email": "bot@example.invalid", + "git-add-files": add_files, + } + + def render(text): + text = re.sub(r"\$\{\{ inputs\.([\w-]+) \}\}", lambda match: inputs[match.group(1)], text) + text = text.replace("${{ github.event.repository.default_branch }}", "main") + self.assertNotIn("${{", text) + return text + + env = git_env(GITHUB_REF_TYPE="branch") + for line in block.split(" env:\n", 1)[1].split(" run: |\n", 1)[0].splitlines(): + key, value = line.strip().split(": ", 1) + env[key] = render(value) + return run(["bash", "-c", render(script(block))], self.work, env, check=False) + + def remote(self, *args): + return run(["git", "--git-dir", str(self.origin), *args], self.root).stdout.strip() + + def test_default_prefix_tags_the_version_without_changes(self): + result = self.push("1.2.3", "Makefile") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("Creating and pushing tag v1.2.3", result.stdout) + self.assertEqual(self.remote("tag", "--list"), "v1.2.3") + + def test_custom_prefix_names_the_tag_and_bump_commit(self): + (self.work / "Makefile").write_text("VERSION ?= 1.2.3\n") + result = self.push("1.2.3", "Makefile", prefix="release-") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.remote("tag", "--list"), "release-1.2.3") + self.assertEqual(self.remote("log", "-1", "--format=%s", "main"), "chore: bump version to release-1.2.3") + self.assertEqual(self.remote("rev-parse", "release-1.2.3^{commit}"), self.remote("rev-parse", "main")) + + def test_existing_tag_is_not_recreated(self): + for prefix, tag in ((None, "v1.2.3"), ("release-", "release-1.2.3")): + with self.subTest(prefix=prefix): + run(["git", "tag", tag], self.work) + result = self.push("1.2.3", "Makefile", prefix=prefix) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn(f"Tag {tag} already exists", result.stdout) + self.assertEqual(self.remote("tag", "--list"), "") + + def test_release_workflow_passes_its_tag_prefix(self): + self.assertIn( + " version-tag-prefix: ${{ inputs.version-tag-prefix }}\n", + step(RELEASE, "push git version", 6), + ) + + if __name__ == "__main__": unittest.main()