TD-DOCS-011: Automate public artifact tuple refresh after releases (#… #90
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | |
| build-phar: | |
| name: Build PHAR | |
| needs: resolve-release | |
| runs-on: ubuntu-latest | |
| 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, build-phar] | |
| 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, build-phar] | |
| 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, build-phar, build-binary, build-binary-windows] | |
| runs-on: ubuntu-latest | |
| if: ${{ always() && needs.resolve-release.result == 'success' && 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 ' "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: Upload public download evidence | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: release-public-download-evidence | |
| path: release-public-download-evidence.json | |
| if-no-files-found: error | |
| - name: Require live docs release audit refresh | |
| env: | |
| DOCS_RELEASE_AUDIT_ARTIFACT: cli | |
| DOCS_RELEASE_AUDIT_VERSION: ${{ needs.resolve-release.outputs.tag }} | |
| run: scripts/ci/check-docs-release-audit.sh |