Skip to content

Commit de249c7

Browse files
Enforce immutable Actions and untrusted contribution boundaries
1 parent 43f3710 commit de249c7

7 files changed

Lines changed: 88 additions & 33 deletions

.github/workflows/docs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ jobs:
4040

4141
deploy:
4242
needs: build
43+
if: github.ref == 'refs/heads/main'
4344
runs-on: ubuntu-latest
4445
permissions:
4546
contents: read

.github/workflows/publish.yml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,8 @@ jobs:
109109
runs-on: ubuntu-latest
110110
if: >-
111111
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) ||
112-
(github.event_name == 'workflow_dispatch' && inputs.publish)
112+
(github.event_name == 'workflow_dispatch' &&
113+
github.ref == 'refs/heads/main' && inputs.publish)
113114
environment: pypi
114115
permissions:
115116
contents: write
@@ -174,7 +175,10 @@ jobs:
174175
publish-test:
175176
needs: build
176177
runs-on: ubuntu-latest
177-
if: github.event_name == 'workflow_dispatch' && !inputs.dry_run && !inputs.publish
178+
if: >-
179+
github.event_name == 'workflow_dispatch' &&
180+
github.ref == 'refs/heads/main' &&
181+
!inputs.dry_run && !inputs.publish
178182
environment: test-pypi
179183
steps:
180184
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8

.github/workflows/release-plan-recovery.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ concurrency:
2424
jobs:
2525
recover:
2626
name: Recover exact Python SDK release
27+
if: github.ref == 'refs/heads/main'
2728
runs-on: ubuntu-latest
2829
timeout-minutes: 10
2930
permissions:
@@ -78,7 +79,7 @@ jobs:
7879
set -euo pipefail
7980
decision=
8081
for attempt in 1 2 3 4 5 6; do
81-
gh run list --workflow publish.yml --limit 100 \
82+
gh run list --workflow publish.yml --event workflow_dispatch --branch main --limit 100 \
8283
--json databaseId,displayTitle,headBranch,headSha,status,conclusion \
8384
> publication-runs.json
8485
decision="$(python scripts/ci/component-release-recovery.py select-publication-run \
@@ -93,7 +94,7 @@ jobs:
9394
fi
9495
done
9596
if [ "$publication_action" = dispatch ]; then
96-
gh workflow run publish.yml --ref "$RELEASE_TAG" \
97+
gh workflow run publish.yml --ref main \
9798
-f release_tag="$RELEASE_TAG" -f release_plan="$PLAN_TAG" -f publish=true
9899
else
99100
printf 'Durable publication run %s is %s/%s; no duplicate dispatch is needed.\n' \

scripts/ci/cli-release-plan-recovery.fixture.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ concurrency:
2323
jobs:
2424
discover:
2525
name: Discover exact CLI release
26+
if: >-
27+
github.event_name != 'workflow_dispatch' ||
28+
github.ref == 'refs/heads/main'
2629
runs-on: ubuntu-latest
2730
timeout-minutes: 10
2831
permissions:
@@ -74,7 +77,9 @@ jobs:
7477
publish:
7578
name: Publish exact CLI release
7679
needs: discover
77-
if: needs.discover.outputs.action == 'publish'
80+
if: >-
81+
github.ref == 'refs/heads/main' &&
82+
needs.discover.outputs.action == 'publish'
7883
runs-on: ubuntu-latest
7984
timeout-minutes: 45
8085
environment: release-plan-publication

scripts/ci/component-release-recovery.py

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -59,14 +59,14 @@
5959
# SHA-256 of durable-workflow/cli's protected release recovery workflow.
6060
# Exact source identity is required because source-pattern matching cannot
6161
# prove that tag creation remains inside the protected repository authority.
62-
CLI_RELEASE_RECOVERY_SHA256 = "29fde398856e0db8c43c14dc3c3544fcb21ad9c79db71661db1637e8f89c588a"
62+
CLI_RELEASE_RECOVERY_SHA256 = "de1e7f37bcbadf3644b53d127abcae11ec823fd6602a682a58617c6b257dae11"
6363

6464
# SHA-256 of durable-workflow/sdk-rust's prepared-plan recovery workflow. The
6565
# verifier normalizes only
6666
# CRLF line endings to LF before hashing. Exact source identity is the bounded
6767
# security contract because arbitrary shell execution cannot be proven safe by
6868
# source-pattern matching.
69-
SDK_RUST_RELEASE_RECOVERY_SHA256 = "0055b197f7ef3f826275bd5514a33891a9cbf10f5d74b9fefa1647f5eefe868a"
69+
SDK_RUST_RELEASE_RECOVERY_SHA256 = "d6bca15d3f09aa3e7ecf6fc796b81a008e3e4cba2fdea10391f8ede0cab3548c"
7070

7171

7272
@dataclass(frozen=True)
@@ -772,8 +772,10 @@ def verify_recovery_workflow_source(name: str, source: str) -> None:
772772
if component.release_workflow is None:
773773
return
774774

775+
dispatch_ref = component.default_branch if name == "sdk-python" else '"$RELEASE_TAG"'
775776
dispatch = re.search(
776-
rf'gh\s+workflow\s+run\s+{re.escape(component.release_workflow)}\s+--ref\s+"\$RELEASE_TAG"',
777+
rf"gh\s+workflow\s+run\s+{re.escape(component.release_workflow)}\s+--ref\s+"
778+
rf"{re.escape(dispatch_ref)}(?=\s|$)",
777779
source,
778780
)
779781
tag_ref_at = source.find('-f ref="refs/tags/$RELEASE_TAG"')
@@ -791,7 +793,7 @@ def verify_recovery_workflow_source(name: str, source: str) -> None:
791793
):
792794
raise RecoveryError(
793795
f"{component.repository} publication must create or verify the declared source tag "
794-
"before dispatching in its exact tag context",
796+
"before dispatching from its protected publication source",
795797
"default-branch-preflight",
796798
)
797799
release_input = f'-f {component.release_tag_input}="$RELEASE_TAG"'
@@ -812,16 +814,24 @@ def select_publication_run(
812814
if not isinstance(runs, list):
813815
raise RecoveryError("publication run metadata must be a JSON array", "publication")
814816

817+
# A protected-main dispatch reports main's head SHA; the exact release
818+
# commit is instead enforced by the immutable tag checkout in publish.yml.
819+
expected_title_prefix = f"Release {release_tag} for "
815820
exact_runs: list[dict[str, Any]] = []
816821
for run in runs:
817-
if not isinstance(run, dict) or run.get("headBranch") != release_tag:
822+
if (
823+
not isinstance(run, dict)
824+
or run.get("headBranch") != COMPONENTS["sdk-python"].default_branch
825+
or not isinstance(run.get("displayTitle"), str)
826+
or not run["displayTitle"].startswith(expected_title_prefix)
827+
):
818828
continue
819-
if run.get("headSha") != release_commit:
820-
raise RecoveryError(
821-
f"publication run {run.get('databaseId')} for {release_tag} is bound to a different source commit",
822-
"publication",
823-
)
824-
if not isinstance(run.get("databaseId"), int) or not isinstance(run.get("status"), str):
829+
if (
830+
not isinstance(run.get("databaseId"), int)
831+
or not isinstance(run.get("headSha"), str)
832+
or not COMMIT_PATTERN.fullmatch(run["headSha"])
833+
or not isinstance(run.get("status"), str)
834+
):
825835
raise RecoveryError("publication run metadata is incomplete", "publication")
826836
exact_runs.append(run)
827837

scripts/ci/sdk-rust-release-plan-recovery.fixture.yml

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,10 @@ jobs:
7070
publish:
7171
name: Publish exact Rust SDK release
7272
needs: discover
73-
if: needs.discover.outputs.action == 'publish'
73+
if: >-
74+
github.repository == 'durable-workflow/sdk-rust' &&
75+
github.ref == 'refs/heads/main' &&
76+
needs.discover.outputs.action == 'publish'
7477
runs-on: ubuntu-latest
7578
timeout-minutes: 30
7679
environment: release-plan-publication
@@ -109,19 +112,21 @@ jobs:
109112
set -euo pipefail
110113
111114
select_publication_run() {
112-
gh run list --workflow release.yml --limit 100 \
113-
--json databaseId,displayTitle,headBranch,headSha,status,conclusion \
115+
gh run list --workflow release.yml --event workflow_dispatch --branch main --limit 100 \
116+
--json databaseId,event,displayTitle,headBranch,headSha,status,conclusion \
114117
> publication-runs.json
115118
python scripts/ci/component-release-recovery.py select-publication-run \
116119
--release-tag "$RELEASE_TAG" --release-commit "$RELEASE_COMMIT" \
120+
--release-plan "$PLAN_TAG" \
117121
--runs publication-runs.json
118122
}
119123
120124
decision="$(select_publication_run)"
121125
IFS=$'\t' read -r publication_action run_id status conclusion <<< "$decision"
122126
if [ "$publication_action" = dispatch ]; then
123-
gh workflow run release.yml --ref "$RELEASE_TAG" \
124-
-f release_tag="$RELEASE_TAG" -f release_plan="$PLAN_TAG"
127+
gh workflow run release.yml --ref main \
128+
-f release_tag="$RELEASE_TAG" -f release_commit="$RELEASE_COMMIT" \
129+
-f release_plan="$PLAN_TAG"
125130
for attempt in {1..12}; do
126131
decision="$(select_publication_run)"
127132
IFS=$'\t' read -r publication_action run_id status conclusion <<< "$decision"

scripts/ci/test-component-release-recovery.py

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@
1212
from pathlib import Path
1313
from unittest import mock
1414

15-
from cli_release_verifier_contract import CliRecoveryWorkflowSourceTest, CliReleaseAuthorityTest
15+
from cli_release_verifier_contract import ( # noqa: F401 - imported for unittest discovery
16+
CliRecoveryWorkflowSourceTest,
17+
CliReleaseAuthorityTest,
18+
)
1619

1720
RECOVERY_SCRIPT = Path(__file__).with_name("component-release-recovery.py")
1821
RUST_WORKFLOW_FIXTURE = Path(__file__).with_name("sdk-rust-release-plan-recovery.fixture.yml")
@@ -433,8 +436,8 @@ def test_rejects_skipped_nonblocking_or_decoy_scoped_steps(self) -> None:
433436
1,
434437
),
435438
"run identity includes an unapproved field": source.replace(
436-
"databaseId,displayTitle,headBranch,headSha,status,conclusion",
437-
"databaseId,displayTitle,headBranch,headSha,status,conclusion,event",
439+
"databaseId,event,displayTitle,headBranch,headSha,status,conclusion",
440+
"databaseId,event,displayTitle,headBranch,headSha,status,conclusion,actor",
438441
1,
439442
),
440443
}
@@ -553,6 +556,27 @@ def test_other_components_keep_the_contents_api_contract(self) -> None:
553556
with self.assertRaises(self.recovery.RecoveryError):
554557
self.recovery.verify_recovery_workflow_source("server", protected_only)
555558

559+
def test_python_recovery_dispatches_publication_from_protected_main(self) -> None:
560+
recovery_source = RECOVERY_WORKFLOW.read_text()
561+
publish_source = PUBLISH_WORKFLOW.read_text()
562+
563+
self.recovery.verify_recovery_workflow_source("sdk-python", recovery_source)
564+
self.assertIn("gh workflow run publish.yml --ref main", recovery_source)
565+
self.assertNotIn('gh workflow run publish.yml --ref "$RELEASE_TAG"', recovery_source)
566+
self.assertIn('-f release_tag="$RELEASE_TAG"', recovery_source)
567+
self.assertIn("github.ref == 'refs/heads/main' && inputs.publish", publish_source)
568+
self.assertIn("format('refs/tags/{0}', inputs.release_tag)", publish_source)
569+
self.assertIn('if [ "$REQUESTED_TAG" != "$package_version" ]', publish_source)
570+
self.assertIn('tag_commit="$(git rev-list -n 1 "$REQUESTED_TAG")"', publish_source)
571+
572+
invalid_recovery_sources = (
573+
recovery_source.replace("--ref main", '--ref "$RELEASE_TAG"', 1),
574+
recovery_source.replace('-f release_tag="$RELEASE_TAG"', '-f release_tag="$GITHUB_REF_NAME"', 1),
575+
)
576+
for invalid_source in invalid_recovery_sources:
577+
with self.assertRaises(self.recovery.RecoveryError):
578+
self.recovery.verify_recovery_workflow_source("sdk-python", invalid_source)
579+
556580

557581
class PublicationRunSelectionTest(unittest.TestCase):
558582
RELEASE_TAG = "1.2.3"
@@ -568,14 +592,14 @@ def run_metadata(
568592
*,
569593
status: str,
570594
conclusion: str | None,
571-
commit: str | None = None,
595+
head_sha: str | None = None,
572596
plan: str = "release-plan/continuity-plan-a",
573597
) -> dict[str, object]:
574598
return {
575599
"databaseId": run_id,
576600
"displayTitle": f"Release {self.RELEASE_TAG} for {plan}",
577-
"headBranch": self.RELEASE_TAG,
578-
"headSha": commit or self.RELEASE_COMMIT,
601+
"headBranch": "main",
602+
"headSha": head_sha or "2" * 40,
579603
"status": status,
580604
"conclusion": conclusion,
581605
}
@@ -592,6 +616,11 @@ def test_failed_plan_a_dispatches_current_plan_b_once(self) -> None:
592616
)
593617
workflow = RECOVERY_WORKFLOW.read_text()
594618
self.assertEqual(workflow.count("gh workflow run publish.yml"), 1)
619+
self.assertIn("gh workflow run publish.yml --ref main", workflow)
620+
self.assertIn(
621+
"gh run list --workflow publish.yml --event workflow_dispatch --branch main",
622+
workflow,
623+
)
595624
self.assertIn('-f release_tag="$RELEASE_TAG" -f release_plan="$PLAN_TAG" -f publish=true', workflow)
596625
self.assertNotIn("gh run rerun", workflow)
597626

@@ -603,13 +632,13 @@ def test_active_exact_run_waits_and_successful_exact_run_completes(self) -> None
603632
successful = self.run_metadata(3, status="completed", conclusion="success")
604633
self.assertEqual(self.select([failed, successful])["action"], "complete")
605634

606-
def test_moved_source_tag_is_rejected(self) -> None:
607-
moved = self.run_metadata(1, status="completed", conclusion="failure", commit="a" * 40)
608-
609-
with self.assertRaises(self.recovery.RecoveryError) as caught:
610-
self.select([moved])
635+
def test_non_main_or_different_release_runs_are_ignored(self) -> None:
636+
tag_context = self.run_metadata(1, status="completed", conclusion="success")
637+
tag_context["headBranch"] = self.RELEASE_TAG
638+
different_release = self.run_metadata(2, status="completed", conclusion="success")
639+
different_release["displayTitle"] = "Release 9.9.9 for release-plan/continuity-plan-a"
611640

612-
self.assertEqual(caught.exception.phase, "publication")
641+
self.assertEqual(self.select([tag_context, different_release])["action"], "dispatch")
613642

614643
def test_fresh_dispatch_keeps_partial_publication_idempotent(self) -> None:
615644
workflow = PUBLISH_WORKFLOW.read_text()

0 commit comments

Comments
 (0)