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
2 changes: 2 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 62 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
188 changes: 188 additions & 0 deletions scripts/ci/test-release-cache-policy.py
Original file line number Diff line number Diff line change
@@ -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()
16 changes: 8 additions & 8 deletions tests/ReleaseInstallerContractTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}',
));
Expand Down Expand Up @@ -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 }}',
));
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 }}',
));
Expand Down Expand Up @@ -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 }}'));
Expand Down Expand Up @@ -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 = [
Expand Down
Loading