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 d253302..75d905f 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 }}) @@ -97,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 @@ -125,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 @@ -216,9 +238,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 +317,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 +345,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 +422,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/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/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 52dd318..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 @@ -472,15 +475,67 @@ 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 + 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 @@ -491,9 +546,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 +573,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 +597,12 @@ 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. -All six native runner labels must be available to the repository. No -additional secret is required for a GitHub-only release. The provenance step +`contents: write` permission to publish the release (and, for `phase = all`, +to push the checksum commit and tag; see `RELEASE_PUSH_TOKEN` above). +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. 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/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" } 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) { 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