Skip to content

Commit ddfc19e

Browse files
Release automation: docs tuple lag must not reverse package publication success (#255)
1 parent 8aa0e86 commit ddfc19e

4 files changed

Lines changed: 282 additions & 41 deletions

File tree

.github/workflows/publish.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ jobs:
158158
DOCS_RELEASE_AUDIT_VERSION: ${{ needs.build.outputs.release_tag }}
159159
DOCS_RELEASE_AUDIT_EVIDENCE: docs-release-audit-evidence.json
160160
DOCS_RELEASE_AUDIT_HANDOFF: docs-release-audit-handoff.json
161+
DOCS_RELEASE_AUDIT_ENFORCEMENT: advisory
161162
run: scripts/ci/check-docs-release-audit.sh
162163

163164
- name: Upload docs release audit evidence

scripts/ci/check-docs-release-audit.sh

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,26 @@ attempts="${DOCS_RELEASE_AUDIT_ATTEMPTS:-6}"
2525
sleep_seconds="${DOCS_RELEASE_AUDIT_RETRY_SLEEP:-20}"
2626
evidence_path="${DOCS_RELEASE_AUDIT_EVIDENCE:-}"
2727
handoff_path="${DOCS_RELEASE_AUDIT_HANDOFF:-}"
28+
enforcement="${DOCS_RELEASE_AUDIT_ENFORCEMENT:-required}"
29+
30+
report_docs_freshness() {
31+
title="$1"
32+
message="$2"
33+
34+
if [ "$enforcement" = "required" ]; then
35+
fail "$title" "$message"
36+
fi
37+
38+
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
39+
{
40+
printf '## %s\n\n' "$title"
41+
printf '%s\n' "$message"
42+
} >> "$GITHUB_STEP_SUMMARY"
43+
fi
44+
45+
printf '::warning title=%s::%s\n' "$title" "$message" >&2
46+
printf '%s\n' "$message" >&2
47+
}
2848

2949
write_unavailable_evidence() {
3050
message="$1"
@@ -35,6 +55,8 @@ write_unavailable_evidence() {
3555
const fs = require('fs');
3656
3757
const [evidencePath, artifact, expected, auditUrl, message] = process.argv.slice(2);
58+
const repository = process.env.GITHUB_REPOSITORY || null;
59+
const runId = process.env.GITHUB_RUN_ID || null;
3860
3961
fs.writeFileSync(evidencePath, `${JSON.stringify({
4062
schema: 'durable-workflow.release.docs-release-audit-evidence',
@@ -44,6 +66,18 @@ fs.writeFileSync(evidencePath, `${JSON.stringify({
4466
artifact,
4567
expected_version: expected,
4668
outcome: 'unavailable',
69+
lifecycle: 'docs_freshness',
70+
publication_outcome_impact: 'none',
71+
source_release_check: {
72+
repository,
73+
ref: process.env.GITHUB_REF_NAME || null,
74+
sha: process.env.GITHUB_SHA || null,
75+
run_id: runId,
76+
run_attempt: process.env.GITHUB_RUN_ATTEMPT || null,
77+
run_url: repository && runId
78+
? `${process.env.GITHUB_SERVER_URL || 'https://github.com'}/${repository}/actions/runs/${runId}`
79+
: null,
80+
},
4781
message,
4882
}, null, 2)}\n`);
4983
NODE
@@ -59,6 +93,11 @@ if [ -z "$expected" ]; then
5993
fail "Docs release-audit version required" "DOCS_RELEASE_AUDIT_VERSION or GITHUB_REF_NAME must name the published artifact version."
6094
fi
6195

96+
case "$enforcement" in
97+
required|advisory) ;;
98+
*) fail "Invalid docs release-audit enforcement" "DOCS_RELEASE_AUDIT_ENFORCEMENT must be required or advisory." ;;
99+
esac
100+
62101
case "$attempts" in
63102
''|*[!0-9]*) fail "Invalid docs release-audit retry count" "DOCS_RELEASE_AUDIT_ATTEMPTS must be a positive integer." ;;
64103
esac
@@ -76,10 +115,10 @@ attempt=1
76115

77116
while [ "$attempt" -le "$attempts" ]; do
78117
if curl -fsSL --retry 3 --retry-all-errors --connect-timeout 10 --max-time 30 -o "$audit_path" "$audit_url"; then
79-
if node - "$audit_path" "$artifact" "$expected" "$audit_url" "$evidence_path" "$handoff_path" <<'NODE'
118+
if node - "$audit_path" "$artifact" "$expected" "$audit_url" "$evidence_path" "$handoff_path" "$enforcement" <<'NODE'
80119
const fs = require('fs');
81120
82-
const [auditPath, artifact, expected, auditUrl, evidencePath, handoffPath] = process.argv.slice(2);
121+
const [auditPath, artifact, expected, auditUrl, evidencePath, handoffPath, enforcement] = process.argv.slice(2);
83122
const title = 'Docs release-audit tuple stale';
84123
const refreshCommand = 'npm run refresh:public-artifact-versions';
85124
const refreshFiles = [
@@ -122,6 +161,8 @@ function docsRefreshHandoff(message, actualVersion, observedVersions) {
122161
schema: 'durable-workflow.release.docs-artifact-tuple-handoff',
123162
schema_version: 1,
124163
action: 'pipeline_ready_item',
164+
lifecycle: 'docs_freshness',
165+
priority: 'low',
125166
reason: 'public_docs_release_audit_stale',
126167
repository: 'durable-workflow.github.io',
127168
target_branch: 'main',
@@ -158,6 +199,7 @@ function docsRefreshHandoff(message, actualVersion, observedVersions) {
158199
'pipeline:ready-item',
159200
'branch:main',
160201
'state:pending',
202+
'priority:P3',
161203
],
162204
acceptance: [
163205
'The public docs release-audit JSON reports the current published artifact tuple.',
@@ -207,6 +249,8 @@ function writeEvidence(outcome, extra = {}) {
207249
artifact,
208250
expected_version: expected,
209251
source_release_check: releaseCheckSource(),
252+
lifecycle: 'docs_freshness',
253+
publication_outcome_impact: 'none',
210254
outcome,
211255
...extra,
212256
}, null, 2)}\n`);
@@ -230,9 +274,10 @@ function fail(message, extra = {}) {
230274
`## ${title}\n\n${message}\n\n`
231275
);
232276
}
233-
console.error(`::error title=${title}::${message}`);
277+
const annotation = enforcement === 'advisory' ? 'warning' : 'error';
278+
console.error(`::${annotation} title=${title}::${message}`);
234279
console.error(message);
235-
process.exit(2);
280+
process.exit(enforcement === 'advisory' ? 0 : 2);
236281
}
237282
238283
let audit;
@@ -294,4 +339,4 @@ done
294339

295340
message="Could not fetch ${audit_url} after ${attempts} attempt(s)."
296341
write_unavailable_evidence "$message"
297-
fail "Docs release-audit unavailable" "$message"
342+
report_docs_freshness "Docs release-audit unavailable" "$message"

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

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,98 @@ def test_fresh_dispatch_keeps_partial_publication_idempotent(self) -> None:
364364
self.assertIn('if ! gh release view "$RELEASE_TAG"', workflow)
365365

366366

367+
class PublishedReleaseRecoveryTest(unittest.TestCase):
368+
RELEASE_TAG = "0.4.101"
369+
RELEASE_COMMIT = "8aa0e86fe51edc1e7aba3d97ddf3dfda8009ee23"
370+
PLAN_TAG = "release-plan/alpha-continuity"
371+
372+
@classmethod
373+
def setUpClass(cls) -> None:
374+
cls.recovery = load_recovery_module()
375+
376+
def test_publication_authority_uses_exact_tag_distribution_and_release(self) -> None:
377+
client = mock.Mock(spec=self.recovery.PublicClient)
378+
identity = {"version": self.RELEASE_TAG, "commit": self.RELEASE_COMMIT}
379+
distribution_evidence = {"kind": "pypi", "source_files_compared": 12}
380+
release_evidence = {"tag_name": self.RELEASE_TAG}
381+
distribution_verifier = mock.Mock(return_value=distribution_evidence)
382+
383+
with (
384+
mock.patch.object(self.recovery, "require_source_tag") as source_tag_verifier,
385+
mock.patch.object(
386+
self.recovery,
387+
"verify_github_release",
388+
return_value=release_evidence,
389+
) as release_verifier,
390+
mock.patch.dict(self.recovery.VERIFIERS, {"pypi": distribution_verifier}),
391+
):
392+
evidence = self.recovery.verify_component(client, "sdk-python", identity)
393+
394+
source_tag_verifier.assert_called_once_with(client, "sdk-python", identity)
395+
distribution_verifier.assert_called_once_with(
396+
client,
397+
self.recovery.COMPONENTS["sdk-python"],
398+
self.RELEASE_TAG,
399+
self.RELEASE_COMMIT,
400+
)
401+
release_verifier.assert_called_once_with(client, "sdk-python", self.RELEASE_TAG)
402+
self.assertEqual(evidence["version"], self.RELEASE_TAG)
403+
self.assertEqual(evidence["commit"], self.RELEASE_COMMIT)
404+
self.assertEqual(evidence["distribution"], distribution_evidence)
405+
self.assertEqual(evidence["github_release"], release_evidence)
406+
407+
def test_exact_public_release_completes_without_publication_dispatch(self) -> None:
408+
client = mock.Mock(spec=self.recovery.PublicClient)
409+
plan = {
410+
"plan": "alpha-continuity",
411+
"channel": "alpha",
412+
"components": {
413+
"server": {"version": "0.2.700", "commit": "2" * 40},
414+
"sdk-python": {"version": self.RELEASE_TAG, "commit": self.RELEASE_COMMIT},
415+
},
416+
}
417+
package_evidence = {
418+
"version": self.RELEASE_TAG,
419+
"commit": self.RELEASE_COMMIT,
420+
"distribution": {"kind": "pypi", "source_files_compared": 12},
421+
"github_release": {"tag_name": self.RELEASE_TAG},
422+
}
423+
424+
def verify_public_component(
425+
_client: object, component: str, _identity: dict[str, str]
426+
) -> dict[str, object]:
427+
if component == "sdk-python":
428+
return package_evidence
429+
return {"version": plan["components"][component]["version"]}
430+
431+
with (
432+
mock.patch.object(self.recovery, "verify_plan_authority", return_value=({}, {})),
433+
mock.patch.object(self.recovery, "verify_component", side_effect=verify_public_component),
434+
mock.patch.object(self.recovery, "resolve_tag", return_value=self.RELEASE_COMMIT),
435+
mock.patch.object(
436+
self.recovery,
437+
"require_python_source_manifest_version",
438+
return_value={"declared_version": self.RELEASE_TAG, "source_commit": self.RELEASE_COMMIT},
439+
),
440+
):
441+
state, outputs = self.recovery.resolve_component(
442+
client,
443+
"sdk-python",
444+
self.PLAN_TAG,
445+
"3" * 40,
446+
plan,
447+
)
448+
449+
self.assertEqual(outputs["action"], "skip")
450+
self.assertEqual(state["phase"], "complete")
451+
self.assertEqual(state["outcome"], "verified")
452+
self.assertEqual(state["public_evidence"], package_evidence)
453+
454+
workflow = RECOVERY_WORKFLOW.read_text()
455+
publication_step = workflow[workflow.index("Start or resume repository-owned publication") :]
456+
self.assertIn("if: steps.recovery.outputs.action == 'publish'", publication_step)
457+
458+
367459
class PythonSourceManifestPreflightTest(unittest.TestCase):
368460
RELEASE_TAG = "0.4.100"
369461
RELEASE_COMMIT = "2018400368cf4251c58b24b3d53a99f0ca3512e3"

0 commit comments

Comments
 (0)