diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index 4864c9084..aa1a35261 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -492,6 +492,19 @@ jobs: "$out_dir/OpenLess_${version}_x86.apk" \ "$out_dir/OpenLess_${version}_x86_64.apk" + - name: Stage direct download aliases + if: needs.plan.outputs.is_tag_release == 'true' + working-directory: openless-all/app + env: + OPENLESS_DOWNLOAD_SOURCE_DIR: ${{ steps.stage.outputs.out_dir }} + OPENLESS_DOWNLOAD_OUTPUT_DIR: ${{ steps.stage.outputs.out_dir }} + run: | + set -euo pipefail + for arch in aarch64 armv7 i686 x86_64; do + OPENLESS_DOWNLOAD_TARGET=android OPENLESS_DOWNLOAD_ARCH="$arch" \ + node scripts/stage-release-download-aliases.mjs + done + - name: Write Android updater manifests working-directory: openless-all/app env: @@ -515,7 +528,7 @@ jobs: out_dir="${{ steps.stage.outputs.out_dir }}" { echo "${{ steps.stage.outputs.release_files }}" - for f in "$out_dir"/*.apk.sig "$out_dir"/latest-android-*.json; do + for f in "$out_dir"/*.apk.sig "$out_dir"/latest-android-*.json "$out_dir"/OpenLess_android_*.apk; do [ -e "$f" ] && echo "$f" done } > "$RUNNER_TEMP/android-release-files.txt" diff --git a/.github/workflows/check-linux-egui.yml b/.github/workflows/check-linux-egui.yml index 2c7f8833e..219ab51aa 100644 --- a/.github/workflows/check-linux-egui.yml +++ b/.github/workflows/check-linux-egui.yml @@ -158,6 +158,22 @@ jobs: ( cd "$OUTPUT" && sha256sum ./*.deb ./*.rpm > SHA256SUMS ) bash scripts/verify-linux-egui-packages.sh + - name: Stage direct download aliases + if: inputs.scope != 'none' && (github.event_name != 'push' || env.RELEASE_TAG != '') + shell: bash + run: | + set -euo pipefail + output=openless-all/app/target/linux-egui-packages + alias_dir="$RUNNER_TEMP/openless-linux-egui-direct-download" + rm -rf "$alias_dir" + mkdir -p "$alias_dir" + debs=("$output"/OpenLess-Linux-egui-*.deb) + rpms=("$output"/OpenLess-Linux-egui-*.rpm) + test "${#debs[@]}" -eq 1 + test "${#rpms[@]}" -eq 1 + cp "${debs[0]}" "$alias_dir/OpenLess_linux_x86_64.deb" + cp "${rpms[0]}" "$alias_dir/OpenLess_linux_x86_64.rpm" + - name: Upload verified Linux packages as workflow artifacts # Push builds keep the release path warm but do not publish artifacts; # the tag release and pull request runs are the consumers. @@ -167,3 +183,11 @@ jobs: name: openless-linux-egui-x86_64 path: openless-all/app/target/linux-egui-packages/* if-no-files-found: error + + - name: Upload direct download aliases + if: inputs.scope != 'none' && (github.event_name != 'push' || env.RELEASE_TAG != '') + uses: actions/upload-artifact@v7 + with: + name: openless-linux-egui-direct-download + path: ${{ runner.temp }}/openless-linux-egui-direct-download/* + if-no-files-found: error diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index 45761ea8b..e6236728e 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -535,6 +535,16 @@ jobs: fi done + - name: Stage direct download alias + if: startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-tauri') + working-directory: openless-all/app + env: + OPENLESS_DOWNLOAD_TARGET: ${{ matrix.updater-target }} + OPENLESS_DOWNLOAD_ARCH: ${{ matrix.updater-arch }} + OPENLESS_DOWNLOAD_SOURCE_DIR: ${{ matrix.updater-target == 'darwin' && 'src-tauri/target/release/bundle/dmg' || 'src-tauri/target/release/bundle/nsis' }} + OPENLESS_DOWNLOAD_OUTPUT_DIR: src-tauri/target/release/downloads + run: node scripts/stage-release-download-aliases.mjs + - name: Upload macOS artifacts if: startsWith(matrix.platform, 'macos') uses: actions/upload-artifact@v4 @@ -648,3 +658,4 @@ jobs: openless-all/app/src-tauri/target/release/bundle/msi/*.msi openless-all/app/src-tauri/target/release/bundle/msi/*.msi.sig openless-all/app/src-tauri/target/release/bundle/latest-*.json + openless-all/app/src-tauri/target/release/downloads/* diff --git a/.github/workflows/sync-homebrew-cask.yml b/.github/workflows/sync-homebrew-cask.yml index 78b687c81..ef2050ebf 100644 --- a/.github/workflows/sync-homebrew-cask.yml +++ b/.github/workflows/sync-homebrew-cask.yml @@ -72,12 +72,12 @@ jobs: mkdir -p "$RUNNER_TEMP/dmg" gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \ --dir "$RUNNER_TEMP/dmg" \ - --pattern '*aarch64.dmg' --pattern '*_x64.dmg' + --pattern 'OpenLess_[0-9]*_aarch64.dmg' --pattern 'OpenLess_[0-9]*_x64.dmg' # Tags omit +build metadata; filenames and the cask must preserve it. version="$(node openless-all/app/scripts/resolve-release-cask-version.mjs "$TAG" "$RUNNER_TEMP/dmg")" - arm_sha="$(sha256sum "$RUNNER_TEMP"/dmg/*aarch64.dmg | cut -d' ' -f1)" - intel_sha="$(sha256sum "$RUNNER_TEMP"/dmg/*_x64.dmg | cut -d' ' -f1)" + arm_sha="$(sha256sum "$RUNNER_TEMP/dmg/OpenLess_${version}_aarch64.dmg" | cut -d' ' -f1)" + intel_sha="$(sha256sum "$RUNNER_TEMP/dmg/OpenLess_${version}_x64.dmg" | cut -d' ' -f1)" { echo "version=$version" diff --git a/RELEASING.md b/RELEASING.md index 49eca050a..e3ce1d3ad 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -55,6 +55,22 @@ from the last `-N` suffix (for example `2.0.0-Beta.2-79` → `2.0.0-Beta.2-80`). Both local packaging and the shared CI/tag build use this explicit revision; never drop it or derive it from the Tauri tag, whose SemVer remains `2.0.0-Beta.2`. +## Direct download aliases + +The release workflows also publish stable, version-independent asset names for the official website: + +- macOS Apple Silicon: `OpenLess_macos_aarch64.dmg` +- macOS Intel: `OpenLess_macos_x86_64.dmg` +- Windows x64: `OpenLess_windows_x86_64-setup.exe` +- Android: `OpenLess_android_.apk` (one each for `arm64-v8a`, `armeabi-v7a`, `x86`, and `x86_64`) +- Linux: `OpenLess_linux_x86_64.deb` and `OpenLess_linux_x86_64.rpm` + +Stable website links use `https://github.com/Open-Less/openless/releases/latest/download/`. +Beta links must use the release tag because `latest` excludes pre-releases: +`https://github.com/Open-Less/openless/releases/download//`. + +The Tauri and Android workflows attach their aliases automatically. Linux remains administrator-attached after product acceptance, so the administrator must attach the two files from the `openless-linux-egui-direct-download` Actions artifact to the accepted Release as well as the versioned packages. Do not replace the versioned assets; the aliases are an additional website-facing contract. + Under the [2026-09-06 2.0 requirements](docs/2.0-requirements.md), Windows and macOS must fully retain their respective Tauri 1.x features. The egui team owns Linux Host/UI work and Linux product acceptance. Linux application gaps do not block the Windows/macOS delivery, but shared Core defects do. Linux packages must not be attached before their own product acceptance. Existing Android builds do not expand this scope into a new full-support commitment. ## Version-sync gate diff --git a/openless-all/app/package.json b/openless-all/app/package.json index 8d97c795e..b47124320 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -30,6 +30,7 @@ "check:windows-ui-config": "node scripts/windows-ui-config.test.mjs", "check:pin-persistence-security": "node scripts/pin-persistence-security-contract.test.mjs", "check:android-updater-pubkey": "node scripts/check-android-updater-pubkey.mjs", + "check:release-download-aliases": "node scripts/stage-release-download-aliases.test.mjs", "check:android-shizuku-scaffolding": "node scripts/merge-android-shizuku-manifest.test.mjs && node scripts/patch-android-shizuku-deps.test.mjs", "format": "prettier --write \"src/**/*.{ts,tsx,css}\" \"android/frontend/**/*.{ts,tsx}\" \"scripts/**/*.{js,mjs,sh}\" \"*.{ts,json,html,sh}\" \"assets/remote-input/*.{js,css,html}\"", "format:check": "prettier --check \"src/**/*.{ts,tsx,css}\" \"android/frontend/**/*.{ts,tsx}\" \"scripts/**/*.{js,mjs,sh}\" \"*.{ts,json,html,sh}\" \"assets/remote-input/*.{js,css,html}\"", diff --git a/openless-all/app/scripts/android-apk-workflow-contract.test.mjs b/openless-all/app/scripts/android-apk-workflow-contract.test.mjs index 945746b91..bd4de23c6 100644 --- a/openless-all/app/scripts/android-apk-workflow-contract.test.mjs +++ b/openless-all/app/scripts/android-apk-workflow-contract.test.mjs @@ -148,6 +148,52 @@ assert.match(workflow, /CARGO_PROFILE_RELEASE_LTO/); assert.match(workflow, /first ABI may compile twice|android-studio-script/); assert.match(workflow, /publish-android-release/); assert.match(workflow, /download-artifact/); +assert.match(workflow, /stage-release-download-aliases\.mjs/); +assert.match(workflow, /OpenLess_android_\*\.apk/); assert.match(workflow, /Rust cache/); +// Exercise the upload list so overlapping globs cannot upload the same asset twice. +const releaseTmp = mkdtempSync(join(tmpdir(), 'openless-apk-assets-')); +try { + const directory = releaseTmp.replace(/\\/g, '/'); + const version = '2.0.0-Beta.3+build.20260925'; + const originals = []; + const names = ANDROID_ABI_MATRIX.flatMap(({ abi, gradleAbi }) => { + const apk = `OpenLess_${version}_${gradleAbi}.apk`; + const alias = `OpenLess_android_${gradleAbi}.apk`; + originals.push(`${directory}/${apk}`); + return [ + apk, + `${apk}.sig`, + alias, + `${alias}.sig`, + `latest-android-${abi}.json`, + `latest-android-${abi}-mirror.json`, + ]; + }); + for (const name of names) writeFileSync(join(releaseTmp, name), 'fixture'); + const step = parsed.jobs['publish-android-release'].steps.find( + (step) => step.id === 'release_assets', + ); + const result = spawnSync('bash', ['-s'], { + input: step.run + .replaceAll('${{ steps.stage.outputs.out_dir }}', '$RUNNER_TEMP') + .replaceAll('${{ steps.stage.outputs.release_files }}', '$RELEASE_FILES'), + env: { + ...process.env, + RUNNER_TEMP: directory, + GITHUB_OUTPUT: `${directory}/output`, + RELEASE_FILES: originals.join('\n'), + }, + encoding: 'utf8', + }); + assert.equal(result.status, 0, result.stderr); + const assets = readFileSync(join(releaseTmp, 'android-release-files.txt'), 'utf8') + .trim() + .split(/\r?\n/); + assert.deepEqual(assets.sort(), names.map((name) => `${directory}/${name}`).sort()); +} finally { + rmSync(releaseTmp, { recursive: true, force: true }); +} + console.log('android-apk-workflow-contract checks passed'); diff --git a/openless-all/app/scripts/linux-egui-release-contract.test.mjs b/openless-all/app/scripts/linux-egui-release-contract.test.mjs index ada8157ea..d948ec7a2 100644 --- a/openless-all/app/scripts/linux-egui-release-contract.test.mjs +++ b/openless-all/app/scripts/linux-egui-release-contract.test.mjs @@ -53,6 +53,9 @@ assert.match(check, /cargo build --locked --release -p openless-linux-egui/); assert.match(check, /bash scripts\/package-linux-egui\.sh/); assert.match(check, /sha256sum \.\/\*\.deb \.\/\*\.rpm > SHA256SUMS/); assert.match(check, /bash scripts\/verify-linux-egui-packages\.sh/); +assert.match(check, /OpenLess_linux_x86_64\.deb/); +assert.match(check, /OpenLess_linux_x86_64\.rpm/); +assert.match(check, /openless-linux-egui-direct-download/); assert.match(gate, /cargo tree --locked/); assert.match(gate, /tauri\|wry\|webkit2gtk/); assert.doesNotMatch(ci + check, /libgtk-3|webkit2gtk|libwebkit/); diff --git a/openless-all/app/scripts/resolve-release-cask-version.test.mjs b/openless-all/app/scripts/resolve-release-cask-version.test.mjs index 89ce497d2..c97465512 100644 --- a/openless-all/app/scripts/resolve-release-cask-version.test.mjs +++ b/openless-all/app/scripts/resolve-release-cask-version.test.mjs @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; import { resolveReleaseCaskVersion } from './resolve-release-cask-version.mjs'; const files = (version) => [`OpenLess_${version}_aarch64.dmg`, `OpenLess_${version}_x64.dmg`]; @@ -54,4 +55,29 @@ assert.match( workflow, /version="\$\(node openless-all\/app\/scripts\/resolve-release-cask-version\.mjs "\$TAG" "\$RUNNER_TEMP\/dmg"\)"/, ); +const downloadPatterns = [...workflow.matchAll(/--pattern '([^']+)'/g)].map((match) => match[1]); +assert.equal(downloadPatterns.length, 2); +for (const version of ['1.3.18', '2.0.0+build.20261015']) { + const selected = spawnSync( + 'bash', + [ + '-c', + 'arm=$1; intel=$2; shift 2; for name; do if [[ "$name" == $arm || "$name" == $intel ]]; then printf "%s\\n" "$name"; fi; done', + 'download-patterns', + ...downloadPatterns, + ...files(version), + 'OpenLess_macos_aarch64.dmg', + 'OpenLess_macos_x86_64.dmg', + ], + { encoding: 'utf8' }, + ); + assert.equal(selected.status, 0, selected.stderr); + const downloaded = selected.stdout.trim().split(/\r?\n/); + assert.deepEqual(downloaded, files(version), 'Homebrew must only download versioned DMGs'); + assert.equal(resolveReleaseCaskVersion(`v${version.split('+')[0]}-tauri`, downloaded), version); + const checksumNames = [...workflow.matchAll(/sha256sum "\$RUNNER_TEMP\/dmg\/([^"]+)"/g)].map( + (match) => match[1].replaceAll('${version}', version), + ); + assert.deepEqual(checksumNames, files(version), 'Homebrew must checksum the resolved version'); +} console.log('resolve-release-cask-version.test.mjs passed'); diff --git a/openless-all/app/scripts/stage-release-download-aliases.mjs b/openless-all/app/scripts/stage-release-download-aliases.mjs new file mode 100644 index 000000000..1746e658a --- /dev/null +++ b/openless-all/app/scripts/stage-release-download-aliases.mjs @@ -0,0 +1,118 @@ +#!/usr/bin/env node +import { copyFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs'; +import { basename, join, resolve } from 'node:path'; +import process from 'node:process'; + +const TARGETS = new Set(['darwin', 'windows', 'android']); +const ANDROID_ABIS = { + aarch64: 'arm64-v8a', + armv7: 'armeabi-v7a', + i686: 'x86', + x86_64: 'x86_64', +}; +const DARWIN_BUNDLE_ARCHES = { + aarch64: 'aarch64', + x86_64: 'x64', +}; + +function readOption(name) { + const index = process.argv.indexOf(`--${name}`); + if (index !== -1) { + const value = process.argv[index + 1]; + if (!value || value.startsWith('--')) { + throw new Error(`Missing value for --${name}`); + } + return value; + } + return process.env[`OPENLESS_DOWNLOAD_${name.replaceAll('-', '_').toUpperCase()}`]; +} + +const target = readOption('target'); +const arch = readOption('arch'); +const sourceDirValue = readOption('source-dir'); +const outputDirValue = readOption('output-dir'); + +if (!TARGETS.has(target)) { + throw new Error(`Invalid --target: ${target || '(missing)'}`); +} +if (!arch) { + throw new Error('--arch is required'); +} +if (!sourceDirValue || !outputDirValue) { + throw new Error('--source-dir and --output-dir are required'); +} + +const sourceDir = resolve(sourceDirValue); +const outputDir = resolve(outputDirValue); + +function chooseSingle(predicate, description) { + if (!existsSync(sourceDir)) { + throw new Error(`Source directory does not exist: ${sourceDir}`); + } + const matches = readdirSync(sourceDir) + .filter((name) => predicate(name)) + .sort(); + if (matches.length !== 1) { + throw new Error( + `Expected exactly one ${description} in ${sourceDir}, found ${matches.length}: ${matches.join(', ') || '(none)'}`, + ); + } + return matches[0]; +} + +function sourceAndAlias() { + if (target === 'darwin') { + if (!['aarch64', 'x86_64'].includes(arch)) { + throw new Error(`Invalid macOS arch: ${arch}`); + } + const bundleArch = DARWIN_BUNDLE_ARCHES[arch]; + const sourceName = chooseSingle( + (name) => name.endsWith(`_${bundleArch}.dmg`) && !name.startsWith('OpenLess_macos_'), + `macOS ${arch} DMG`, + ); + return { + sourceName, + aliasName: `OpenLess_macos_${arch}.dmg`, + }; + } + + if (target === 'windows') { + if (arch !== 'x86_64') { + throw new Error(`Invalid Windows arch: ${arch}`); + } + const sourceName = chooseSingle( + (name) => name.endsWith('_x64-setup.exe') && !name.startsWith('OpenLess_windows_'), + 'Windows x64 NSIS installer', + ); + return { + sourceName, + aliasName: 'OpenLess_windows_x86_64-setup.exe', + }; + } + + const abi = ANDROID_ABIS[arch]; + if (!abi) { + throw new Error(`Invalid Android updater arch: ${arch}`); + } + const sourceName = chooseSingle( + (name) => name.endsWith(`_${abi}.apk`) && !name.startsWith('OpenLess_android_'), + `Android ${abi} APK`, + ); + return { + sourceName, + aliasName: `OpenLess_android_${abi}.apk`, + }; +} + +const { sourceName, aliasName } = sourceAndAlias(); +mkdirSync(outputDir, { recursive: true }); +const sourcePath = join(sourceDir, sourceName); +const aliasPath = join(outputDir, aliasName); +copyFileSync(sourcePath, aliasPath); + +const signaturePath = `${sourcePath}.sig`; +if (existsSync(signaturePath)) { + copyFileSync(signaturePath, `${aliasPath}.sig`); +} + +console.log(`Staged ${basename(sourcePath)} as ${basename(aliasPath)}`); diff --git a/openless-all/app/scripts/stage-release-download-aliases.test.mjs b/openless-all/app/scripts/stage-release-download-aliases.test.mjs new file mode 100644 index 000000000..233f203df --- /dev/null +++ b/openless-all/app/scripts/stage-release-download-aliases.test.mjs @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const scriptPath = fileURLToPath(new URL('./stage-release-download-aliases.mjs', import.meta.url)); + +function run(options) { + const result = spawnSync(process.execPath, [scriptPath], { + cwd: fileURLToPath(new URL('..', import.meta.url)), + env: { + ...process.env, + OPENLESS_DOWNLOAD_TARGET: options.target, + OPENLESS_DOWNLOAD_ARCH: options.arch, + OPENLESS_DOWNLOAD_SOURCE_DIR: options.sourceDir, + OPENLESS_DOWNLOAD_OUTPUT_DIR: options.outputDir, + }, + encoding: 'utf8', + }); + return result; +} + +const root = mkdtempSync(join(tmpdir(), 'openless-download-aliases-')); +try { + const darwinSource = join(root, 'darwin-source'); + const darwinOutput = join(root, 'darwin-output'); + mkdirSync(darwinSource); + writeFileSync(join(darwinSource, 'OpenLess_2.0.0_aarch64.dmg'), 'arm64 dmg'); + let result = run({ + target: 'darwin', + arch: 'aarch64', + sourceDir: darwinSource, + outputDir: darwinOutput, + }); + assert.equal(result.status, 0, result.stderr); + assert.equal(readFileSync(join(darwinOutput, 'OpenLess_macos_aarch64.dmg'), 'utf8'), 'arm64 dmg'); + + const darwinIntelSource = join(root, 'darwin-intel-source'); + const darwinIntelOutput = join(root, 'darwin-intel-output'); + mkdirSync(darwinIntelSource); + writeFileSync(join(darwinIntelSource, 'OpenLess_2.0.0_x64.dmg'), 'x64 dmg'); + result = run({ + target: 'darwin', + arch: 'x86_64', + sourceDir: darwinIntelSource, + outputDir: darwinIntelOutput, + }); + assert.equal(result.status, 0, result.stderr); + assert.equal( + readFileSync(join(darwinIntelOutput, 'OpenLess_macos_x86_64.dmg'), 'utf8'), + 'x64 dmg', + ); + + const windowsSource = join(root, 'windows-source'); + const windowsOutput = join(root, 'windows-output'); + mkdirSync(windowsSource); + writeFileSync(join(windowsSource, 'OpenLess_2.0.0_x64-setup.exe'), 'windows exe'); + writeFileSync(join(windowsSource, 'OpenLess_2.0.0_x64-setup.exe.sig'), 'signature'); + result = run({ + target: 'windows', + arch: 'x86_64', + sourceDir: windowsSource, + outputDir: windowsOutput, + }); + assert.equal(result.status, 0, result.stderr); + assert.equal( + readFileSync(join(windowsOutput, 'OpenLess_windows_x86_64-setup.exe'), 'utf8'), + 'windows exe', + ); + assert.equal( + readFileSync(join(windowsOutput, 'OpenLess_windows_x86_64-setup.exe.sig'), 'utf8'), + 'signature', + ); + + const androidSource = join(root, 'android-source'); + const androidOutput = join(root, 'android-output'); + mkdirSync(androidSource); + writeFileSync(join(androidSource, 'OpenLess_2.0.0_arm64-v8a.apk'), 'android apk'); + result = run({ + target: 'android', + arch: 'aarch64', + sourceDir: androidSource, + outputDir: androidOutput, + }); + assert.equal(result.status, 0, result.stderr); + assert.equal( + readFileSync(join(androidOutput, 'OpenLess_android_arm64-v8a.apk'), 'utf8'), + 'android apk', + ); + + const duplicateSource = join(root, 'duplicate-source'); + mkdirSync(duplicateSource); + writeFileSync(join(duplicateSource, 'OpenLess_1_aarch64.dmg'), 'one'); + writeFileSync(join(duplicateSource, 'OpenLess_2_aarch64.dmg'), 'two'); + result = run({ + target: 'darwin', + arch: 'aarch64', + sourceDir: duplicateSource, + outputDir: join(root, 'duplicate-output'), + }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Expected exactly one macOS aarch64 DMG/); +} finally { + rmSync(root, { recursive: true, force: true }); +} + +console.log('stage-release-download-aliases checks passed');