1- """Validate the protected component release- recovery workflow tuple ."""
1+ """Resolve and validate the qualified component recovery- workflow authority ."""
22
33from __future__ import annotations
44
88from collections .abc import Mapping
99from typing import Any
1010
11- SCHEMA = "durable-workflow.component-release-recovery-authority/v1 "
11+ SCHEMA = "durable-workflow.component-release-recovery-authority/v2 "
1212CONTROL_REPOSITORY = "durable-workflow/.github"
1313AUTHORITY_REF = "main"
1414AUTHORITY_PATH = "release-recovery/authority.json"
15+ QUALIFICATION_WORKFLOW = ".github/workflows/beta-candidate.yml"
16+ QUALIFICATION_EVENT = "push"
17+ QUALIFICATION_REF_PATH = f"{ QUALIFICATION_WORKFLOW } @{ AUTHORITY_REF } "
1518WORKFLOW_PATH = ".github/workflows/release-plan-recovery.yml"
1619SOURCE_IDENTITY = {
1720 "repository" : CONTROL_REPOSITORY ,
1821 "ref" : f"refs/heads/{ AUTHORITY_REF } " ,
1922 "path" : AUTHORITY_PATH ,
23+ "qualification" : {
24+ "workflow" : QUALIFICATION_WORKFLOW ,
25+ "event" : QUALIFICATION_EVENT ,
26+ },
2027}
2128
2229
@@ -28,13 +35,123 @@ def normalized_source_sha256(source: str) -> str:
2835 return hashlib .sha256 (source .replace ("\r \n " , "\n " ).encode ("utf-8" )).hexdigest ()
2936
3037
31- def authority_url () -> str :
38+ def authority_ref_url () -> str :
39+ return f"https://api.github.com/repos/{ CONTROL_REPOSITORY } /commits/{ AUTHORITY_REF } "
40+
41+
42+ def authority_url (commit : str ) -> str :
3243 return (
3344 f"https://api.github.com/repos/{ CONTROL_REPOSITORY } /contents/{ AUTHORITY_PATH } "
34- f"?ref={ AUTHORITY_REF } "
45+ f"?ref={ commit } "
46+ )
47+
48+
49+ def qualification_runs_url (commit : str ) -> str :
50+ workflow = QUALIFICATION_WORKFLOW .rsplit ("/" , 1 )[- 1 ]
51+ return (
52+ f"https://api.github.com/repos/{ CONTROL_REPOSITORY } /actions/workflows/{ workflow } /runs"
53+ f"?branch={ AUTHORITY_REF } &event={ QUALIFICATION_EVENT } &head_sha={ commit } &per_page=100"
54+ )
55+
56+
57+ def validate_authority_commit (value : Any ) -> str :
58+ commit = value .get ("sha" ) if isinstance (value , dict ) else None
59+ if (
60+ not isinstance (commit , str )
61+ or len (commit ) != 40
62+ or any (character not in "0123456789abcdef" for character in commit )
63+ ):
64+ raise RecoveryWorkflowAuthorityError ("recovery workflow authority ref has an invalid commit" )
65+ return commit
66+
67+
68+ def _qualification_evidence (run : dict [str , Any ], commit : str ) -> dict [str , Any ]:
69+ run_id = run .get ("id" )
70+ run_attempt = run .get ("run_attempt" )
71+ if (
72+ not isinstance (run_id , int )
73+ or isinstance (run_id , bool )
74+ or run_id < 1
75+ or not isinstance (run_attempt , int )
76+ or isinstance (run_attempt , bool )
77+ or run_attempt < 1
78+ ):
79+ raise RecoveryWorkflowAuthorityError (
80+ "recovery workflow authority qualification has an invalid run identity"
81+ )
82+ return {
83+ "workflow" : QUALIFICATION_WORKFLOW ,
84+ "path" : run ["path" ],
85+ "event" : QUALIFICATION_EVENT ,
86+ "head_branch" : AUTHORITY_REF ,
87+ "head_sha" : commit ,
88+ "run_id" : run_id ,
89+ "run_attempt" : run_attempt ,
90+ "status" : "completed" ,
91+ "conclusion" : "success" ,
92+ "url" : f"https://github.com/{ CONTROL_REPOSITORY } /actions/runs/{ run_id } " ,
93+ }
94+
95+
96+ def validate_authority_qualification (value : Any , commit : str ) -> dict [str , Any ]:
97+ runs = value .get ("workflow_runs" ) if isinstance (value , dict ) else None
98+ if not isinstance (runs , list ):
99+ raise RecoveryWorkflowAuthorityError (
100+ "recovery workflow authority qualification response has an invalid shape"
101+ )
102+
103+ candidates = [
104+ run
105+ for run in runs
106+ if isinstance (run , dict )
107+ and run .get ("path" ) in (QUALIFICATION_WORKFLOW , QUALIFICATION_REF_PATH )
108+ and run .get ("event" ) == QUALIFICATION_EVENT
109+ and run .get ("head_branch" ) == AUTHORITY_REF
110+ ]
111+ if not candidates :
112+ raise RecoveryWorkflowAuthorityError (
113+ "recovery workflow authority qualification is absent for the resolved commit"
114+ )
115+ if any (run .get ("head_sha" ) != commit for run in candidates ):
116+ raise RecoveryWorkflowAuthorityError (
117+ "recovery workflow authority qualification is bound to another commit"
118+ )
119+
120+ successful = [
121+ run
122+ for run in candidates
123+ if run .get ("status" ) == "completed" and run .get ("conclusion" ) == "success"
124+ ]
125+ if successful :
126+ return _qualification_evidence (successful [0 ], commit )
127+ if any (run .get ("status" ) != "completed" for run in candidates ):
128+ raise RecoveryWorkflowAuthorityError (
129+ "recovery workflow authority qualification is pending for the resolved commit"
130+ )
131+ if any (run .get ("conclusion" ) == "cancelled" for run in candidates ):
132+ raise RecoveryWorkflowAuthorityError (
133+ "recovery workflow authority qualification was cancelled for the resolved commit"
134+ )
135+ raise RecoveryWorkflowAuthorityError (
136+ "recovery workflow authority qualification failed for the resolved commit"
35137 )
36138
37139
140+ def qualified_source_identity (
141+ raw : bytes ,
142+ commit : str ,
143+ qualification : dict [str , Any ],
144+ ) -> dict [str , Any ]:
145+ return {
146+ "repository" : CONTROL_REPOSITORY ,
147+ "ref" : f"refs/heads/{ AUTHORITY_REF } " ,
148+ "commit" : commit ,
149+ "path" : AUTHORITY_PATH ,
150+ "sha256" : hashlib .sha256 (raw ).hexdigest (),
151+ "qualification" : qualification ,
152+ }
153+
154+
38155def validate_authority (
39156 value : Any ,
40157 components : Mapping [str , tuple [str , str ]],
@@ -83,6 +200,20 @@ def decode_authority(
83200 return validate_authority (value , components )
84201
85202
203+ def load_qualified_authority (
204+ client : Any ,
205+ components : Mapping [str , tuple [str , str ]],
206+ ) -> tuple [dict [str , dict [str , str ]], dict [str , Any ]]:
207+ commit = validate_authority_commit (client .json (authority_ref_url ()))
208+ qualification = validate_authority_qualification (
209+ client .json (qualification_runs_url (commit )),
210+ commit ,
211+ )
212+ raw = client .bytes (authority_url (commit ), accept = "application/vnd.github.raw+json" )
213+ workflows = decode_authority (raw , components )
214+ return workflows , qualified_source_identity (raw , commit , qualification )
215+
216+
86217def verify_workflow_source (name : str , source : str , expected_sha256 : str ) -> str :
87218 actual_sha256 = normalized_source_sha256 (source )
88219 if not hmac .compare_digest (actual_sha256 , expected_sha256 ):
0 commit comments