Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 93 additions & 58 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
# Fork build: Windows only. Branches and PRs produce the NSIS installer as an
# artifact. A pushed tag vX.Y.Z also signs the updater package (secrets
# TAURI_SIGNING_PRIVATE_KEY and TAURI_SIGNING_PRIVATE_KEY_PASSWORD) and
# publishes a GitHub Release with the installer and latest.json, which the
# app's updater reads.
# Checks and the installer build run in parallel. Every build uses one fast
# release profile and one cache key, so PRs reuse main's compiled dependencies
# Fork build for Windows, macOS and Linux. Branches and PRs build every platform
# and upload the packages as artifacts (with a disposable updater key). A pushed
# tag vX.Y.Z signs the updater packages (secrets TAURI_SIGNING_PRIVATE_KEY and
# TAURI_SIGNING_PRIVATE_KEY_PASSWORD) and publishes a GitHub Release with the
# packages and latest.json, which the app's updater reads.
# The Windows build is required for a release; macOS and Linux are optional: if
# one of them fails the release goes out without it (and says so). Re-run the
# workflow on the tag with `republish` to add it later.
# Checks and the builds run in parallel. Every build uses one fast release
# profile and one cache key per target, so PRs reuse main's compiled dependencies
# (GitHub lets a PR restore caches saved by its base branch).
name: Build desktop apps
on:
workflow_dispatch:
inputs:
republish:
description: 'Run on a version tag: upload over the already published release (adds platforms that failed before)'
type: boolean
default: false
push:
branches: [main]
tags: ['v*']
Expand All @@ -20,6 +28,16 @@ concurrency:
group: build-${{ github.ref }}
cancel-in-progress: true
jobs:
scripts:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- run: sudo apt-get update && sudo apt-get install -y minisign shellcheck
- run: node --test scripts/release-test.cjs scripts/publish-release-test.cjs scripts/install-test.cjs
- run: bash -n scripts/install.sh && shellcheck scripts/install.sh
checks:
runs-on: windows-latest
steps:
Expand All @@ -42,33 +60,60 @@ jobs:
- run: pnpm test
- run: pnpm check
build:
runs-on: windows-latest
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
target: x86_64-pc-windows-msvc
bundle: nsis
- os: macos-latest
target: aarch64-apple-darwin
bundle: app,dmg
- os: macos-latest
target: x86_64-apple-darwin
bundle: app,dmg
- os: ubuntu-24.04
target: x86_64-unknown-linux-gnu
bundle: deb,appimage
runs-on: ${{ matrix.os }}
env:
TARGET: x86_64-pc-windows-msvc
TARGET: ${{ matrix.target }}
MACOSX_DEPLOYMENT_TARGET: '13.0'
APPIMAGE_EXTRACT_AND_RUN: '1'
steps:
- uses: actions/checkout@v4
- name: Install Linux dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libayatana-appindicator3-dev librsvg2-dev patchelf libudev-dev libasound2-dev libxcb1-dev libxcb-randr0-dev libpipewire-0.3-dev libclang-dev libgbm-dev libegl1-mesa-dev libwayland-dev libxkbcommon-dev libdbus-1-dev pulseaudio pulseaudio-utils xvfb
- uses: actions/setup-node@v4
with:
node-version: 24
- uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
targets: ${{ matrix.target }}
components: clippy
- uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
key: build
workspaces: |
src-tauri
control
key: build-${{ matrix.target }}
cache-on-failure: true
- run: npm install --global pnpm@11.25.0
- run: pnpm install --frozen-lockfile
- name: Check the tag matches the app version
if: startsWith(github.ref, 'refs/tags/v')
run: node scripts/release.cjs version ${{ github.ref_name }}
- name: Sign updater packages (release tags only)
if: startsWith(github.ref, 'refs/tags/v')
# Release tags use the repository key; everything else a disposable one, so
# the packaging pipeline (including updater signatures) is exercised too.
- name: Configure signed updater packages
run: node scripts/configure-updater.cjs
env:
RELEASE_BUILD: 'true'
RELEASE_SIGNING_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
RELEASE_BUILD: ${{ startsWith(github.ref, 'refs/tags/v') }}
RELEASE_SIGNING_KEY: ${{ startsWith(github.ref, 'refs/tags/v') && secrets.TAURI_SIGNING_PRIVATE_KEY || '' }}
- name: Signing key password
if: startsWith(github.ref, 'refs/tags/v')
shell: bash
Expand All @@ -81,59 +126,49 @@ jobs:
echo "CARGO_PROFILE_RELEASE_LTO=false" >> "$GITHUB_ENV"
echo "CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16" >> "$GITHUB_ENV"
echo "CARGO_PROFILE_RELEASE_INCREMENTAL=true" >> "$GITHUB_ENV"
- name: Build installer
shell: bash
# Windows is linted and tested by the checks job; the other platforms
# compile different code (cfg), so they need their own pass.
- name: Lint and test (macOS, Linux)
if: runner.os != 'Windows'
run: |
if [ -f .local/updater-build.json ]; then config="--config .local/updater-build.json"; fi
pnpm tauri build $config --target "$TARGET" --bundles nsis
pnpm check
pnpm test
- name: Build the command-line tool
run: cargo build --locked --manifest-path control/Cargo.toml --release --target ${{ matrix.target }}
- name: Build the app
run: pnpm tauri build --config .local/updater-build.json --target ${{ matrix.target }} --bundles ${{ matrix.bundle }}
- name: Verify Linux package contents and app startup
if: runner.os == 'Linux'
run: bash scripts/check-linux.sh
- name: Stage release assets
run: node scripts/release.cjs stage ${{ matrix.target }} dist/release
- uses: actions/upload-artifact@v4
with:
name: dynamiclight-${{ env.TARGET }}
path: |
src-tauri/target/${{ env.TARGET }}/release/bundle/nsis/*.exe
src-tauri/target/${{ env.TARGET }}/release/bundle/nsis/*.exe.sig
name: dynamiclight-${{ matrix.target }}
path: dist/release/*
if-no-files-found: error
release:
needs: [checks, build]
if: startsWith(github.ref, 'refs/tags/v')
needs: [scripts, checks, build]
# A failed macOS or Linux build must not block the release: assemble drops
# the platforms without packages and refuses to go on without Windows.
if: always() && startsWith(github.ref, 'refs/tags/v') && needs.scripts.result == 'success' && needs.checks.result == 'success'
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- uses: actions/download-artifact@v4
with:
name: dynamiclight-x86_64-pc-windows-msvc
path: bundle
- name: Assemble release assets and latest.json
shell: bash
env:
TAG: ${{ github.ref_name }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
mkdir dist
exe=$(ls bundle/*.exe | grep -v '\.sig$' | head -n1)
name=dynamiclight-desktop-windows-x64-setup.exe
cp "$exe" "dist/$name"
cp "$exe.sig" "dist/$name.sig"
node -e '
const fs = require("fs");
const [tag, repo, name] = process.argv.slice(1);
const signature = fs.readFileSync(`dist/${name}.sig`, "utf8").trim();
const url = `https://github.com/${repo}/releases/download/${tag}/${name}`;
fs.writeFileSync("dist/latest.json", JSON.stringify({
version: tag.replace(/^v/, ""),
notes: `DynamicLight ${tag}`,
pub_date: new Date().toISOString(),
platforms: {
"windows-x86_64": { signature, url },
"windows-x86_64-nsis": { signature, url },
},
}, null, 2));
' "$TAG" "$REPO" "$name"
cat dist/latest.json
pattern: dynamiclight-*
path: dist/release
merge-multiple: true
- run: sudo apt-get update && sudo apt-get install -y minisign
- name: Publish GitHub Release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
run: gh release create "$TAG" dist/* --repo "${{ github.repository }}" --title "DynamicLight $TAG" --generate-notes
ALLOW_PARTIAL_RELEASE: 'true'
REPUBLISH: ${{ inputs.republish && 'true' || 'false' }}
run: node scripts/publish-release.cjs dist/release
2 changes: 1 addition & 1 deletion scripts/install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ if ($architecture -ne 'AMD64') { throw "No desktop package for Windows/$architec
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
$scratch = Join-Path ([IO.Path]::GetTempPath()) ('dynamiclight-install-' + [guid]::NewGuid())
$null = New-Item -ItemType Directory -Path $scratch
$releases = 'https://github.com/mskutin/DynamicLight/releases'
$releases = 'https://github.com/BrenOne/DynamicLight/releases'
$asset = 'dynamiclight-desktop-windows-x64-setup.exe'
if ($Cli) { $asset = 'dynamiclight-cli-windows-x64.exe' }
try {
Expand Down
6 changes: 3 additions & 3 deletions scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ case "$os:$arch" in
Linux:x86_64|Linux:amd64)
if "$appimage"; then asset=dynamiclight-desktop-linux-x64.AppImage
else asset=dynamiclight-desktop-linux-x64.deb; fi ;;
*) fail "No desktop package for $os/$arch. See https://github.com/mskutin/DynamicLight/releases" ;;
*) fail "No desktop package for $os/$arch. See https://github.com/BrenOne/DynamicLight/releases" ;;
esac
if "$appimage" && [ "$os" != Linux ]; then fail '--appimage is only supported on Linux'; fi
if "$cli"; then
Expand All @@ -65,7 +65,7 @@ cleanup() {
}
trap cleanup EXIT
fetch() { curl --fail --silent --show-error --location --retry 3 --proto '=https' --proto-redir '=https' "$1" -o "$2"; }
releases=https://github.com/mskutin/DynamicLight/releases
releases=https://github.com/BrenOne/DynamicLight/releases
if [ "$version" = latest ]; then
fetch "$releases/latest/download/release-version.txt" "$scratch/version"
version=$(tr -d '\r\n' < "$scratch/version")
Expand Down Expand Up @@ -137,7 +137,7 @@ case "$os" in
mv -f "$destination/DynamicLight.AppImage.new" "$destination/DynamicLight.AppImage"
install -m 644 "$scratch/69-dynamiclight.rules" "$destination/69-dynamiclight.rules"
printf 'Installed DynamicLight %s at %s/DynamicLight.AppImage\n' "$version" "$destination"
echo 'USB access and audio/FUSE dependencies require the one-time setup in https://github.com/mskutin/DynamicLight/blob/main/docs/linux.md'
echo 'USB access and audio/FUSE dependencies require the one-time setup in https://github.com/BrenOne/DynamicLight/blob/main/docs/linux.md'
printf 'USB rule downloaded to %s/69-dynamiclight.rules\n' "$destination"
else
command -v apt-get >/dev/null || fail 'This system has no apt-get. Use --appimage and follow docs/linux.md for runtime dependencies'
Expand Down
15 changes: 10 additions & 5 deletions scripts/publish-release.cjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { version, assemble } = require('./release.cjs');
const { version, assemble, cli } = require('./release.cjs');

function publishRelease({ root, directory, repo, sha, ref, event }, execute = (args, input) =>
function publishRelease({ root, directory, repo, sha, ref, event, partial = false, republish = false }, execute = (args, input) =>
spawnSync('gh', args, { input, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 })) {
if (!['push', 'workflow_dispatch'].includes(event) || !(ref === 'refs/heads/main' || ref?.startsWith('refs/tags/v'))) {
throw new Error('Publishing is allowed only for main or version-tag push/manual runs');
Expand All @@ -29,7 +29,8 @@ function publishRelease({ root, directory, repo, sha, ref, event }, execute = (a
// List releases with authentication so interrupted drafts are included too.
const releases = JSON.parse(gh(['api', `${base}/releases?per_page=100`, '--paginate', '--slurp'])).flat();
let release = releases.find(item => item.tag_name === tag);
if (release && !release.draft) {
// `republish` re-uploads over a published release (same tag, same commit), e.g. to add a platform that failed before.
if (release && !release.draft && !republish) {
console.log(`${tag} is already published; leaving its tag and assets unchanged.`);
return 'unchanged';
}
Expand All @@ -45,8 +46,11 @@ function publishRelease({ root, directory, repo, sha, ref, event }, execute = (a
}

// Validate the complete current-run asset set before creating any remote state.
assemble(root, directory, tag);
const notes = fs.readFileSync(path.join(root, 'docs/release-notes.md'), 'utf8');
const { missing } = assemble(root, directory, tag, { partial });
let notes = fs.readFileSync(path.join(root, 'docs/release-notes.md'), 'utf8');
const unreleased = missing.map(target => cli[target]?.replace(/^dynamiclight-cli-/, '').replace(/\.exe$/, '')).join(', ');
if (missing.length) notes += `\n\n**Not in this release:** ${unreleased} (the build failed; run the workflow again on this tag with republish to add it).`;
if (partial) notes += '\n\nmacOS and Linux builds are experimental: they are built automatically but not yet tested on a real controller.';
if (!tagExists) api(`${base}/git/refs`, 'POST', { ref: `refs/tags/${tag}`, sha });
const prerelease = tag.includes('-');
if (!release) {
Expand All @@ -72,5 +76,6 @@ if (require.main === module) {
root: path.resolve(__dirname, '..'), directory: process.argv[2] || 'dist/release',
repo: process.env.GITHUB_REPOSITORY, sha: process.env.GITHUB_SHA,
ref: process.env.GITHUB_REF, event: process.env.GITHUB_EVENT_NAME,
partial: process.env.ALLOW_PARTIAL_RELEASE === 'true', republish: process.env.REPUBLISH === 'true',
});
}
42 changes: 41 additions & 1 deletion scripts/release-test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ test('stages all platform bundles and hashes exactly the complete release', t =>
assert.equal(manifest.version, version(dir));
assert.equal(Object.keys(manifest.platforms).length, 4);
for (const platform of Object.values(manifest.platforms)) {
assert.ok(platform.url.startsWith(`https://github.com/mskutin/DynamicLight/releases/download/v${version(dir)}/`));
assert.ok(platform.url.startsWith(`https://github.com/BrenOne/DynamicLight/releases/download/v${version(dir)}/`));
assert.ok(platform.signature.length > 100);
}
for (const line of checksums) {
Expand Down Expand Up @@ -100,3 +100,43 @@ test('signatures reject a different key, changed bytes and a relabeled version',
fs.appendFileSync(file, 'changed');
assert.throws(() => verifySignature(file, signature, key, version(dir)), /Invalid updater signature/);
});

test('a partial release needs Windows and leaves failed platforms out of latest.json', t => {
const dir = fixture(t);
const sign = signingFixture(dir);
const stageTarget = (target, output) => {
for (const [folder, ext] of bundles[target]) {
const source = path.join(dir, 'src-tauri/target', target, 'release/bundle', folder);
fs.mkdirSync(source, { recursive: true });
fs.writeFileSync(path.join(source, `package${ext}`), `fixture ${target} ${ext}`);
}
const [, folder, ext] = updates[target];
const source = path.join(dir, 'src-tauri/target', target, 'release/bundle', folder);
fs.mkdirSync(source, { recursive: true });
const file = path.join(source, `package${ext}`);
fs.writeFileSync(file, `fixture ${target} ${ext}`);
sign(file);
const binary = path.join(dir, 'control/target', target, 'release', target.includes('windows') ? 'dynamiclight.exe' : 'dynamiclight');
fs.mkdirSync(path.dirname(binary), { recursive: true });
fs.writeFileSync(binary, 'CLI fixture');
stage(dir, target, output);
};
const windows = 'x86_64-pc-windows-msvc';
const linux = 'x86_64-unknown-linux-gnu';
const output = path.join(dir, 'dist');
stageTarget(linux, output);
assert.throws(() => assemble(dir, output, `v${version(dir)}`, { partial: true }), /Windows packages are required/);
fs.rmSync(output, { recursive: true });
stageTarget(windows, output);
assert.throws(() => assemble(dir, output, `v${version(dir)}`), /every supported platform/);
const { missing } = assemble(dir, output, `v${version(dir)}`, { partial: true });
assert.deepEqual(missing.sort(), ['aarch64-apple-darwin', linux, 'x86_64-apple-darwin'].sort());
const manifest = JSON.parse(fs.readFileSync(path.join(output, 'latest.json')));
assert.deepEqual(Object.keys(manifest.platforms), ['windows-x86_64']);
// A platform that only has some of its packages is dropped as a whole.
fs.rmSync(output, { recursive: true });
stageTarget(windows, output);
fs.writeFileSync(path.join(output, bundles[linux][0][2]), 'orphan');
assert.deepEqual(assemble(dir, output, `v${version(dir)}`, { partial: true }).missing.includes(linux), true);
assert.ok(!fs.existsSync(path.join(output, bundles[linux][0][2])));
});
Loading
Loading