diff --git a/.github/workflows/README.md b/.github/workflows/README.md index b185f1f..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: @@ -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. @@ -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 052e323..a3a6b6c 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 @@ -345,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 }} @@ -393,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/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/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 new file mode 100644 index 0000000..32e9f8d --- /dev/null +++ b/tests/test_release_workflow.py @@ -0,0 +1,152 @@ +"""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" +BUILD = ROOT / ".github/workflows/build.yml" +GIT_PUSH = ROOT / "release/git-push/action.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)) + + +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)) + + +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()