Files
OneCAD-Simulation/.github/workflows/release.yml
T
jyvikoandClaude Opus 5.5 e468133f9a ci: package releases in parallel with the verify jobs; only the draft job waits for both
The release job (the only one that signs SHA256SUMS.txt or touches a GitHub Release) now needs
every verify and packaging job; packaging no longer waits for verify. Verify jobs restore
develop's CI caches through shared keys (same CARGO_* env as ci.yml); saving stays main-only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 16:52:48 -04:00

425 lines
21 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Release builds (docs/releasing.md), after SolidCraft's and VectorCraft's release.yml
# (MIT OR Apache-2.0; see ATTRIBUTION.md).
#
# A push to `main` (fast-forwarded from a verified `develop` commit; main holds released code only)
# builds every package and creates or updates the
# DRAFT GitHub Release "FieldCraft v<version>", with SHA256SUMS.txt (signed with the release key
# into SHA256SUMS.txt.minisig when the FIELDCRAFT_SIGNING_KEY secret exists) and generated notes. The
# version is [workspace.package] version in Cargo.toml; a suffix (0.2.0-rc.1) makes a
# pre-release. Every package, and the release itself, carries THIRD-PARTY-LICENSES.txt, generated
# here by `cargo xtask licences` (it is not committed). A person verifies the draft and publishes
# it; this workflow never publishes, and pushes to develop or tags never start it.
#
# macOS universal (arm64 + x86_64) .dmg with FieldCraft.app, the CLI as a .zip packaging/macos
# Linux x86_64 (glibc 2.35+) .tar.gz, .AppImage, .deb and .rpm packaging/linux
# Windows x64 .zip with fieldcraft.exe and fieldcraft-cli.exe packaging/windows
#
# macOS: signed with a Developer ID and notarized when the APPLE_* secrets exist, ad-hoc
# otherwise (the release notes say which). Linux and Windows builds are not signed.
#
# The verify jobs run everything CI can: the full `cargo xtask ci`, `cargo xtask test --heavy` and
# `cargo xtask oracle` on Linux, and the oracle on macOS. Develop's CI tests only what each change
# affects (ci.yml), so this is where a release proves all of it. The packaging jobs build in
# parallel with them and only upload workflow artifacts; the `release` job, the only one that
# signs SHA256SUMS.txt or touches a GitHub Release, needs every verify and packaging job, so a
# failing verify means no draft and no signature.
#
# Dry run: `gh workflow run release.yml --ref <branch>` verifies and builds everything and uploads the
# `release-assets` artifact without creating a release. `-f draft=true` on `main` retries
# the draft.
name: Release
on:
push:
branches: [main]
workflow_dispatch:
inputs:
draft:
description: "Create/update the draft release (main only; off: build artifacts only)"
type: boolean
default: false
permissions:
contents: read
concurrency:
group: release-${{ github.ref }}
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
cancel-in-progress: false
env:
# The same CARGO_* environment as ci.yml: rust-cache hashes it into the cache key, so a
# difference would stop the verify jobs restoring develop's caches. (rust-cache already sets
# CARGO_INCREMENTAL=0 for every job.)
CARGO_TERM_COLOR: always
jobs:
version:
runs-on: ubuntu-24.04
outputs:
version: ${{ steps.v.outputs.version }}
prerelease: ${{ steps.v.outputs.prerelease }}
draft: ${{ steps.v.outputs.draft }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- id: v
name: Resolve the version
env:
REQUEST_DRAFT: ${{ github.event_name == 'push' || inputs.draft }}
run: |
. packaging/env.sh
if ! printf '%s' "$VERSION" | grep -Eq '^(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 "::error::Cargo.toml must contain a version like 1.2.3 or 1.2.3-rc.1, not '$VERSION'"
exit 1
fi
if [ "$REQUEST_DRAFT" = true ] && [ "$GITHUB_REF" != refs/heads/main ]; then
echo "::error::Draft releases are built from main only"
exit 1
fi
case "$VERSION" in *-*) pre=true ;; *) pre=false ;; esac
{ echo "version=$VERSION"; echo "prerelease=$pre"; echo "draft=$REQUEST_DRAFT"; } >>"$GITHUB_OUTPUT"
echo "### FieldCraft v$VERSION (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
# The full gate, the heavy tests and the oracle on Linux and macOS: the `release` job needs them,
# so a release never drafts or signs a commit that fails any of them. Each restores the cache of
# its ci.yml counterpart on develop (same shared-key), so it starts warm.
verify-ci:
name: Verify (cargo xtask ci)
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
shared-key: ci
# Release runs are rare: only release-branch runs keep a cache, so dry runs don't evict CI's.
save-if: ${{ github.ref == 'refs/heads/main' }}
- run: cargo xtask ci
verify-heavy:
name: Verify (cargo xtask test --heavy)
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
shared-key: heavy
save-if: ${{ github.ref == 'refs/heads/main' }}
- run: cargo xtask test --heavy
verify-oracle:
name: Verify (cargo xtask oracle, ${{ matrix.os }})
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-24.04
cache: oracle
- os: macos-14
cache: oracle-macos
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
shared-key: ${{ matrix.cache }}
save-if: ${{ github.ref == 'refs/heads/main' }}
- run: cargo xtask oracle
- name: docs/oracle.md is current
run: git diff --exit-code docs/oracle.md
macos:
name: macOS universal
needs: version
# Signing, notarization and analytics secrets live in the `release` environment, which only
# `main` may use; a dry run on another branch asks for no environment and builds unsigned.
environment: ${{ github.ref == 'refs/heads/main' && 'release' || '' }}
runs-on: macos-14
timeout-minutes: 120
outputs:
signing: ${{ steps.package.outputs.signing }}
env:
FIELDCRAFT_VERSION: ${{ needs.version.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-macos-universal
# Release runs are rare: only release-branch runs keep a cache, so dry runs don't evict CI's.
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Generate THIRD-PARTY-LICENSES.txt (fails when a crate's licence can't be resolved)
run: cargo xtask licences
- name: Import the signing certificate (skipped without APPLE_CERTIFICATE)
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: packaging/macos/import-cert.sh
- name: Build, sign, notarize and package (the dmg is mounted and checked)
id: package
env:
# The analytics project key (docs/privacy.md), built in; never printed.
FIELDCRAFT_POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: packaging/macos/package.sh --arch universal
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-macos
path: dist/release/*
if-no-files-found: error
- name: Remove the temporary keychain
if: always()
run: |
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
linux:
name: Linux x86_64
needs: version
# The oldest GitHub-hosted image, so the binaries need only glibc >= 2.35.
# Signing, notarization and analytics secrets live in the `release` environment, which only
# `main` may use; a dry run on another branch asks for no environment and builds unsigned.
environment: ${{ github.ref == 'refs/heads/main' && 'release' || '' }}
runs-on: ubuntu-22.04
timeout-minutes: 90
env:
FIELDCRAFT_VERSION: ${{ needs.version.outputs.version }}
# The .deb and .rpm builders (both MIT), pinned.
CARGO_DEB_VERSION: 3.8.0
CARGO_GENERATE_RPM_VERSION: 0.21.0
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install packaging tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends desktop-file-utils lintian rpm
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-linux-x86_64
# Release runs are rare: only release-branch runs keep a cache, so dry runs don't evict CI's.
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Generate THIRD-PARTY-LICENSES.txt (fails when a crate's licence can't be resolved)
run: cargo xtask licences
- name: Install cargo-deb and cargo-generate-rpm
run: |
cargo install --locked --version "$CARGO_DEB_VERSION" cargo-deb
cargo install --locked --version "$CARGO_GENERATE_RPM_VERSION" cargo-generate-rpm
- name: Build and package (tar.gz, AppImage, deb, rpm)
env:
# The analytics project key (docs/privacy.md), built in; never printed.
FIELDCRAFT_POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
run: packaging/linux/package.sh
- name: Check the packages
run: |
. packaging/env.sh
base="fieldcraft-$VERSION-linux-x86_64"
deb="dist/release/fieldcraft_${VERSION}_amd64.deb"
rpm="dist/release/fieldcraft-$VERSION.x86_64.rpm"
list="$(tar -tzf "dist/release/$base.tar.gz")"
for f in bin/fieldcraft bin/fieldcraft-cli "share/applications/$APP_ID.desktop" \
share/doc/fieldcraft/THIRD-PARTY-LICENSES.txt; do
grep -Fx -- "$base/$f" <<<"$list"
done
# The .deb and .rpm carry the tar.gz's files under /usr (the .deb adds its copyright
# file); their lists live in apps/fieldcraft/Cargo.toml, so a drift fails here.
files() { grep -v '/$' | sed -E "s#^(\./|/|$base/)##; s#^usr/##" | grep -vx 'share/doc/fieldcraft/copyright' | sort; }
diff <(files <<<"$list") <(dpkg-deb --fsys-tarfile "$deb" | tar -t | files)
diff <(files <<<"$list") <(rpm -qpl "$rpm" | files)
dpkg-deb --info "$deb"
rpm -qpi "$rpm"
echo "rpm requires:"; rpm -qpR "$rpm"
# The release notes live on GitHub, so the package has no Debian changelog (a native
# package built upstream, not by a distribution).
lintian --info --fail-on error --suppress-tags no-changelog "$deb"
ldd target/release/fieldcraft
for appimage in dist/release/*.AppImage; do
chmod +x "$appimage"
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
done
- name: Install the .deb and .rpm in containers
run: |
# The oldest supported Ubuntu (the build's glibc) and a current Fedora. Recommended and
# weak dependencies (GPU drivers) are skipped to keep it quick; the hard ones must resolve.
# Both images leave out documentation when installing (dpkg path-exclude, dnf nodocs), so
# the licences are checked in the package's file list rather than on disk.
docker run --rm -v "$PWD/dist/release:/dist:ro" ubuntu:22.04 bash -euxc '
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends /dist/*.deb
fieldcraft-cli --version
fieldcraft --version
dpkg -L fieldcraft | grep -Fx /usr/share/doc/fieldcraft/THIRD-PARTY-LICENSES.txt'
docker run --rm -v "$PWD/dist/release:/dist:ro" fedora:44 bash -euxc '
dnf install -y -q --setopt=install_weak_deps=False /dist/*.rpm
fieldcraft-cli --version
fieldcraft --version
rpm -ql fieldcraft | grep -Fx /usr/share/doc/fieldcraft/THIRD-PARTY-LICENSES.txt
dnf install -y -q rpmlint
rpmlint /dist/*.rpm || true'
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-linux-x86_64
path: dist/release/*
if-no-files-found: error
windows:
name: Windows x64
needs: version
# Signing, notarization and analytics secrets live in the `release` environment, which only
# `main` may use; a dry run on another branch asks for no environment and builds unsigned.
environment: ${{ github.ref == 'refs/heads/main' && 'release' || '' }}
runs-on: windows-latest
timeout-minutes: 90
env:
FIELDCRAFT_VERSION: ${{ needs.version.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-windows-x64
# Release runs are rare: only release-branch runs keep a cache, so dry runs don't evict CI's.
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Generate THIRD-PARTY-LICENSES.txt (fails when a crate's licence can't be resolved)
run: cargo xtask licences
- name: Build and package (zip)
shell: pwsh
env:
# The analytics project key (docs/privacy.md), built in; never printed.
FIELDCRAFT_POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
run: ./packaging/windows/package.ps1
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-windows-x64
path: dist/release/*
if-no-files-found: error
release:
name: Draft release (or dry-run artifacts)
# The only job that publishes anything: it waits for every verify and packaging job.
needs: [version, verify-ci, verify-heavy, verify-oracle, macos, linux, windows]
# Signing, notarization and analytics secrets live in the `release` environment, which only
# `main` may use; a dry run on another branch asks for no environment and builds unsigned.
environment: ${{ github.ref == 'refs/heads/main' && 'release' || '' }}
runs-on: ubuntu-24.04
permissions:
contents: write
env:
VERSION: ${{ needs.version.outputs.version }}
PRERELEASE: ${{ needs.version.outputs.prerelease }}
MACOS_SIGNING: ${{ needs.macos.outputs.signing }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: release-*
path: artifacts
merge-multiple: true
- uses: dtolnay/rust-toolchain@stable
# The same file every package carries, attached to the release on its own.
- name: Generate THIRD-PARTY-LICENSES.txt
run: cargo xtask licences && cp THIRD-PARTY-LICENSES.txt artifacts/
- name: Check the set and write the checksums
run: |
for f in "fieldcraft-$VERSION-macos-universal.dmg" "fieldcraft-cli-$VERSION-macos-universal.zip" \
"fieldcraft-$VERSION-linux-x86_64.tar.gz" "fieldcraft-$VERSION-linux-x86_64.AppImage" \
"fieldcraft_${VERSION}_amd64.deb" "fieldcraft-$VERSION.x86_64.rpm" \
"fieldcraft-$VERSION-windows-x64.zip" THIRD-PARTY-LICENSES.txt; do
test -s "artifacts/$f" || { echo "::error::missing $f"; exit 1; }
done
(cd artifacts && sha256sum -- * | tee SHA256SUMS.txt)
- name: Sign SHA256SUMS.txt with the release key (skipped without FIELDCRAFT_SIGNING_KEY)
id: sign
env:
FIELDCRAFT_SIGNING_KEY: ${{ secrets.FIELDCRAFT_SIGNING_KEY }}
# rsign2 (MIT), a minisign-compatible signer, pinned.
RSIGN_VERSION: 0.6.7
run: |
if [ -z "$FIELDCRAFT_SIGNING_KEY" ]; then
echo "::warning::FIELDCRAFT_SIGNING_KEY is not set: SHA256SUMS.txt is not signed, so the app cannot install this release itself (docs/releasing.md)"
echo "signed=false" >>"$GITHUB_OUTPUT"
exit 0
fi
cargo install --locked --quiet --root "$RUNNER_TEMP/rsign" rsign2 --version "$RSIGN_VERSION"
rsign="$RUNNER_TEMP/rsign/bin/rsign"
key="$RUNNER_TEMP/release.key"
(umask 077 && printf '%s\n' "$FIELDCRAFT_SIGNING_KEY" >"$key")
"$rsign" sign -W -s "$key" -t "FieldCraft v$VERSION SHA256SUMS.txt" -x artifacts/SHA256SUMS.txt.minisig artifacts/SHA256SUMS.txt
rm -f "$key"
# The signature must verify with the public key the app is built with.
public="$(sed -n 's/^pub const PUBLIC_KEY: &str = "\(.*\)";$/\1/p' crates/update/src/lib.rs)"
"$rsign" verify -q -P "$public" -x artifacts/SHA256SUMS.txt.minisig artifacts/SHA256SUMS.txt
echo "signed=true" >>"$GITHUB_OUTPUT"
- name: Write the notes
env:
SIGNED: ${{ steps.sign.outputs.signed }}
run: |
# The macOS paragraph says how the app was signed (packaging/macos/package.sh).
case "$MACOS_SIGNING" in
notarized) macos="The macOS app is signed with a Developer ID and notarized by Apple." ;;
signed) macos="The macOS app is signed with a Developer ID but **not notarized**: the first time, right-click \`FieldCraft.app\` in Applications and choose **Open**." ;;
*) macos="The macOS app is **ad-hoc signed and not notarized**: macOS blocks the first launch. Right-click \`FieldCraft.app\` in Applications and choose **Open** (then **Open** again), or run \`xattr -dr com.apple.quarantine /Applications/FieldCraft.app\`." ;;
esac
# The updates paragraph says whether the app can install this release itself.
if [ "$SIGNED" = true ]; then
updates="\`SHA256SUMS.txt\` is signed with the FieldCraft release key (\`SHA256SUMS.txt.minisig\`), so FieldCraft's Help › Check for Updates can verify and install this release."
else
updates="This release's \`SHA256SUMS.txt\` is **not signed**: FieldCraft's in-app updates can't verify it and won't install it; download it from this page instead."
fi
awk -v v="$VERSION" -v m="$macos" -v u="$updates" '{ gsub(/\{version\}/, v); gsub(/\{macos_signing\}/, m); gsub(/\{update_signing\}/, u); print }' \
.github/release-notes.md >notes.md
cat notes.md >>"$GITHUB_STEP_SUMMARY"
- name: Upload the dry-run artifacts
if: needs.version.outputs.draft != 'true'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-assets
path: |
artifacts/*
notes.md
if-no-files-found: error
- name: Create or update the draft release
if: needs.version.outputs.draft == 'true'
working-directory: artifacts
run: |
tag="v$VERSION"
title="FieldCraft v$VERSION"
if gh api "repos/$GH_REPO/git/matching-refs/tags/$tag" --jq '.[].ref' | grep -Fxq "refs/tags/$tag"; then
echo "::error::Tag $tag already exists. Choose a new version; never move a release tag."
exit 1
fi
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
if [ "$draft" != true ]; then
echo "::error::Release $tag is already published. Bump the version before releasing."
exit 1
fi
# A rebuild of the same version replaces the draft's assets (removing names it no longer
# makes); its notes are kept.
assets="$(gh release view "$tag" --json assets --jq '.assets[].name')"
while IFS= read -r asset; do
if [ -n "$asset" ]; then gh release delete-asset "$tag" "$asset" --yes; fi
done <<<"$assets"
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
gh release upload "$tag" -- *
else
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" \
--notes-file ../notes.md --generate-notes --prerelease="$PRERELEASE" -- *
fi
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`: $(gh release view "$tag" --json url --jq .url)" >>"$GITHUB_STEP_SUMMARY"