Skip to content

CLI release workflow: gate docs audit before public asset upload (#142) #91

CLI release workflow: gate docs audit before public asset upload (#142)

CLI release workflow: gate docs audit before public asset upload (#142) #91

Workflow file for this run

name: Release
on:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+*'
workflow_dispatch:
inputs:
tag:
description: 'Release tag (for manual dispatch, e.g. 0.0.1-test or v0.0.1-test)'
required: true
permissions:
attestations: write
contents: write
id-token: write
env:
SPC_EXTENSIONS: curl,mbstring,openssl,phar,tokenizer,ctype,filter,fileinfo,iconv,sockets
STANDALONE_RUNTIME_EXTENSIONS: curl,mbstring,openssl,phar,tokenizer,ctype,filter,fileinfo,iconv,sockets
STANDALONE_RUNTIME_EXTENSIONS_WINDOWS: mbstring,openssl,phar,tokenizer,ctype,filter,fileinfo,iconv,sockets
# Windows uses Symfony's native stream transport over OpenSSL. Avoid
# requesting ext-curl from phpmicro there because the Windows build can
# compile successfully while the generated micro runtime does not load curl.
SPC_EXTENSIONS_WINDOWS: mbstring,openssl,phar,tokenizer,ctype,filter,fileinfo,iconv,sockets
PHP_VERSION: '8.4'
PHP_VERSION_WINDOWS: '8.4'
BOX_VERSION: '4.6.6'
SPC_DOWNLOAD_RETRY: '5'
SPC_DOWNLOAD_OUTER_ATTEMPTS: '4'
# Passed to every spc step so it can use authenticated GitHub API calls
# when fetching source tarballs / pre-built libs (avoids 403 rate limits).
GITHUB_TOKEN: ${{ github.token }}
jobs:
resolve-release:
name: Resolve release tag
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.resolve.outputs.tag }}
steps:
- id: resolve
shell: bash
env:
DISPATCH_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
tag="$DISPATCH_TAG"
else
tag="${GITHUB_REF_NAME}"
fi
if [ -z "$tag" ]; then
echo "::error::Release tag is required for manual dispatch"
exit 1
fi
raw_tag="$tag"
tag="${tag#v}"
if ! printf '%s\n' "$tag" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z][0-9A-Za-z.-]*)?$'; then
echo "::error::Invalid release tag: $raw_tag"
exit 1
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "Resolved release tag: $tag"
release-preflight:
name: Release preflight
needs: resolve-release
runs-on: ubuntu-latest
outputs:
public_assets_present: ${{ steps.public_assets.outputs.present }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.resolve-release.outputs.tag }}
- name: Inspect existing public release assets
id: public_assets
env:
RELEASE_TAG: ${{ needs.resolve-release.outputs.tag }}
run: |
set -euo pipefail
tag="${RELEASE_TAG#v}"
release_repo="${DURABLE_WORKFLOW_REPO:-durable-workflow/cli}"
evidence="release-preflight-public-assets-evidence.json"
present="false"
status="missing_or_incomplete"
message="No complete public CLI asset set was found for ${tag}."
if DURABLE_WORKFLOW_RELEASE_ASSET_ATTEMPTS=1 DURABLE_WORKFLOW_RELEASE_ASSET_RETRY_SLEEP=0 \
scripts/verify-public-release-assets.sh "$tag"; then
present="true"
status="present"
message="A complete public CLI asset set is already downloadable for ${tag}."
fi
echo "present=${present}" >> "$GITHUB_OUTPUT"
checked_at="$(date -u +%FT%TZ)"
node - "$evidence" "$tag" "$release_repo" "$status" "$message" "$checked_at" "$present" <<'NODE'
const fs = require('fs');
const [path, tag, repo, status, message, checkedAt, present] = process.argv.slice(2);
const assets = [
'dw.phar',
'dw-linux-x86_64',
'dw-linux-aarch64',
'dw-macos-aarch64',
'dw-windows-x86_64.exe',
'dw.rb',
'install.sh',
'install.ps1',
'verify-release.sh',
'SHA256SUMS',
];
const urls = assets.map((asset) => ({
name: asset,
url: `https://github.com/${repo}/releases/download/${tag}/${asset}`,
}));
fs.writeFileSync(path, `${JSON.stringify({
schema: 'durable-workflow.cli.public-release-assets-preflight',
tag,
repository: repo,
checked_at: checkedAt,
outcome: status,
message,
purpose: present === 'true'
? 'existing-public-assets-rerun-gate'
: 'pre-upload-public-asset-presence-check',
installable_artifacts: {
complete_public_asset_set: present === 'true',
version: tag,
},
assets: urls,
}, null, 2)}\n`);
NODE
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
{
printf '## Public CLI asset preflight\n\n'
printf '%s\n\n' "$message"
if [ "$present" = "true" ]; then
printf 'Because the complete public asset set already exists, the live docs release-audit gate runs before any rebuild or upload.\n'
else
printf 'The live docs release-audit gate will run after this release publishes and verifies the public download surface.\n'
fi
} >> "$GITHUB_STEP_SUMMARY"
fi
- name: Require live docs release audit for existing public assets
if: steps.public_assets.outputs.present == 'true'
env:
DOCS_RELEASE_AUDIT_ARTIFACT: cli
DOCS_RELEASE_AUDIT_VERSION: ${{ needs.resolve-release.outputs.tag }}
DOCS_RELEASE_AUDIT_EVIDENCE: docs-release-audit-evidence.json
run: scripts/ci/check-docs-release-audit.sh
- name: Upload release preflight evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: release-preflight-evidence
path: |
release-preflight-public-assets-evidence.json
docs-release-audit-evidence.json
if-no-files-found: warn
build-phar:
name: Build PHAR
needs: [resolve-release, release-preflight]
runs-on: ubuntu-latest
if: ${{ always() && needs.resolve-release.result == 'success' && needs.release-preflight.result == 'success' && needs.release-preflight.outputs.public_assets_present != 'true' }}
outputs:
phar-name: dw.phar
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ needs.resolve-release.outputs.tag }}
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ env.PHP_VERSION }}
tools: composer:v2, box:${{ env.BOX_VERSION }}
coverage: none
ini-values: phar.readonly=0
- name: Pin SOURCE_DATE_EPOCH for reproducible build
run: |
# Pin the build clock to the tag-pointed commit so the PHAR
# produced here matches the PHAR an operator rebuilds locally
# from the same tag. See docs/distribution.md.
epoch=$(git log -1 --pretty=%ct)
echo "SOURCE_DATE_EPOCH=$epoch" >> "$GITHUB_ENV"
echo "SOURCE_DATE_EPOCH pinned to $epoch ($(date -u -d "@$epoch" +%FT%TZ))"
- name: Generate build metadata
env:
DW_CLI_VERSION: ${{ needs.resolve-release.outputs.tag }}
run: |
export DW_CLI_COMMIT="$(git rev-parse HEAD)"
php scripts/generate-build-info.php
- name: Install dependencies
run: composer install --no-dev --prefer-dist --no-interaction --optimize-autoloader
- name: Normalize input mtimes for reproducible PHAR
run: |
# Box hashes archive entry mtimes. Pinning them to the build
# epoch (which is itself the commit time) makes the PHAR's
# entry table deterministic across reruns of the same tag.
find src schemas bin vendor -exec touch -h -d "@$SOURCE_DATE_EPOCH" {} +
[ -f src/GeneratedBuildInfo.php ] && touch -h -d "@$SOURCE_DATE_EPOCH" src/GeneratedBuildInfo.php
- name: Build PHAR
run: box compile --no-parallel
- name: Verify PHAR
run: |
php build/dw.phar --version || true
box info build/dw.phar
- uses: actions/upload-artifact@v4
with:
name: dw-phar
path: build/dw.phar
if-no-files-found: error
build-binary:
name: Build ${{ matrix.name }}
needs: [resolve-release, release-preflight, build-phar]
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' }}
strategy:
fail-fast: false
matrix:
include:
- name: linux-x86_64
runner: ubuntu-latest
spc_asset: spc-linux-x86_64
- name: linux-aarch64
runner: ubuntu-24.04-arm
spc_asset: spc-linux-aarch64
- name: macos-aarch64
runner: macos-14
spc_asset: spc-macos-aarch64
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.resolve-release.outputs.tag }}
- name: Download PHAR artifact
uses: actions/download-artifact@v4
with:
name: dw-phar
path: build/
- name: Download spc
run: |
mkdir -p build/.tools
for attempt in 1 2 3 4 5; do
if curl -fsSL --retry 3 --retry-all-errors --connect-timeout 10 \
-o build/.tools/spc \
"https://dl.static-php.dev/static-php-cli/spc-bin/nightly/${{ matrix.spc_asset }}"; then
break
fi
echo "spc download attempt $attempt failed, retrying in 10s..."
sleep 10
done
test -s build/.tools/spc
chmod +x build/.tools/spc
- name: Cache spc downloads
uses: actions/cache@v4
with:
path: |
build/.tools/downloads
build/.tools/source
key: spc-${{ matrix.name }}-php${{ env.PHP_VERSION }}-${{ hashFiles('.github/workflows/release.yml') }}
restore-keys: |
spc-${{ matrix.name }}-php${{ env.PHP_VERSION }}-
- name: Fetch PHP sources & extension deps
working-directory: build/.tools
run: |
case "$SPC_DOWNLOAD_OUTER_ATTEMPTS" in
''|*[!0-9]*)
echo "::error::SPC_DOWNLOAD_OUTER_ATTEMPTS must be a positive integer"
exit 1
;;
esac
if [ "$SPC_DOWNLOAD_OUTER_ATTEMPTS" -lt 1 ]; then
echo "::error::SPC_DOWNLOAD_OUTER_ATTEMPTS must be at least 1"
exit 1
fi
attempt=1
while [ "$attempt" -le "$SPC_DOWNLOAD_OUTER_ATTEMPTS" ]; do
./spc doctor --auto-fix || true
if ./spc download --with-php=${{ env.PHP_VERSION }} \
--for-extensions="$SPC_EXTENSIONS" --prefer-pre-built \
--without-suggestions --retry="${SPC_DOWNLOAD_RETRY}"; then
exit 0
fi
if [ "$attempt" -lt "$SPC_DOWNLOAD_OUTER_ATTEMPTS" ]; then
delay=$((attempt * 30))
echo "::warning::spc dependency download attempt ${attempt}/${SPC_DOWNLOAD_OUTER_ATTEMPTS} failed; retrying in ${delay}s"
sleep "$delay"
fi
attempt=$((attempt + 1))
done
echo "::error::spc dependency download failed after ${SPC_DOWNLOAD_OUTER_ATTEMPTS} attempts"
exit 1
- name: Build phpmicro SAPI with extensions
working-directory: build/.tools
run: ./spc build "$SPC_EXTENSIONS" --build-micro
- name: Upload spc build logs (always)
if: always()
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.name }}-spc-logs
path: build/.tools/log/
if-no-files-found: warn
- name: Combine PHAR into standalone binary
working-directory: build/.tools
run: |
./spc micro:combine ../../build/dw.phar \
--output=../../build/dw-${{ matrix.name }}
- name: Smoke test binary
run: |
chmod +x build/dw-${{ matrix.name }}
./build/dw-${{ matrix.name }} --version
./build/dw-${{ matrix.name }} runtime:check
./build/dw-${{ matrix.name }} list
- uses: actions/upload-artifact@v4
with:
name: dw-${{ matrix.name }}
path: build/dw-${{ matrix.name }}
if-no-files-found: error
build-binary-windows:
name: Build windows-x86_64
needs: [resolve-release, release-preflight, build-phar]
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' }}
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.resolve-release.outputs.tag }}
- name: Download PHAR artifact
uses: actions/download-artifact@v4
with:
name: dw-phar
path: build/
- name: Setup PHP for spc
uses: shivammathur/setup-php@v2
with:
php-version: ${{ env.PHP_VERSION_WINDOWS }}
tools: composer:v2
coverage: none
- name: Clone spc
shell: pwsh
run: |
git clone --depth=1 https://github.com/crazywhalecc/static-php-cli.git build\spc-src
- name: Install spc Composer dependencies
shell: pwsh
working-directory: build\spc-src
run: composer install --no-dev --prefer-dist --no-interaction
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Cache spc downloads
uses: actions/cache@v4
with:
path: |
build/spc-src/downloads
build/spc-src/source
key: spc-src-windows-x86_64-php${{ env.PHP_VERSION_WINDOWS }}-${{ hashFiles('.github/workflows/release.yml') }}
restore-keys: |
spc-src-windows-x86_64-php${{ env.PHP_VERSION_WINDOWS }}-
- name: Fetch PHP sources & extension deps
shell: pwsh
working-directory: build\spc-src
run: |
$outerAttempts = [int]$env:SPC_DOWNLOAD_OUTER_ATTEMPTS
if ($outerAttempts -lt 1) {
Write-Error "SPC_DOWNLOAD_OUTER_ATTEMPTS must be at least 1"
exit 1
}
for ($attempt = 1; $attempt -le $outerAttempts; $attempt++) {
php bin\spc doctor --auto-fix
php bin\spc download --with-php=${{ env.PHP_VERSION_WINDOWS }} `
--for-extensions="$env:SPC_EXTENSIONS_WINDOWS" --prefer-pre-built `
--without-suggestions --retry="$env:SPC_DOWNLOAD_RETRY"
if ($LASTEXITCODE -eq 0) {
break
}
if ($attempt -lt $outerAttempts) {
$delay = $attempt * 30
Write-Warning "spc dependency download attempt $attempt/$outerAttempts failed; retrying in ${delay}s"
Start-Sleep -Seconds $delay
} else {
Write-Error "spc dependency download failed after $outerAttempts attempts"
exit $LASTEXITCODE
}
}
Remove-Item -Recurse -Force source\php-src -ErrorAction SilentlyContinue
php bin\spc extract php-src
- name: Patch PHP OpenSSL 3 compatibility
shell: pwsh
working-directory: build\spc-src
run: |
@'
import pathlib
import re
headers = list(pathlib.Path("source").rglob("php_openssl.h"))
if not headers:
print("php_openssl.h was not found after spc extract; continuing without local OpenSSL patch.")
raise SystemExit(0)
patched = []
compat = "#ifndef ERR_NUM_ERRORS\n#define ERR_NUM_ERRORS 16\n#endif\n\n"
for header in headers:
text = header.read_text()
if "ERR_NUM_ERRORS" not in text or "#define ERR_NUM_ERRORS 16" in text:
continue
patched_text, count = re.subn(r"(#include\s+<openssl/err\.h>\r?\n)", r"\1" + compat, text, count=1)
if count == 1:
text = patched_text
else:
text = compat + text
header.write_text(text)
patched.append(str(header))
if patched:
print("Patched OpenSSL ERR_NUM_ERRORS compatibility in:")
for path in patched:
print(f" {path}")
else:
print("No ERR_NUM_ERRORS compatibility patch was needed.")
'@ | Set-Content -Path patch-php-openssl.py -Encoding utf8
python patch-php-openssl.py
Remove-Item -Recurse -Force buildroot -ErrorAction SilentlyContinue
- name: Build phpmicro SAPI with extensions
shell: pwsh
working-directory: build\spc-src
run: php bin\spc build "$env:SPC_EXTENSIONS_WINDOWS" --build-micro --debug
- name: Upload spc build logs (always)
if: always()
uses: actions/upload-artifact@v4
with:
name: windows-spc-logs
path: build/spc-src/log/
if-no-files-found: warn
- name: Combine PHAR into standalone binary
shell: pwsh
working-directory: build\spc-src
run: |
php bin\spc micro:combine ..\..\build\dw.phar `
--output=..\..\build\dw-windows-x86_64.exe
- name: Smoke test binary
shell: pwsh
run: |
.\build\dw-windows-x86_64.exe --version
.\build\dw-windows-x86_64.exe runtime:check
.\build\dw-windows-x86_64.exe list
- uses: actions/upload-artifact@v4
with:
name: dw-windows-x86_64
path: build/dw-windows-x86_64.exe
if-no-files-found: error
release:
name: Publish Release
# Every supported standalone platform must publish before the release
# asset list, SHA256SUMS, installers, and public download endpoints are
# considered complete.
needs: [resolve-release, release-preflight, build-phar, build-binary, build-binary-windows]
runs-on: ubuntu-latest
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' }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.resolve-release.outputs.tag }}
- name: Download release artifacts
uses: actions/download-artifact@v4
with:
path: dist
pattern: dw-*
merge-multiple: true
- name: Add installer scripts
run: |
cp scripts/install.sh dist/install.sh
cp scripts/install.ps1 dist/install.ps1
cp scripts/verify-release.sh dist/verify-release.sh
- name: Validate release artifacts
run: |
set -euo pipefail
required=(
dw.phar
dw-linux-x86_64
dw-linux-aarch64
dw-macos-aarch64
dw-windows-x86_64.exe
install.sh
install.ps1
verify-release.sh
)
for artifact in "${required[@]}"; do
if [ ! -s "dist/$artifact" ]; then
echo "::error::Missing required release artifact: $artifact"
exit 1
fi
done
sh -n dist/install.sh
sh -n dist/verify-release.sh
pwsh -NoProfile -Command '$null = [scriptblock]::Create((Get-Content -Raw dist/install.ps1))'
- name: Generate Homebrew formula
run: scripts/generate-homebrew-formula.sh dist "${{ needs.resolve-release.outputs.tag }}"
- name: Generate checksums
working-directory: dist
run: sha256sum -- * > SHA256SUMS
- name: Attest release artifacts
uses: actions/attest-build-provenance@v2
with:
subject-path: dist/*
- name: Write release notes
run: |
set -euo pipefail
tag="${{ needs.resolve-release.outputs.tag }}"
cat > release-notes.md <<EOF
Durable Workflow CLI ${tag}
This release publishes the \`dw\` command-line client and its supported public distribution assets:
- installer scripts for POSIX shells and PowerShell
- the portable PHAR package
- standalone binaries for Linux x86_64, Linux aarch64, macOS aarch64, and Windows x86_64
- SHA256SUMS for artifact verification
Download the assets below and verify them with the bundled \`verify-release.sh\` helper or your platform checksum tooling.
EOF
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.resolve-release.outputs.tag }}
name: ${{ needs.resolve-release.outputs.tag }}
body_path: release-notes.md
files: |
dist/*
generate_release_notes: true
fail_on_unmatched_files: true
- name: Verify public release downloads
run: |
set -euo pipefail
tag="${{ needs.resolve-release.outputs.tag }}"
tag="${tag#v}"
release_repo="${DURABLE_WORKFLOW_REPO:-durable-workflow/cli}"
attempts="${DURABLE_WORKFLOW_RELEASE_ASSET_ATTEMPTS:-12}"
sleep_seconds="${DURABLE_WORKFLOW_RELEASE_ASSET_RETRY_SLEEP:-10}"
case "$attempts" in
''|*[!0-9]*)
echo "::error::DURABLE_WORKFLOW_RELEASE_ASSET_ATTEMPTS must be a positive integer"
exit 1
;;
esac
case "$sleep_seconds" in
''|*[!0-9]*)
echo "::error::DURABLE_WORKFLOW_RELEASE_ASSET_RETRY_SLEEP must be a non-negative integer"
exit 1
;;
esac
if [ "$attempts" -lt 1 ]; then
echo "::error::DURABLE_WORKFLOW_RELEASE_ASSET_ATTEMPTS must be at least 1"
exit 1
fi
public_asset_url() {
printf 'https://github.com/%s/releases/download/%s/%s' "$release_repo" "$tag" "$1"
}
wait_for_asset() {
asset="$1"
url="$(public_asset_url "$asset")"
attempt=1
while [ "$attempt" -le "$attempts" ]; do
if curl -fsSLI --retry 3 --retry-all-errors --connect-timeout 10 "$url" >/dev/null; then
printf '%s: OK\n' "$asset"
return 0
fi
if [ "$attempt" -lt "$attempts" ]; then
printf 'Waiting for public release asset (%s/%s): %s\n' "$attempt" "$attempts" "$asset" >&2
sleep "$sleep_seconds"
fi
attempt=$((attempt + 1))
done
echo "::error::Public release asset is not downloadable: $url"
return 1
}
public_assets=(
dw.phar
dw-linux-x86_64
dw-linux-aarch64
dw-macos-aarch64
dw-windows-x86_64.exe
dw.rb
install.sh
install.ps1
verify-release.sh
SHA256SUMS
)
for asset in "${public_assets[@]}"; do
wait_for_asset "$asset"
done
evidence="release-public-download-evidence.json"
release_commit="$(git rev-parse HEAD)"
verified_at="$(date -u +%FT%TZ)"
{
printf '{\n'
printf ' "tag": "%s",\n' "$tag"
printf ' "commit": "%s",\n' "$release_commit"
printf ' "artifact_versions": {"cli": "%s"},\n' "$tag"
printf ' "installable_artifacts": {"verified_public_downloads": true, "version": "%s"},\n' "$tag"
printf ' "verified_at": "%s",\n' "$verified_at"
printf ' "outcome": "pass",\n'
printf ' "assets": [\n'
last_index=$((${#public_assets[@]} - 1))
for i in "${!public_assets[@]}"; do
asset="${public_assets[$i]}"
comma=","
if [ "$i" -eq "$last_index" ]; then
comma=""
fi
printf ' {"name": "%s", "url": "%s"}%s\n' "$asset" "$(public_asset_url "$asset")" "$comma"
done
printf ' ]\n'
printf '}\n'
} > "$evidence"
installer="$(mktemp)"
install_dir="$(mktemp -d)"
trap 'rm -rf "$installer" "$install_dir"' EXIT
curl -fsSL --retry 3 --retry-all-errors --connect-timeout 10 \
-o "$installer" "$(public_asset_url install.sh)"
VERSION="$tag" DURABLE_WORKFLOW_INSTALL_DIR="$install_dir" \
DURABLE_WORKFLOW_RELEASE_BASE_URL="https://github.com/${release_repo}/releases" \
DURABLE_WORKFLOW_BIN_NAME=dw-release-check sh "$installer"
"$install_dir/dw-release-check" --version
- name: Verify live docs release audit after public downloads
env:
DOCS_RELEASE_AUDIT_ARTIFACT: cli
DOCS_RELEASE_AUDIT_VERSION: ${{ needs.resolve-release.outputs.tag }}
DOCS_RELEASE_AUDIT_EVIDENCE: docs-release-audit-evidence.json
run: scripts/ci/check-docs-release-audit.sh
- name: Upload release evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: release-evidence
path: |
release-public-download-evidence.json
docs-release-audit-evidence.json
if-no-files-found: warn