From 0c966b31ca18d67cdc02b69f46a1a22e87a39a0c Mon Sep 17 00:00:00 2001 From: vib-tools Date: Thu, 27 Aug 2026 19:14:14 -0700 Subject: [PATCH] fix(ci): pin actions and freeze CI toolchains --- .github/workflows/ci.yml | 58 +++++---- constraints/ci-linux-py312.txt | 19 +++ constraints/ci-tools.txt | 6 + constraints/ci-windows-py312.txt | 15 +++ tests/test_ci_reproducibility.py | 152 ++++++++++++++++++++++++ tests/test_p14_distribution_pipeline.py | 6 +- tests/test_repository_contracts.py | 2 +- 7 files changed, 228 insertions(+), 30 deletions(-) create mode 100644 constraints/ci-linux-py312.txt create mode 100644 constraints/ci-tools.txt create mode 100644 constraints/ci-windows-py312.txt create mode 100644 tests/test_ci_reproducibility.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96612ec..19e060c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,14 +15,14 @@ env: jobs: test: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 env: QT_QPA_PLATFORM: offscreen steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: - python-version: '3.12' + python-version: '3.12.14' - name: Install Linux Qt runtime dependencies run: | sudo apt-get update @@ -32,35 +32,41 @@ jobs: libopengl0 \ libxkbcommon-x11-0 \ libxcb-cursor0 + - name: Pin pip + run: python -m pip install --disable-pip-version-check "pip==26.2.1" - name: Install dependencies - run: python -m pip install -r requirements.txt + run: python -m pip install -r requirements.txt -c constraints/ci-linux-py312.txt - name: Audit run: python scripts/test/audit.py windows-test: - runs-on: windows-latest + runs-on: windows-2025 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: - python-version: '3.12' + python-version: '3.12.10' architecture: 'x64' + - name: Pin pip + run: python -m pip install --disable-pip-version-check "pip==26.2.1" - name: Install application dependencies - run: python -m pip install -r requirements.txt + run: python -m pip install -r requirements.txt -c constraints/ci-windows-py312.txt - name: Full regression audit run: python scripts/test/audit.py windows-build: if: startsWith(github.ref, 'refs/tags/v') - runs-on: windows-latest + runs-on: windows-2025 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: - python-version: '3.12' + python-version: '3.12.10' architecture: 'x64' + - name: Pin pip + run: python -m pip install --disable-pip-version-check "pip==26.2.1" - name: Install application dependencies - run: python -m pip install -r requirements.txt + run: python -m pip install -r requirements.txt -c constraints/ci-windows-py312.txt - name: Full regression audit run: python scripts/test/audit.py - name: Verify workflow version mapping @@ -73,7 +79,7 @@ jobs: - name: Build wheel shell: pwsh run: | - python -m pip install --upgrade setuptools wheel + python -m pip install --disable-pip-version-check -r constraints/ci-tools.txt New-Item -ItemType Directory -Force -Path dist\wheel | Out-Null python -m pip wheel . --no-deps --no-build-isolation -w dist\wheel $wheel = (Get-ChildItem dist\wheel\*.whl | Select-Object -First 1).FullName @@ -82,9 +88,9 @@ jobs: shell: pwsh run: | python -m venv .p14-wheel-venv - .\.p14-wheel-venv\Scripts\python.exe -m pip install --upgrade pip + .\.p14-wheel-venv\Scripts\python.exe -m pip install --disable-pip-version-check "pip==26.2.1" $wheel = (Get-ChildItem dist\wheel\*.whl | Select-Object -First 1).FullName - .\.p14-wheel-venv\Scripts\python.exe -m pip install $wheel + .\.p14-wheel-venv\Scripts\python.exe -m pip install -c constraints/ci-windows-py312.txt $wheel - name: Native PySide6/keyring/resource smoke shell: pwsh run: | @@ -178,9 +184,9 @@ jobs: } - name: Setup .NET for WiX - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 with: - dotnet-version: '8.0.x' + dotnet-version: '8.0.424' - name: Install WiX Toolset shell: pwsh @@ -265,7 +271,7 @@ jobs: python scripts/test/p14_distribution_audit.py --release-dir dist\release --version $env:INVIO_VERSION - name: Upload Windows distribution artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: invio-windows-distribution path: dist/release/* @@ -274,18 +280,18 @@ jobs: release: if: startsWith(github.ref, 'refs/tags/v') needs: [test, windows-test, windows-build] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: write steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: - python-version: '3.12' + python-version: '3.12.14' - name: Verify release tag matches Invio version run: python scripts/build/version_info.py --expect-tag "${GITHUB_REF_NAME}" - name: Download Windows distribution artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: invio-windows-distribution path: release diff --git a/constraints/ci-linux-py312.txt b/constraints/ci-linux-py312.txt new file mode 100644 index 0000000..c3cc9fa --- /dev/null +++ b/constraints/ci-linux-py312.txt @@ -0,0 +1,19 @@ +# Invio CI lock: Ubuntu 24.04 / CPython 3.12.14 +# Frozen from successful main CI environment on 2026-08-27. +PySide6==6.11.2 +PySide6_Addons==6.11.2 +PySide6_Essentials==6.11.2 +shiboken6==6.11.2 +openpyxl==3.1.5 +keyring==25.7.0 +truststore==0.10.4 +et-xmlfile==2.0.0 +SecretStorage==3.5.0 +jeepney==0.9.0 +cryptography==50.0.1 +cffi==2.1.1 +pycparser==3.0 +jaraco.classes==3.4.0 +jaraco.context==6.1.2 +jaraco.functools==4.6.0 +more-itertools==11.1.0 diff --git a/constraints/ci-tools.txt b/constraints/ci-tools.txt new file mode 100644 index 0000000..62f63ae --- /dev/null +++ b/constraints/ci-tools.txt @@ -0,0 +1,6 @@ +# Invio tag-build Python tooling lock. +# Versions are taken from the successful published v1.0.0.1.50.1 release build. +pip==26.2.1 +setuptools==84.0.0 +wheel==0.48.0 +packaging==26.3 diff --git a/constraints/ci-windows-py312.txt b/constraints/ci-windows-py312.txt new file mode 100644 index 0000000..cd48f72 --- /dev/null +++ b/constraints/ci-windows-py312.txt @@ -0,0 +1,15 @@ +# Invio CI lock: Windows Server 2025 / CPython 3.12.10 x64 +# Frozen from successful main CI environment on 2026-08-27. +PySide6==6.11.2 +PySide6_Addons==6.11.2 +PySide6_Essentials==6.11.2 +shiboken6==6.11.2 +openpyxl==3.1.5 +keyring==25.7.0 +truststore==0.10.4 +pywin32-ctypes==0.2.3 +et-xmlfile==2.0.0 +jaraco.classes==3.4.0 +jaraco.context==6.1.2 +jaraco.functools==4.6.0 +more-itertools==11.1.0 diff --git a/tests/test_ci_reproducibility.py b/tests/test_ci_reproducibility.py new file mode 100644 index 0000000..83c8964 --- /dev/null +++ b/tests/test_ci_reproducibility.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import re +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = ROOT / ".github" / "workflows" / "ci.yml" + +EXPECTED_ACTION_PINS = { + "actions/checkout": "fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09", + "actions/setup-python": "ece7cb06caefa5fff74198d8649806c4678c61a1", + "actions/setup-dotnet": "26b0ec14cb23fa6904739307f278c14f94c95bf1", + "actions/upload-artifact": "b7c566a772e6b6bfb58ed0dc250532a479d7789f", + "actions/download-artifact": "37930b1c2abaa49bbe596cd826c3c89aef350131", + "Nuitka/Nuitka-Action": "99c9d3ab258c7008c0604617d925574101327e5d", +} + +EXPECTED_LINUX_CONSTRAINTS = { + "PySide6==6.11.2", + "PySide6_Addons==6.11.2", + "PySide6_Essentials==6.11.2", + "shiboken6==6.11.2", + "openpyxl==3.1.5", + "keyring==25.7.0", + "truststore==0.10.4", + "et-xmlfile==2.0.0", + "SecretStorage==3.5.0", + "jeepney==0.9.0", + "cryptography==50.0.1", + "cffi==2.1.1", + "pycparser==3.0", + "jaraco.classes==3.4.0", + "jaraco.context==6.1.2", + "jaraco.functools==4.6.0", + "more-itertools==11.1.0", +} + +EXPECTED_WINDOWS_CONSTRAINTS = { + "PySide6==6.11.2", + "PySide6_Addons==6.11.2", + "PySide6_Essentials==6.11.2", + "shiboken6==6.11.2", + "openpyxl==3.1.5", + "keyring==25.7.0", + "truststore==0.10.4", + "pywin32-ctypes==0.2.3", + "et-xmlfile==2.0.0", + "jaraco.classes==3.4.0", + "jaraco.context==6.1.2", + "jaraco.functools==4.6.0", + "more-itertools==11.1.0", +} + +EXPECTED_TOOL_LOCK = { + "pip==26.2.1", + "setuptools==84.0.0", + "wheel==0.48.0", + "packaging==26.3", +} + + +def _requirements(path: Path) -> set[str]: + return { + line.strip() + for line in path.read_text(encoding="utf-8").splitlines() + if line.strip() and not line.lstrip().startswith("#") + } + + +class CiReproducibilityTests(unittest.TestCase): + def test_all_external_actions_are_immutable_sha_pinned(self): + workflow = WORKFLOW.read_text(encoding="utf-8") + uses_lines = [line.strip() for line in workflow.splitlines() if line.strip().startswith("- uses:")] + self.assertTrue(uses_lines) + pattern = re.compile(r"^- uses: ([^@\s]+)@([0-9a-f]{40})(?:\s+#\s+\S+)?$") + for line in uses_lines: + with self.subTest(line=line): + self.assertIsNotNone(pattern.fullmatch(line)) + self.assertNotRegex(workflow, r"uses:\s+[^\s]+@(v\d+|main|master|latest)(?:\s|$)") + + def test_expected_node24_action_revisions_are_pinned(self): + workflow = WORKFLOW.read_text(encoding="utf-8") + seen: dict[str, set[str]] = {} + for owner_repo, sha in re.findall(r"uses:\s+([^@\s]+)@([0-9a-f]{40})", workflow): + seen.setdefault(owner_repo, set()).add(sha) + self.assertEqual(set(seen), set(EXPECTED_ACTION_PINS)) + for action, expected_sha in EXPECTED_ACTION_PINS.items(): + with self.subTest(action=action): + self.assertEqual(seen[action], {expected_sha}) + + def test_runner_python_and_dotnet_versions_are_explicit(self): + workflow = WORKFLOW.read_text(encoding="utf-8") + self.assertEqual(workflow.count("runs-on: ubuntu-24.04"), 2) + self.assertEqual(workflow.count("runs-on: windows-2025"), 2) + self.assertEqual(workflow.count("python-version: '3.12.14'"), 2) + self.assertEqual(workflow.count("python-version: '3.12.10'"), 2) + self.assertIn("dotnet-version: '8.0.424'", workflow) + for floating in ("ubuntu-latest", "windows-latest", "python-version: '3.12'", "dotnet-version: '8.0.x'"): + with self.subTest(floating=floating): + self.assertNotIn(floating, workflow) + + def test_platform_dependency_constraints_match_frozen_ci_environment(self): + self.assertEqual(_requirements(ROOT / "constraints" / "ci-linux-py312.txt"), EXPECTED_LINUX_CONSTRAINTS) + self.assertEqual(_requirements(ROOT / "constraints" / "ci-windows-py312.txt"), EXPECTED_WINDOWS_CONSTRAINTS) + self.assertEqual(_requirements(ROOT / "constraints" / "ci-tools.txt"), EXPECTED_TOOL_LOCK) + + def test_workflow_uses_constraints_and_exact_build_tools_without_floating_upgrades(self): + workflow = WORKFLOW.read_text(encoding="utf-8") + self.assertIn("python -m pip install -r requirements.txt -c constraints/ci-linux-py312.txt", workflow) + self.assertEqual( + workflow.count("python -m pip install -r requirements.txt -c constraints/ci-windows-py312.txt"), + 2, + ) + self.assertIn("python -m pip install --disable-pip-version-check -r constraints/ci-tools.txt", workflow) + self.assertEqual(workflow.count('python -m pip install --disable-pip-version-check "pip==26.2.1"'), 3) + self.assertIn( + ".\\.p14-wheel-venv\\Scripts\\python.exe -m pip install -c constraints/ci-windows-py312.txt $wheel", + workflow, + ) + self.assertNotIn("pip install --upgrade setuptools wheel", workflow) + self.assertNotIn("pip install --upgrade pip", workflow) + + def test_update_fix_jobs_remain_source_only_and_release_jobs_remain_tag_gated(self): + workflow = WORKFLOW.read_text(encoding="utf-8") + linux_test = workflow.split("\n test:\n", 1)[1].split("\n windows-test:\n", 1)[0] + windows_test = workflow.split("\n windows-test:\n", 1)[1].split("\n windows-build:\n", 1)[0] + windows_build = workflow.split("\n windows-build:\n", 1)[1].split("\n release:\n", 1)[0] + release = workflow.split("\n release:\n", 1)[1] + forbidden = ( + "pip wheel", + "Nuitka", + "wix build", + "msiexec", + "prepare_windows_distribution.py", + "finalize_release_checksums.py", + "actions/upload-artifact", + "gh release", + ) + for job_name, block in (("test", linux_test), ("windows-test", windows_test)): + with self.subTest(job=job_name): + self.assertIn("python scripts/test/audit.py", block) + for fragment in forbidden: + self.assertNotIn(fragment, block) + self.assertIn("if: startsWith(github.ref, 'refs/tags/v')", windows_build) + self.assertIn("if: startsWith(github.ref, 'refs/tags/v')", release) + self.assertIn("needs: [test, windows-test, windows-build]", release) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_p14_distribution_pipeline.py b/tests/test_p14_distribution_pipeline.py index ac51f77..81578c7 100644 --- a/tests/test_p14_distribution_pipeline.py +++ b/tests/test_p14_distribution_pipeline.py @@ -153,8 +153,8 @@ def test_github_workflow_builds_wheel_nuitka_onedir_wix_msi_and_tag_release(self "$wixVersion = (wix --version).Trim()", "$wixCoreVersion = ($wixVersion -split '\\+', 2)[0]", "if ($wixCoreVersion -ne $env:WIX_VERSION)", - "actions/setup-dotnet@v4", - "dotnet-version: '8.0.x'", + "actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1", + "dotnet-version: '8.0.424'", "Nuitka/Nuitka-Action@99c9d3ab258c7008c0604617d925574101327e5d", "mode: standalone", "enable-plugins: pyside6", @@ -173,7 +173,7 @@ def test_github_workflow_builds_wheel_nuitka_onedir_wix_msi_and_tag_release(self "INVIO_P14_COMPILED_TLS_SMOKE", "wix build build\\Invio.wxs -arch x64 -pdbtype none", "p14_distribution_audit.py", - "actions/upload-artifact@v4", + "actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f", "startsWith(github.ref, 'refs/tags/v')", "gh release create", "gh release upload", diff --git a/tests/test_repository_contracts.py b/tests/test_repository_contracts.py index 84234f4..aa09a24 100644 --- a/tests/test_repository_contracts.py +++ b/tests/test_repository_contracts.py @@ -254,7 +254,7 @@ def test_p14_candidate_records_are_truthful_and_packaging_contract_is_present(se self.assertIn('"src.core.settings"', pyproject) self.assertIn('"providers.packages.stripe"', pyproject) self.assertIn('"assets.icons"', pyproject) - self.assertIn("windows-latest", workflow) + self.assertIn("runs-on: windows-2025", workflow) self.assertIn("p14_windows_smoke.py", workflow) self.assertIn("P14 CERTIFICATION PENDING", roadmap) self.assertIn("Production-ready: NO", release)