diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index 72a50a904..772e20e67 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -28,6 +28,7 @@ on: - 'scripts/release/package_versions.sh' - 'scripts/test_package_service_scripts.sh' - 'scripts/test_package_versions.sh' + - 'scripts/test_docker_workflow.py' - 'scripts/security/check_performance_ab_workflow.sh' - 'scripts/security/check_preview_release_workflow.sh' - 'scripts/security/check_tier_artifact_workflow.sh' @@ -46,6 +47,7 @@ on: - 'scripts/release/package_versions.sh' - 'scripts/test_package_service_scripts.sh' - 'scripts/test_package_versions.sh' + - 'scripts/test_docker_workflow.py' - 'scripts/security/check_performance_ab_workflow.sh' - 'scripts/security/check_preview_release_workflow.sh' - 'scripts/security/check_tier_artifact_workflow.sh' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7c611d655..b96bd76c7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -818,9 +818,7 @@ jobs: echo "" echo "🐳 Docker Images:" - if [[ "$BUILD_TYPE" == "preview" ]]; then - echo "⏭️ Preview tags do not publish Docker images" - elif [[ "$INPUT_BUILD_DOCKER" == "false" ]]; then + if [[ "$INPUT_BUILD_DOCKER" == "false" ]]; then echo "⏭️ Docker image build was skipped (binary only build)" elif [[ "$BUILD_STATUS" == "success" ]]; then echo "🔄 Docker images will be built and pushed automatically via workflow_run event" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index eb134bc78..fb9a80481 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -47,7 +47,7 @@ on: default: true type: boolean version: - description: "Version to build (latest for stable release, or specific version like v1.0.0, v1.0.0-alpha1)" + description: "Version to build (latest, v1.0.0, v1.0.0-alpha1, or v1.0.1-preview.1)" required: false default: "latest" type: string @@ -82,8 +82,7 @@ jobs: github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && - github.event.workflow_run.head_branch != 'main' && - !contains(github.event.workflow_run.head_branch, '-preview')) + github.event.workflow_run.head_branch != 'main') runs-on: ubuntu-latest timeout-minutes: 30 outputs: @@ -197,7 +196,14 @@ jobs: version="${version#v}" fi - if [[ "$version" == *"alpha"* ]] || [[ "$version" == *"beta"* ]] || [[ "$version" == *"rc"* ]]; then + if [[ "$version" =~ -preview\.[0-9]+$ ]]; then + build_type="preview" + is_prerelease=true + echo "🔍 Building Docker image for preview: $version (version tags only)" + elif [[ "$version" == *"-preview"* ]]; then + echo "❌ Invalid preview tag: $version (expected suffix: -preview.)" >&2 + exit 1 + elif [[ "$version" == *"alpha"* ]] || [[ "$version" == *"beta"* ]] || [[ "$version" == *"rc"* ]]; then build_type="prerelease" is_prerelease=true # Pre-GA policy: prereleases update latest until the first stable tag exists. @@ -249,11 +255,14 @@ jobs: echo "🚀 Building with latest stable release version" ;; *-preview*) + if [[ ! "$input_version" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?-preview\.[0-9]+$ ]]; then + echo "❌ Invalid preview version: $input_version" >&2 + exit 1 + fi build_type="preview" is_prerelease=true - should_build=false - should_push=false - echo "⏭️ Preview tags do not publish Docker images" + version="${input_version#v}" + echo "🔍 Building with preview version: $version (version tags only)" ;; # Prerelease versions (must match first, more specific) v*alpha*|v*beta*|v*rc*|*alpha*|*beta*|*rc*) @@ -611,6 +620,10 @@ jobs: echo "" case "$BUILD_TYPE" in + "preview") + echo "🔍 Preview Docker image has been built with ${VERSION} tags" + echo "⏭️ Preview images do not update latest or prerelease channels" + ;; "release") echo "🚀 Release Docker image has been built with ${VERSION} tags" echo "✅ This image is ready for production use" diff --git a/scripts/security/check_preview_release_workflow.sh b/scripts/security/check_preview_release_workflow.sh index 5ce21fb8d..3f7456193 100755 --- a/scripts/security/check_preview_release_workflow.sh +++ b/scripts/security/check_preview_release_workflow.sh @@ -203,8 +203,7 @@ IFS= read -r -d '' expected_docker_automatic_guard <<'EOF' || true github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && - github.event.workflow_run.head_branch != 'main' && - !contains(github.event.workflow_run.head_branch, '-preview')) + github.event.workflow_run.head_branch != 'main') EOF expected_docker_automatic_guard=${expected_docker_automatic_guard%$'\n'} require_job_if "$docker_workflow" "build-check" "$expected_docker_automatic_guard" @@ -216,15 +215,7 @@ require_line "$docker_workflow" ' SOURCE_REVISION="$(git rev-parse HEAD require_line "$docker_workflow" ' LABELS="$LABELS,org.opencontainers.image.revision=$SOURCE_REVISION"' "Docker revision label" require_absent "$docker_workflow" 'org.opencontainers.image.revision=${{ github.sha }}' "Docker revision must not use the workflow branch SHA" -docker_manual_guard=$(awk ' - $0 == " *-preview*)" { in_preview = 1 } - in_preview { print } - in_preview && $0 == " ;;" { exit } -' "$docker_workflow") -for assignment in 'build_type="preview"' 'is_prerelease=true' 'should_build=false' 'should_push=false'; do - name="${assignment%%=*}" - require_assignment "$docker_manual_guard" "$name" "${assignment#*=}" -done +python3 scripts/test_docker_workflow.py IFS= read -r -d '' expected_helm_guard <<'EOF' || true if: | diff --git a/scripts/test_docker_workflow.py b/scripts/test_docker_workflow.py new file mode 100755 index 000000000..127f74dda --- /dev/null +++ b/scripts/test_docker_workflow.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""Exercise the Docker workflow's version selection and tags without publishing.""" + +import os +import re +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = ROOT / ".github/workflows/docker.yml" +REGISTRIES = ("rustfs/rustfs", "ghcr.io/rustfs/rustfs", "quay.io/rustfs/rustfs") + + +def run_body(name): + lines = WORKFLOW.read_text().splitlines() + start = lines.index(f" - name: {name}") + start = lines.index(" run: |", start) + 1 + body = [] + for line in lines[start:]: + if line.strip() and not line.startswith(" "): + break + body.append(line[10:]) + return "\n".join(body) + + +class DockerWorkflowTests(unittest.TestCase): + def run_step(self, name, context, **env): + script = re.sub( + r"\$\{\{\s*(.*?)\s*\}\}", lambda m: context[m[1]], run_body(name) + ) + # Fail on unexpected Git calls so these tests cannot contact a remote. + mock_git = """ +git() { + if [[ "$1" == rev-parse ]]; then + echo 0123456789abcdef + elif [[ "$1 $2 $3" == "ls-remote --tags --refs" ]]; then + if [[ "$STABLE_EXISTS" == true ]]; then + echo "0123456789abcdef refs/tags/v1.0.0" + fi + elif [[ "$1 $2" == "ls-remote --exit-code" ]]; then + [[ "$4" == "$EXISTING_TAG" ]] + else + return 99 + fi +} +""" + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "output" + result = subprocess.run( + ["bash", "-e", "-o", "pipefail", "-c", mock_git + script], + env={**os.environ, "GITHUB_OUTPUT": str(output), **env}, + text=True, + capture_output=True, + check=False, + ) + values = ( + dict(line.split("=", 1) for line in output.read_text().splitlines()) + if output.exists() + else {} + ) + return result, values + + def classify( + self, version, event="workflow_run", push="true", stable="true", tag=None, **env + ): + return self.run_step( + "Check build conditions", + {"github.event_name": event}, + GITHUB_SHA="workflow-sha", + HEAD_SHA="release-sha", + HEAD_BRANCH=version, + INPUT_VERSION=version, + INPUT_PUSH_IMAGES=push, + INPUT_FORCE_REBUILD="false", + STABLE_EXISTS=stable, + EXISTING_TAG=tag or f"refs/tags/{version}", + **{"CONCLUSION": "success", "TRIGGERING_EVENT": "push", **env}, + ) + + def assert_tags(self, values, suffix="", channels=()): + context = { + f"needs.build-check.outputs.{key}": value for key, value in values.items() + } + context.update( + { + "matrix.suffix": suffix, + "env.REGISTRY_DOCKERHUB": REGISTRIES[0], + "env.REGISTRY_GHCR": REGISTRIES[1], + "env.REGISTRY_QUAY": REGISTRIES[2], + "github.server_url": "https://github.com", + "github.repository": "rustfs/rustfs", + } + ) + result, metadata = self.run_step("Extract metadata and generate tags", context) + self.assertEqual(result.returncode, 0, result.stderr) + expected = { + f"{registry}:{tag}{suffix}" + for registry in REGISTRIES + for tag in (values["version"], *channels) + } + self.assertEqual(set(metadata["tags"].split(",")), expected) + + def test_preview_version_tags_only(self): + versions = ( + "1.0.1-preview.8", + "v1.0.1-preview.8", + "1.0.0-alpha.1-preview.2", + "1.0.0-beta.1-preview.2", + "v1.0.0-rc.1-preview.2", + ) + for event in ("workflow_run", "workflow_dispatch"): + for version in versions: + for stable in ("true", "false"): + with self.subTest(event=event, version=version, stable=stable): + result, values = self.classify(version, event, stable=stable) + self.assertEqual(result.returncode, 0, result.stderr) + for key, expected in { + "should_build": "true", + "should_push": "true", + "build_type": "preview", + "is_prerelease": "true", + "create_latest": "false", + "version": version.removeprefix("v"), + }.items(): + self.assertEqual(values[key], expected, key) + expected_ref = ( + "release-sha" + if event == "workflow_run" + else f"refs/tags/{version}" + ) + self.assertEqual(values["source_ref"], expected_ref) + for suffix in ("", "-glibc"): + self.assert_tags(values, suffix) + + def test_manual_preview_dry_run_and_tag_fallback(self): + result, values = self.classify( + "1.0.1-preview.8", + "workflow_dispatch", + push="false", + tag="refs/tags/v1.0.1-preview.8", + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(values["should_build"], "true") + self.assertEqual(values["should_push"], "false") + self.assertEqual(values["source_ref"], "refs/tags/v1.0.1-preview.8") + + def test_full_tag_ref(self): + result, values = self.classify("refs/tags/v1.0.1-preview.8") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(values["build_type"], "preview") + self.assertEqual(values["version"], "1.0.1-preview.8") + self.assert_tags(values) + + def test_invalid_preview_and_missing_tag_fail(self): + for event in ("workflow_run", "workflow_dispatch"): + for version in ( + "1.0.1-preview", + "1.0.1-preview.x", + "1.0.1-preview.8-extra", + ): + with self.subTest(event=event, version=version): + result, _ = self.classify(version, event) + self.assertNotEqual(result.returncode, 0) + result, _ = self.classify( + "1.0.1-preview.8", "workflow_dispatch", tag="refs/tags/missing" + ) + self.assertNotEqual(result.returncode, 0) + + def test_existing_release_channels(self): + for event in ("workflow_run", "workflow_dispatch"): + for stable in ("true", "false"): + for channel in (None, "alpha", "beta", "rc"): + version = f"1.0.1-{channel}.1" if channel else "1.0.1" + with self.subTest(event=event, stable=stable, channel=channel): + result, values = self.classify(version, event, stable=stable) + self.assertEqual(result.returncode, 0, result.stderr) + latest = channel is None or stable == "false" + self.assertEqual(values["create_latest"], str(latest).lower()) + channels = ([channel] if channel else []) + ( + ["latest"] if latest else [] + ) + self.assert_tags(values, channels=channels) + + def test_non_release_builds_are_skipped(self): + for version, env in ( + ("main", {}), + ("feature/test", {}), + ("1.0.1-preview.8", {"CONCLUSION": "failure"}), + ("1.0.1-preview.8", {"TRIGGERING_EVENT": "workflow_dispatch"}), + ): + result, values = self.classify(version, **env) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(values["should_build"], "false") + + +if __name__ == "__main__": + unittest.main()