Skip to content

Commit 2d096bc

Browse files
Accept protected Rust source-tag recovery in release-plan verification (#247)
1 parent 2018400 commit 2d096bc

4 files changed

Lines changed: 486 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ jobs:
4242
python-version: "3.12"
4343
- run: pip install build twine
4444
- run: sh -n scripts/ci/check-docs-release-audit.sh
45+
- name: Verify release recovery source contracts
46+
run: python scripts/ci/test-component-release-recovery.py
4547
- run: python -m build
4648
- run: twine check dist/*
4749
- run: python scripts/smoke-built-package.py

scripts/ci/component-release-recovery.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import contextlib
88
import datetime as dt
99
import hashlib
10+
import hmac
1011
import json
1112
import os
1213
import re
@@ -36,6 +37,13 @@
3637
ALPHA_VERSION_PATTERN = re.compile(r"^2\.0\.0-alpha\.[1-9][0-9]*$")
3738
BETA_VERSION_PATTERN = re.compile(r"^2\.0\.0-beta\.[1-9][0-9]*$")
3839

40+
# SHA-256 of durable-workflow/sdk-rust's release recovery workflow at main
41+
# commit 31e87f4aa13a7fd255fd277a62c43c96ee1532ab. The verifier normalizes only
42+
# CRLF line endings to LF before hashing. Exact source identity is the bounded
43+
# security contract because arbitrary shell execution cannot be proven safe by
44+
# source-pattern matching.
45+
SDK_RUST_RELEASE_RECOVERY_SHA256 = "8938ed8a7b029c492c08b3243c649adbed013ac3cd3dec57f9e23f396e46d079"
46+
3947

4048
@dataclass(frozen=True)
4149
class Component:
@@ -290,6 +298,17 @@ def discover_plan(client: PublicClient, requested_tag: str | None) -> tuple[str,
290298

291299
def verify_recovery_workflow_source(name: str, source: str) -> None:
292300
component = COMPONENTS[name]
301+
if name == "sdk-rust":
302+
normalized_source = source.replace("\r\n", "\n").encode("utf-8")
303+
actual_digest = hashlib.sha256(normalized_source).hexdigest()
304+
if not hmac.compare_digest(actual_digest, SDK_RUST_RELEASE_RECOVERY_SHA256):
305+
raise RecoveryError(
306+
f"{component.repository} release recovery workflow does not match the approved "
307+
"protected publication source identity",
308+
"default-branch-preflight",
309+
)
310+
return
311+
293312
if not re.search(r"(?m)^ schedule:\s*$", source) or not re.search(
294313
r"(?m)^ workflow_dispatch:\s*$", source
295314
):
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
name: Release plan recovery
2+
3+
run-name: Recover Rust SDK from ${{ inputs.plan_tag || 'latest public release plan' }}
4+
5+
on:
6+
schedule:
7+
- cron: '47 * * * *'
8+
workflow_dispatch:
9+
inputs:
10+
plan_tag:
11+
description: Immutable release-plan tag; empty selects the newest public plan
12+
required: false
13+
type: string
14+
default: ''
15+
16+
permissions:
17+
actions: write
18+
attestations: read
19+
contents: read
20+
21+
concurrency:
22+
group: release-plan-recovery-sdk-rust-${{ inputs.plan_tag || 'latest' }}
23+
cancel-in-progress: false
24+
25+
jobs:
26+
discover:
27+
name: Discover exact Rust SDK release
28+
runs-on: ubuntu-latest
29+
timeout-minutes: 10
30+
outputs:
31+
action: ${{ steps.recovery.outputs.action }}
32+
plan: ${{ steps.recovery.outputs.plan }}
33+
plan_tag: ${{ steps.recovery.outputs.plan_tag }}
34+
version: ${{ steps.recovery.outputs.version }}
35+
commit: ${{ steps.recovery.outputs.commit }}
36+
steps:
37+
- uses: actions/checkout@v6
38+
39+
- name: Discover plan and verify upstream public artifacts
40+
id: recovery
41+
env:
42+
GITHUB_TOKEN: ${{ github.token }}
43+
REQUESTED_PLAN_TAG: ${{ inputs.plan_tag }}
44+
run: |
45+
arguments=(
46+
resolve
47+
--component sdk-rust
48+
--plan-output release-plan.json
49+
--evidence release-recovery-evidence.json
50+
--github-output "$GITHUB_OUTPUT"
51+
)
52+
if [ "$GITHUB_EVENT_NAME" = schedule ]; then
53+
arguments+=(--allow-empty)
54+
elif [ -n "$REQUESTED_PLAN_TAG" ]; then
55+
arguments+=(--plan-tag "$REQUESTED_PLAN_TAG")
56+
fi
57+
python scripts/ci/component-release-recovery.py "${arguments[@]}"
58+
59+
- name: Retain recovery evidence
60+
if: always()
61+
uses: actions/upload-artifact@v4
62+
with:
63+
name: sdk-rust-release-recovery-${{ steps.recovery.outputs.plan || github.run_id }}
64+
path: |
65+
release-plan.json
66+
release-recovery-evidence.json
67+
if-no-files-found: warn
68+
69+
publish:
70+
name: Publish exact Rust SDK release
71+
needs: discover
72+
if: needs.discover.outputs.action == 'publish'
73+
runs-on: ubuntu-latest
74+
timeout-minutes: 30
75+
environment: release-plan-publication
76+
permissions:
77+
actions: write
78+
contents: read
79+
steps:
80+
- uses: actions/checkout@v6
81+
with:
82+
fetch-depth: 0
83+
ssh-key: ${{ secrets.RELEASE_PLAN_DEPLOY_KEY }}
84+
85+
- name: Restore the immutable release plan
86+
uses: actions/download-artifact@v4
87+
with:
88+
name: sdk-rust-release-recovery-${{ needs.discover.outputs.plan }}
89+
path: recovery-input
90+
91+
- name: Create or verify the exact planned source tag
92+
env:
93+
PLAN_TAG: ${{ needs.discover.outputs.plan_tag }}
94+
RELEASE_TAG: ${{ needs.discover.outputs.version }}
95+
RELEASE_COMMIT: ${{ needs.discover.outputs.commit }}
96+
run: |
97+
python scripts/ci/publish-planned-tag.py \
98+
--tag "$RELEASE_TAG" --commit "$RELEASE_COMMIT" --plan-tag "$PLAN_TAG" \
99+
--evidence release-tag-publication-evidence.json
100+
101+
- name: Start or resume repository-owned publication
102+
env:
103+
GH_TOKEN: ${{ github.token }}
104+
PLAN_TAG: ${{ needs.discover.outputs.plan_tag }}
105+
RELEASE_TAG: ${{ needs.discover.outputs.version }}
106+
RELEASE_COMMIT: ${{ needs.discover.outputs.commit }}
107+
run: |
108+
set -euo pipefail
109+
110+
select_publication_run() {
111+
gh run list --workflow release.yml --limit 100 \
112+
--json databaseId,displayTitle,headBranch,headSha,status,conclusion \
113+
> publication-runs.json
114+
python scripts/ci/component-release-recovery.py select-publication-run \
115+
--release-tag "$RELEASE_TAG" --release-commit "$RELEASE_COMMIT" \
116+
--runs publication-runs.json
117+
}
118+
119+
decision="$(select_publication_run)"
120+
IFS=$'\t' read -r publication_action run_id status conclusion <<< "$decision"
121+
if [ "$publication_action" = dispatch ]; then
122+
gh workflow run release.yml --ref "$RELEASE_TAG" \
123+
-f release_tag="$RELEASE_TAG" -f release_plan="$PLAN_TAG"
124+
for attempt in {1..12}; do
125+
decision="$(select_publication_run)"
126+
IFS=$'\t' read -r publication_action run_id status conclusion <<< "$decision"
127+
[ "$publication_action" != dispatch ] && break
128+
[ "$attempt" -eq 12 ] || sleep 5
129+
done
130+
if [ "$publication_action" = dispatch ]; then
131+
printf 'Publication dispatch did not become observable for %s at %s.\n' \
132+
"$RELEASE_TAG" "$RELEASE_COMMIT" >&2
133+
exit 1
134+
fi
135+
fi
136+
137+
if [ "$publication_action" = rerun ]; then
138+
gh run rerun "$run_id"
139+
publication_action=wait
140+
fi
141+
if [ "$publication_action" = wait ]; then
142+
gh run watch "$run_id" --exit-status --interval 10
143+
else
144+
printf 'Durable publication run %s is %s/%s; no duplicate dispatch is needed.\n' \
145+
"$run_id" "$status" "${conclusion:-pending}"
146+
fi
147+
148+
- name: Verify crates.io source identity and the GitHub Release
149+
env:
150+
GITHUB_TOKEN: ${{ github.token }}
151+
run: |
152+
python scripts/ci/component-release-recovery.py verify \
153+
--component sdk-rust --plan recovery-input/release-plan.json \
154+
--attempts 6 --sleep 10 --evidence release-completion-evidence.json
155+
156+
- name: Retain publication evidence
157+
if: always()
158+
uses: actions/upload-artifact@v4
159+
with:
160+
name: sdk-rust-release-publication-${{ needs.discover.outputs.plan }}
161+
path: |
162+
release-tag-publication-evidence.json
163+
publication-runs.json
164+
release-completion-evidence.json
165+
if-no-files-found: warn

0 commit comments

Comments
 (0)