Skip to content

Commit 2760bc4

Browse files
Refresh public artifact tuple after 2026-06-11 releases (#198)
1 parent b0f07c5 commit 2760bc4

4 files changed

Lines changed: 308 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ jobs:
3939
with:
4040
python-version: "3.12"
4141
- run: pip install build twine
42+
- run: sh -n scripts/ci/check-docs-release-audit.sh
4243
- run: python -m build
4344
- run: twine check dist/*
4445
- run: python scripts/smoke-built-package.py

.github/workflows/publish.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ jobs:
4848
runs-on: ubuntu-latest
4949
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
5050
steps:
51+
- uses: actions/checkout@v6
52+
5153
- uses: actions/download-artifact@v8
5254
with:
5355
name: dist
@@ -59,6 +61,28 @@ jobs:
5961
password: ${{ secrets.PYPI_TOKEN }}
6062
print-hash: true
6163

64+
verify-docs-release-audit:
65+
needs: publish
66+
runs-on: ubuntu-latest
67+
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
68+
steps:
69+
- uses: actions/checkout@v6
70+
71+
- name: Verify live docs release audit after PyPI publish
72+
env:
73+
DOCS_RELEASE_AUDIT_ARTIFACT: sdk-python
74+
DOCS_RELEASE_AUDIT_VERSION: ${{ github.ref_name }}
75+
DOCS_RELEASE_AUDIT_EVIDENCE: docs-release-audit-evidence.json
76+
run: scripts/ci/check-docs-release-audit.sh
77+
78+
- name: Upload docs release audit evidence
79+
if: always()
80+
uses: actions/upload-artifact@v4
81+
with:
82+
name: docs-release-audit-evidence
83+
path: docs-release-audit-evidence.json
84+
if-no-files-found: warn
85+
6286
publish-test:
6387
needs: build
6488
runs-on: ubuntu-latest
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
#!/usr/bin/env sh
2+
3+
set -eu
4+
5+
fail() {
6+
title="$1"
7+
message="$2"
8+
9+
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
10+
{
11+
printf '## %s\n\n' "$title"
12+
printf '%s\n' "$message"
13+
} >> "$GITHUB_STEP_SUMMARY"
14+
fi
15+
16+
printf '::error title=%s::%s\n' "$title" "$message" >&2
17+
printf '%s\n' "$message" >&2
18+
exit 1
19+
}
20+
21+
artifact="${DOCS_RELEASE_AUDIT_ARTIFACT:-}"
22+
expected="${DOCS_RELEASE_AUDIT_VERSION:-${GITHUB_REF_NAME:-}}"
23+
audit_url="${DOCS_RELEASE_AUDIT_URL:-https://durable-workflow.com/docs-page-release-audit.json}"
24+
attempts="${DOCS_RELEASE_AUDIT_ATTEMPTS:-6}"
25+
sleep_seconds="${DOCS_RELEASE_AUDIT_RETRY_SLEEP:-20}"
26+
evidence_path="${DOCS_RELEASE_AUDIT_EVIDENCE:-}"
27+
28+
write_unavailable_evidence() {
29+
message="$1"
30+
31+
[ -n "$evidence_path" ] || return 0
32+
33+
node - "$evidence_path" "$artifact" "$expected" "$audit_url" "$message" <<'NODE'
34+
const fs = require('fs');
35+
36+
const [evidencePath, artifact, expected, auditUrl, message] = process.argv.slice(2);
37+
38+
fs.writeFileSync(evidencePath, `${JSON.stringify({
39+
schema: 'durable-workflow.release.docs-release-audit-evidence',
40+
checked_at: new Date().toISOString(),
41+
surface: 'public_docs_release_audit',
42+
audit_url: auditUrl,
43+
artifact,
44+
expected_version: expected,
45+
outcome: 'unavailable',
46+
message,
47+
}, null, 2)}\n`);
48+
NODE
49+
}
50+
51+
case "$artifact" in
52+
cli|sdk-python|server|workflow|waterline) ;;
53+
*) fail "Docs release-audit artifact required" "DOCS_RELEASE_AUDIT_ARTIFACT must be one of cli, sdk-python, server, workflow, or waterline." ;;
54+
esac
55+
56+
expected="${expected#v}"
57+
if [ -z "$expected" ]; then
58+
fail "Docs release-audit version required" "DOCS_RELEASE_AUDIT_VERSION or GITHUB_REF_NAME must name the published artifact version."
59+
fi
60+
61+
case "$attempts" in
62+
''|*[!0-9]*) fail "Invalid docs release-audit retry count" "DOCS_RELEASE_AUDIT_ATTEMPTS must be a positive integer." ;;
63+
esac
64+
case "$sleep_seconds" in
65+
''|*[!0-9]*) fail "Invalid docs release-audit retry delay" "DOCS_RELEASE_AUDIT_RETRY_SLEEP must be a non-negative integer." ;;
66+
esac
67+
if [ "$attempts" -lt 1 ]; then
68+
fail "Invalid docs release-audit retry count" "DOCS_RELEASE_AUDIT_ATTEMPTS must be at least 1."
69+
fi
70+
71+
tmp_dir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
72+
audit_path="${tmp_dir}/docs-page-release-audit-${artifact}-${expected}-$$.json"
73+
trap 'rm -f "$audit_path"' EXIT HUP INT TERM
74+
attempt=1
75+
76+
while [ "$attempt" -le "$attempts" ]; do
77+
if curl -fsSL --retry 3 --retry-all-errors --connect-timeout 10 --max-time 30 -o "$audit_path" "$audit_url"; then
78+
if node - "$audit_path" "$artifact" "$expected" "$audit_url" "$evidence_path" <<'NODE'
79+
const fs = require('fs');
80+
81+
const [auditPath, artifact, expected, auditUrl, evidencePath] = process.argv.slice(2);
82+
const title = 'Docs release-audit tuple stale';
83+
84+
function releaseCheckSource() {
85+
const serverUrl = process.env.GITHUB_SERVER_URL || 'https://github.com';
86+
const repository = process.env.GITHUB_REPOSITORY || null;
87+
const runId = process.env.GITHUB_RUN_ID || null;
88+
const runAttempt = process.env.GITHUB_RUN_ATTEMPT || null;
89+
90+
return {
91+
repository,
92+
ref: process.env.GITHUB_REF_NAME || null,
93+
sha: process.env.GITHUB_SHA || null,
94+
run_id: runId,
95+
run_attempt: runAttempt,
96+
run_url: repository && runId
97+
? `${serverUrl}/${repository}/actions/runs/${runId}`
98+
: null,
99+
};
100+
}
101+
102+
function docsRefreshRequest(message, actualVersion, observedVersions) {
103+
const staleArtifact = {
104+
name: artifact,
105+
expected_version: expected,
106+
live_version: actualVersion,
107+
};
108+
109+
return {
110+
schema: 'durable-workflow.docs.refresh-request',
111+
reason: 'public_docs_release_audit_stale',
112+
repository: 'durable-workflow.github.io',
113+
target_branch: 'main',
114+
refresh_command: 'npm run refresh:public-artifact-versions',
115+
stale_artifact: staleArtifact,
116+
observed_artifact_versions: observedVersions,
117+
source_release_check: releaseCheckSource(),
118+
ready_item: {
119+
title: `Refresh public docs artifact tuple for ${artifact} ${expected}`,
120+
body: [
121+
message,
122+
'',
123+
`Expected ${artifact} ${expected}; live docs release audit reports ${actualVersion || '<missing>'}.`,
124+
'Refresh scripts/public-artifact-versions.json and docs/compatibility.md through the normal docs merge path.',
125+
].join('\n'),
126+
acceptance: [
127+
'The public docs release-audit JSON reports the current published artifact tuple.',
128+
'Stable 1.x remains the default public docs line.',
129+
'The refresh lands through the docs merge gate, not from a public release workflow.',
130+
],
131+
},
132+
};
133+
}
134+
135+
function writeEvidence(outcome, extra = {}) {
136+
if (!evidencePath) {
137+
return;
138+
}
139+
140+
fs.writeFileSync(evidencePath, `${JSON.stringify({
141+
schema: 'durable-workflow.release.docs-release-audit-evidence',
142+
checked_at: new Date().toISOString(),
143+
surface: 'public_docs_release_audit',
144+
audit_url: auditUrl,
145+
artifact,
146+
expected_version: expected,
147+
source_release_check: releaseCheckSource(),
148+
outcome,
149+
...extra,
150+
}, null, 2)}\n`);
151+
}
152+
153+
function retry(message) {
154+
writeEvidence('retry', {message});
155+
console.error(message);
156+
process.exit(3);
157+
}
158+
159+
function fail(message, extra = {}) {
160+
writeEvidence('stale', {
161+
message,
162+
...extra,
163+
});
164+
165+
if (process.env.GITHUB_STEP_SUMMARY) {
166+
fs.appendFileSync(
167+
process.env.GITHUB_STEP_SUMMARY,
168+
`## ${title}\n\n${message}\n\n`
169+
);
170+
}
171+
console.error(`::error title=${title}::${message}`);
172+
console.error(message);
173+
process.exit(2);
174+
}
175+
176+
let audit;
177+
try {
178+
audit = JSON.parse(fs.readFileSync(auditPath, 'utf8'));
179+
} catch (err) {
180+
retry(`${auditUrl} did not return parseable JSON: ${err.message}`);
181+
}
182+
183+
if (audit.schema !== 'durable-workflow.docs.page-release-audit') {
184+
retry(`${auditUrl} returned schema ${audit.schema || '<missing>'}, not durable-workflow.docs.page-release-audit.`);
185+
}
186+
187+
const versions = audit.artifact_versions;
188+
if (!versions || typeof versions !== 'object' || Array.isArray(versions)) {
189+
retry(`${auditUrl} must contain an artifact_versions object.`);
190+
}
191+
192+
const actual = versions[artifact];
193+
if (actual !== expected) {
194+
const actualVersion = Object.prototype.hasOwnProperty.call(versions, artifact) ? actual : null;
195+
const message = `${auditUrl} reports artifact_versions.${artifact}=${actual || '<missing>'}, expected ${expected}. ` +
196+
'Run npm run refresh:public-artifact-versions in durable-workflow.github.io and land scripts/public-artifact-versions.json plus docs/compatibility.md through the normal docs merge path before treating this release as fully surfaced.';
197+
198+
fail(
199+
`${message} When DOCS_RELEASE_AUDIT_EVIDENCE is set, the uploaded evidence includes a docs_refresh_request payload for the gate-owned refresh path.`,
200+
{
201+
actual_version: actualVersion,
202+
observed_artifact_versions: versions,
203+
docs_refresh_request: docsRefreshRequest(message, actualVersion, versions),
204+
}
205+
);
206+
}
207+
208+
writeEvidence('pass', {actual_version: actual});
209+
console.log(`${auditUrl} confirms artifact_versions.${artifact}=${expected}.`);
210+
NODE
211+
then
212+
exit 0
213+
else
214+
node_status=$?
215+
if [ "$node_status" -eq 2 ]; then
216+
exit 1
217+
fi
218+
fi
219+
fi
220+
221+
if [ "$attempt" -lt "$attempts" ]; then
222+
printf 'Waiting for docs release-audit JSON (%s/%s): %s\n' "$attempt" "$attempts" "$audit_url" >&2
223+
sleep "$sleep_seconds"
224+
fi
225+
attempt=$((attempt + 1))
226+
done
227+
228+
message="Could not fetch ${audit_url} after ${attempts} attempt(s)."
229+
write_unavailable_evidence "$message"
230+
fail "Docs release-audit unavailable" "$message"
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import re
2+
from pathlib import Path
3+
4+
5+
REPO_ROOT = Path(__file__).resolve().parents[1]
6+
7+
8+
def read_repo_file(path: str) -> str:
9+
return (REPO_ROOT / path).read_text(encoding="utf-8")
10+
11+
12+
def workflow_job_block(workflow: str, job_name: str) -> str:
13+
match = re.search(rf"(?ms)^ {re.escape(job_name)}:\n.*?(?=^ [A-Za-z0-9_-]+:\n|\Z)", workflow)
14+
assert match is not None, f"{job_name} job is missing from publish workflow"
15+
return match.group(0)
16+
17+
18+
def test_publish_workflow_runs_docs_audit_in_recoverable_job() -> None:
19+
workflow = read_repo_file(".github/workflows/publish.yml")
20+
auditor = read_repo_file("scripts/ci/check-docs-release-audit.sh")
21+
publish_job = workflow_job_block(workflow, "publish")
22+
docs_audit_job = workflow_job_block(workflow, "verify-docs-release-audit")
23+
24+
for expected in [
25+
"Verify live docs release audit after PyPI publish",
26+
"DOCS_RELEASE_AUDIT_ARTIFACT: sdk-python",
27+
"DOCS_RELEASE_AUDIT_VERSION: ${{ github.ref_name }}",
28+
"DOCS_RELEASE_AUDIT_EVIDENCE: docs-release-audit-evidence.json",
29+
"scripts/ci/check-docs-release-audit.sh",
30+
"Upload docs release audit evidence",
31+
"docs-release-audit-evidence.json",
32+
]:
33+
assert expected in workflow
34+
35+
assert "permissions:\n contents: read" in workflow
36+
assert "contents: write" not in workflow
37+
assert "pypa/gh-action-pypi-publish@release/v1" in publish_job
38+
assert "Verify live docs release audit after PyPI publish" not in publish_job
39+
assert "Upload docs release audit evidence" not in publish_job
40+
assert "needs: publish" in docs_audit_job
41+
assert "durable-workflow.release.docs-release-audit-evidence" in auditor
42+
assert "schema: 'durable-workflow.docs.refresh-request'" in auditor
43+
assert "repository: 'durable-workflow.github.io'" in auditor
44+
assert "refresh_command: 'npm run refresh:public-artifact-versions'" in auditor
45+
assert "observed_artifact_versions: versions" in auditor
46+
47+
publish_offset = workflow.index("Publish to PyPI")
48+
docs_audit_job_offset = workflow.index("verify-docs-release-audit:")
49+
docs_audit_offset = docs_audit_job.index("Verify live docs release audit after PyPI publish")
50+
upload_offset = docs_audit_job.index("Upload docs release audit evidence")
51+
52+
assert publish_offset < docs_audit_job_offset
53+
assert docs_audit_offset < upload_offset

0 commit comments

Comments
 (0)