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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.

Expand All @@ -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`.
1 change: 1 addition & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
16 changes: 7 additions & 9 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -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 }}
Expand Down
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion release/git-push/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
22 changes: 13 additions & 9 deletions release/git-push/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
152 changes: 152 additions & 0 deletions tests/test_release_workflow.py
Original file line number Diff line number Diff line change
@@ -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()
Loading