ci: build Windows MSI on GitHub #3
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: Windows MSI | |
| on: | |
| pull_request: | |
| paths: | |
| - .github/workflows/windows-msi.yml | |
| - ci/release/windows/** | |
| - ci/release/build.sh | |
| - ci/release/release.sh | |
| - ci/release/README.md | |
| push: | |
| tags: | |
| - v* | |
| workflow_dispatch: | |
| inputs: | |
| version: | |
| description: Existing release version to use as the Windows archive fixture | |
| required: true | |
| type: string | |
| default: v0.7.1 | |
| upload_release: | |
| description: Upload the verified MSI to the draft release | |
| required: true | |
| type: boolean | |
| default: false | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: windows-msi-${{ github.event.pull_request.number || inputs.version || github.ref }} | |
| cancel-in-progress: false | |
| jobs: | |
| validate: | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 70 | |
| outputs: | |
| archive_asset_id: ${{ steps.release.outputs.archive_asset_id }} | |
| archive_digest: ${{ steps.release.outputs.archive_digest }} | |
| checkout_commit: ${{ steps.release.outputs.checkout_commit }} | |
| release_id: ${{ steps.release.outputs.release_id }} | |
| tag_commit: ${{ steps.release.outputs.tag_commit }} | |
| upload_release: ${{ steps.release.outputs.upload_release }} | |
| version: ${{ steps.release.outputs.version }} | |
| steps: | |
| - name: Pin release and Windows archive | |
| id: release | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| REQUESTED_UPLOAD: ${{ inputs.upload_release || false }} | |
| REQUESTED_VERSION: ${{ inputs.version }} | |
| run: | | |
| set -euo pipefail | |
| case "$GITHUB_EVENT_NAME" in | |
| pull_request) | |
| version=v0.7.1 | |
| upload_release=false | |
| ;; | |
| push) | |
| version=$GITHUB_REF_NAME | |
| upload_release=true | |
| ;; | |
| workflow_dispatch) | |
| version=$REQUESTED_VERSION | |
| upload_release=$REQUESTED_UPLOAD | |
| if [[ "$upload_release" == true && "$GITHUB_REF" != refs/heads/master ]]; then | |
| echo "release uploads must be dispatched from master" >&2 | |
| exit 1 | |
| fi | |
| ;; | |
| *) | |
| echo "unsupported event: $GITHUB_EVENT_NAME" >&2 | |
| exit 1 | |
| ;; | |
| esac | |
| if ! printf '%s\n' "$version" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$'; then | |
| echo "version must be a v-prefixed semantic version" >&2 | |
| exit 1 | |
| fi | |
| attempts=1 | |
| if [[ "$GITHUB_EVENT_NAME" == push ]]; then | |
| # The release script pushes the tag before it creates the draft release and | |
| # uploads archives. Wait for all six without rebuilding them here. | |
| attempts=240 | |
| fi | |
| release_json= | |
| archive_json= | |
| expected_assets=$(jq -cn --arg version "$version" '[ | |
| "d2-\($version)-linux-amd64.tar.gz", | |
| "d2-\($version)-linux-arm64.tar.gz", | |
| "d2-\($version)-macos-amd64.tar.gz", | |
| "d2-\($version)-macos-arm64.tar.gz", | |
| "d2-\($version)-windows-amd64.tar.gz", | |
| "d2-\($version)-windows-arm64.tar.gz" | |
| ]') | |
| for attempt in $(seq 1 "$attempts"); do | |
| release_json=$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$version" 2>/dev/null || true) | |
| if [[ -n "$release_json" ]]; then | |
| if printf '%s' "$release_json" | jq -e --argjson expected "$expected_assets" ' | |
| [.assets[] | select(.name as $name | $expected | index($name))] as $matches | | |
| ($matches | length) == ($expected | length) and | |
| all($expected[]; | |
| . as $name | ([$matches[] | select(.name == $name)] | length) == 1) and | |
| all($matches[]; | |
| .state == "uploaded" and .size > 0 and | |
| ((.digest // "") | test("^sha256:[0-9a-f]{64}$"))) | |
| ' >/dev/null; then | |
| archive="d2-$version-windows-amd64.tar.gz" | |
| archive_json=$(printf '%s' "$release_json" | jq -c --arg archive "$archive" \ | |
| '[.assets[] | select(.name == $archive)] | if length == 1 then .[0] else empty end') | |
| break | |
| fi | |
| fi | |
| if [[ "$attempt" -eq "$attempts" ]]; then | |
| echo "$version does not have six complete release archives" >&2 | |
| exit 1 | |
| fi | |
| sleep 15 | |
| done | |
| release_id=$(printf '%s' "$release_json" | jq -er '.id') | |
| release_tag=$(printf '%s' "$release_json" | jq -er '.tag_name') | |
| release_draft=$(printf '%s' "$release_json" | jq -r '.draft') | |
| archive_asset_id=$(printf '%s' "$archive_json" | jq -er '.id') | |
| archive_digest=$(printf '%s' "$archive_json" | jq -r '.digest') | |
| archive_size=$(printf '%s' "$archive_json" | jq -r '.size') | |
| if [[ "$release_tag" != "$version" ]]; then | |
| echo "release tag does not match $version" >&2 | |
| exit 1 | |
| fi | |
| if [[ "$upload_release" == true && "$release_draft" != true ]]; then | |
| echo "$version must remain a draft until its MSI is uploaded" >&2 | |
| exit 1 | |
| fi | |
| if ! printf '%s\n' "$archive_digest" | grep -Eq '^sha256:[0-9a-f]{64}$'; then | |
| echo "Windows archive does not have a GitHub SHA-256 digest" >&2 | |
| exit 1 | |
| fi | |
| if [[ "$archive_size" -le 0 ]]; then | |
| echo "Windows archive is empty" >&2 | |
| exit 1 | |
| fi | |
| tag_object=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$version") | |
| tag_type=$(printf '%s' "$tag_object" | jq -er '.object.type') | |
| tag_commit=$(printf '%s' "$tag_object" | jq -er '.object.sha') | |
| for _ in 1 2 3 4 5; do | |
| if [[ "$tag_type" == commit ]]; then | |
| break | |
| fi | |
| if [[ "$tag_type" != tag ]]; then | |
| echo "tag resolves to unsupported object type $tag_type" >&2 | |
| exit 1 | |
| fi | |
| tag_object=$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$tag_commit") | |
| tag_type=$(printf '%s' "$tag_object" | jq -er '.object.type') | |
| tag_commit=$(printf '%s' "$tag_object" | jq -er '.object.sha') | |
| done | |
| if [[ "$tag_type" != commit || ! "$tag_commit" =~ ^[0-9a-f]{40}$ ]]; then | |
| echo "unable to peel $version to a commit" >&2 | |
| exit 1 | |
| fi | |
| checkout_commit=$GITHUB_SHA | |
| if [[ "$upload_release" == true ]]; then | |
| checkout_commit=$tag_commit | |
| fi | |
| { | |
| echo "archive_asset_id=$archive_asset_id" | |
| echo "archive_digest=$archive_digest" | |
| echo "checkout_commit=$checkout_commit" | |
| echo "release_id=$release_id" | |
| echo "tag_commit=$tag_commit" | |
| echo "upload_release=$upload_release" | |
| echo "version=$version" | |
| } >>"$GITHUB_OUTPUT" | |
| build: | |
| needs: validate | |
| runs-on: windows-2022 | |
| timeout-minutes: 30 | |
| env: | |
| ARCHIVE_ASSET_ID: ${{ needs.validate.outputs.archive_asset_id }} | |
| ARCHIVE_DIGEST: ${{ needs.validate.outputs.archive_digest }} | |
| VERSION: ${{ needs.validate.outputs.version }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ needs.validate.outputs.checkout_commit }} | |
| persist-credentials: false | |
| - name: Install pinned WiX toolset | |
| shell: pwsh | |
| run: | | |
| dotnet --info | |
| dotnet tool install --tool-path "$env:RUNNER_TEMP\wix" wix --version 4.0.0-preview.1 | |
| "$env:RUNNER_TEMP\wix" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append | |
| - name: Download and verify release archive | |
| shell: pwsh | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| $archive = "$env:RUNNER_TEMP\d2-$env:VERSION-windows-amd64.tar.gz" | |
| $headers = @{ | |
| Accept = 'application/octet-stream' | |
| Authorization = "Bearer $env:GH_TOKEN" | |
| 'X-GitHub-Api-Version' = '2022-11-28' | |
| } | |
| $downloadParameters = @{ | |
| Headers = $headers | |
| Uri = "https://api.github.com/repos/$env:GITHUB_REPOSITORY/releases/assets/$env:ARCHIVE_ASSET_ID" | |
| OutFile = $archive | |
| } | |
| Invoke-WebRequest @downloadParameters | |
| $expected = $env:ARCHIVE_DIGEST.Substring('sha256:'.Length) | |
| $actual = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() | |
| if ($actual -cne $expected) { | |
| throw "archive SHA-256 is $actual, expected $expected" | |
| } | |
| - name: Build and verify MSI | |
| shell: pwsh | |
| env: | |
| ALLOW_FIXTURE_NOTICES: ${{ needs.validate.outputs.upload_release != 'true' && needs.validate.outputs.version == 'v0.7.1' }} | |
| run: | | |
| $parameters = @{ | |
| Version = $env:VERSION | |
| ArchivePath = "$env:RUNNER_TEMP\d2-$env:VERSION-windows-amd64.tar.gz" | |
| OutputDirectory = "$env:RUNNER_TEMP\windows-msi" | |
| } | |
| if ($env:ALLOW_FIXTURE_NOTICES -eq 'true') { | |
| $parameters.AllowFixtureNotices = $true | |
| } | |
| ./ci/release/windows/build.ps1 @parameters | |
| - name: Upload verified MSI artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: windows-msi | |
| path: ${{ runner.temp }}/windows-msi/*.msi | |
| if-no-files-found: error | |
| compression-level: 0 | |
| retention-days: 7 | |
| upload: | |
| if: needs.validate.outputs.upload_release == 'true' | |
| needs: | |
| - validate | |
| - build | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: write | |
| env: | |
| ARCHIVE_ASSET_ID: ${{ needs.validate.outputs.archive_asset_id }} | |
| ARCHIVE_DIGEST: ${{ needs.validate.outputs.archive_digest }} | |
| RELEASE_ID: ${{ needs.validate.outputs.release_id }} | |
| TAG_COMMIT: ${{ needs.validate.outputs.tag_commit }} | |
| VERSION: ${{ needs.validate.outputs.version }} | |
| steps: | |
| - uses: actions/download-artifact@v4 | |
| with: | |
| name: windows-msi | |
| path: ${{ runner.temp }}/windows-msi | |
| - name: Revalidate release snapshot and upload MSI | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| peel_tag() { | |
| local tag_object tag_type tag_commit | |
| tag_object=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$VERSION") | |
| tag_type=$(printf '%s' "$tag_object" | jq -er '.object.type') | |
| tag_commit=$(printf '%s' "$tag_object" | jq -er '.object.sha') | |
| for _ in 1 2 3 4 5; do | |
| if [[ "$tag_type" == commit ]]; then | |
| break | |
| fi | |
| if [[ "$tag_type" != tag ]]; then | |
| echo "tag resolves to unsupported object type $tag_type" >&2 | |
| return 1 | |
| fi | |
| tag_object=$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$tag_commit") | |
| tag_type=$(printf '%s' "$tag_object" | jq -er '.object.type') | |
| tag_commit=$(printf '%s' "$tag_object" | jq -er '.object.sha') | |
| done | |
| if [[ "$tag_type" != commit || ! "$tag_commit" =~ ^[0-9a-f]{40}$ ]]; then | |
| echo "unable to peel $VERSION to a commit" >&2 | |
| return 1 | |
| fi | |
| printf '%s\n' "$tag_commit" | |
| } | |
| release_json=$(gh api "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID") | |
| if [[ "$(printf '%s' "$release_json" | jq -r '.id')" != "$RELEASE_ID" || | |
| "$(printf '%s' "$release_json" | jq -r '.draft')" != true || | |
| "$(printf '%s' "$release_json" | jq -r '.tag_name')" != "$VERSION" ]]; then | |
| echo "$VERSION is no longer the expected draft release" >&2 | |
| exit 1 | |
| fi | |
| archive="d2-$VERSION-windows-amd64.tar.gz" | |
| archive_json=$(printf '%s' "$release_json" | jq -c --arg archive "$archive" \ | |
| '[.assets[] | select(.name == $archive)] | if length == 1 then .[0] else empty end') | |
| if [[ -z "$archive_json" || | |
| "$(printf '%s' "$archive_json" | jq -r '.id')" != "$ARCHIVE_ASSET_ID" || | |
| "$(printf '%s' "$archive_json" | jq -r '.digest')" != "$ARCHIVE_DIGEST" ]]; then | |
| echo "Windows archive changed after validation" >&2 | |
| exit 1 | |
| fi | |
| if [[ "$(peel_tag)" != "$TAG_COMMIT" ]]; then | |
| echo "$VERSION moved after validation" >&2 | |
| exit 1 | |
| fi | |
| asset="d2-$VERSION-windows-amd64.msi" | |
| msi="$RUNNER_TEMP/windows-msi/$asset" | |
| test -s "$msi" | |
| local_digest="sha256:$(sha256sum "$msi" | awk '{print $1}')" | |
| local_size=$(stat -c %s "$msi") | |
| existing=$(printf '%s' "$release_json" | jq -c --arg asset "$asset" \ | |
| '[.assets[] | select(.name == $asset)]') | |
| existing_count=$(printf '%s' "$existing" | jq -r 'length') | |
| case "$existing_count" in | |
| 0) | |
| gh release upload "$VERSION" "$msi" --repo "$GITHUB_REPOSITORY" | |
| ;; | |
| 1) | |
| if [[ "$(printf '%s' "$existing" | jq -r '.[0].state')" != uploaded || | |
| "$(printf '%s' "$existing" | jq -r '.[0].size')" != "$local_size" || | |
| "$(printf '%s' "$existing" | jq -r '.[0].digest')" != "$local_digest" ]]; then | |
| echo "$asset already exists with different bytes" >&2 | |
| exit 1 | |
| fi | |
| echo "$asset is already attached with the verified digest" | |
| ;; | |
| *) | |
| echo "draft release has duplicate $asset assets" >&2 | |
| exit 1 | |
| ;; | |
| esac | |
| for attempt in 1 2 3 4 5; do | |
| final_release=$(gh api "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID") | |
| if [[ "$(printf '%s' "$final_release" | jq -r '.id')" != "$RELEASE_ID" || | |
| "$(printf '%s' "$final_release" | jq -r '.draft')" != true || | |
| "$(printf '%s' "$final_release" | jq -r '.tag_name')" != "$VERSION" ]]; then | |
| echo "$VERSION changed or was published during MSI upload" >&2 | |
| exit 1 | |
| fi | |
| final_archive=$(printf '%s' "$final_release" | jq -c --arg archive "$archive" \ | |
| '[.assets[] | select(.name == $archive)] | if length == 1 then .[0] else empty end') | |
| if [[ -z "$final_archive" || | |
| "$(printf '%s' "$final_archive" | jq -r '.id')" != "$ARCHIVE_ASSET_ID" || | |
| "$(printf '%s' "$final_archive" | jq -r '.digest')" != "$ARCHIVE_DIGEST" ]]; then | |
| echo "Windows archive changed during MSI upload" >&2 | |
| exit 1 | |
| fi | |
| if [[ "$(peel_tag)" != "$TAG_COMMIT" ]]; then | |
| echo "$VERSION moved during MSI upload" >&2 | |
| exit 1 | |
| fi | |
| uploaded=$(printf '%s' "$final_release" | jq -c --arg asset "$asset" \ | |
| '[.assets[] | select(.name == $asset)] | if length == 1 then .[0] else empty end') | |
| if [[ -n "$uploaded" && | |
| "$(printf '%s' "$uploaded" | jq -r '.state')" == uploaded && | |
| "$(printf '%s' "$uploaded" | jq -r '.size')" == "$local_size" && | |
| "$(printf '%s' "$uploaded" | jq -r '.digest')" == "$local_digest" ]]; then | |
| break | |
| fi | |
| if [[ "$attempt" -eq 5 ]]; then | |
| echo "uploaded MSI digest was not verified" >&2 | |
| exit 1 | |
| fi | |
| sleep 2 | |
| done | |
| echo "Uploaded $asset ($local_digest) to draft release $VERSION" |