Skip to content

Commit 5cb65fd

Browse files
CLI release workflow: gate docs audit before public asset upload (#142)
1 parent b366783 commit 5cb65fd

4 files changed

Lines changed: 234 additions & 19 deletions

File tree

.github/workflows/build.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ jobs:
2626
sh -n scripts/install.sh
2727
sh -n scripts/generate-homebrew-formula.sh
2828
sh -n scripts/verify-release.sh
29+
sh -n scripts/ci/check-docs-release-audit.sh
2930
bash -n scripts/verify-public-release-assets.sh
3031
bash -n scripts/verify-reproducible-build.sh
3132
pwsh -NoProfile -Command '$null = [scriptblock]::Create((Get-Content -Raw scripts/install.ps1))'

.github/workflows/release.yml

Lines changed: 125 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,115 @@ jobs:
6767
echo "tag=$tag" >> "$GITHUB_OUTPUT"
6868
echo "Resolved release tag: $tag"
6969
70+
release-preflight:
71+
name: Release preflight
72+
needs: resolve-release
73+
runs-on: ubuntu-latest
74+
outputs:
75+
public_assets_present: ${{ steps.public_assets.outputs.present }}
76+
steps:
77+
- uses: actions/checkout@v4
78+
with:
79+
ref: ${{ needs.resolve-release.outputs.tag }}
80+
81+
- name: Inspect existing public release assets
82+
id: public_assets
83+
env:
84+
RELEASE_TAG: ${{ needs.resolve-release.outputs.tag }}
85+
run: |
86+
set -euo pipefail
87+
tag="${RELEASE_TAG#v}"
88+
release_repo="${DURABLE_WORKFLOW_REPO:-durable-workflow/cli}"
89+
evidence="release-preflight-public-assets-evidence.json"
90+
present="false"
91+
status="missing_or_incomplete"
92+
message="No complete public CLI asset set was found for ${tag}."
93+
94+
if DURABLE_WORKFLOW_RELEASE_ASSET_ATTEMPTS=1 DURABLE_WORKFLOW_RELEASE_ASSET_RETRY_SLEEP=0 \
95+
scripts/verify-public-release-assets.sh "$tag"; then
96+
present="true"
97+
status="present"
98+
message="A complete public CLI asset set is already downloadable for ${tag}."
99+
fi
100+
101+
echo "present=${present}" >> "$GITHUB_OUTPUT"
102+
103+
checked_at="$(date -u +%FT%TZ)"
104+
node - "$evidence" "$tag" "$release_repo" "$status" "$message" "$checked_at" "$present" <<'NODE'
105+
const fs = require('fs');
106+
107+
const [path, tag, repo, status, message, checkedAt, present] = process.argv.slice(2);
108+
const assets = [
109+
'dw.phar',
110+
'dw-linux-x86_64',
111+
'dw-linux-aarch64',
112+
'dw-macos-aarch64',
113+
'dw-windows-x86_64.exe',
114+
'dw.rb',
115+
'install.sh',
116+
'install.ps1',
117+
'verify-release.sh',
118+
'SHA256SUMS',
119+
];
120+
121+
const urls = assets.map((asset) => ({
122+
name: asset,
123+
url: `https://github.com/${repo}/releases/download/${tag}/${asset}`,
124+
}));
125+
126+
fs.writeFileSync(path, `${JSON.stringify({
127+
schema: 'durable-workflow.cli.public-release-assets-preflight',
128+
tag,
129+
repository: repo,
130+
checked_at: checkedAt,
131+
outcome: status,
132+
message,
133+
purpose: present === 'true'
134+
? 'existing-public-assets-rerun-gate'
135+
: 'pre-upload-public-asset-presence-check',
136+
installable_artifacts: {
137+
complete_public_asset_set: present === 'true',
138+
version: tag,
139+
},
140+
assets: urls,
141+
}, null, 2)}\n`);
142+
NODE
143+
144+
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
145+
{
146+
printf '## Public CLI asset preflight\n\n'
147+
printf '%s\n\n' "$message"
148+
if [ "$present" = "true" ]; then
149+
printf 'Because the complete public asset set already exists, the live docs release-audit gate runs before any rebuild or upload.\n'
150+
else
151+
printf 'The live docs release-audit gate will run after this release publishes and verifies the public download surface.\n'
152+
fi
153+
} >> "$GITHUB_STEP_SUMMARY"
154+
fi
155+
156+
- name: Require live docs release audit for existing public assets
157+
if: steps.public_assets.outputs.present == 'true'
158+
env:
159+
DOCS_RELEASE_AUDIT_ARTIFACT: cli
160+
DOCS_RELEASE_AUDIT_VERSION: ${{ needs.resolve-release.outputs.tag }}
161+
DOCS_RELEASE_AUDIT_EVIDENCE: docs-release-audit-evidence.json
162+
run: scripts/ci/check-docs-release-audit.sh
163+
164+
- name: Upload release preflight evidence
165+
if: always()
166+
uses: actions/upload-artifact@v4
167+
with:
168+
name: release-preflight-evidence
169+
path: |
170+
release-preflight-public-assets-evidence.json
171+
docs-release-audit-evidence.json
172+
if-no-files-found: warn
173+
70174
build-phar:
71175
name: Build PHAR
72-
needs: resolve-release
176+
needs: [resolve-release, release-preflight]
73177
runs-on: ubuntu-latest
178+
if: ${{ always() && needs.resolve-release.result == 'success' && needs.release-preflight.result == 'success' && needs.release-preflight.outputs.public_assets_present != 'true' }}
74179
outputs:
75180
phar-name: dw.phar
76181
steps:
@@ -130,7 +235,8 @@ jobs:
130235

131236
build-binary:
132237
name: Build ${{ matrix.name }}
133-
needs: [resolve-release, build-phar]
238+
needs: [resolve-release, release-preflight, build-phar]
239+
if: ${{ always() && needs.resolve-release.result == 'success' && needs.release-preflight.result == 'success' && needs.release-preflight.outputs.public_assets_present != 'true' && needs.build-phar.result == 'success' }}
134240
strategy:
135241
fail-fast: false
136242
matrix:
@@ -248,7 +354,8 @@ jobs:
248354

249355
build-binary-windows:
250356
name: Build windows-x86_64
251-
needs: [resolve-release, build-phar]
357+
needs: [resolve-release, release-preflight, build-phar]
358+
if: ${{ always() && needs.resolve-release.result == 'success' && needs.release-preflight.result == 'success' && needs.release-preflight.outputs.public_assets_present != 'true' && needs.build-phar.result == 'success' }}
252359
runs-on: windows-latest
253360
steps:
254361
- uses: actions/checkout@v4
@@ -399,9 +506,9 @@ jobs:
399506
# Every supported standalone platform must publish before the release
400507
# asset list, SHA256SUMS, installers, and public download endpoints are
401508
# considered complete.
402-
needs: [resolve-release, build-phar, build-binary, build-binary-windows]
509+
needs: [resolve-release, release-preflight, build-phar, build-binary, build-binary-windows]
403510
runs-on: ubuntu-latest
404-
if: ${{ always() && needs.resolve-release.result == 'success' && needs.build-phar.result == 'success' && needs.build-binary.result == 'success' && needs.build-binary-windows.result == 'success' }}
511+
if: ${{ always() && needs.resolve-release.result == 'success' && needs.release-preflight.result == 'success' && needs.release-preflight.outputs.public_assets_present != 'true' && needs.build-phar.result == 'success' && needs.build-binary.result == 'success' && needs.build-binary-windows.result == 'success' }}
405512
steps:
406513
- uses: actions/checkout@v4
407514
with:
@@ -562,6 +669,7 @@ jobs:
562669
printf ' "tag": "%s",\n' "$tag"
563670
printf ' "commit": "%s",\n' "$release_commit"
564671
printf ' "artifact_versions": {"cli": "%s"},\n' "$tag"
672+
printf ' "installable_artifacts": {"verified_public_downloads": true, "version": "%s"},\n' "$tag"
565673
printf ' "verified_at": "%s",\n' "$verified_at"
566674
printf ' "outcome": "pass",\n'
567675
printf ' "assets": [\n'
@@ -588,15 +696,19 @@ jobs:
588696
DURABLE_WORKFLOW_BIN_NAME=dw-release-check sh "$installer"
589697
"$install_dir/dw-release-check" --version
590698
591-
- name: Upload public download evidence
592-
uses: actions/upload-artifact@v4
593-
with:
594-
name: release-public-download-evidence
595-
path: release-public-download-evidence.json
596-
if-no-files-found: error
597-
598-
- name: Require live docs release audit refresh
699+
- name: Verify live docs release audit after public downloads
599700
env:
600701
DOCS_RELEASE_AUDIT_ARTIFACT: cli
601702
DOCS_RELEASE_AUDIT_VERSION: ${{ needs.resolve-release.outputs.tag }}
703+
DOCS_RELEASE_AUDIT_EVIDENCE: docs-release-audit-evidence.json
602704
run: scripts/ci/check-docs-release-audit.sh
705+
706+
- name: Upload release evidence
707+
if: always()
708+
uses: actions/upload-artifact@v4
709+
with:
710+
name: release-evidence
711+
path: |
712+
release-public-download-evidence.json
713+
docs-release-audit-evidence.json
714+
if-no-files-found: warn

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

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,30 @@ expected="${DOCS_RELEASE_AUDIT_VERSION:-${GITHUB_REF_NAME:-}}"
2323
audit_url="${DOCS_RELEASE_AUDIT_URL:-https://durable-workflow.com/docs-page-release-audit.json}"
2424
attempts="${DOCS_RELEASE_AUDIT_ATTEMPTS:-6}"
2525
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.cli.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+
}
2650

2751
case "$artifact" in
2852
cli|sdk-python|server|workflow|waterline) ;;
@@ -45,23 +69,47 @@ if [ "$attempts" -lt 1 ]; then
4569
fi
4670

4771
tmp_dir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
48-
audit_path="${tmp_dir}/docs-page-release-audit.json"
72+
audit_path="${tmp_dir}/docs-page-release-audit-${artifact}-${expected}-$$.json"
73+
trap 'rm -f "$audit_path"' EXIT HUP INT TERM
4974
attempt=1
5075

5176
while [ "$attempt" -le "$attempts" ]; do
5277
if curl -fsSL --retry 3 --retry-all-errors --connect-timeout 10 --max-time 30 -o "$audit_path" "$audit_url"; then
53-
if node - "$audit_path" "$artifact" "$expected" "$audit_url" <<'NODE'
78+
if node - "$audit_path" "$artifact" "$expected" "$audit_url" "$evidence_path" <<'NODE'
5479
const fs = require('fs');
5580
56-
const [auditPath, artifact, expected, auditUrl] = process.argv.slice(2);
81+
const [auditPath, artifact, expected, auditUrl, evidencePath] = process.argv.slice(2);
5782
const title = 'Docs release-audit tuple stale';
5883
84+
function writeEvidence(outcome, extra = {}) {
85+
if (!evidencePath) {
86+
return;
87+
}
88+
89+
fs.writeFileSync(evidencePath, `${JSON.stringify({
90+
schema: 'durable-workflow.cli.docs-release-audit-evidence',
91+
checked_at: new Date().toISOString(),
92+
surface: 'public_docs_release_audit',
93+
audit_url: auditUrl,
94+
artifact,
95+
expected_version: expected,
96+
outcome,
97+
...extra,
98+
}, null, 2)}\n`);
99+
}
100+
59101
function retry(message) {
102+
writeEvidence('retry', {message});
60103
console.error(message);
61104
process.exit(3);
62105
}
63106
64-
function fail(message) {
107+
function fail(message, extra = {}) {
108+
writeEvidence('stale', {
109+
message,
110+
...extra,
111+
});
112+
65113
if (process.env.GITHUB_STEP_SUMMARY) {
66114
fs.appendFileSync(
67115
process.env.GITHUB_STEP_SUMMARY,
@@ -91,12 +139,16 @@ if (!versions || typeof versions !== 'object' || Array.isArray(versions)) {
91139
92140
const actual = versions[artifact];
93141
if (actual !== expected) {
142+
const actualVersion = Object.prototype.hasOwnProperty.call(versions, artifact) ? actual : null;
143+
94144
fail(
95145
`${auditUrl} reports artifact_versions.${artifact}=${actual || '<missing>'}, expected ${expected}. ` +
96-
'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.'
146+
'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.',
147+
{actual_version: actualVersion}
97148
);
98149
}
99150
151+
writeEvidence('pass', {actual_version: actual});
100152
console.log(`${auditUrl} confirms artifact_versions.${artifact}=${expected}.`);
101153
NODE
102154
then
@@ -116,4 +168,6 @@ NODE
116168
attempt=$((attempt + 1))
117169
done
118170

119-
fail "Docs release-audit unavailable" "Could not fetch ${audit_url} after ${attempts} attempt(s)."
171+
message="Could not fetch ${audit_url} after ${attempts} attempt(s)."
172+
write_unavailable_evidence "$message"
173+
fail "Docs release-audit unavailable" "$message"

tests/ReleaseInstallerContractTest.php

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,19 @@ public function test_installers_are_versioned_release_assets(): void
2020
self::assertStringContainsString('ref: ${{ needs.resolve-release.outputs.tag }}', $releaseWorkflow);
2121
self::assertStringContainsString('DW_CLI_VERSION: ${{ needs.resolve-release.outputs.tag }}', $releaseWorkflow);
2222
self::assertStringContainsString('DW_CLI_COMMIT="$(git rev-parse HEAD)"', $releaseWorkflow);
23+
self::assertStringContainsString('release-preflight:', $releaseWorkflow);
24+
self::assertStringContainsString('public_assets_present: ${{ steps.public_assets.outputs.present }}', $releaseWorkflow);
25+
self::assertStringContainsString('echo "present=${present}" >> "$GITHUB_OUTPUT"', $releaseWorkflow);
26+
self::assertStringContainsString('existing-public-assets-rerun-gate', $releaseWorkflow);
27+
self::assertStringContainsString('pre-upload-public-asset-presence-check', $releaseWorkflow);
28+
self::assertStringContainsString('complete_public_asset_set: present === \'true\'', $releaseWorkflow);
29+
self::assertStringContainsString('Require live docs release audit for existing public assets', $releaseWorkflow);
30+
self::assertStringContainsString("if: steps.public_assets.outputs.present == 'true'", $releaseWorkflow);
31+
self::assertStringContainsString('DOCS_RELEASE_AUDIT_EVIDENCE: docs-release-audit-evidence.json', $releaseWorkflow);
32+
self::assertStringContainsString('release-preflight-public-assets-evidence.json', $releaseWorkflow);
33+
self::assertStringContainsString('needs: [resolve-release, release-preflight]', $releaseWorkflow);
34+
self::assertStringContainsString("needs.release-preflight.result == 'success'", $releaseWorkflow);
35+
self::assertStringContainsString("needs.release-preflight.outputs.public_assets_present != 'true'", $releaseWorkflow);
2336
self::assertStringContainsString('cp scripts/install.sh dist/install.sh', $releaseWorkflow);
2437
self::assertStringContainsString('cp scripts/install.ps1 dist/install.ps1', $releaseWorkflow);
2538
self::assertStringContainsString('cp scripts/verify-release.sh dist/verify-release.sh', $releaseWorkflow);
@@ -35,6 +48,10 @@ public function test_installers_are_versioned_release_assets(): void
3548
self::assertStringContainsString('.\\build\\dw-windows-x86_64.exe runtime:check', $releaseWorkflow);
3649
self::assertStringContainsString('release-public-download-evidence.json', $releaseWorkflow);
3750
self::assertStringContainsString('"artifact_versions": {"cli": "%s"}', $releaseWorkflow);
51+
self::assertStringContainsString('"installable_artifacts": {"verified_public_downloads": true, "version": "%s"}', $releaseWorkflow);
52+
self::assertStringContainsString('Verify live docs release audit after public downloads', $releaseWorkflow);
53+
self::assertStringContainsString('name: release-evidence', $releaseWorkflow);
54+
self::assertStringNotContainsString('"docs_release_audit": {"artifact": "cli", "version": "%s", "checked_before_public_upload": true', $releaseWorkflow);
3855
self::assertStringContainsString('install.sh', $releaseWorkflow);
3956
self::assertStringContainsString('install.ps1', $releaseWorkflow);
4057
self::assertStringContainsString('verify-release.sh', $releaseWorkflow);
@@ -50,6 +67,21 @@ public function test_installers_are_versioned_release_assets(): void
5067
self::assertStringContainsString('--without-suggestions --retry="${SPC_DOWNLOAD_RETRY}"', $releaseWorkflow);
5168
self::assertStringContainsString('--without-suggestions --retry="$env:SPC_DOWNLOAD_RETRY"', $releaseWorkflow);
5269
self::assertStringContainsString('name: ${{ matrix.name }}-spc-logs', $releaseWorkflow);
70+
self::assertStringNotContainsString('Require live docs release audit refresh', $releaseWorkflow);
71+
72+
$preflightDocsGatePosition = strpos($releaseWorkflow, 'Require live docs release audit for existing public assets');
73+
$buildPosition = strpos($releaseWorkflow, 'build-phar:');
74+
$uploadPosition = strpos($releaseWorkflow, 'Create GitHub Release');
75+
$publicDownloadPosition = strpos($releaseWorkflow, 'Verify public release downloads');
76+
$postUploadDocsGatePosition = strpos($releaseWorkflow, 'Verify live docs release audit after public downloads');
77+
self::assertIsInt($preflightDocsGatePosition);
78+
self::assertIsInt($buildPosition);
79+
self::assertIsInt($uploadPosition);
80+
self::assertIsInt($publicDownloadPosition);
81+
self::assertIsInt($postUploadDocsGatePosition);
82+
self::assertLessThan($buildPosition, $preflightDocsGatePosition);
83+
self::assertLessThan($uploadPosition, $preflightDocsGatePosition);
84+
self::assertLessThan($postUploadDocsGatePosition, $publicDownloadPosition);
5385
}
5486

5587
public function test_build_validates_installer_scripts(): void
@@ -60,6 +92,7 @@ public function test_build_validates_installer_scripts(): void
6092
self::assertStringContainsString('sh -n scripts/generate-homebrew-formula.sh', $buildWorkflow);
6193
self::assertStringContainsString('sh -n scripts/verify-release.sh', $buildWorkflow);
6294
self::assertStringContainsString('bash -n scripts/verify-public-release-assets.sh', $buildWorkflow);
95+
self::assertStringContainsString('sh -n scripts/ci/check-docs-release-audit.sh', $buildWorkflow);
6396
self::assertStringContainsString('scripts/install.ps1', $buildWorkflow);
6497
}
6598

@@ -82,6 +115,21 @@ public function test_release_includes_checksum_and_attestation_verifier(): void
82115
self::assertStringContainsString('verify-release.sh --attest', $readme);
83116
}
84117

118+
public function test_docs_release_audit_writes_preflight_evidence(): void
119+
{
120+
$auditor = self::readRepoFile('scripts/ci/check-docs-release-audit.sh');
121+
122+
self::assertStringContainsString('DOCS_RELEASE_AUDIT_EVIDENCE', $auditor);
123+
self::assertStringContainsString('durable-workflow.cli.docs-release-audit-evidence', $auditor);
124+
self::assertStringContainsString('docs-page-release-audit-${artifact}-${expected}-$$.json', $auditor);
125+
self::assertStringContainsString('trap \'rm -f "$audit_path"\' EXIT HUP INT TERM', $auditor);
126+
self::assertStringContainsString("surface: 'public_docs_release_audit'", $auditor);
127+
self::assertStringContainsString("outcome: 'unavailable'", $auditor);
128+
self::assertStringContainsString("writeEvidence('stale'", $auditor);
129+
self::assertStringContainsString("writeEvidence('pass'", $auditor);
130+
self::assertStringContainsString('actual_version: actualVersion', $auditor);
131+
}
132+
85133
public function test_release_publishes_generated_homebrew_formula(): void
86134
{
87135
$releaseWorkflow = self::readRepoFile('.github/workflows/release.yml');

0 commit comments

Comments
 (0)