Skip to content

ci: build Windows MSI on GitHub #3

ci: build Windows MSI on GitHub

ci: build Windows MSI on GitHub #3

Workflow file for this run

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"