diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 03de5f6..e3a2c26 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -3,8 +3,10 @@ name: Publish Docker Image on: push: branches: - - main - dev + tags: + - "[0-9]*.[0-9]*.[0-9]*" + - "v[0-9]*.[0-9]*.[0-9]*" workflow_dispatch: inputs: image_repository: @@ -50,6 +52,12 @@ jobs: version="$(python -m setuptools_scm)" echo "version=${version}" >> "$GITHUB_OUTPUT" + - name: Validate release channel + id: release_channel + env: + PACKAGE_VERSION: ${{ steps.package_version.outputs.version }} + run: bash scripts/docker-release-channel.sh + - name: Log in to Docker Hub uses: docker/login-action@v3 with: @@ -77,7 +85,7 @@ jobs: with: images: ${{ steps.image_repo.outputs.image_repository }} tags: | - type=raw,value=main,enable=${{ github.ref == 'refs/heads/main' }} + type=raw,value=main,enable=${{ steps.release_channel.outputs.channel == 'main' }} type=raw,value=dev,enable=${{ github.ref == 'refs/heads/dev' }} type=raw,value=v${{ steps.package_version.outputs.version }} type=sha,format=short @@ -99,10 +107,10 @@ jobs: - name: Notify Home Assistant add-on repository if: >- github.repository == 'openhop-dev/openhop_repeater' && - (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') + (steps.release_channel.outputs.channel == 'main' || steps.release_channel.outputs.channel == 'dev') env: DISPATCH_TOKEN: ${{ secrets.HA_ADDON_REPO_DISPATCH_TOKEN }} - CHANNEL: ${{ github.ref_name }} + CHANNEL: ${{ steps.release_channel.outputs.channel }} REVISION: ${{ github.sha }} run: | if [ -z "${DISPATCH_TOKEN}" ]; then diff --git a/scripts/docker-release-channel.sh b/scripts/docker-release-channel.sh new file mode 100644 index 0000000..ba42709 --- /dev/null +++ b/scripts/docker-release-channel.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Validate stable publication before Docker login/build. Dev remains push-driven. +set -euo pipefail + +channel="" +case "${GITHUB_REF:?}" in + refs/heads/dev) channel=dev ;; + refs/heads/main|refs/tags/*) + git fetch origin main + git merge-base --is-ancestor HEAD FETCH_HEAD || { + printf '%s\n' 'Release commit must belong to main.' >&2 + exit 1 + } + if [[ "$GITHUB_REF" == refs/tags/* ]]; then + tag="${GITHUB_REF#refs/tags/}" + else + tag="$(git describe --tags --exact-match HEAD)" + fi + [[ "$tag" =~ ^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || { + printf '%s\n' 'Main publication requires an exact stable X.Y.Z or vX.Y.Z tag.' >&2 + exit 1 + } + [[ "$(git rev-parse "refs/tags/${tag}^{commit}")" == "$(git rev-parse HEAD)" ]] || exit 1 + [[ "${PACKAGE_VERSION:?}" == "${tag#v}" ]] || { + printf '%s\n' 'Computed package version does not match the release tag.' >&2 + exit 1 + } + channel=main + ;; +esac +printf 'channel=%s\n' "$channel" >> "${GITHUB_OUTPUT:?}" diff --git a/tests/test_docker_release_channel.py b/tests/test_docker_release_channel.py new file mode 100644 index 0000000..1ff9c33 --- /dev/null +++ b/tests/test_docker_release_channel.py @@ -0,0 +1,89 @@ +"""Exercise publication guards with real temporary Git history; no registry writes.""" + +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + + +SCRIPT = Path(__file__).resolve().parents[1] / "scripts/docker-release-channel.sh" + + +class ReleaseChannelTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.repo = self.root / "repo" + self.repo.mkdir() + self.git("init", "-b", "main") + self.git("config", "user.name", "Test") + self.git("config", "user.email", "test@example.invalid") + self.git("commit", "--allow-empty", "-m", "base") + self.git("remote", "add", "origin", str(self.repo)) + self.git("tag", "1.2.3") + + def git(self, *args): + return subprocess.run( + ["git", *args], cwd=self.repo, check=True, capture_output=True, text=True + ).stdout.strip() + + def run_guard(self, ref, version, channel=None): + output = self.root / "output" + output.write_text("") + result = subprocess.run( + ["bash", str(SCRIPT)], + cwd=self.repo, + env={**os.environ, "GITHUB_REF": ref, "PACKAGE_VERSION": version, + "GITHUB_OUTPUT": str(output)}, + capture_output=True, + text=True, + ) + if channel is None: + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertEqual(output.read_text(), "") + else: + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(output.read_text(), f"channel={channel}\n") + + def test_release_tag(self): + self.run_guard("refs/tags/1.2.3", "1.2.3", "main") + + def test_v_release_tag(self): + self.git("tag", "v1.2.3") + self.run_guard("refs/tags/v1.2.3", "1.2.3", "main") + + def test_manual_tagged_main(self): + self.run_guard("refs/heads/main", "1.2.3", "main") + + def test_untagged_main(self): + self.git("commit", "--allow-empty", "-m", "untagged") + self.run_guard("refs/heads/main", "1.2.4.dev1") + + def test_prerelease_rejected(self): + self.git("tag", "1.2.4.dev1") + self.run_guard("refs/tags/1.2.4.dev1", "1.2.4.dev1") + + def test_version_mismatch_rejected(self): + self.run_guard("refs/tags/1.2.3", "1.2.3.dev348") + + def test_tag_on_non_main_commit_rejected(self): + self.git("checkout", "-b", "dev") + self.git("commit", "--allow-empty", "-m", "dev only") + self.git("tag", "1.2.4") + self.run_guard("refs/tags/1.2.4", "1.2.4") + + def test_tag_points_elsewhere_rejected(self): + self.git("commit", "--allow-empty", "-m", "new main") + self.run_guard("refs/tags/1.2.3", "1.2.3") + + def test_dev_unchanged(self): + self.run_guard("refs/heads/dev", "1.2.4.dev7", "dev") + + def test_feature_manual_does_not_publish_main(self): + self.run_guard("refs/heads/fix/example", "1.2.4.dev7", "") + + +if __name__ == "__main__": + unittest.main()