From d969419a8f521adf192e017df8bf92c980f2e412 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 30 Oct 2026 08:46:47 -0400 Subject: [PATCH 1/3] Release in one dispatch: build, pin checksums, tag and publish Add phase=all (now the default) to release.yml. After the existing build and stage jobs, a new pin-release job verifies the staged SHA256SUMS, checks that master still points at the built commit and that vX.Y.Z is free, then commits only host-checksums.txt on that commit, tags it and pushes master and the tag in one atomic, non-forced push. The existing publish and publish-crates jobs then run against that tag from the same run. phase=build and phase=publish keep working as the manual fallback. The commit/tag logic lives in prepare-release.ps1 -CommitAndTag -SourceRevision, so the manual path can use it too and the helper regression test covers it. The push uses GITHUB_TOKEN, or the optional RELEASE_PUSH_TOKEN secret when master or tags gain protection rules. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 141 +++++++++++++++++++++++++++------- rust/PRODUCTIZATION.md | 89 ++++++++++++++++----- rust/prepare-release.ps1 | 39 +++++++++- rust/tests/test-build-app.ps1 | 28 +++++++ 4 files changed, 249 insertions(+), 48 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d253302..e4cf3c0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,24 +1,25 @@ name: Release # Release procedure (see rust/PRODUCTIZATION.md#releasing): -# 1. Prepare and merge the version, then dispatch phase=build from master. -# -> stage tarballs and SHA256SUMS as Actions artifacts (no tag/release). -# 2. Download SHA256SUMS from that run, commit host checksums to master. -# Push the consumer tag vX.Y.Z on that checksum-bearing commit. -# 3. Dispatch phase=publish with build_run_id and version X.Y.Z. -# -> verify a standalone git consumer, create release vX.Y.Z. -# Non-draft by default; draft=true stages a draft instead. -# Optionally publish crates after approval in `crates-io`. +# Prepare and merge the version, then dispatch phase=all from master: +# build -> stage tarballs and SHA256SUMS as Actions artifacts. +# pin-release -> commit host checksums on master, tag vX.Y.Z on it. +# publish -> verify a standalone git consumer, create release vX.Y.Z. +# Non-draft by default; draft=true stages a draft instead. +# Optionally publish crates after approval in `crates-io`. +# The manual fallback splits this into phase=build, a local checksum +# commit and tag push, then phase=publish with build_run_id. on: workflow_dispatch: inputs: phase: - description: Build hosts first; publish after committing checksums and pushing the final tag - default: build + description: all builds, pins checksums on master, tags and publishes; build and publish are the manual two-dispatch path + default: all required: true type: choice options: + - all - build - publish version: @@ -31,7 +32,7 @@ on: default: false type: boolean build_run_id: - description: Successful build-phase run ID whose artifacts and source must match the final tag + description: Publish phase only; successful build-phase run ID whose artifacts and source must match the final tag default: '' type: string draft: @@ -49,7 +50,7 @@ concurrency: jobs: version: name: check version - if: inputs.phase == 'build' + if: inputs.phase == 'build' || inputs.phase == 'all' runs-on: ubuntu-22.04 permissions: contents: read @@ -68,7 +69,11 @@ jobs: $version = Get-RustoloniaReleaseVersion -RustoloniaRoot $PWD if ($env:GITHUB_REF -ne 'refs/heads/master') { throw 'Dispatch the host build from master after merging the release preparation.' } if ($env:REQUESTED_VERSION -ne $version) { throw "Requested version $env:REQUESTED_VERSION does not match version $version." } - if ($env:PUBLISH_CRATES -eq 'true') { throw 'publish_crates is only valid for the publish phase.' } + if ($env:PHASE -eq 'build' -and $env:PUBLISH_CRATES -eq 'true') { throw 'publish_crates is only valid for the publish and all phases.' } + if ($env:PHASE -eq 'all') { + if ($env:BUILD_RUN_ID) { throw 'build_run_id is only used by the publish phase; phase=all builds its own hosts.' } + if ($env:DRAFT -eq 'true' -and $env:PUBLISH_CRATES -eq 'true') { throw 'Cannot publish crates for a draft release.' } + } if (@(git tag --list "v$version").Count -gt 0) { throw "Final tag v$version already exists; use the publish phase for its staged build." } if ((Get-Content build/SharedVersion.props -Raw) -notmatch "$([regex]::Escape($version))") { throw "build/SharedVersion.props does not match version $version." @@ -77,7 +82,10 @@ jobs: "prerelease=$($version.Contains('-').ToString().ToLowerInvariant())" >> $env:GITHUB_OUTPUT env: REQUESTED_VERSION: ${{ inputs.version }} + PHASE: ${{ inputs.phase }} PUBLISH_CRATES: ${{ inputs.publish_crates }} + DRAFT: ${{ inputs.draft }} + BUILD_RUN_ID: ${{ inputs.build_run_id }} build: name: host (${{ matrix.rid }}) @@ -216,9 +224,77 @@ jobs: if-no-files-found: error retention-days: 90 + # phase=all: commit the staged hashes on the build source and tag it, as the + # manual path does locally. The push is atomic and never forced, so it fails + # without changing anything if master moved or the tag appeared meanwhile. + pin-release: + name: pin checksums and tag v${{ needs.version.outputs.version }} + needs: [version, stage-release] + if: inputs.phase == 'all' + runs-on: ubuntu-22.04 + permissions: + contents: write + outputs: + commit: ${{ steps.pin.outputs.commit }} + env: + VERSION: ${{ needs.version.outputs.version }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - uses: actions/download-artifact@v4 + with: + name: rustolonia-release-${{ needs.version.outputs.version }} + path: ${{ runner.temp }}/release/v${{ needs.version.outputs.version }} + + - name: Verify staged asset hashes + working-directory: ${{ runner.temp }}/release/v${{ needs.version.outputs.version }} + run: sha256sum --check --strict SHA256SUMS + + - name: Check that master is still the build source and the tag is free + shell: pwsh + run: | + $master = gh api "repos/$env:GH_REPO/git/ref/heads/master" --jq .object.sha + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + if ($master -ne $env:GITHUB_SHA) { throw "master moved from the build source $env:GITHUB_SHA to $master; dispatch a new release run." } + $tags = @(gh api "repos/$env:GH_REPO/git/matching-refs/tags/v$env:VERSION" --jq '.[].ref') + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + if ($tags -contains "refs/tags/v$env:VERSION") { throw "Tag v$env:VERSION already exists; use the publish phase." } + + - id: pin + name: Commit host checksums and tag + shell: pwsh + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + ./rust/prepare-release.ps1 -Checksums -CommitAndTag -SourceRevision $env:GITHUB_SHA ` + -SumsFile (Join-Path $env:RUNNER_TEMP 'release' "v$env:VERSION" 'SHA256SUMS') + "commit=$(git rev-parse HEAD)" >> $env:GITHUB_OUTPUT + + # RELEASE_PUSH_TOKEN is only needed if rules on master or v* tags block + # GITHUB_TOKEN; see rust/PRODUCTIZATION.md#releasing. + - name: Push master and the tag + env: + PUSH_TOKEN: ${{ secrets.RELEASE_PUSH_TOKEN || github.token }} + run: | + set -euo pipefail + auth=$(printf 'x-access-token:%s' "$PUSH_TOKEN" | base64 -w0) + echo "::add-mask::$auth" + git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic $auth" \ + push --atomic "https://github.com/$GH_REPO.git" HEAD:refs/heads/master "refs/tags/v$VERSION" + publish: name: publish ${{ inputs.version }} - if: github.event_name == 'workflow_dispatch' && inputs.phase == 'publish' + needs: pin-release + # The publish phase runs alone; phase=all continues once pin-release pushed the tag. + if: >- + !cancelled() && github.event_name == 'workflow_dispatch' && + (inputs.phase == 'publish' || (inputs.phase == 'all' && needs.pin-release.result == 'success')) runs-on: ubuntu-22.04 permissions: contents: write @@ -227,7 +303,9 @@ jobs: VERSION: ${{ inputs.version }} GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} + PHASE: ${{ inputs.phase }} BUILD_RUN_ID: ${{ inputs.build_run_id }} + PINNED_COMMIT: ${{ needs.pin-release.outputs.commit }} DRAFT: ${{ inputs.draft }} PUBLISH_CRATES: ${{ inputs.publish_crates }} steps: @@ -253,28 +331,37 @@ jobs: shell: pwsh run: | if ($env:GITHUB_REF -ne 'refs/heads/master') { throw 'Dispatch publication from master.' } - if ($env:BUILD_RUN_ID -notmatch '^[1-9][0-9]*$') { throw 'Pass the successful build-phase build_run_id.' } if ($env:DRAFT -eq 'true' -and $env:PUBLISH_CRATES -eq 'true') { throw 'Cannot publish crates for a draft release.' } - $runJson = gh api "repos/$env:GH_REPO/actions/runs/$env:BUILD_RUN_ID" - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - $run = $runJson | ConvertFrom-Json - if ($run.conclusion -ne 'success' -or $run.event -ne 'workflow_dispatch' -or - $run.head_branch -ne 'master' -or $run.path -ne '.github/workflows/release.yml') { - throw 'The build must be a successful Release dispatch from master.' + if ($env:PHASE -eq 'all') { + # This run built the hosts from GITHUB_SHA and pushed the tag itself. + $sourceSha = $env:GITHUB_SHA + if ((git rev-parse HEAD) -ne $env:PINNED_COMMIT) { throw "v$env:VERSION does not point at the checksum commit $env:PINNED_COMMIT pushed by this run." } + } + else { + if ($env:BUILD_RUN_ID -notmatch '^[1-9][0-9]*$') { throw 'Pass the successful build-phase build_run_id.' } + $runJson = gh api "repos/$env:GH_REPO/actions/runs/$env:BUILD_RUN_ID" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $run = $runJson | ConvertFrom-Json + if ($run.conclusion -ne 'success' -or $run.event -ne 'workflow_dispatch' -or + $run.head_branch -ne 'master' -or $run.path -ne '.github/workflows/release.yml') { + throw 'The build must be a successful Release dispatch from master.' + } + $sourceSha = $run.head_sha } - git merge-base --is-ancestor $run.head_sha HEAD + git merge-base --is-ancestor $sourceSha HEAD if ($LASTEXITCODE -ne 0) { throw 'The consumer tag must descend from the build source.' } - $changed = @(git diff --name-only $run.head_sha HEAD | + $changed = @(git diff --name-only $sourceSha HEAD | Where-Object { $_ -ne 'rust/rustolonia-sys/host-checksums.txt' }) if ($LASTEXITCODE -ne 0) { throw 'Could not compare the build source and consumer tag.' } if ($changed.Count -gt 0) { throw "Sources changed since the build: $($changed -join ', ')" } + # Without a token, download-artifact reads this run's own artifacts (phase=all). - uses: actions/download-artifact@v4 with: name: rustolonia-release-${{ inputs.version }} path: ${{ runner.temp }}/release/v${{ inputs.version }} - run-id: ${{ inputs.build_run_id }} - github-token: ${{ github.token }} + run-id: ${{ inputs.phase == 'publish' && inputs.build_run_id || github.run_id }} + github-token: ${{ inputs.phase == 'publish' && github.token || '' }} - name: Verify staged asset hashes shell: pwsh @@ -321,7 +408,9 @@ jobs: publish-crates: name: publish crates ${{ inputs.version }} needs: publish - if: github.event_name == 'workflow_dispatch' && inputs.phase == 'publish' && inputs.publish_crates && !inputs.draft + if: >- + !cancelled() && needs.publish.result == 'success' && github.event_name == 'workflow_dispatch' && + (inputs.phase == 'publish' || inputs.phase == 'all') && inputs.publish_crates && !inputs.draft runs-on: ubuntu-22.04 environment: crates-io permissions: diff --git a/rust/PRODUCTIZATION.md b/rust/PRODUCTIZATION.md index 52dd318..196e629 100644 --- a/rust/PRODUCTIZATION.md +++ b/rust/PRODUCTIZATION.md @@ -472,12 +472,63 @@ another organization repository alone does not prove this repository allows it. ### Releasing Releases are driven by `.github/workflows/release.yml` and -`rust/prepare-release.ps1`: +`rust/prepare-release.ps1`. After the version bump is on `master`, one +dispatch with `phase = all` (the default) builds, pins, tags, and publishes. + +#### One-dispatch release 1. `pwsh rust/prepare-release.ps1 -Version X.Y.Z` sets the version of the three crates, the exact `rustolonia-sys` pin, `release-manifest.json`, and `build/SharedVersion.props`, clears `host-checksums.txt`, and refreshes - `Cargo.lock`. Commit to `master`, then dispatch `phase = build` with + `Cargo.lock`. Merge that change to `master` through a pull request. +2. Dispatch **Release** from `master`: + + ```powershell + gh workflow run release.yml --repo Devolutions/rustolonia --ref master -f phase=all -f version=12.1.2 -f publish_crates=false -f draft=false + ``` + + The run executes the build jobs described in step 2 of the manual path + below. Then `pin-release` verifies the staged `SHA256SUMS`, checks that + `master` still points at the commit the run built and that `vX.Y.Z` does + not exist, and runs + `prepare-release.ps1 -Checksums -SumsFile -CommitAndTag -SourceRevision `. + That makes one commit, "Pin rustolonia X.Y.Z release host checksums", + whose parent is the build commit and which changes only + `host-checksums.txt`, plus the annotated tag `vX.Y.Z` on it. Both are + pushed with one atomic, non-forced `git push --atomic`. If `master` moved + or the tag appeared in the meantime, nothing is pushed. The `publish` and + `publish-crates` jobs then run unchanged against that tag, as in steps 4 + and 5 below. They take the artifacts from the same run and require the tag + to point at the commit `pin-release` pushed. `draft` and `publish_crates` + behave as in the manual path. Do not merge to `master` while the run is + building: the run would then fail before pushing, and a new dispatch is + needed. + +The push uses `GITHUB_TOKEN` with `contents: write`, which works while +`master` and `v*` tags have no branch protection or rulesets (true for +12.1.2: `master` is unprotected and the repository has no rulesets). Commits +and tags pushed with `GITHUB_TOKEN` do not start other workflows. That is +fine here, because publication happens in the same run, but **Avalonia Rust** +CI does not run on the checksum commit. If `master` or tags are later +protected, either let GitHub Actions bypass those rules or add a repository +secret named `RELEASE_PUSH_TOKEN`. Use a fine-grained personal access token +for an account on the bypass list, with **Contents: Read and write** on +`Devolutions/rustolonia` only. The push step uses it when present, and +pushes made with it do trigger CI. A GitHub App installation token cannot be +stored as this secret because it expires after an hour. If the rules +require signed commits, use the manual path. + +If any job before the push fails, nothing is committed or tagged, so fix the +cause and dispatch `phase = all` again. If `publish` or `publish-crates` +fails after the push, the tag already exists and a new `phase = all` run +refuses to start. Re-run only the failed jobs of the same run +(`gh run rerun RUN_ID --failed`). They reuse its artifacts, build commit, +and pinned commit. `phase = publish` cannot take over, because it requires +a `build_run_id` whose run succeeded. + +#### Manual two-dispatch release (fallback) + +1. Prepare and merge the version as above, then dispatch `phase = build` with `version = X.Y.Z`. No build tag or GitHub release is created. 2. The build checks that the requested version matches every version, packages the default host on native runners for all six RIDs, builds and launches a @@ -491,9 +542,11 @@ Releases are driven by `.github/workflows/release.yml` and `pwsh rust/prepare-release.ps1 -Checksums -SumsFile ` writes the default tarball hashes into `host-checksums.txt`. Commit only this file to `master`, then push the - final consumer tag `vX.Y.Z` on that commit. NativeAOT output is not - guaranteed to be bit-for-bit reproducible, so the hashes always come from - the release build itself. + final consumer tag `vX.Y.Z` on that commit. Adding `-CommitAndTag` + (optionally with `-SourceRevision `) makes the commit and + tag locally, with the same checks `phase = all` uses. NativeAOT output is + not guaranteed to be bit-for-bit reproducible, so the hashes always come + from the release build itself. 4. Run the **Release** workflow manually with `phase = publish` and `version = X.Y.Z` from `master`, setting `build_run_id` to the successful build-phase run ID and leaving `publish_crates = false` for a GitHub-only release. @@ -516,26 +569,22 @@ Releases are driven by `.github/workflows/release.yml` and This cannot be combined with `draft = true`. Re-running publication for an existing release fails rather than overwriting assets. -For the first GitHub-only release, this PR already prepares version `12.1.0`. -After merging it, start **Release** from `master` with `phase = build`, -`version = 12.1.0`, and `publish_crates = false`. The build phase validates -the version and stages the six host builds without pushing any tags. -Only the checksum-bearing `v12.1.0` tag is needed. Equivalently: +For a GitHub-only release of 12.1.2 the manual path is, equivalently: ```powershell -gh workflow run release.yml --ref master -f phase=build -f version=12.1.0 -f publish_crates=false +gh workflow run release.yml --ref master -f phase=build -f version=12.1.2 -f publish_crates=false # Wait for the build to succeed; replace RUN_ID below with its run ID. -gh run download RUN_ID --name rustolonia-release-12.1.0 --dir release-assets +gh run download RUN_ID --name rustolonia-release-12.1.2 --dir release-assets # In an up-to-date master checkout: pwsh ./rust/prepare-release.ps1 -Checksums -SumsFile release-assets/SHA256SUMS git add rust/rustolonia-sys/host-checksums.txt -git commit -m "Pin rustolonia 12.1.0 host checksums" -git tag -a v12.1.0 -m "rustolonia 12.1.0" -git push origin master v12.1.0 -gh workflow run release.yml --ref master -f phase=publish -f version=12.1.0 -f build_run_id=RUN_ID -f publish_crates=false -f draft=false +git commit -m "Pin rustolonia 12.1.2 release host checksums" +git tag -a v12.1.2 -m "rustolonia 12.1.2" +git push origin master v12.1.2 +gh workflow run release.yml --ref master -f phase=publish -f version=12.1.2 -f build_run_id=RUN_ID -f publish_crates=false -f draft=false ``` -Do not push `v12.1.0` before committing the actual six host hashes: Cargo +Do not push `vX.Y.Z` before committing the actual six host hashes: Cargo downloads the source at the tag, not later commits on `master`. Wait for the manual publication gate before telling consumers to use that tag. If any source change is needed after staging the host build, run a new build before @@ -544,9 +593,11 @@ mixing changed source with the existing artifacts. `-FromRelease` remains available to read checksums from an existing `vX.Y.Z` release, not for staging. The repository must allow GitHub Actions and the workflow's -`contents: write` permission to publish the release. +`contents: write` permission to publish the release (and, for `phase = all`, +to push the checksum commit and tag; see `RELEASE_PUSH_TOKEN` above). All six native runner labels must be available to the repository. No -additional secret is required for a GitHub-only release. The provenance step +additional secret is required for a GitHub-only release while `master` and +tags are unprotected. The provenance step uses GitHub's `id-token: write` and `attestations: write` permissions. Signing is enabled by setting the `AVALONIA_RUST_SIGN_COMMAND` repository diff --git a/rust/prepare-release.ps1 b/rust/prepare-release.ps1 index 0133ee4..5ae722f 100644 --- a/rust/prepare-release.ps1 +++ b/rust/prepare-release.ps1 @@ -13,8 +13,9 @@ Two steps, each followed by a commit: rust/release-manifest.json and in build/SharedVersion.props, clears rust/rustolonia-sys/host-checksums.txt and refreshes Cargo.lock (and the crates' entries in the in-repository apps' lock files). - Commit to master, then dispatch .github/workflows/release.yml with - phase=build. It stages host tarballs and SHA256SUMS as Actions artifacts. + Commit to master, then dispatch .github/workflows/release.yml. With + phase=all (the default) that one run does everything below; with + phase=build it only stages host tarballs and SHA256SUMS as artifacts. 2. -Checksums (-SumsFile | -FromRelease) Writes host-checksums.txt from SHA256SUMS (symbol tarballs are left out). @@ -23,6 +24,11 @@ Two steps, each followed by a commit: Commit, then push `v` on this commit. Dispatch phase=publish with build_run_id to create the GitHub release (non-draft by default). crates.io publishing and draft releases are opt-in. + + -CommitAndTag also makes that commit (only host-checksums.txt) and the + annotated tag v, without pushing. It refuses a dirty tree, an + existing tag, or a HEAD other than -SourceRevision (the build source). + phase=all runs it, then pushes master and the tag atomically. #> [CmdletBinding(DefaultParameterSetName = 'Version')] param( @@ -38,6 +44,10 @@ param( [string]$SumsFile, [Parameter(ParameterSetName = 'Checksums')] [switch]$FromRelease, + [Parameter(ParameterSetName = 'Checksums')] + [switch]$CommitAndTag, + [Parameter(ParameterSetName = 'Checksums')] + [string]$SourceRevision, [string]$RustoloniaRoot = (Split-Path -Parent $PSScriptRoot) ) @@ -90,11 +100,22 @@ if ($PSCmdlet.ParameterSetName -eq 'Version') { } } $null = Get-RustoloniaReleaseVersion -RustoloniaRoot $root - Write-Host "Version set to $Version. Commit to master, then dispatch phase=build (no tag needed)." + Write-Host "Version set to $Version. Merge to master, then dispatch release.yml with phase=all (no tag needed)." return } $version = Get-RustoloniaReleaseVersion -RustoloniaRoot $root +$checksumPath = 'rust/rustolonia-sys/host-checksums.txt' +if ($SourceRevision -and -not $CommitAndTag) { throw '-SourceRevision is only used with -CommitAndTag.' } +if ($CommitAndTag) { + $head = (& git -C $root rev-parse HEAD).Trim() + if ($SourceRevision) { + $source = (& git -C $root rev-parse --verify "$SourceRevision^{commit}").Trim() + if ($head -ne $source) { throw "HEAD $head is not the build source revision $source; rebuild from the current master." } + } + if (@(& git -C $root status --porcelain --untracked-files=no).Count -gt 0) { throw 'The working tree has changes; -CommitAndTag commits only host-checksums.txt.' } + if (@(& git -C $root tag --list "v$version").Count -gt 0) { throw "Tag v$version already exists." } +} if ($FromRelease) { if ($SumsFile) { throw 'Pass either -SumsFile or -FromRelease, not both.' } $download = Join-Path ([IO.Path]::GetTempPath()) ('rustolonia-sums-' + [guid]::NewGuid().ToString('N')) @@ -127,3 +148,15 @@ foreach ($rid in $script:RidTargets.Keys) { $lines = @($entries.Keys | Sort-Object -CaseSensitive | ForEach-Object { "$($entries[$_]) $_" }) Write-Utf8Lf $checksumFile ("$checksumHeader`n" + ($lines -join "`n") + "`n") Write-Host "Wrote $($lines.Count) checksums for v$version to $checksumFile." +if (-not $CommitAndTag) { return } + +$changed = @(& git -C $root status --porcelain --untracked-files=no | ForEach-Object { $_.Substring(3) }) +if ($changed.Count -eq 0) { throw "host-checksums.txt already lists these hashes; the build source must not carry v$version checksums." } +if ($changed.Count -ne 1 -or $changed[0] -ne $checksumPath) { throw "Unexpected changes: $($changed -join ', ')" } +& git -C $root add -- $checksumPath +& git -C $root commit --quiet -m "Pin rustolonia $version release host checksums" +& git -C $root tag -a "v$version" -m "rustolonia $version" +$parent = (& git -C $root rev-parse 'HEAD^').Trim() +$committed = @(& git -C $root diff --name-only $head HEAD) +if ($parent -ne $head -or $committed.Count -ne 1 -or $committed[0] -ne $checksumPath) { throw 'The checksum commit must change only host-checksums.txt on top of the build source.' } +Write-Host "Committed $((& git -C $root rev-parse HEAD).Trim()) and tagged v$version. Push with: git push --atomic origin HEAD:refs/heads/master v$version" diff --git a/rust/tests/test-build-app.ps1 b/rust/tests/test-build-app.ps1 index 25ce200..aa0fbdb 100644 --- a/rust/tests/test-build-app.ps1 +++ b/rust/tests/test-build-app.ps1 @@ -729,6 +729,34 @@ version = "0.1.0" [IO.File]::WriteAllLines($sumsFile, [string[]]($sumsLines | Select-Object -Skip 2)) Assert-Throws { & $prepareRelease -Checksums -SumsFile $sumsFile -RustoloniaRoot $releaseRoot } 'no entry for rustolonia-host-9\.8\.7-rc\.1-win-x64' + # phase=all commits the checksums on the build source and tags it. + [IO.File]::WriteAllLines($sumsFile, [string[]]$sumsLines) + & $prepareRelease -Version 9.8.7-rc.1 -SkipCargoUpdate -RustoloniaRoot $releaseRoot | Out-Null + & git -C $releaseRoot init --quiet + foreach ($setting in @('user.name=Release Test', 'user.email=release@example.invalid', 'commit.gpgsign=false', 'tag.gpgsign=false', 'core.autocrlf=false')) { + $key, $value = $setting -split '=', 2 + & git -C $releaseRoot config $key $value + } + & git -C $releaseRoot add --all + & git -C $releaseRoot commit --quiet -m 'Prepare release 9.8.7-rc.1' + $releaseSource = (& git -C $releaseRoot rev-parse HEAD).Trim() + Assert-Throws { & $prepareRelease -Checksums -SumsFile $sumsFile -RustoloniaRoot $releaseRoot -SourceRevision $releaseSource } 'only used with -CommitAndTag' + $propsPath = Join-Path $releaseRoot 'build/SharedVersion.props' + Add-Content -LiteralPath $propsPath -Value '' + Assert-Throws { & $prepareRelease -Checksums -SumsFile $sumsFile -CommitAndTag -SourceRevision $releaseSource -RustoloniaRoot $releaseRoot } 'working tree has changes' + & git -C $releaseRoot checkout --quiet -- build/SharedVersion.props + & $prepareRelease -Checksums -SumsFile $sumsFile -CommitAndTag -SourceRevision $releaseSource -RustoloniaRoot $releaseRoot | Out-Null + Assert-True ((& git -C $releaseRoot rev-parse 'HEAD^').Trim() -eq $releaseSource) 'The checksum commit must sit directly on the build source.' + Assert-True ((& git -C $releaseRoot rev-parse 'v9.8.7-rc.1^{commit}').Trim() -eq (& git -C $releaseRoot rev-parse HEAD).Trim()) 'The release tag must point at the checksum commit.' + Assert-True ((& git -C $releaseRoot cat-file -t 'v9.8.7-rc.1').Trim() -eq 'tag') 'The release tag must be annotated.' + Assert-True (((& git -C $releaseRoot diff --name-only $releaseSource HEAD) -join ',') -eq 'rust/rustolonia-sys/host-checksums.txt') 'The checksum commit must change only host-checksums.txt.' + Assert-True ((& git -C $releaseRoot log -1 --format=%s).Trim() -eq 'Pin rustolonia 9.8.7-rc.1 release host checksums') 'Unexpected checksum commit message.' + Assert-True (@(& git -C $releaseRoot status --porcelain).Count -eq 0) 'The checksum commit must leave a clean tree.' + Assert-Throws { & $prepareRelease -Checksums -SumsFile $sumsFile -CommitAndTag -SourceRevision $releaseSource -RustoloniaRoot $releaseRoot } 'is not the build source revision' + Assert-Throws { & $prepareRelease -Checksums -SumsFile $sumsFile -CommitAndTag -RustoloniaRoot $releaseRoot } 'Tag v9\.8\.7-rc\.1 already exists' + & git -C $releaseRoot tag -d v9.8.7-rc.1 | Out-Null + Assert-Throws { & $prepareRelease -Checksums -SumsFile $sumsFile -CommitAndTag -RustoloniaRoot $releaseRoot } 'already lists these hashes' + if ($RunNativeSmoke) { $nativeTempRoot = [IO.Path]::GetTempPath() if ($IsMacOS) { From f7c3ce84bec4e3f5481aaf1113ba71feb78e98af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 30 Oct 2026 08:54:04 -0400 Subject: [PATCH 2/3] Prepare release 12.1.2 Generated with `rust/prepare-release.ps1 -Version 12.1.2` (no hand edits): the three crates, the exact rustolonia-sys pin, release-manifest.json and SharedVersion.props move to 12.1.2, host-checksums.txt is cleared, and Cargo.lock plus the in-repository apps' lock files pick up the new crate versions. Co-Authored-By: Claude Opus 5.5 --- apps/archive-tool/Cargo.lock | 4 ++-- apps/hash-calculator/Cargo.lock | 4 ++-- apps/pdf-viewer/Cargo.lock | 4 ++-- apps/system-monitor/NeoHtop.App/Cargo.lock | 4 ++-- build/SharedVersion.props | 2 +- rust/Cargo.lock | 6 +++--- rust/release-manifest.json | 2 +- rust/rustolonia-bindgen/Cargo.toml | 2 +- rust/rustolonia-sys/Cargo.toml | 2 +- rust/rustolonia-sys/host-checksums.txt | 6 ------ rust/rustolonia/Cargo.toml | 4 ++-- 11 files changed, 17 insertions(+), 23 deletions(-) diff --git a/apps/archive-tool/Cargo.lock b/apps/archive-tool/Cargo.lock index 9a19491..79dc435 100644 --- a/apps/archive-tool/Cargo.lock +++ b/apps/archive-tool/Cargo.lock @@ -697,14 +697,14 @@ dependencies = [ [[package]] name = "rustolonia" -version = "12.1.1" +version = "12.1.2" dependencies = [ "rustolonia-sys", ] [[package]] name = "rustolonia-sys" -version = "12.1.1" +version = "12.1.2" dependencies = [ "dirs", "flate2", diff --git a/apps/hash-calculator/Cargo.lock b/apps/hash-calculator/Cargo.lock index 5866506..6202ec2 100644 --- a/apps/hash-calculator/Cargo.lock +++ b/apps/hash-calculator/Cargo.lock @@ -486,14 +486,14 @@ dependencies = [ [[package]] name = "rustolonia" -version = "12.1.1" +version = "12.1.2" dependencies = [ "rustolonia-sys", ] [[package]] name = "rustolonia-sys" -version = "12.1.1" +version = "12.1.2" dependencies = [ "dirs", "flate2", diff --git a/apps/pdf-viewer/Cargo.lock b/apps/pdf-viewer/Cargo.lock index 588ae25..7e996cc 100644 --- a/apps/pdf-viewer/Cargo.lock +++ b/apps/pdf-viewer/Cargo.lock @@ -1588,14 +1588,14 @@ dependencies = [ [[package]] name = "rustolonia" -version = "12.1.1" +version = "12.1.2" dependencies = [ "rustolonia-sys", ] [[package]] name = "rustolonia-sys" -version = "12.1.1" +version = "12.1.2" dependencies = [ "dirs", "flate2", diff --git a/apps/system-monitor/NeoHtop.App/Cargo.lock b/apps/system-monitor/NeoHtop.App/Cargo.lock index 8fbe502..0da565d 100644 --- a/apps/system-monitor/NeoHtop.App/Cargo.lock +++ b/apps/system-monitor/NeoHtop.App/Cargo.lock @@ -563,14 +563,14 @@ dependencies = [ [[package]] name = "rustolonia" -version = "12.1.1" +version = "12.1.2" dependencies = [ "rustolonia-sys", ] [[package]] name = "rustolonia-sys" -version = "12.1.1" +version = "12.1.2" dependencies = [ "dirs", "flate2", diff --git a/build/SharedVersion.props b/build/SharedVersion.props index fff12c6..f831be4 100644 --- a/build/SharedVersion.props +++ b/build/SharedVersion.props @@ -2,7 +2,7 @@ xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> Rustolonia - 12.1.1 + 12.1.2 Rustolonia contributors Copyright $([System.DateTime]::Now.ToString(`yyyy`)) Rustolonia contributors. Upstream Avalonia material retains its original copyright notices. https://github.com/Devolutions/rustolonia diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 71a732d..808d614 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -442,7 +442,7 @@ dependencies = [ [[package]] name = "rustolonia" -version = "12.1.1" +version = "12.1.2" dependencies = [ "avalonia-sample", "rustolonia-sys", @@ -450,7 +450,7 @@ dependencies = [ [[package]] name = "rustolonia-bindgen" -version = "12.1.1" +version = "12.1.2" dependencies = [ "serde", "serde_json", @@ -459,7 +459,7 @@ dependencies = [ [[package]] name = "rustolonia-sys" -version = "12.1.1" +version = "12.1.2" dependencies = [ "dirs", "flate2", diff --git a/rust/release-manifest.json b/rust/release-manifest.json index efc7d83..ca502ce 100644 --- a/rust/release-manifest.json +++ b/rust/release-manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "rustoloniaVersion": "12.1.1", + "rustoloniaVersion": "12.1.2", "producerPin": "8eeda4f6f546165b3f72e63c9f42247abb306905", "producerRemote": "https://github.com/AvaloniaUI/Avalonia", "patches": [ diff --git a/rust/rustolonia-bindgen/Cargo.toml b/rust/rustolonia-bindgen/Cargo.toml index 4ac8d6f..da3cf88 100644 --- a/rust/rustolonia-bindgen/Cargo.toml +++ b/rust/rustolonia-bindgen/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustolonia-bindgen" -version = "12.1.1" +version = "12.1.2" edition = "2021" license = "MIT" description = "Code generator that emits rustolonia-sys/rustolonia from projection.ir.json" diff --git a/rust/rustolonia-sys/Cargo.toml b/rust/rustolonia-sys/Cargo.toml index 5c81e70..56b6cdf 100644 --- a/rust/rustolonia-sys/Cargo.toml +++ b/rust/rustolonia-sys/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustolonia-sys" -version = "12.1.1" +version = "12.1.2" edition = "2021" license = "MIT" description = "Raw nano-COM bindings for the Avalonia NativeAOT host" diff --git a/rust/rustolonia-sys/host-checksums.txt b/rust/rustolonia-sys/host-checksums.txt index 9a8227d..2c3ece2 100644 --- a/rust/rustolonia-sys/host-checksums.txt +++ b/rust/rustolonia-sys/host-checksums.txt @@ -2,9 +2,3 @@ # version, in `sha256sum` format. build.rs refuses to use a downloaded host # whose hash is not listed here. rust/prepare-release.ps1 fills this in from # the release build before the consumer tag or crates are published. -d4128440d820e43478f57e0390dc2014b01175b0ccce334790faf403b7a4fbe4 rustolonia-host-12.1.1-linux-arm64.tar.gz -5a5df2ab385289e8856d40ec083abefde0859204cd9a2f0b546f0bcf0679f761 rustolonia-host-12.1.1-linux-x64.tar.gz -d22d774c76b0b2afc8d1a8449720fb095d6633cce3479504e0f22a58fed1a42e rustolonia-host-12.1.1-osx-arm64.tar.gz -a8b56f3fbe4cb6d1f372e70849b91dcf2d4258dd80f24f32a78581a0e402bb9b rustolonia-host-12.1.1-osx-x64.tar.gz -fdae3d82a4bd7b9357293525d48191bcf7789dc8542d554ae711dff310cfd2b3 rustolonia-host-12.1.1-win-arm64.tar.gz -767c8337cae2987a308beac38bf008850fc737589354d31345ae4753c2aa03bb rustolonia-host-12.1.1-win-x64.tar.gz diff --git a/rust/rustolonia/Cargo.toml b/rust/rustolonia/Cargo.toml index 1a26feb..934efbf 100644 --- a/rust/rustolonia/Cargo.toml +++ b/rust/rustolonia/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustolonia" -version = "12.1.1" +version = "12.1.2" edition = "2021" license = "MIT" description = "Idiomatic Rust API for Avalonia" @@ -19,7 +19,7 @@ no-download = ["rustolonia-sys/no-download"] dev-host-path = ["rustolonia-sys/dev-host-path"] [dependencies] -rustolonia-sys = { version = "=12.1.1", path = "../rustolonia-sys", default-features = false } +rustolonia-sys = { version = "=12.1.2", path = "../rustolonia-sys", default-features = false } [dev-dependencies] avalonia-sample = { path = "../avalonia-sample" } From a9671d79157e5b3ccd04e2e283daec80bd12213a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 30 Oct 2026 09:57:40 -0400 Subject: [PATCH 3/3] Build osx-x64 on Apple silicon instead of Intel macOS runners The macos-15-intel job was the slowest in both workflows: about an hour for the CI release gate (osx-arm64: ~30 min) and 21 minutes for the release host build (next slowest: 13). - release.yml builds the osx-x64 host on macos-14 with the x86_64-apple-darwin Rust target and installs Rosetta 2 if needed, so the standalone-app smoke tests still launch the x64 app. - test-host-download.ps1 builds with --target for an osx-x64 RID on an arm64 Mac (Rosetta launches it) and rejects other cross RIDs early. - CI drops osx-x64 from the native release gate; the existing cross-build (osx-x64) job on macos-14 now also packages the host tarball and runs the same standalone-app smoke test under Rosetta, rehearsing the release path on every PR. x64 macOS no longer runs the managed test suite or native-hardware smoke tests in CI. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/avalonia-rust.yml | 21 +++++++++++++++++---- .github/workflows/release.yml | 16 +++++++++++++++- rust/PLATFORMS.md | 2 +- rust/PRODUCTIZATION.md | 11 ++++++++--- rust/tests/test-host-download.ps1 | 20 ++++++++++++++++---- 5 files changed, 57 insertions(+), 13 deletions(-) diff --git a/.github/workflows/avalonia-rust.yml b/.github/workflows/avalonia-rust.yml index d1c570e..05ff82d 100644 --- a/.github/workflows/avalonia-rust.yml +++ b/.github/workflows/avalonia-rust.yml @@ -76,10 +76,8 @@ jobs: rid: linux-x64 platform: X11 architecture: x64 - - os: macos-15-intel - rid: osx-x64 - platform: OSX - architecture: x64 + # osx-x64 is covered by cross-build on Apple silicon; Intel macOS + # runners took about an hour for this job. - os: macos-14 rid: osx-arm64 platform: OSX @@ -450,3 +448,18 @@ jobs: throw 'Downloaded cross-package executable lost its execute permission.' } } + + # Release builds the osx-x64 host on Apple silicon too; rehearse that + # here and launch the x64 app through Rosetta 2. + - name: Install Rosetta 2 for the x64 smoke test + if: matrix.rid == 'osx-x64' + run: | + /usr/bin/arch -x86_64 /usr/bin/true 2>/dev/null || sudo softwareupdate --install-rosetta --agree-to-license + /usr/bin/arch -x86_64 /usr/bin/true + + - name: Build and launch a standalone app against the packaged crates and a cross-built host tarball + if: matrix.rid == 'osx-x64' + shell: pwsh + run: | + ./rust/package-host.ps1 -Rid ${{ matrix.rid }} -OutputRoot rust/artifacts/host + ./rust/tests/test-standalone-app.ps1 -Rid ${{ matrix.rid }} -TarballDirectory rust/artifacts/host -WorkDirectory "$env:RUNNER_TEMP/standalone-app" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e4cf3c0..75d905f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -105,7 +105,9 @@ jobs: # The oldest runner image sets the glibc floor (2.35). - { os: ubuntu-22.04, rid: linux-x64 } - { os: ubuntu-22.04-arm, rid: linux-arm64 } - - { os: macos-15-intel, rid: osx-x64 } + # Intel macOS runners are slow; Apple silicon cross-builds osx-x64 + # and smoke-launches it through Rosetta 2. + - { os: macos-14, rid: osx-x64, rust-target: x86_64-apple-darwin } - { os: macos-14, rid: osx-arm64 } steps: - uses: actions/checkout@v4 @@ -133,14 +135,26 @@ jobs: - name: Set up Rust shell: pwsh + env: + RUST_TARGET: ${{ matrix.rust-target }} run: | rustup show if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + if ($env:RUST_TARGET) { + rustup target add $env:RUST_TARGET + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } cargo --version if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } rustc --version if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Install Rosetta 2 for the x64 smoke test + if: matrix.rust-target == 'x86_64-apple-darwin' + run: | + /usr/bin/arch -x86_64 /usr/bin/true 2>/dev/null || sudo softwareupdate --install-rosetta --agree-to-license + /usr/bin/arch -x86_64 /usr/bin/true + - name: Install Linux smoke-test display if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install --yes xvfb diff --git a/rust/PLATFORMS.md b/rust/PLATFORMS.md index 4db69e9..def66e3 100644 --- a/rust/PLATFORMS.md +++ b/rust/PLATFORMS.md @@ -91,7 +91,7 @@ Release tarballs (`rust/package-host.ps1`) define the minimum OS a published | --- | --- | --- | | `win-x64`, `win-arm64` | `windows-latest` / `windows-11-arm` runners | Windows 10 1607 / Server 2016 (.NET 10 support floor) | | `linux-x64`, `linux-arm64` | `ubuntu-22.04` / `ubuntu-22.04-arm` runners | glibc 2.35 | -| `osx-x64`, `osx-arm64` | `macos-15-intel` / `macos-14` runners, `MACOSX_DEPLOYMENT_TARGET=12.0` | macOS 12 | +| `osx-x64`, `osx-arm64` | `macos-14` (Apple silicon) runners for both; osx-x64 is cross-built and smoke-tested under Rosetta 2; `MACOSX_DEPLOYMENT_TARGET=12.0` | macOS 12 | Linux hosts are glibc-only; musl RIDs are not published. They need an X11 display (XWayland works), the X11 client libraries (`libX11`, `libICE`, diff --git a/rust/PRODUCTIZATION.md b/rust/PRODUCTIZATION.md index 196e629..cb939ea 100644 --- a/rust/PRODUCTIZATION.md +++ b/rust/PRODUCTIZATION.md @@ -310,7 +310,10 @@ an absent target with the corresponding `rustup target add` command: Packaging supports same-OS cross-architecture builds when the required native and Rust toolchains are available. Executing a packaged application requires a matching native runner. Cross-build artifacts are therefore not evidence of -native runtime coverage. +native runtime coverage. The exception is osx-x64: CI and the release build it +on Apple silicon and launch the x64 app through Rosetta 2 instead of using the +slow Intel macOS runners. Rosetta covers smoke launches only. Intel hardware +and the managed test suite on x64 macOS are no longer exercised in CI. ### Signing hook @@ -531,7 +534,8 @@ a `build_run_id` whose run succeeded. 1. Prepare and merge the version as above, then dispatch `phase = build` with `version = X.Y.Z`. No build tag or GitHub release is created. 2. The build checks that the requested version matches every version, packages the - default host on native runners for all six RIDs, builds and launches a + default host for all six RIDs (osx-x64 on Apple silicon, launched under + Rosetta 2; the rest on native runners), builds and launches a standalone app against the packaged crates through the `rustolonia-sys` download path (`rust/tests/test-standalone-app.ps1`), and stages an Actions artifact named `rustolonia-release-X.Y.Z` with the six tarballs, their @@ -595,7 +599,8 @@ available to read checksums from an existing `vX.Y.Z` release, not for staging. The repository must allow GitHub Actions and the workflow's `contents: write` permission to publish the release (and, for `phase = all`, to push the checksum commit and tag; see `RELEASE_PUSH_TOKEN` above). -All six native runner labels must be available to the repository. No +All five runner labels must be available to the repository (osx-x64 and +osx-arm64 share `macos-14`). No additional secret is required for a GitHub-only release while `master` and tags are unprotected. The provenance step uses GitHub's `id-token: write` and `attestations: write` permissions. diff --git a/rust/tests/test-host-download.ps1 b/rust/tests/test-host-download.ps1 index e52407a..dbafcc9 100644 --- a/rust/tests/test-host-download.ps1 +++ b/rust/tests/test-host-download.ps1 @@ -15,6 +15,9 @@ With -AppManifest, builds that standalone Cargo package (binary -AppName) instead of a repository example. -ShipHost copies the cached host next to the executable before launching, as a distributable build without the `dev-host-path` feature requires. + +An osx-x64 -Rid on Apple silicon builds for x86_64-apple-darwin and +launches through Rosetta 2, which must be installed. #> param( [Parameter(Mandatory)] @@ -77,15 +80,24 @@ foreach ($name in $overrides.Keys) { try { $targetDir = Join-Path $WorkDirectory 'target' $exeSuffix = if ($IsWindows) { '.exe' } else { '' } + $ridTarget = Get-RidTargetInfo -Rid $Rid + $cargoTarget = @() + $outputDir = Join-Path $targetDir 'release' + if ($ridTarget.Arch -ne (Get-CurrentRuntimeArchitecture)) { + # Apple silicon runs x64 apps through Rosetta 2; other cross RIDs cannot launch here. + if (-not ($IsMacOS -and $ridTarget.Arch -eq 'x64')) { throw "A $Rid app cannot be launched on this $(Get-CurrentRuntimeArchitecture) machine." } + $cargoTarget = @('--target', $ridTarget.Triple) + $outputDir = Join-Path $targetDir $ridTarget.Triple 'release' + } if ($AppManifest) { if (-not $AppName) { throw '-AppName is required with -AppManifest.' } - cargo build --manifest-path $AppManifest --release --target-dir $targetDir - $exe = Join-Path $targetDir 'release' ($AppName + $exeSuffix) + cargo build --manifest-path $AppManifest --release --target-dir $targetDir @cargoTarget + $exe = Join-Path $outputDir ($AppName + $exeSuffix) $Example = $AppName } else { cargo build --locked --manifest-path (Join-Path $RustoloniaRoot 'rust' 'Cargo.toml') ` - -p rustolonia --example $Example --release --target-dir $targetDir - $exe = Join-Path $targetDir 'release' 'examples' ($Example + $exeSuffix) + -p rustolonia --example $Example --release --target-dir $targetDir @cargoTarget + $exe = Join-Path $outputDir 'examples' ($Example + $exeSuffix) } $hostFile = Join-Path $cache $version $Rid (Get-RidTargetInfo -Rid $Rid).HostFileName