diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 22948ee..820ca6d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -93,6 +93,8 @@ jobs: pwsh -NoProfile -Command '$null = [scriptblock]::Create((Get-Content -Raw scripts/install.ps1))' - name: Verify release contracts run: | + python -m pip install --only-binary :all: PyYAML==6.0.2 + python scripts/ci/test-release-cache-policy.py python scripts/ci/test-native-prerequisites.py node --test scripts/ci/test-release-workflow-metadata.js node --test scripts/ci/test-standalone-runtime.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7ccbc57..963d375 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -466,12 +466,13 @@ jobs: steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: - ref: ${{ needs.resolve-release.outputs.commit }} + ref: ${{ github.sha }} + persist-credentials: false - name: Checkout qualified phpmicro toolchain policy uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: - ref: ${{ needs.resolve-release.outputs.control_commit }} + ref: ${{ github.sha }} path: release-toolchain-control persist-credentials: false sparse-checkout: | @@ -552,7 +553,7 @@ jobs: (needs.resolve-release.outputs.cache_warm != 'true' && ((github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main')))) - uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: build/.tools/native-prerequisite-cache/musl-${{ env.MUSL_VERSION }}.tar.gz key: native-prerequisite-${{ env.NATIVE_PREREQUISITE_CACHE_SCHEMA }}-musl-${{ env.MUSL_VERSION }}-${{ env.MUSL_SHA256 }} @@ -622,6 +623,23 @@ jobs: )" echo "toolchain_id=$native_toolchain_id" >> "$GITHUB_OUTPUT" + - name: Save verified native prerequisites for trusted cache warm + if: >- + runner.os == 'Linux' && + github.repository == 'durable-workflow/cli' && + needs.resolve-release.outputs.cache_warm == 'true' && + github.ref == 'refs/heads/main' && + needs.resolve-release.outputs.commit == github.sha && + needs.resolve-release.outputs.control_commit == github.sha && + (github.event_name == 'push' || + github.event_name == 'schedule' || + github.event_name == 'workflow_dispatch') && + steps.native-prerequisite-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: build/.tools/native-prerequisite-cache/musl-${{ env.MUSL_VERSION }}.tar.gz + key: native-prerequisite-${{ env.NATIVE_PREREQUISITE_CACHE_SCHEMA }}-musl-${{ env.MUSL_VERSION }}-${{ env.MUSL_SHA256 }} + - name: Resolve exact phpmicro cache identity id: phpmicro-toolchain shell: bash @@ -694,7 +712,7 @@ jobs: (needs.resolve-release.outputs.cache_warm != 'true' && ((github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main')))) - uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: | build/.tools/buildroot/bin/micro.sfx @@ -836,6 +854,24 @@ jobs: cat standalone-runtime-verification.json rm standalone-runtime-probe + - name: Save verified phpmicro for trusted cache warm + if: >- + github.repository == 'durable-workflow/cli' && + needs.resolve-release.outputs.cache_warm == 'true' && + github.ref == 'refs/heads/main' && + needs.resolve-release.outputs.commit == github.sha && + needs.resolve-release.outputs.control_commit == github.sha && + (github.event_name == 'push' || + github.event_name == 'schedule' || + github.event_name == 'workflow_dispatch') && + steps.phpmicro-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + build/.tools/buildroot/bin/micro.sfx + build/.tools/phpmicro-cache.manifest + key: phpmicro-${{ env.PHPMICRO_CACHE_SCHEMA }}-${{ steps.phpmicro-toolchain.outputs.toolchain_id }} + - name: Record phpmicro toolchain timing if: always() working-directory: build/.tools @@ -1048,12 +1084,13 @@ jobs: steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: - ref: ${{ needs.resolve-release.outputs.commit }} + ref: ${{ github.sha }} + persist-credentials: false - name: Checkout qualified phpmicro toolchain policy uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: - ref: ${{ needs.resolve-release.outputs.control_commit }} + ref: ${{ github.sha }} path: release-toolchain-control persist-credentials: false sparse-checkout: | @@ -1239,7 +1276,7 @@ jobs: (needs.resolve-release.outputs.cache_warm != 'true' && ((github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main')))) - uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: | build/.tools/buildroot/bin/micro.sfx @@ -1423,6 +1460,24 @@ jobs: Get-Content -Raw standalone-runtime-verification.json Remove-Item -Force $runtimeProbe + - name: Save verified phpmicro for trusted cache warm + if: >- + github.repository == 'durable-workflow/cli' && + needs.resolve-release.outputs.cache_warm == 'true' && + github.ref == 'refs/heads/main' && + needs.resolve-release.outputs.commit == github.sha && + needs.resolve-release.outputs.control_commit == github.sha && + (github.event_name == 'push' || + github.event_name == 'schedule' || + github.event_name == 'workflow_dispatch') && + steps.phpmicro-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + build/.tools/buildroot/bin/micro.sfx + build/.tools/phpmicro-cache.manifest + key: phpmicro-${{ env.PHPMICRO_CACHE_SCHEMA }}-${{ steps.phpmicro-toolchain.outputs.toolchain_id }} + - name: Record phpmicro toolchain timing if: always() shell: pwsh diff --git a/scripts/ci/test-release-cache-policy.py b/scripts/ci/test-release-cache-policy.py new file mode 100644 index 0000000..7235d1b --- /dev/null +++ b/scripts/ci/test-release-cache-policy.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 + +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +import yaml + + +ROOT = Path(__file__).resolve().parents[2] +WORKFLOW = ROOT / ".github/workflows/release.yml" +CACHE_REVISION = "caa296126883cff596d87d8935842f9db880ef25" +MAIN = "a" * 40 +RELEASE = "b" * 40 + + +def compact(expression: str) -> str: + return " ".join(expression.split()) + + +class ReleaseCachePolicyTest(unittest.TestCase): + def setUp(self) -> None: + self.workflow = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) + + def native_jobs(self): + for name in ("build-binary", "build-binary-windows"): + yield self.workflow["jobs"][name] + + def test_restores_have_no_automatic_save_post_action(self) -> None: + actions = [ + step + for job in self.native_jobs() + for step in job["steps"] + if step.get("uses", "").startswith("actions/cache") + ] + self.assertEqual(6, len(actions)) + self.assertEqual( + {f"actions/cache/{mode}@{CACHE_REVISION}" for mode in ("restore", "save")}, + {step["uses"] for step in actions}, + ) + for job in self.native_jobs(): + restores = { + step["id"]: step + for step in job["steps"] + if step.get("uses", "").startswith("actions/cache/restore@") + } + saves = [ + step for step in job["steps"] + if step.get("uses", "").startswith("actions/cache/save@") + ] + self.assertEqual(len(restores), len(saves)) + for save in saves: + cache = ( + "native-prerequisite-cache" + if save["with"]["key"].startswith("native-prerequisite-") + else "phpmicro-cache" + ) + self.assertEqual(restores[cache]["with"], save["with"]) + self.assertNotIn("restore-keys", save["with"]) + + def test_only_verified_default_branch_cache_warming_can_save(self) -> None: + saves = [ + (job, index, step) + for job in self.native_jobs() + for index, step in enumerate(job["steps"]) + if step.get("uses", "").startswith("actions/cache/save@") + ] + self.assertEqual(3, len(saves)) + for job, index, step in saves: + native = step["with"]["key"].startswith("native-prerequisite-") + cache = "native-prerequisite-cache" if native else "phpmicro-cache" + expected = ( + ("runner.os == 'Linux' && " if native else "") + + "github.repository == 'durable-workflow/cli' && " + + "needs.resolve-release.outputs.cache_warm == 'true' && " + + "github.ref == 'refs/heads/main' && " + + "needs.resolve-release.outputs.commit == github.sha && " + + "needs.resolve-release.outputs.control_commit == github.sha && " + + "(github.event_name == 'push' || " + + "github.event_name == 'schedule' || " + + "github.event_name == 'workflow_dispatch') && " + + f"steps.{cache}.outputs.cache-hit != 'true'" + ) + self.assertEqual(expected, compact(step["if"])) + guard = "native-prerequisites" if native else "runtime-policy" + guard_index = next( + i for i, candidate in enumerate(job["steps"]) + if candidate.get("id") == guard + ) + self.assertGreater(index, guard_index) + self.assertNotIn("continue-on-error", job["steps"][guard_index]) + self.assertNotIn("if", job["steps"][guard_index]) + + def test_cache_writing_jobs_only_check_out_the_trusted_event_commit(self) -> None: + for job in self.native_jobs(): + checkouts = [ + step for step in job["steps"] + if step.get("uses", "").startswith("actions/checkout@") + ] + self.assertEqual(2, len(checkouts)) + for checkout in checkouts: + self.assertEqual("${{ github.sha }}", checkout["with"]["ref"]) + self.assertIs(False, checkout["with"]["persist-credentials"]) + + def resolve(self, event, ref, operation="", commit="", tag=""): + step = next( + step + for step in self.workflow["jobs"]["resolve-release"]["steps"] + if step.get("id") == "resolve" + ) + sha = RELEASE if ref.startswith("refs/tags/") else MAIN + with tempfile.TemporaryDirectory(prefix="cli-release-cache-") as directory: + output = Path(directory) / "outputs" + env = dict(os.environ) + env.update({ + "GITHUB_EVENT_NAME": event, + "GITHUB_REF_NAME": ref.removeprefix("refs/heads/").removeprefix("refs/tags/"), + "GITHUB_OUTPUT": str(output), + "DISPATCH_OPERATION": operation, + "DISPATCH_TAG": tag, + "DISPATCH_COMMIT": commit, + "PUSH_COMMIT": sha, + "CONTROL_REF": ref, + "CONTROL_COMMIT": sha, + }) + result = subprocess.run( + ["bash", "-euo", "pipefail", "-c", step["run"]], + cwd=ROOT, + env=env, + capture_output=True, + text=True, + timeout=10, + ) + values = { + key: value + for key, _, value in ( + line.partition("=") for line in output.read_text().splitlines() + ) + } if output.exists() else {} + return result, values, sha + + def test_warm_events_use_the_same_protected_source_and_control(self) -> None: + for event, operation in ( + ("push", ""), + ("schedule", ""), + ("workflow_dispatch", "phpmicro-cache-warm"), + ): + with self.subTest(event=event): + result, values, sha = self.resolve( + event, "refs/heads/main", operation, RELEASE, "2.3.3" + ) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual("true", values["cache_warm"]) + self.assertEqual("", values["tag"]) + self.assertEqual(sha, values["commit"]) + self.assertEqual(sha, values["control_commit"]) + self.assertEqual("refs/heads/main", values["control_ref"]) + + def test_tag_and_manual_releases_never_select_cache_warming(self) -> None: + for event, ref, operation, commit, tag in ( + ("push", "refs/tags/2.3.3", "", "", ""), + ("workflow_dispatch", "refs/heads/main", "release", RELEASE, "2.3.3"), + ): + with self.subTest(event=event): + result, values, _ = self.resolve(event, ref, operation, commit, tag) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual("false", values["cache_warm"]) + self.assertEqual(RELEASE, values["commit"]) + self.assertEqual("2.3.3", values["tag"]) + + def test_untrusted_refs_and_incomplete_releases_are_rejected(self) -> None: + for event, ref, operation, commit, tag in ( + ("schedule", "refs/heads/topic", "", "", ""), + ("workflow_dispatch", "refs/heads/topic", "phpmicro-cache-warm", "", ""), + ("workflow_dispatch", "refs/heads/topic", "release", RELEASE, "2.3.3"), + ("workflow_dispatch", "refs/heads/main", "release", RELEASE, ""), + ("workflow_dispatch", "refs/heads/main", "release", "short", "2.3.3"), + ): + with self.subTest(event=event, ref=ref, operation=operation, commit=commit): + result, values, _ = self.resolve(event, ref, operation, commit, tag) + self.assertNotEqual(0, result.returncode) + self.assertEqual({}, values) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/ReleaseInstallerContractTest.php b/tests/ReleaseInstallerContractTest.php index 54d48bd..0fe2086 100644 --- a/tests/ReleaseInstallerContractTest.php +++ b/tests/ReleaseInstallerContractTest.php @@ -285,13 +285,13 @@ public function test_release_phpmicro_toolchain_is_pinned_verified_and_trust_sco self::assertStringNotContainsString('/nightly/', $releaseWorkflow); self::assertStringNotContainsString('git clone', $releaseWorkflow); - self::assertSame(3, substr_count($releaseWorkflow, "github.repository == 'durable-workflow/cli'")); + self::assertSame(6, substr_count($releaseWorkflow, "github.repository == 'durable-workflow/cli'")); self::assertSame(3, substr_count($releaseWorkflow, "github.event_name != 'pull_request'")); self::assertSame(3, substr_count( $releaseWorkflow, "(github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main')", )); - self::assertSame(2, substr_count( + self::assertSame(4, substr_count( $releaseWorkflow, 'key: phpmicro-${{ env.PHPMICRO_CACHE_SCHEMA }}-${{ steps.phpmicro-toolchain.outputs.toolchain_id }}', )); @@ -320,12 +320,12 @@ public function test_release_phpmicro_toolchain_is_pinned_verified_and_trust_sco ] as $cacheInput) { self::assertGreaterThanOrEqual(2, substr_count($releaseWorkflow, $cacheInput), $cacheInput); } - self::assertSame(2, substr_count($releaseWorkflow, 'build/.tools/buildroot/bin/micro.sfx')); + self::assertSame(4, substr_count($releaseWorkflow, 'build/.tools/buildroot/bin/micro.sfx')); self::assertSame(2, substr_count( $releaseWorkflow, 'Checkout qualified phpmicro toolchain policy', )); - self::assertSame(5, substr_count( + self::assertSame(3, substr_count( $releaseWorkflow, 'ref: ${{ needs.resolve-release.outputs.control_commit }}', )); @@ -404,7 +404,7 @@ public function test_release_checks_the_produced_runtime_before_combining_the_ap self::assertIsInt($guard); self::assertIsInt($combine); self::assertLessThan($combine, $guard); - $stepEnd = strpos($platform, '- name: Record phpmicro toolchain timing', $guard); + $stepEnd = strpos($platform, "\n - ", $guard); self::assertIsInt($stepEnd); $step = substr($platform, $guard, $stepEnd - $guard); self::assertStringNotContainsString('if:', $step); @@ -451,7 +451,7 @@ public function test_release_phpmicro_cache_has_a_non_publishing_default_branch_ "needs.resolve-release.outputs.cache_warm != 'true' &&\n". " ((github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))", )); - self::assertSame(2, substr_count( + self::assertSame(4, substr_count( $releaseWorkflow, 'key: phpmicro-${{ env.PHPMICRO_CACHE_SCHEMA }}-${{ steps.phpmicro-toolchain.outputs.toolchain_id }}', )); @@ -526,7 +526,7 @@ public function test_release_binds_the_exact_source_at_publication(): void self::assertStringContainsString('release-control/scripts/verify-public-release-assets.sh', $releaseWorkflow); self::assertStringContainsString('durable-workflow.cli.release-control-authority/v1', $releaseWorkflow); self::assertStringContainsString('"control": {"ref": "%s", "commit": "%s"}', $releaseWorkflow); - self::assertSame(5, substr_count($releaseWorkflow, 'ref: ${{ needs.resolve-release.outputs.commit }}')); + self::assertSame(3, substr_count($releaseWorkflow, 'ref: ${{ needs.resolve-release.outputs.commit }}')); self::assertStringNotContainsString('|| github.ref }}', $releaseWorkflow); self::assertStringContainsString('EXPECTED_COMMIT: ${{ needs.resolve-release.outputs.commit }}', $releaseWorkflow); self::assertSame(2, substr_count($releaseWorkflow, 'RELEASE_COMMIT: ${{ needs.resolve-release.outputs.commit }}')); @@ -563,7 +563,7 @@ public function test_release_rejects_tag_movement_before_publication(): void self::assertTrue(chmod($fakeGh, 0755)); self::assertStringContainsString('PUSH_COMMIT: ${{ github.sha }}', $releaseWorkflow); - self::assertSame(5, substr_count($releaseWorkflow, 'ref: ${{ needs.resolve-release.outputs.commit }}')); + self::assertSame(3, substr_count($releaseWorkflow, 'ref: ${{ needs.resolve-release.outputs.commit }}')); self::assertSame(2, substr_count($releaseWorkflow, 'RELEASE_COMMIT: ${{ needs.resolve-release.outputs.commit }}')); $environment = [