Automate weekly releases and synchronize application versions

This commit is contained in:
Hakan Seven committed 2026-09-05 02:13:20 +03:00
1 parent 28d3aa5be2
commit 13afc1479f
24 files changed
+659 -202

No files matched your search

+5 -1
View File
@@ -16,7 +16,9 @@ jobs:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Validate control contract
run: cargo test --lib app::control::tests
run: |
cargo test --lib app::control::tests
cargo test --lib io::update_check::tests
- name: Validate MCP smoke script
if: runner.os != 'Windows'
run: python3 -m py_compile docs/automation/mcp_smoke.py
@@ -24,6 +26,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate weekly release automation
run: python3 scripts/test_release.py
- uses: dtolnay/rust-toolchain@stable
- run: cargo test --lib mcp::tests
- run: cargo build
+79 -7
View File
@@ -1,12 +1,17 @@
# Build the wasm web app and publish it to GitHub Pages on every release
# (issue #45). No threads are used on wasm (rayon runs sequentially), so the
# COOP/COEP headers GitHub Pages can't set are not needed.
name: Deploy web (GitHub Pages)
run-name: ${{ github.event.release.tag_name || github.ref_name }} Web
on:
release:
types: [published]
workflow_call:
inputs:
tag:
required: true
type: string
commit:
required: true
type: string
workflow_dispatch:
permissions:
@@ -23,8 +28,42 @@ concurrency:
jobs:
build:
runs-on: ubuntu-22.04
outputs:
tag: ${{ steps.source.outputs.tag }}
commit: ${{ steps.source.outputs.commit }}
steps:
- name: Resolve release source
id: source
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.tag || github.event.release.tag_name }}
RELEASE_COMMIT: ${{ inputs.commit }}
run: |
if [ -z "$RELEASE_TAG" ]; then
RELEASE_TAG=$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName --jq .tagName)
fi
COMMIT=$(gh api "repos/$GITHUB_REPOSITORY/commits/$RELEASE_TAG" --jq .sha)
if [ -n "$RELEASE_COMMIT" ] && [ "$RELEASE_COMMIT" != "$COMMIT" ]; then
echo "::error::Release tag and commit do not match"
exit 1
fi
echo "tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT"
echo "commit=$COMMIT" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v4
with:
ref: ${{ steps.source.outputs.commit }}
- name: Validate release version
env:
RELEASE_TAG: ${{ steps.source.outputs.tag }}
run: |
if [ -f scripts/release.py ]; then
python3 scripts/release.py versions --tag "$RELEASE_TAG"
else
VERSION=$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1)
test "v$VERSION" = "$RELEASE_TAG"
fi
- uses: dtolnay/rust-toolchain@stable
with:
@@ -51,11 +90,19 @@ jobs:
tool: wasm-bindgen@${{ steps.wasm-bindgen-version.outputs.version }}
- name: Build web bundle
run: trunk build --release --public-url /app/ --dist dist/app --html-output index.html web-app.html
run: trunk build --locked --release --public-url /app/ --dist dist/app --html-output index.html web-app.html
- name: Assemble landing page
run: sh scripts/assemble-site.sh
- name: Record release version and source
env:
RELEASE_TAG: ${{ steps.source.outputs.tag }}
RELEASE_COMMIT: ${{ steps.source.outputs.commit }}
run: |
jq -n --arg version "${RELEASE_TAG#v}" --arg commit "$RELEASE_COMMIT" \
'{version: $version, commit: $commit}' > dist/app/release.json
# GitHub Pages runs Jekyll, which ignores files; opt out so the
# wasm-bindgen output is served verbatim.
- run: touch dist/.nojekyll
@@ -248,9 +295,7 @@ jobs:
print(f"videos: {len(json.loads(output.read_text(encoding='utf-8')))}")
PY
# GitHub's Discussions API is authenticated GraphQL. Generate a public,
# token-free snapshot next to the web app; discussion activity (including
# pin/unpin) triggers this workflow so pinned entries stay at the top.
# Publish a public snapshot of GitHub discussions.
- name: Generate discussions.json
env:
GH_TOKEN: ${{ github.token }}
@@ -313,3 +358,30 @@ jobs:
steps:
- id: deployment
uses: actions/deploy-pages@v4
- name: Verify deployed release
env:
PAGE_URL: ${{ steps.deployment.outputs.page_url }}
RELEASE_TAG: ${{ needs.build.outputs.tag }}
RELEASE_COMMIT: ${{ needs.build.outputs.commit }}
run: |
python3 - <<'PY'
import json
import os
import time
import urllib.request
expected = {"version": os.environ["RELEASE_TAG"].removeprefix("v"), "commit": os.environ["RELEASE_COMMIT"]}
url = os.environ["PAGE_URL"].rstrip("/") + "/app/release.json?commit=" + expected["commit"]
for attempt in range(12):
try:
with urllib.request.urlopen(url, timeout=20) as response:
if json.load(response) == expected:
print("Verified web release", expected["version"], expected["commit"])
break
except (OSError, ValueError) as error:
print(error)
time.sleep(10)
else:
raise SystemExit("Deployed web version does not match the release")
PY
+83 -93
View File
@@ -3,6 +3,17 @@ name: Release
on:
release:
types: [published]
workflow_call:
inputs:
tag:
required: true
type: string
commit:
required: true
type: string
target:
default: all
type: string
workflow_dispatch:
inputs:
target:
@@ -14,6 +25,9 @@ on:
- all
- snap
env:
RELEASE_TAG: ${{ inputs.tag || github.event.release.tag_name }}
jobs:
build-appimage:
runs-on: ubuntu-22.04
@@ -23,6 +37,13 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ inputs.commit || github.sha }}
- name: Validate package versions
id: version
shell: bash
run: python3 scripts/release.py versions --tag "$RELEASE_TAG"
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
@@ -38,7 +59,7 @@ jobs:
- name: Build
env:
OCS_PATREON_TOKEN: ${{ secrets.OCS_PATREON_TOKEN }}
run: cargo build --release
run: cargo build --locked --release
- name: Prepare AppDir
run: |
@@ -80,13 +101,13 @@ jobs:
mv Open*CAD*Studio*.AppImage OpenCADStudio.AppImage
- name: Upload AppImage to release
if: github.event_name == 'release'
if: env.RELEASE_TAG != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
ASSET=OpenCADStudio-${{ github.ref_name }}-linux-x86_64.AppImage
ASSET=OpenCADStudio-${{ env.RELEASE_TAG }}-linux-x86_64.AppImage
mv OpenCADStudio.AppImage "$ASSET"
gh release upload ${{ github.ref_name }} "$ASSET" --clobber --repo ${{ github.repository }}
gh release upload ${{ env.RELEASE_TAG }} "$ASSET" --clobber --repo ${{ github.repository }}
build-snap:
needs: build-appimage
@@ -104,6 +125,13 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ inputs.commit || github.sha }}
- name: Validate package versions
id: version
shell: bash
run: python3 scripts/release.py versions --tag "$RELEASE_TAG"
- name: Download shared Linux payload
uses: actions/download-artifact@v4
@@ -114,22 +142,8 @@ jobs:
- name: Set package version
id: package
env:
EVENT_NAME: ${{ github.event_name }}
REF_NAME: ${{ github.ref_name }}
VERSION: ${{ steps.version.outputs.version }}
run: |
if [ "$EVENT_NAME" = "release" ]; then
VERSION="${REF_NAME#v}"
if [ "v$VERSION" != "$REF_NAME" ]; then
echo "::error::Release tag must be vX.Y.Z"
exit 1
fi
else
VERSION="$(sed -n 's/^version = "\([0-9][0-9.]*\)"/\1/p' Cargo.toml | head -1)"
fi
if ! printf '%s' "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::Invalid package version: $VERSION"
exit 1
fi
sed -i "s/^version: .*/version: '$VERSION'/" snap/snapcraft.yaml
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
@@ -148,14 +162,14 @@ jobs:
echo "snap=$PACKAGE" >> "$GITHUB_OUTPUT"
- name: Upload snap to release
if: github.event_name == 'release'
if: env.RELEASE_TAG != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SNAP: ${{ steps.snap_package.outputs.snap }}
run: gh release upload "${{ github.ref_name }}" "$SNAP" --clobber
run: gh release upload "${{ env.RELEASE_TAG }}" "$SNAP" --clobber
- name: Upload snap as workflow artifact
if: github.event_name == 'workflow_dispatch'
if: env.RELEASE_TAG == ''
uses: actions/upload-artifact@v4
with:
name: OpenCADStudio-v${{ steps.package.outputs.version }}-linux-x86_64-snap
@@ -163,14 +177,14 @@ jobs:
if-no-files-found: error
- name: Publish snap to edge
if: github.event_name == 'release' && env.SNAPCRAFT_STORE_CREDENTIALS != ''
if: env.RELEASE_TAG != '' && env.SNAPCRAFT_STORE_CREDENTIALS != ''
uses: snapcore/action-publish@v1
with:
snap: ${{ steps.snap_package.outputs.snap }}
release: edge
build-windows:
if: github.event_name == 'release' || inputs.target == 'all'
if: github.event_name == 'release' || inputs.tag != '' || inputs.target == 'all'
runs-on: windows-latest
permissions:
contents: write
@@ -178,6 +192,13 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ inputs.commit || github.sha }}
- name: Validate package versions
id: version
shell: bash
run: python3 scripts/release.py versions --tag "$RELEASE_TAG"
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
@@ -185,11 +206,6 @@ jobs:
- name: Convert SVG icon to ICO
shell: pwsh
run: |
# ImageMagick is pre-installed on windows-latest. The
# auto-resize generates every size Explorer / Add-Remove
# Programs / the Start Menu shortcut may request from a
# single source SVG. Must run BEFORE the build so build.rs
# can embed the icon into the .exe (issue #107).
magick assets/logo.svg `
-define icon:auto-resize=16,24,32,48,64,128,256 `
packaging/windows/AppIcon.ico
@@ -209,17 +225,10 @@ jobs:
env:
OCS_PATREON_TOKEN: ${{ secrets.OCS_PATREON_TOKEN }}
run: |
cargo build --release
# The MSI bundles the DWG thumbnail provider DLL. That cdylib is a
# workspace member, not an app dependency, so a plain `cargo build`
# never produces it — build the crate as an explicit target.
cargo build --release -p dwg-thumbnailer-win
cargo build --locked --release
cargo build --locked --release -p dwg-thumbnailer-win
- name: Sign executable (Azure Trusted Signing)
# Sign the bare .exe *before* the MSI is built so the copy packed
# into the installer carries the signature too. AZURE_CLIENT_SECRET
# authenticates the App Registration; the remaining AZURE_* secrets
# select the Trusted Signing account / certificate profile.
uses: azure/artifact-signing-action@v2
with:
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -236,22 +245,9 @@ jobs:
- name: Build MSI installer
shell: pwsh
run: |
$version = "${{ github.ref_name }}" -replace '^v', ''
# workflow_dispatch runs use the branch name as ref_name, which
# is not a valid MSI ProductVersion — fall back to 0.0.0 so test
# builds still produce an installer.
if ($version -notmatch '^\d+(\.\d+){0,3}$') { $version = "0.0.0" }
# WiX Toolset 3.x is pre-installed on windows-latest; $env:WIX
# points to the install root. candle compiles .wxs → .wixobj
# and light links it into the final .msi.
$version = "${{ steps.version.outputs.msi }}"
$candle = Join-Path $env:WIX "bin\candle.exe"
$light = Join-Path $env:WIX "bin\light.exe"
# Resolve to absolute paths and pass each `-d<name>=<value>`
# as one quoted argument. PowerShell's native-command argument
# passing strips unquoted `-d…=…` strings on the `=` sign,
# which would otherwise hand candle an empty Source variable
# and `target\release\OpenCADStudio.exe` as a stray input
# filename.
$exePath = (Resolve-Path target\release\OpenCADStudio.exe).Path
$iconPath = (Resolve-Path packaging\windows\AppIcon.ico).Path
# GPL-3 license RTF shown on the installer's EULA page.
@@ -294,16 +290,12 @@ jobs:
timestamp-digest: SHA256
- name: Upload artifacts to release
if: github.event_name == 'release'
if: env.RELEASE_TAG != ''
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$tag = "${{ github.ref_name }}"
# Ship both the bare .exe (portable, no install) and the
# .msi (installer with Start Menu shortcut + .dwg / .dxf
# file association). The `-portable` / `-installer` suffix
# makes the distinction obvious on the release page.
$tag = "${{ env.RELEASE_TAG }}"
$exe = "OpenCADStudio-$tag-windows-x86_64-portable.exe"
$msi = "OpenCADStudio-$tag-windows-x86_64-installer.msi"
Move-Item target\release\OpenCADStudio.exe $exe
@@ -313,7 +305,7 @@ jobs:
--clobber --repo ${{ github.repository }}
build-macos:
if: github.event_name == 'release' || inputs.target == 'all'
if: github.event_name == 'release' || inputs.tag != '' || inputs.target == 'all'
runs-on: macos-14 # Apple Silicon (arm64) only
permissions:
contents: write
@@ -321,6 +313,13 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ inputs.commit || github.sha }}
- name: Validate package versions
id: version
shell: bash
run: python3 scripts/release.py versions --tag "$RELEASE_TAG"
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
@@ -336,11 +335,8 @@ jobs:
env:
OCS_PATREON_TOKEN: ${{ secrets.OCS_PATREON_TOKEN }}
run: |
cargo build --release --target aarch64-apple-darwin
# The QuickLook extension links libdwg_thumbnailer.a. The staticlib
# crate-type is only emitted when the crate is built as a target (as a
# plain dependency cargo produces just the rlib), so build it here.
cargo build --release --target aarch64-apple-darwin -p dwg-thumbnailer
cargo build --locked --release --target aarch64-apple-darwin
cargo build --locked --release --target aarch64-apple-darwin -p dwg-thumbnailer
- name: Build .icns from SVG
run: |
@@ -375,14 +371,8 @@ jobs:
- name: Build DWG QuickLook thumbnail extension (.appex)
run: |
# A QuickLook thumbnail App Extension, built without an Xcode project:
# swiftc compiles the provider, links the Rust core static lib (its C
# ABI) + the system frameworks, and we hand-assemble the .appex bundle.
# `cargo build` already produced libdwg_thumbnailer.a (workspace member,
# crate-type staticlib). The whole app is ad-hoc signed below, which
# deep-signs the embedded extension too.
set -e
VERSION="${{ github.ref_name }}"; VERSION="${VERSION#v}"; [ -z "$VERSION" ] && VERSION="0.0.0"
VERSION="${{ steps.version.outputs.cargo }}"
EXT=DWGThumbnail.appex
rm -rf "$EXT"; mkdir -p "$EXT/Contents/MacOS"
swiftc \
@@ -412,27 +402,16 @@ jobs:
# QuickLook thumbnail extension.
cp -R DWGThumbnail.appex "$APP/Contents/PlugIns/"
# Substitute version into Info.plist.
VERSION="${{ github.ref_name }}"
VERSION="${VERSION#v}"
[ -z "$VERSION" ] && VERSION="0.0.0"
VERSION="${{ steps.version.outputs.cargo }}"
sed "s/__VERSION__/$VERSION/g" packaging/Info.plist > "$APP/Contents/Info.plist"
- name: Ad-hoc code-sign the bundle
run: |
# No paid Apple Developer ID is available, so the app cannot be
# notarised. An *ad-hoc* signature (identity "-") at least gives the
# bundle a valid self-signature: this avoids the harshest Gatekeeper
# verdict ("app is damaged, Move to Trash") on a quarantined download
# and is mandatory anyway for arm64 binaries to execute. Sign the
# whole bundle deeply, then verify.
codesign --force --deep --sign - --timestamp=none OpenCADStudio.app
codesign --verify --strict --verbose=2 OpenCADStudio.app
- name: Create .dmg
run: |
# hdiutil intermittently fails with "Resource busy" on the hosted
# runner (a stale mount or background indexer holding the image).
# Detach any leftover volume and retry a few times before giving up.
set +e
for attempt in 1 2 3 4 5; do
hdiutil detach "/Volumes/Open CAD Studio" >/dev/null 2>&1 || true
@@ -449,24 +428,20 @@ jobs:
test -f OpenCADStudio.dmg
- name: Upload .dmg to release
if: github.event_name == 'release'
if: env.RELEASE_TAG != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
ASSET=OpenCADStudio-${{ github.ref_name }}-macos-arm64.dmg
ASSET=OpenCADStudio-${{ env.RELEASE_TAG }}-macos-arm64.dmg
mv OpenCADStudio.dmg "$ASSET"
gh release upload ${{ github.ref_name }} "$ASSET" --clobber --repo ${{ github.repository }}
gh release upload ${{ env.RELEASE_TAG }} "$ASSET" --clobber --repo ${{ github.repository }}
- name: Emit Homebrew cask sha256
if: github.event_name == 'release'
if: env.RELEASE_TAG != ''
run: |
# Print the values needed to bump the Homebrew cask
# (packaging/homebrew/open-cad-studio.rb) for this release, so the
# tap can be updated with a copy-paste instead of computing the
# digest by hand.
VERSION="${{ github.ref_name }}"
VERSION="${{ env.RELEASE_TAG }}"
VERSION="${VERSION#v}"
SHA=$(shasum -a 256 OpenCADStudio-${{ github.ref_name }}-macos-arm64.dmg | awk '{print $1}')
SHA=$(shasum -a 256 OpenCADStudio-${{ env.RELEASE_TAG }}-macos-arm64.dmg | awk '{print $1}')
{
echo "## Homebrew cask bump"
echo "Update \`packaging/homebrew/open-cad-studio.rb\`:"
@@ -475,3 +450,18 @@ jobs:
echo " sha256 \"$SHA\""
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
verify:
needs: [build-appimage, build-snap, build-windows, build-macos]
if: github.event_name == 'release' || inputs.tag != ''
runs-on: ubuntu-22.04
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.commit || github.sha }}
- name: Verify native release assets and notes
env:
GH_TOKEN: ${{ github.token }}
run: python3 scripts/release.py verify-native --tag "$RELEASE_TAG"
+68
View File
@@ -0,0 +1,68 @@
name: Weekly release
on:
schedule:
- cron: '0 12 * * 0'
workflow_dispatch:
inputs:
publish:
description: Publish this week's release (otherwise preview notes only)
type: boolean
default: false
permissions:
contents: write
actions: read
discussions: read
pages: write
id-token: write
concurrency:
group: weekly-release
cancel-in-progress: false
jobs:
prepare:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-22.04
outputs:
ready: ${{ steps.release.outputs.ready }}
tag: ${{ steps.release.outputs.tag }}
commit: ${{ steps.release.outputs.commit }}
steps:
- uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
- name: Validate release automation
run: python3 scripts/test_release.py
- name: Prepare release and verify published notes
id: release
env:
GH_TOKEN: ${{ github.token }}
PUBLISH: ${{ github.event_name == 'schedule' || inputs.publish }}
run: |
if [ "$PUBLISH" = true ]; then
python3 scripts/release.py prepare --publish
else
python3 scripts/release.py prepare
fi
# GITHUB_TOKEN releases do not trigger release events, so call both builds.
web:
needs: prepare
if: needs.prepare.outputs.ready == 'true'
uses: ./.github/workflows/pages.yml
with:
tag: ${{ needs.prepare.outputs.tag }}
commit: ${{ needs.prepare.outputs.commit }}
secrets: inherit
native:
needs: prepare
if: needs.prepare.outputs.ready == 'true'
uses: ./.github/workflows/release.yml
with:
tag: ${{ needs.prepare.outputs.tag }}
commit: ${{ needs.prepare.outputs.commit }}
secrets: inherit