From 3f0190e581c81329228bd5f3865d7888d0b8f608 Mon Sep 17 00:00:00 2001 From: zknpr <96851588+zknpr@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:21:48 +0200 Subject: [PATCH 1/3] build(release): build, verify and draft releases from CI - release.yml: builds the macOS DMG, Linux .deb and Windows NSIS installer on their own runners from the viewer pin, with no cache. On macOS a fresh sync must reproduce the committed viewer-dist. Each job runs the tests and checks its package: strict codesign, a glibc floor covering every ELF in the .deb, and notices plus the native engine inside the installer. A v* tag also drafts the release with notices, MPL sources, SHA256SUMS and build-provenance attestations. Publishing stays manual. - scripts/release: version agreement across the three version files and the tag, MPL-2.0 crate collection verified against Cargo.lock, and an assembler that owns asset names and refuses a missing or unexpected asset. - Packaging: THIRD_PARTY_NOTICES.txt, LICENSE.md and third-party/ ship inside every package. Linux builds only .deb and declares libc6 >= 2.38, which the native engine needs. tauri.macos.conf.json signs ad-hoc with the hardened runtime, so local and CI bundles pass strict verification without a manual codesign step. --- .github/workflows/release.yml | 232 +++++++++++++++++++++++++++++++ package.json | 2 +- scripts/release/assemble.mjs | 155 +++++++++++++++++++++ scripts/release/mpl-sources.mjs | 126 +++++++++++++++++ scripts/release/release.test.mjs | 154 ++++++++++++++++++++ scripts/release/version.mjs | 62 +++++++++ src-tauri/src/lib.rs | 1 + src-tauri/tauri.conf.json | 5 +- src-tauri/tauri.linux.conf.json | 4 +- src-tauri/tauri.macos.conf.json | 7 + 10 files changed, 744 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 scripts/release/assemble.mjs create mode 100644 scripts/release/mpl-sources.mjs create mode 100644 scripts/release/release.test.mjs create mode 100644 scripts/release/version.mjs create mode 100644 src-tauri/tauri.macos.conf.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e07ec66 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,232 @@ +name: Release + +# Builds each package on its own OS from the committed viewer pin, and drafts a +# GitHub release for a v* tag. Publishing the draft stays a manual maintainer +# step. Nothing here restores a cache: every package comes from a clean runner, +# so a poisoned cache can never reach a release. Pull requests that touch the +# pipeline run the build jobs only. +on: + push: + tags: ['v*'] + workflow_dispatch: + pull_request: + paths: + - .github/workflows/release.yml + - scripts/release/** + - scripts/sync-viewer.mjs + - src-tauri/tauri*.conf.json + - viewer-dist/manifest.json + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +env: + VIEWER_REPOSITORY: https://github.com/zknpr/SQLite-Explorer + +jobs: + build: + name: Build ${{ matrix.platform }} + strategy: + fail-fast: false + matrix: + include: + - platform: macos + runner: macos-latest + bundles: app,dmg + # tjs needs glibc 2.38, so older build hosts would not widen support; + # 24.04 is also the release-tested Linux. + - platform: linux + runner: ubuntu-24.04 + bundles: deb + - platform: windows + runner: windows-latest + bundles: nsis + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + defaults: + run: + shell: bash + env: + PLATFORM: ${{ matrix.platform }} + BUNDLES: ${{ matrix.bundles }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + + - name: Check the version files agree (and match the tag) + env: + REF_TYPE: ${{ github.ref_type }} + REF_NAME: ${{ github.ref_name }} + run: | + if [ "$REF_TYPE" = tag ]; then + node scripts/release/version.mjs --tag "$REF_NAME" + else + node scripts/release/version.mjs + fi + + - name: Install Linux build dependencies + if: matrix.platform == 'linux' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev \ + librsvg2-dev libxdo-dev libssl-dev libsm-dev libice-dev patchelf file + + - name: Install Rust 1.93.0 + run: | + rustup toolchain install 1.93.0 --profile minimal + rustup default 1.93.0 + + - name: Install dependencies + run: npm ci + + - name: Fetch the viewer source pin + run: | + pin=$(node -p "require('./viewer-dist/manifest.json').ref") + git init -q ../SQLite-Explorer + git -C ../SQLite-Explorer fetch -q --depth 1 "$VIEWER_REPOSITORY" "$pin" + echo "VIEWER_PIN=$pin" >> "$GITHUB_ENV" + + - name: Sync this platform's viewer and native runtime from the pin + run: | + cp viewer-dist/manifest.json "$RUNNER_TEMP/committed-manifest.json" + node scripts/sync-viewer.mjs --source ../SQLite-Explorer --ref "$VIEWER_PIN" + node scripts/sync-viewer.mjs --verify + + # The committed viewer-dist/ targets Apple Silicon macOS, so here the + # fresh sync must reproduce it byte for byte. + - name: The committed viewer-dist matches the pin + if: matrix.platform == 'macos' + run: | + node -e ' + const fs = require("node:fs"); + const files = (p) => JSON.stringify(JSON.parse(fs.readFileSync(p, "utf8")).files); + if (files(process.argv[1]) !== files("viewer-dist/manifest.json")) { + console.error("committed viewer-dist differs from a fresh sync of the pin"); + process.exit(1); + }' "$RUNNER_TEMP/committed-manifest.json" + + - name: Tests + run: | + npm test + cargo test --locked --manifest-path src-tauri/Cargo.toml --lib + + - name: Build + run: npm run tauri build -- --bundles "$BUNDLES" + + - name: Check and stage the macOS package + if: matrix.platform == 'macos' + run: | + app="src-tauri/target/release/bundle/macos/SQLite Explorer.app" + codesign --verify --deep --strict --verbose=2 "$app" + test -f "$app/Contents/Resources/THIRD_PARTY_NOTICES.txt" + test -x "$app/Contents/Resources/native/tjs" + dmgs=(src-tauri/target/release/bundle/dmg/*.dmg) + [ "${#dmgs[@]}" -eq 1 ] || { echo "expected one DMG, found ${#dmgs[@]}"; exit 1; } + hdiutil verify "${dmgs[0]}" + mkdir dist + cp "${dmgs[0]}" "dist/$(node scripts/release/assemble.mjs asset-name macos)" + + - name: Check and stage the Linux package + if: matrix.platform == 'linux' + run: | + debs=(src-tauri/target/release/bundle/deb/*.deb) + [ "${#debs[@]}" -eq 1 ] || { echo "expected one .deb, found ${#debs[@]}"; exit 1; } + deb="${debs[0]}" + dpkg-deb --info "$deb" + # Listed once into a variable: `grep -q` on a live pipe exits early, + # and under pipefail the writer's SIGPIPE would fail a passing check. + contents=$(dpkg-deb --contents "$deb") + grep -q 'THIRD_PARTY_NOTICES.txt$' <<<"$contents" + grep -q '/native/tjs$' <<<"$contents" + # apt must refuse a system whose glibc is older than any binary in + # the package needs, so the declared floor has to cover them all. + declared=$(dpkg-deb -f "$deb" Depends | grep -oE 'libc6 \(>= [0-9.]+\)' | grep -oE '[0-9]+\.[0-9]+' || true) + [ -n "$declared" ] || { echo "the package declares no libc6 floor"; exit 1; } + root="$RUNNER_TEMP/deb-root" + dpkg-deb -x "$deb" "$root" + required=$(find "$root" -type f -exec file {} + | grep ELF | cut -d: -f1 | while read -r elf; do + strings -a "$elf" | grep -oE '^GLIBC_[0-9]+\.[0-9]+$' || true + done | sed 's/GLIBC_//' | sort -V | tail -1) + echo "declared libc6 >= $declared; binaries need GLIBC_$required" + [ "$(printf '%s\n%s\n' "$required" "$declared" | sort -V | tail -1)" = "$declared" ] \ + || { echo "binaries need glibc $required but the package declares $declared"; exit 1; } + mkdir dist + cp "$deb" "dist/$(node scripts/release/assemble.mjs asset-name linux)" + + - name: Check and stage the Windows package + if: matrix.platform == 'windows' + run: | + exes=(src-tauri/target/release/bundle/nsis/*-setup.exe) + [ "${#exes[@]}" -eq 1 ] || { echo "expected one installer, found ${#exes[@]}"; exit 1; } + listing=$(7z l "${exes[0]}") + grep -q 'THIRD_PARTY_NOTICES.txt' <<<"$listing" + grep -q 'tjs.exe' <<<"$listing" + mkdir dist + cp "${exes[0]}" "dist/$(node scripts/release/assemble.mjs asset-name windows)" + + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: package-${{ matrix.platform }} + path: dist/ + if-no-files-found: error + + release: + name: Draft the release + needs: build + if: github.event_name == 'push' && github.ref_type == 'tag' + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: write + id-token: write + attestations: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + + - name: Install Rust 1.93.0 + run: | + rustup toolchain install 1.93.0 --profile minimal + rustup default 1.93.0 + + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + pattern: package-* + merge-multiple: true + path: dist + + - name: Add notices and MPL sources, then write the manifest, checksums and notes + env: + REF_NAME: ${{ github.ref_name }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + cp THIRD_PARTY_NOTICES.txt dist/ + node scripts/release/mpl-sources.mjs --out dist + node scripts/release/assemble.mjs assemble --dist dist --tag "$REF_NAME" \ + --commit "$GITHUB_SHA" --run-url "$RUN_URL" --notes "$RUNNER_TEMP/notes.md" + + - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: dist/* + + - name: Create the draft release + env: + GH_TOKEN: ${{ github.token }} + REF_NAME: ${{ github.ref_name }} + run: | + gh release create "$REF_NAME" dist/* --repo "$GITHUB_REPOSITORY" --draft --verify-tag \ + --title "SQLite Explorer ${REF_NAME#v}" --notes-file "$RUNNER_TEMP/notes.md" diff --git a/package.json b/package.json index fa22463..2bb812d 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "tauri": "tauri", "sync-viewer": "node scripts/sync-viewer.mjs", - "test": "node --test scripts/*.test.mjs", + "test": "node --test scripts/*.test.mjs scripts/release/*.test.mjs", "test:gui": "node scripts/qa/port-regressions.mjs" }, "license": "MIT", diff --git a/scripts/release/assemble.mjs b/scripts/release/assemble.mjs new file mode 100644 index 0000000..90ad8f3 --- /dev/null +++ b/scripts/release/assemble.mjs @@ -0,0 +1,155 @@ +#!/usr/bin/env node +/** + * Release asset names and the release job's bookkeeping, in one place so the + * build jobs and the release job cannot drift apart. + * + * node scripts/release/assemble.mjs asset-name + * node scripts/release/assemble.mjs assemble --dist --tag + * --commit --run-url --notes + * + * `assemble` refuses a dist directory that is missing a platform or holds + * anything unexpected, so a partial or polluted release is never drafted. It + * writes release-manifest.json and SHA256SUMS into the dist directory and the + * draft release notes to --notes. + */ +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { readVersions, resolveVersion } from './version.mjs'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +export const REPOSITORY = 'zknpr/SQLite-Explorer-App'; +export const VIEWER_REPOSITORY = 'zknpr/SQLite-Explorer'; + +export const PLATFORMS = { + macos: { asset: (v) => `SQLite-Explorer-${v}-macos-aarch64.dmg`, label: 'macOS, Apple Silicon' }, + linux: { asset: (v) => `SQLite-Explorer-${v}-linux-amd64.deb`, label: 'Linux x86-64 (.deb)' }, + windows: { asset: (v) => `SQLite-Explorer-${v}-windows-x64-setup.exe`, label: 'Windows x86-64' } +}; +export const SHARED_ASSETS = ['THIRD_PARTY_NOTICES.txt', 'mpl-dependency-sources.tar.gz']; +const GENERATED = ['release-manifest.json', 'SHA256SUMS']; + +export function expectedAssets(version) { + return [...Object.values(PLATFORMS).map((p) => p.asset(version)), ...SHARED_ASSETS].sort(); +} + +/** Missing and unexpected files, or null when the directory is exactly the release. */ +export function checkDist(names, version) { + const present = names.filter((name) => !GENERATED.includes(name)).sort(); + const expected = expectedAssets(version); + const missing = expected.filter((name) => !present.includes(name)); + const unexpected = present.filter((name) => !expected.includes(name)); + return missing.length || unexpected.length ? { missing, unexpected } : null; +} + +export function sha256File(file) { + return createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +} + +export function buildManifest({ version, tag, commit, runUrl, viewerRef, assets }) { + return { + product: 'SQLite Explorer', + version, + tag, + repository: REPOSITORY, + commit, + buildRun: runUrl, + viewerSource: { repository: VIEWER_REPOSITORY, ref: viewerRef }, + signing: { + macos: 'ad-hoc signature; not Developer ID signed or notarized', + windows: 'unsigned', + linux: 'unsigned package; verify SHA256SUMS and the build attestation' + }, + assets + }; +} + +/** `sha256sum -c` format, sorted by name. */ +export function checksumsText(entries) { + return [...entries].sort((a, b) => a.name.localeCompare(b.name)) + .map(({ name, sha256 }) => `${sha256} ${name}`).join('\n') + '\n'; +} + +export function releaseNotes({ version, commit, runUrl, viewerRef }) { + const rows = Object.values(PLATFORMS).map((p) => `| ${p.label} | \`${p.asset(version)}\` |`); + return `SQLite Explorer ${version}: a desktop SQLite browser and editor built on the SQLite Explorer viewer. + +Built by [this workflow run](${runUrl}) from ${REPOSITORY}@${commit}, with the viewer from ${VIEWER_REPOSITORY}@${viewerRef}. + +| Platform | Download | +| --- | --- | +${rows.join('\n')} + +## Before you install + +- **macOS:** the app has an ad-hoc signature and is not notarized. The first launch is blocked; open **System Settings → Privacy & Security** and choose **Open Anyway** for SQLite Explorer. +- **Windows:** the installer is unsigned. SmartScreen shows "Windows protected your PC"; choose **More info → Run anyway**. If **Smart App Control** is on, Windows blocks unsigned programs and offers no override, so this release cannot be installed there. +- **Linux:** the package needs glibc 2.38 or newer (Ubuntu 24.04+, Debian 13+). Install it with \`sudo apt install ./${PLATFORMS.linux.asset(version)}\`. + +See the [installation notes](https://github.com/${REPOSITORY}#install) for uninstalling and for the platforms this release was tested on. + +## Verify the download + +\`\`\`sh +sha256sum --check --ignore-missing SHA256SUMS +gh attestation verify ${PLATFORMS.linux.asset(version)} --repo ${REPOSITORY} +\`\`\` + +## Licenses + +SQLite Explorer is MIT-licensed. Each package carries \`THIRD_PARTY_NOTICES.txt\`, which is also attached here. \`mpl-dependency-sources.tar.gz\` holds the unmodified sources of the MPL-2.0 dependencies. Signing status is described in the [code signing policy](https://github.com/${REPOSITORY}/blob/${commit}/docs/code-signing-policy.md). +`; +} + +function arg(argv, flag) { + const i = argv.indexOf(flag); + if (i < 0 || !argv[i + 1]) throw new Error(`missing ${flag}`); + return argv[i + 1]; +} + +function main(argv) { + const [command, ...rest] = argv; + if (command === 'asset-name') { + const platform = PLATFORMS[rest[0]]; + if (!platform) throw new Error(`asset-name needs one of ${Object.keys(PLATFORMS).join(', ')}`); + const version = resolveVersion(readVersions()); + if (version instanceof Error) throw version; + console.log(platform.asset(version)); + return; + } + if (command !== 'assemble') throw new Error('usage: assemble.mjs asset-name | assemble --dist ... --tag ... --commit ... --run-url ... --notes ...'); + const dist = path.resolve(arg(rest, '--dist')); + const tag = arg(rest, '--tag'); + const commit = arg(rest, '--commit'); + const runUrl = arg(rest, '--run-url'); + const notesPath = path.resolve(arg(rest, '--notes')); + if (!/^[0-9a-f]{40}$/.test(commit)) throw new Error(`commit ${commit} is not a full SHA`); + const version = resolveVersion(readVersions(), tag); + if (version instanceof Error) throw version; + const problems = checkDist(fs.readdirSync(dist), version); + if (problems) { + throw new Error(`dist is not the release: missing [${problems.missing.join(', ')}], unexpected [${problems.unexpected.join(', ')}]`); + } + const viewerRef = JSON.parse(fs.readFileSync(path.join(repoRoot, 'viewer-dist', 'manifest.json'), 'utf8')).ref; + const assets = expectedAssets(version).map((name) => { + const file = path.join(dist, name); + return { name, bytes: fs.statSync(file).size, sha256: sha256File(file) }; + }); + const manifestPath = path.join(dist, 'release-manifest.json'); + fs.writeFileSync(manifestPath, JSON.stringify(buildManifest({ version, tag, commit, runUrl, viewerRef, assets }), null, 2) + '\n'); + const sums = [...assets, { name: 'release-manifest.json', sha256: sha256File(manifestPath) }]; + fs.writeFileSync(path.join(dist, 'SHA256SUMS'), checksumsText(sums)); + fs.writeFileSync(notesPath, releaseNotes({ version, commit, runUrl, viewerRef })); + for (const { name, sha256 } of sums) console.log(`assemble: ${sha256} ${name}`); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + try { + main(process.argv.slice(2)); + } catch (error) { + console.error(`assemble: ${error.message}`); + process.exit(1); + } +} diff --git a/scripts/release/mpl-sources.mjs b/scripts/release/mpl-sources.mjs new file mode 100644 index 0000000..7f5497e --- /dev/null +++ b/scripts/release/mpl-sources.mjs @@ -0,0 +1,126 @@ +#!/usr/bin/env node +/** + * Collect the unmodified sources of every MPL-2.0 crate in the locked Cargo + * graph, for a binary release to ship beside its installers. MPL 2.0 §3.2 + * requires a binary distribution to make the covered source available. + * + * node scripts/release/mpl-sources.mjs --out + * + * Writes /mpl-dependency-sources.tar.gz holding each `.crate` exactly as + * crates.io serves it plus SOURCES.txt. Every archive must match the checksum + * Cargo.lock pins, so the shipped source is provably the source that was + * built. The whole lock graph is covered (all targets), which is a superset + * of what any one platform links. + */ +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const CRATES_IO = 'registry+https://github.com/rust-lang/crates.io-index'; + +/** + * Whether a crate's SPDX expression obliges us under MPL. A top-level OR lets + * the distributor pick any alternative, so `MIT OR MPL-2.0` does not. Anything + * this simple reader cannot split safely (parentheses) is treated as + * obligating whenever it mentions MPL: shipping extra source is harmless, + * omitting required source is not. + */ +export function requiresMpl(expression) { + if (typeof expression !== 'string' || !/MPL/.test(expression)) return false; + if (/[()]/.test(expression)) return true; + return expression.split(/\s+OR\s+|\//).every((alternative) => /MPL/.test(alternative)); +} + +/** name@version → checksum, from Cargo.lock's [[package]] tables. */ +export function parseLockChecksums(lockText) { + const checksums = new Map(); + // A Windows checkout may carry CRLF; the line anchors below expect LF. + for (const block of lockText.replace(/\r\n/g, '\n').split(/^\[\[package\]\]$/m).slice(1)) { + const field = (key) => block.match(new RegExp(`^${key} = "([^"]*)"$`, 'm'))?.[1]; + const name = field('name'); + const version = field('version'); + if (name && version) checksums.set(`${name}@${version}`, { source: field('source'), checksum: field('checksum') }); + } + return checksums; +} + +/** The MPL-obligating packages of `cargo metadata`, each with its pinned checksum. */ +export function selectMplPackages(metadata, checksums) { + const selected = []; + for (const pkg of metadata.packages) { + if (!requiresMpl(pkg.license)) continue; + const lock = checksums.get(`${pkg.name}@${pkg.version}`); + if (!lock) throw new Error(`${pkg.name} ${pkg.version} is not in Cargo.lock`); + if (lock.source !== CRATES_IO || !/^[0-9a-f]{64}$/.test(lock.checksum ?? '')) { + throw new Error(`${pkg.name} ${pkg.version} is not a checksummed crates.io package; collect its source by hand`); + } + selected.push({ name: pkg.name, version: pkg.version, license: pkg.license, checksum: lock.checksum }); + } + return selected.sort((a, b) => `${a.name}@${a.version}`.localeCompare(`${b.name}@${b.version}`)); +} + +export const crateUrl = ({ name, version }) => `https://static.crates.io/crates/${name}/${name}-${version}.crate`; + +/** Download each crate and refuse any byte that differs from Cargo.lock. */ +export async function fetchVerified(packages, fetchBytes) { + const files = []; + for (const pkg of packages) { + const bytes = await fetchBytes(crateUrl(pkg)); + const actual = createHash('sha256').update(bytes).digest('hex'); + if (actual !== pkg.checksum) { + throw new Error(`${pkg.name} ${pkg.version}: downloaded sha256 ${actual} != Cargo.lock ${pkg.checksum}`); + } + files.push({ ...pkg, file: `${pkg.name}-${pkg.version}.crate`, bytes }); + } + return files; +} + +export function sourcesListing(files) { + return [ + 'Unmodified sources of the MPL-2.0 Cargo dependencies in this release.', + 'Each archive is byte-identical to crates.io and matches the checksum in Cargo.lock.', + '', + ...files.map((f) => `${f.file} ${f.license} sha256=${f.checksum} ${crateUrl(f)}`), + '' + ].join('\n'); +} + +async function main() { + const argv = process.argv.slice(2); + const outIndex = argv.indexOf('--out'); + if (outIndex < 0 || !argv[outIndex + 1]) throw new Error('usage: mpl-sources.mjs --out '); + const outDir = path.resolve(argv[outIndex + 1]); + const manifest = path.join(repoRoot, 'src-tauri', 'Cargo.toml'); + const metadata = JSON.parse(execFileSync('cargo', ['metadata', '--locked', '--format-version', '1', '--manifest-path', manifest], { + encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 + })); + const checksums = parseLockChecksums(fs.readFileSync(path.join(repoRoot, 'src-tauri', 'Cargo.lock'), 'utf8')); + const packages = selectMplPackages(metadata, checksums); + if (packages.length === 0) throw new Error('no MPL-2.0 crates found; check the selection before shipping without sources'); + const files = await fetchVerified(packages, async (url) => { + const response = await fetch(url, { signal: AbortSignal.timeout(60_000) }); + if (!response.ok) throw new Error(`${url}: HTTP ${response.status}`); + return Buffer.from(await response.arrayBuffer()); + }); + const staging = fs.mkdtempSync(path.join(outDir, '.mpl-')); + try { + const dir = path.join(staging, 'mpl-dependency-sources'); + fs.mkdirSync(dir); + for (const f of files) fs.writeFileSync(path.join(dir, f.file), f.bytes); + fs.writeFileSync(path.join(dir, 'SOURCES.txt'), sourcesListing(files)); + execFileSync('tar', ['-czf', path.join(outDir, 'mpl-dependency-sources.tar.gz'), '-C', staging, 'mpl-dependency-sources']); + } finally { + fs.rmSync(staging, { recursive: true, force: true }); + } + for (const f of files) console.log(`mpl-sources: ${f.file} verified`); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(`mpl-sources: ${error.message}`); + process.exit(1); + }); +} diff --git a/scripts/release/release.test.mjs b/scripts/release/release.test.mjs new file mode 100644 index 0000000..5800a7a --- /dev/null +++ b/scripts/release/release.test.mjs @@ -0,0 +1,154 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { readVersions, resolveVersion } from './version.mjs'; +import { crateUrl, fetchVerified, parseLockChecksums, requiresMpl, selectMplPackages } from './mpl-sources.mjs'; + +const CRATES_IO = 'registry+https://github.com/rust-lang/crates.io-index'; +const sha = (text) => createHash('sha256').update(text).digest('hex'); + +test('the checked-in version files agree', () => { + const version = resolveVersion(readVersions()); + assert.equal(typeof version, 'string', String(version)); +}); + +test('version drift, a mismatched tag and a non-semver version are refused', () => { + const agreed = { a: '0.3.0', b: '0.3.0', c: '0.3.0' }; + assert.equal(resolveVersion(agreed), '0.3.0'); + assert.equal(resolveVersion(agreed, 'v0.3.0'), '0.3.0'); + assert.match(resolveVersion(agreed, 'v0.3.1').message, /does not match/); + assert.match(resolveVersion(agreed, '0.3.0').message, /expected v0\.3\.0/); + assert.match(resolveVersion({ ...agreed, b: '0.2.0' }).message, /b=0\.2\.0/); + assert.match(resolveVersion({ ...agreed, c: undefined }).message, /no version in c/); + assert.match(resolveVersion({ a: '1.0' }).message, /not semver/); +}); + +test('only expressions with no non-MPL alternative oblige MPL source', () => { + assert.equal(requiresMpl('MPL-2.0'), true); + assert.equal(requiresMpl('MPL-2.0 AND MIT'), true); + assert.equal(requiresMpl('MIT OR MPL-2.0'), false); + assert.equal(requiresMpl('MIT/MPL-2.0'), false); + assert.equal(requiresMpl('(MIT OR Apache-2.0) AND MPL-2.0'), true, 'unparsed shapes fail closed'); + assert.equal(requiresMpl('MIT OR Apache-2.0'), false); + assert.equal(requiresMpl(null), false); +}); + +test('MPL packages are selected with their Cargo.lock checksums, and unpinned ones are refused', () => { + const lock = [ + '# This file is automatically @generated by Cargo.', + 'version = 4', + '', + '[[package]]', + 'name = "selectors"', + 'version = "0.36.1"', + `source = "${CRATES_IO}"`, + `checksum = "${'a'.repeat(64)}"`, + '', + '[[package]]', + 'name = "serde"', + 'version = "1.0.0"', + `source = "${CRATES_IO}"`, + `checksum = "${'b'.repeat(64)}"`, + '', + '[[package]]', + 'name = "local-mpl"', + 'version = "0.1.0"', + '' + ].join('\n'); + const checksums = parseLockChecksums(lock); + assert.deepEqual(parseLockChecksums(lock.replace(/\n/g, '\r\n')), checksums, 'a CRLF checkout parses the same'); + assert.equal(checksums.get('selectors@0.36.1').checksum, 'a'.repeat(64)); + const metadata = (extra = []) => ({ + packages: [ + { name: 'serde', version: '1.0.0', license: 'MIT OR Apache-2.0' }, + { name: 'selectors', version: '0.36.1', license: 'MPL-2.0' }, + ...extra + ] + }); + assert.deepEqual(selectMplPackages(metadata(), checksums), [ + { name: 'selectors', version: '0.36.1', license: 'MPL-2.0', checksum: 'a'.repeat(64) } + ]); + assert.throws(() => selectMplPackages(metadata([{ name: 'local-mpl', version: '0.1.0', license: 'MPL-2.0' }]), checksums), + /not a checksummed crates\.io package/); + assert.throws(() => selectMplPackages(metadata([{ name: 'ghost', version: '9.9.9', license: 'MPL-2.0' }]), checksums), + /not in Cargo\.lock/); +}); + +test('a downloaded crate that differs from Cargo.lock is refused', async () => { + const good = Buffer.from('the real crate'); + const pkg = { name: 'selectors', version: '0.36.1', license: 'MPL-2.0', checksum: sha(good) }; + const urls = []; + const files = await fetchVerified([pkg], async (url) => { urls.push(url); return good; }); + assert.deepEqual(urls, [crateUrl(pkg)]); + assert.equal(files[0].file, 'selectors-0.36.1.crate'); + await assert.rejects(fetchVerified([pkg], async () => Buffer.from('tampered')), /downloaded sha256 .* != Cargo\.lock/); +}); + +test('the MPL crates are pinned in Cargo.lock and listed in the notice inventory', () => { + const lockText = fs.readFileSync(new URL('../../src-tauri/Cargo.lock', import.meta.url), 'utf8'); + const checksums = parseLockChecksums(lockText); + const inventory = JSON.parse(fs.readFileSync(new URL('../../third-party/inventory.json', import.meta.url), 'utf8')); + const listed = JSON.stringify(inventory); + for (const name of ['cssparser', 'cssparser-macros', 'dtoa-short', 'option-ext', 'selectors']) { + const entry = [...checksums.keys()].find((key) => key.startsWith(`${name}@`)); + assert.ok(entry, `${name} is in Cargo.lock`); + assert.ok(listed.includes(name), `${name} is in the notice inventory`); + } +}); + +test('a release directory must hold exactly the three packages and the shared assets', async () => { + const { checkDist, expectedAssets } = await import('./assemble.mjs'); + const full = expectedAssets('0.3.0'); + assert.deepEqual(full, [ + 'SQLite-Explorer-0.3.0-linux-amd64.deb', + 'SQLite-Explorer-0.3.0-macos-aarch64.dmg', + 'SQLite-Explorer-0.3.0-windows-x64-setup.exe', + 'THIRD_PARTY_NOTICES.txt', + 'mpl-dependency-sources.tar.gz' + ]); + assert.equal(checkDist(full, '0.3.0'), null); + assert.equal(checkDist([...full, 'SHA256SUMS', 'release-manifest.json'], '0.3.0'), null, 'regenerated files are tolerated'); + assert.deepEqual(checkDist(full.filter((n) => !n.endsWith('.deb')), '0.3.0'), + { missing: ['SQLite-Explorer-0.3.0-linux-amd64.deb'], unexpected: [] }); + assert.deepEqual(checkDist([...full, 'SQLite Explorer_0.3.0_amd64.AppImage'], '0.3.0'), + { missing: [], unexpected: ['SQLite Explorer_0.3.0_amd64.AppImage'] }); + assert.ok(checkDist(full, '0.3.1'), 'assets of another version are not this release'); +}); + +test('SHA256SUMS is sha256sum-compatible and sorted', async () => { + const { checksumsText } = await import('./assemble.mjs'); + assert.equal(checksumsText([{ name: 'b', sha256: '2'.repeat(64) }, { name: 'a', sha256: '1'.repeat(64) }]), + `${'1'.repeat(64)} a\n${'2'.repeat(64)} b\n`); +}); + +test('assemble writes a manifest and checksums that verify, and refuses a polluted directory', async () => { + const { execFileSync } = await import('node:child_process'); + const os = await import('node:os'); + const path = await import('node:path'); + const { expectedAssets } = await import('./assemble.mjs'); + const version = resolveVersion(readVersions()); + const dist = fs.mkdtempSync(path.join(os.tmpdir(), 'assemble-test-')); + try { + for (const name of expectedAssets(version)) fs.writeFileSync(path.join(dist, name), `bytes of ${name}`); + const run = (tag) => execFileSync(process.execPath, [fileURLToPath(new URL('./assemble.mjs', import.meta.url)), 'assemble', + '--dist', dist, '--tag', tag, '--commit', 'c'.repeat(40), '--run-url', 'https://example.invalid/run', + '--notes', path.join(dist, '..', `${path.basename(dist)}-notes.md`)], { encoding: 'utf8', stdio: 'pipe' }); + run(`v${version}`); + const manifest = JSON.parse(fs.readFileSync(path.join(dist, 'release-manifest.json'), 'utf8')); + assert.equal(manifest.version, version); + assert.equal(manifest.assets.length, expectedAssets(version).length); + for (const line of fs.readFileSync(path.join(dist, 'SHA256SUMS'), 'utf8').trim().split('\n')) { + const [hash, name] = line.split(' '); + assert.equal(createHash('sha256').update(fs.readFileSync(path.join(dist, name))).digest('hex'), hash, name); + } + assert.throws(() => run('v0.0.0-not-this'), /does not match/); + fs.writeFileSync(path.join(dist, 'stray.zip'), 'x'); + assert.throws(() => run(`v${version}`), /unexpected \[stray\.zip\]/); + } finally { + fs.rmSync(dist, { recursive: true, force: true }); + fs.rmSync(`${dist}-notes.md`, { force: true }); + } +}); diff --git a/scripts/release/version.mjs b/scripts/release/version.mjs new file mode 100644 index 0000000..62d09a4 --- /dev/null +++ b/scripts/release/version.mjs @@ -0,0 +1,62 @@ +#!/usr/bin/env node +/** + * The app version lives in three files that nothing keeps in step: + * package.json, src-tauri/Cargo.toml and src-tauri/tauri.conf.json (the one + * the installers and About dialog report). A release must agree on all three, + * and a tag build must match them too. + * + * node scripts/release/version.mjs print the version, fail on drift + * node scripts/release/version.mjs --tag vX also require the tag to match + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); + +export function readVersions(root = repoRoot) { + const read = (file) => fs.readFileSync(path.join(root, file), 'utf8'); + const cargo = read('src-tauri/Cargo.toml').match(/^\[package\][^[]*?^version\s*=\s*"([^"]+)"/m); + return { + 'package.json': JSON.parse(read('package.json')).version, + 'src-tauri/Cargo.toml': cargo ? cargo[1] : undefined, + 'src-tauri/tauri.conf.json': JSON.parse(read('src-tauri/tauri.conf.json')).version + }; +} + +/** The single agreed version, or an Error naming every disagreement. */ +export function resolveVersion(versions, tag) { + const values = Object.entries(versions); + const missing = values.filter(([, value]) => typeof value !== 'string' || value === ''); + if (missing.length > 0) { + return new Error(`no version in ${missing.map(([file]) => file).join(', ')}`); + } + const distinct = new Set(values.map(([, value]) => value)); + if (distinct.size !== 1) { + return new Error(`versions disagree: ${values.map(([file, value]) => `${file}=${value}`).join(', ')}`); + } + const [version] = distinct; + if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) { + return new Error(`version ${version} is not semver`); + } + if (tag !== undefined && tag !== `v${version}`) { + return new Error(`tag ${tag} does not match version ${version} (expected v${version})`); + } + return version; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const argv = process.argv.slice(2); + const tagIndex = argv.indexOf('--tag'); + const tag = tagIndex >= 0 ? argv[tagIndex + 1] : undefined; + if (tagIndex >= 0 && !tag) { + console.error('version: --tag needs a value'); + process.exit(1); + } + const result = resolveVersion(readVersions(), tag); + if (result instanceof Error) { + console.error(`version: ${result.message}`); + process.exit(1); + } + console.log(result); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 410843f..9bc75fa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4302,6 +4302,7 @@ mod tests { ("tauri.conf.json", include_str!("../tauri.conf.json")), ("tauri.windows.conf.json", include_str!("../tauri.windows.conf.json")), ("tauri.linux.conf.json", include_str!("../tauri.linux.conf.json")), + ("tauri.macos.conf.json", include_str!("../tauri.macos.conf.json")), ("tauri.qa.conf.json", include_str!("../tauri.qa.conf.json")), ] { assert!(!text.contains("useHttpsScheme"), "{name} sets useHttpsScheme"); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 5cbd095..4e925e9 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -39,7 +39,10 @@ "active": true, "targets": ["dmg", "app"], "resources": { - "../viewer-dist/native/": "native/" + "../viewer-dist/native/": "native/", + "../LICENSE.md": "LICENSE.md", + "../THIRD_PARTY_NOTICES.txt": "THIRD_PARTY_NOTICES.txt", + "../third-party/": "third-party/" }, "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"], "fileAssociations": [ diff --git a/src-tauri/tauri.linux.conf.json b/src-tauri/tauri.linux.conf.json index bc4a2d9..460e2b5 100644 --- a/src-tauri/tauri.linux.conf.json +++ b/src-tauri/tauri.linux.conf.json @@ -1,9 +1,9 @@ { "bundle": { - "targets": ["deb", "appimage"], + "targets": ["deb"], "linux": { "deb": { - "depends": ["libsm6", "libice6"], + "depends": ["libsm6", "libice6", "libc6 (>= 2.38)"], "desktopTemplate": "linux/sqlite-explorer.desktop" } } diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json new file mode 100644 index 0000000..7be8109 --- /dev/null +++ b/src-tauri/tauri.macos.conf.json @@ -0,0 +1,7 @@ +{ + "bundle": { + "macOS": { + "signingIdentity": "-" + } + } +} From 5a4a09c15c288f329a71dc33c1bd2cd8a9165fa7 Mon Sep 17 00:00:00 2001 From: zknpr <96851588+zknpr@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:21:48 +0200 Subject: [PATCH 2/3] docs: install instructions, code signing policy and distribution gates - README: an Install section with checksum and attestation verification, the macOS Open Anyway step, SmartScreen and Smart App Control on Windows, the Linux glibc floor, and uninstall steps for each platform. - docs/code-signing-policy.md: the signing status of each package, what SignPath signing will and will not cover (the upstream tjs.exe stays unsigned), roles, and the privacy statement. - release-readiness: the pipeline, notices, AppImage and glibc gates, plus the release procedure. --- README.md | 74 ++++++++++++++++++++++++------------- docs/code-signing-policy.md | 61 ++++++++++++++++++++++++++++++ docs/release-readiness.md | 26 +++++++++---- third-party/README.md | 6 ++- 4 files changed, 133 insertions(+), 34 deletions(-) create mode 100644 docs/code-signing-policy.md diff --git a/README.md b/README.md index 86ea41e..25d5eef 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,10 @@ Linux and Windows. It shares its viewer with the and runs in a Tauri desktop shell. The current desktop version is **0.2.0**, integrating the extension's **1.8.1** -release and desktop-specific fixes. This repository does not publish binary -releases yet; build from a checkout as described below. See -[release readiness](docs/release-readiness.md) for the remaining distribution -gates. +release and desktop-specific fixes. Packages for macOS, Linux and Windows are +attached to each [release](https://github.com/zknpr/SQLite-Explorer-App/releases); +see [Install](#install). See [release readiness](docs/release-readiness.md) for +the remaining distribution gates. ## Working with databases @@ -29,7 +29,7 @@ refuse; retain pending work with a separate export before reloading. | Platform tested | Package | Coverage | | --- | --- | --- | | macOS, Apple Silicon | `.app` | Native/WASM workflows; remaining OS checks are recorded in [macOS QA](docs/macos-release-qa.md) | -| Ubuntu 24.04, x86-64, Xfce/X11 | Debian, AppImage | Installed-app checks; shutdown/restart checks used the Debian package | +| Ubuntu 24.04, x86-64, Xfce/X11 | Debian package | Installed-app checks, shutdown/restart | | Windows 11, x86-64 | NSIS installer | Installed-app checks, shutdown/restart and post-boot saves | See [Linux and Windows QA](docs/linux-windows-release-qa.md) for tested package @@ -37,9 +37,40 @@ hashes and limits. Other Linux desktops and Wayland remain unverified. Linux session-end cancellation requires a working inherited XSMP session connection. Forced termination or power loss cannot preserve unsaved edits. -The Windows build is unsigned. The local macOS build has an ad-hoc signature, -without Developer ID signing or notarization. Downloaded-package trust prompts -have not been signed off. +## Install + +Download the package for your system from the +[releases page](https://github.com/zknpr/SQLite-Explorer-App/releases). The +packages are not signed with a publisher identity yet; see the +[code signing policy](docs/code-signing-policy.md). Verify a download before +installing it: + +```sh +sha256sum --check --ignore-missing SHA256SUMS +gh attestation verify --repo zknpr/SQLite-Explorer-App +``` + +**macOS (Apple Silicon).** Open the `.dmg` and drag SQLite Explorer to +Applications. The app has an ad-hoc signature and is not notarized, so macOS +blocks the first launch. Open **System Settings → Privacy & Security** and +choose **Open Anyway** for SQLite Explorer. To uninstall, move the app to the +Trash; settings live in `~/Library/Application Support/xyz.zknpr.sqlite-explorer`. + +**Windows (x86-64).** Run the `-setup.exe` installer. It is unsigned, so +SmartScreen shows "Windows protected your PC"; choose **More info → Run +anyway**. If **Smart App Control** is on, Windows blocks unsigned programs and +offers no override, so the installer cannot run there. To uninstall, use +**Settings → Apps → Installed apps → SQLite Explorer → Uninstall**. + +**Linux (x86-64, Debian/Ubuntu).** The package needs glibc 2.38 or newer +(Ubuntu 24.04+, Debian 13+): + +```sh +sudo apt install ./SQLite-Explorer--linux-amd64.deb +``` + +To uninstall, run `sudo apt remove sqlite-explorer`. AppImage packages are not +provided. ## Build from a checkout @@ -74,29 +105,22 @@ npm test cargo test --locked --manifest-path src-tauri/Cargo.toml --lib ``` -On Linux and Windows, create the platform's packages: +Create the platform's packages: ```sh npm run tauri build ``` -On macOS without a configured signing identity, build and seal a local `.app`: - -```sh -npm run tauri build -- --bundles app -codesign --force --sign - 'src-tauri/target/release/bundle/macos/SQLite Explorer.app' -codesign --verify --deep --strict 'src-tauri/target/release/bundle/macos/SQLite Explorer.app' -``` - -The explicit ad-hoc signing step seals the app's resources. The linker's default -signature alone fails strict bundle verification. This local signature does not -provide Developer ID signing or notarization. Distribution packages need their -own [signing and packaging setup](https://v2.tauri.app/distribute/). +Packages appear under `src-tauri/target/release/bundle/`: `.app` and DMG on +macOS, Debian on Linux and NSIS on Windows. `src-tauri/tauri.macos.conf.json` +signs the macOS app ad-hoc with the hardened runtime, which seals its resources +so `codesign --verify --deep --strict` passes; it is not Developer ID signing or +notarization. Selecting another native target in the sync script stages its +resources; it does not cross-compile the app. -Packages appear under `src-tauri/target/release/bundle/`: `.app` for the local -macOS command above, Debian/AppImage on Linux and NSIS on Windows. A configured -macOS distribution build can also produce a DMG. Selecting another native target -in the sync script stages its resources; it does not cross-compile the app. +Release packages are built by the [release workflow](.github/workflows/release.yml), +which runs these steps on each OS from the pinned viewer source and drafts a +release for a `v*` tag. For development, run `npm run tauri dev`. Packaged-app checks are still required for native dialogs, OS integration and the custom asset protocol. diff --git a/docs/code-signing-policy.md b/docs/code-signing-policy.md new file mode 100644 index 0000000..2c74fbb --- /dev/null +++ b/docs/code-signing-policy.md @@ -0,0 +1,61 @@ +# Code signing policy + +Every release package is built by the [release workflow](../.github/workflows/release.yml) +on GitHub-hosted runners from a tagged commit of this repository. The viewer and +native runtime come from the exact +[SQLite Explorer extension](https://github.com/zknpr/SQLite-Explorer) commit +recorded in `viewer-dist/manifest.json`. Releases are drafted automatically and +published by a maintainer. + +## Current signing status + +| Package | Signature | +| --- | --- | +| macOS `.dmg` | Ad-hoc signature with the hardened runtime. Not signed with an Apple Developer ID and not notarized. | +| Windows installer | Unsigned. Signing through the SignPath Foundation is planned. | +| Linux `.deb` | Unsigned, as is usual for packages installed directly rather than from an apt repository. | + +Unsigned and ad-hoc packages are still verifiable. Each release lists +`SHA256SUMS`, and every asset carries a GitHub build-provenance attestation that +ties it to the workflow run and commit that produced it: + +```sh +sha256sum --check --ignore-missing SHA256SUMS +gh attestation verify --repo zknpr/SQLite-Explorer-App +``` + +### Windows: what signing will and will not cover + +Once signing is approved, the application executable and the installer will be +signed. The bundled native database engine (`tjs.exe`, from the +[txiki.js](https://github.com/zknpr/txiki.js) runtime) and its query-plan +library are built by another project and stay unsigned inside the signed +installer, as the SignPath Foundation's terms allow for upstream binaries. + +Windows **Smart App Control** blocks unsigned programs and offers no per-app +override. While it is on, the current unsigned installer cannot run. After +signing, the app and installer may run, but Smart App Control may still block +the unsigned native engine. The app then opens databases with its built-in WASM +engine. That engine holds the whole database in memory, so very large databases +may not open. + +## Team and roles + +| Role | Members | +| --- | --- | +| Authors (commit without further review) | [@zknpr](https://github.com/zknpr) | +| Reviewers (review changes from anyone else) | [@zknpr](https://github.com/zknpr) | +| Approvers (approve each signing request) | [@zknpr](https://github.com/zknpr) | + +Changes from contributors outside this list are merged only after review. +Build scripts and workflow files count as source and get the same review. + +## Privacy + +This program will not transfer any information to other networked systems +unless specifically requested by the user or the person installing or operating +it. It has no telemetry, update checks or crash reporting. The bundled +components (the viewer, the sql.js WASM engine and the txiki.js native engine) +make no network connections in this application. One exception is outside the +app itself: on a Windows system without the Microsoft Edge WebView2 Runtime, +the installer downloads that runtime from Microsoft. Windows 11 includes it. diff --git a/docs/release-readiness.md b/docs/release-readiness.md index 16c0dea..83af398 100644 --- a/docs/release-readiness.md +++ b/docs/release-readiness.md @@ -1,9 +1,9 @@ # Release readiness Status checked October 8, 2026, for desktop 0.2.0 and the extension 1.8.1 -integration. The source is public under MIT. This repository does not publish -binary releases yet; the gates below separate source publication from binary -distribution. +integration. The source is public under MIT. Release packages are built by the +[release workflow](../.github/workflows/release.yml); the gates below separate +source publication from binary distribution. ## Source and build provenance @@ -38,13 +38,15 @@ downloaded package or close the macOS interaction gates. | Security policy | [SECURITY.md](../SECURITY.md), private vulnerability reporting | | Continuous integration | Apple Silicon macOS: manifest verification, script tests, shell unit tests, clippy. No Linux or Windows lane | | Navigation pin and CSP `base-uri`/`form-action`/`frame-ancestors` | Live-verified in an Apple Silicon macOS QA build against a pre-change control build. Linux and Windows runtime behaviour unverified | -| Third-party notices | Notices and inventory are in this repository; a binary release must ship them with the covered MPL Cargo sources | -| AppImage distribution | Withheld pending notice/source inventory for its additional bundled Linux system libraries | +| Release pipeline | Builds macOS, Linux and Windows packages on their own OS from the viewer pin, with no cache, and drafts a release for a `v*` tag. Each asset gets SHA256SUMS and a build-provenance attestation. Publishing is manual | +| Third-party notices | Bundled inside every package; the release also attaches them with the MPL Cargo sources, which are checksum-verified against Cargo.lock | +| AppImage distribution | Not distributed: its extra bundled Linux system libraries have no notice/source inventory. Linux ships as `.deb` | +| Linux glibc floor | The native engine needs glibc 2.38; the `.deb` declares `libc6 (>= 2.38)` and the workflow fails if any packaged binary needs more | | Final macOS installed-package checks | Open cases and older tested source are recorded in [macOS QA](macos-release-qa.md); rerun against the final candidate | | Linux/Windows installed-package checks | Passed for the packages and environments in [Linux/Windows QA](linux-windows-release-qa.md), including real shutdown/restart. Those packages predate the navigation pin and CSP change; rerun the affected checks on new packages | | Other Linux desktops, Wayland and physical keyboards | Unverified; limit platform claims to observed coverage | -| Windows distribution | Unsigned; no signing setup configured; clean-machine downloaded-package/SmartScreen check unverified | -| macOS distribution | Local ad-hoc signature only; Developer ID signing/notarization and downloaded-package checks unverified | +| Windows distribution | Unsigned. SignPath Foundation signing requires a prior public release, so the first release ships unsigned, then apply. Signing will cover the app and installer, not the upstream `tjs.exe`. Smart App Control blocks the unsigned installer; whether it blocks `tjs.exe` under a signed app (the app would fall back to WASM) is untested. SmartScreen on a clean machine unverified | +| macOS distribution | Ad-hoc signature with the hardened runtime, built by the workflow; no Developer ID signing or notarization by choice. Gatekeeper's Open Anyway flow on a downloaded package unverified | The source MIT license does not replace third-party licenses. The [notice inventory](../third-party/inventory.json) records identities, source @@ -52,6 +54,16 @@ URLs and notice hashes. It includes build and platform-specific dependencies as well as linked code. Codicons font assets are CC BY 4.0; their supporting code is MIT. Recheck the inventory when dependencies or packaging change. +## Releasing + +1. Bump the version in `package.json`, `src-tauri/Cargo.toml` and + `src-tauri/tauri.conf.json`; `scripts/release/version.mjs` refuses drift. +2. Merge the bump through a reviewed pull request. +3. A maintainer pushes the `v` tag on that commit. The workflow builds, + verifies and attaches every asset to a draft release. +4. Run the candidate checks below on the drafted packages, then publish the + draft. + ## Candidate checks Before publishing a binary release, retain the app revision, viewer source pin, diff --git a/third-party/README.md b/third-party/README.md index 46c72d2..41a5e81 100644 --- a/third-party/README.md +++ b/third-party/README.md @@ -23,8 +23,10 @@ feature selections in the pinned txiki.js artifact workflow, including libuv, QuickJS, mimalloc, mbedTLS crypto, miniz, SQLite and embedded JavaScript. A binary release must include the unmodified MPL-covered Cargo source archives, -with SHA256 values matching Cargo.lock. AppImage redistribution is deferred -until the extra bundled Linux system libraries are inventoried separately. +with SHA256 values matching Cargo.lock; `scripts/release/mpl-sources.mjs` +collects and verifies them. Every package also bundles these notice files. No +AppImage is distributed: its extra bundled Linux system libraries have not been +inventoried. Regenerate and review the inventory when the source pins, dependency locks or packaging change. Preserve upstream copyright and attribution notices. From d49510187c86d7f1acafc922e53961dcadb95b9e Mon Sep 17 00:00:00 2001 From: zknpr <96851588+zknpr@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:39:13 +0200 Subject: [PATCH 3/3] fix(release): LF checkouts, the real glibc floor, and deb metadata Found by the first run of the release workflow: - Windows: two source-scanning shell tests failed because the runner's checkout converted lib.rs to CRLF, so "#[cfg(test)]\nmod tests" was never found. .gitattributes now checks out every text file as LF on every platform, which also keeps the sha256-manifested viewer-dist byte-exact. The index holds no CRLF, so no content changes. - Linux: the glibc-floor check caught the app binary built on Ubuntu 24.04 needing GLIBC_2.39, above the declared 2.38. The .deb now declares libc6 (>= 2.39); Ubuntu 24.04 and Debian 13 still qualify. - The .deb is named sq-lite-explorer (Tauri derives it from the product name), so the README's uninstall command was wrong. Add a short and long description in place of the control file's "(none)". --- .gitattributes | 4 ++++ .github/workflows/release.yml | 4 ++-- README.md | 5 +++-- docs/release-readiness.md | 2 +- scripts/release/assemble.mjs | 2 +- src-tauri/tauri.conf.json | 2 ++ src-tauri/tauri.linux.conf.json | 2 +- 7 files changed, 14 insertions(+), 7 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..41a836f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Check out every text file with LF on every platform. Shell tests scan their +# own source for "\n"-delimited markers, and viewer-dist/ is sha256-manifested, +# so a CRLF checkout on Windows would fail both. +* text=auto eol=lf diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e07ec66..f515d86 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,8 +37,8 @@ jobs: - platform: macos runner: macos-latest bundles: app,dmg - # tjs needs glibc 2.38, so older build hosts would not widen support; - # 24.04 is also the release-tested Linux. + # tjs needs glibc 2.38 and the app built here 2.39, so an older build + # host would not widen support much; 24.04 is the release-tested Linux. - platform: linux runner: ubuntu-24.04 bundles: deb diff --git a/README.md b/README.md index 25d5eef..38144a8 100644 --- a/README.md +++ b/README.md @@ -62,14 +62,15 @@ anyway**. If **Smart App Control** is on, Windows blocks unsigned programs and offers no override, so the installer cannot run there. To uninstall, use **Settings → Apps → Installed apps → SQLite Explorer → Uninstall**. -**Linux (x86-64, Debian/Ubuntu).** The package needs glibc 2.38 or newer +**Linux (x86-64, Debian/Ubuntu).** The package needs glibc 2.39 or newer (Ubuntu 24.04+, Debian 13+): ```sh sudo apt install ./SQLite-Explorer--linux-amd64.deb ``` -To uninstall, run `sudo apt remove sqlite-explorer`. AppImage packages are not +To uninstall, run `sudo apt remove sq-lite-explorer` (the package name Tauri +derives from the product name). AppImage packages are not provided. ## Build from a checkout diff --git a/docs/release-readiness.md b/docs/release-readiness.md index 83af398..0c42544 100644 --- a/docs/release-readiness.md +++ b/docs/release-readiness.md @@ -41,7 +41,7 @@ downloaded package or close the macOS interaction gates. | Release pipeline | Builds macOS, Linux and Windows packages on their own OS from the viewer pin, with no cache, and drafts a release for a `v*` tag. Each asset gets SHA256SUMS and a build-provenance attestation. Publishing is manual | | Third-party notices | Bundled inside every package; the release also attaches them with the MPL Cargo sources, which are checksum-verified against Cargo.lock | | AppImage distribution | Not distributed: its extra bundled Linux system libraries have no notice/source inventory. Linux ships as `.deb` | -| Linux glibc floor | The native engine needs glibc 2.38; the `.deb` declares `libc6 (>= 2.38)` and the workflow fails if any packaged binary needs more | +| Linux glibc floor | The native engine needs glibc 2.38 and the app built on Ubuntu 24.04 needs 2.39; the `.deb` declares `libc6 (>= 2.39)` and the workflow fails if any packaged binary needs more | | Final macOS installed-package checks | Open cases and older tested source are recorded in [macOS QA](macos-release-qa.md); rerun against the final candidate | | Linux/Windows installed-package checks | Passed for the packages and environments in [Linux/Windows QA](linux-windows-release-qa.md), including real shutdown/restart. Those packages predate the navigation pin and CSP change; rerun the affected checks on new packages | | Other Linux desktops, Wayland and physical keyboards | Unverified; limit platform claims to observed coverage | diff --git a/scripts/release/assemble.mjs b/scripts/release/assemble.mjs index 90ad8f3..bd19f54 100644 --- a/scripts/release/assemble.mjs +++ b/scripts/release/assemble.mjs @@ -86,7 +86,7 @@ ${rows.join('\n')} - **macOS:** the app has an ad-hoc signature and is not notarized. The first launch is blocked; open **System Settings → Privacy & Security** and choose **Open Anyway** for SQLite Explorer. - **Windows:** the installer is unsigned. SmartScreen shows "Windows protected your PC"; choose **More info → Run anyway**. If **Smart App Control** is on, Windows blocks unsigned programs and offers no override, so this release cannot be installed there. -- **Linux:** the package needs glibc 2.38 or newer (Ubuntu 24.04+, Debian 13+). Install it with \`sudo apt install ./${PLATFORMS.linux.asset(version)}\`. +- **Linux:** the package needs glibc 2.39 or newer (Ubuntu 24.04+, Debian 13+). Install it with \`sudo apt install ./${PLATFORMS.linux.asset(version)}\`. See the [installation notes](https://github.com/${REPOSITORY}#install) for uninstalling and for the platforms this release was tested on. diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4e925e9..beb28a6 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -37,6 +37,8 @@ }, "bundle": { "active": true, + "shortDescription": "Browse and edit SQLite databases", + "longDescription": "A desktop SQLite browser and editor: browse tables and views, edit cells with undo and explicit save, run SQL with parameters and query plans, and import or export CSV and JSON.", "targets": ["dmg", "app"], "resources": { "../viewer-dist/native/": "native/", diff --git a/src-tauri/tauri.linux.conf.json b/src-tauri/tauri.linux.conf.json index 460e2b5..66c730a 100644 --- a/src-tauri/tauri.linux.conf.json +++ b/src-tauri/tauri.linux.conf.json @@ -3,7 +3,7 @@ "targets": ["deb"], "linux": { "deb": { - "depends": ["libsm6", "libice6", "libc6 (>= 2.38)"], + "depends": ["libsm6", "libice6", "libc6 (>= 2.39)"], "desktopTemplate": "linux/sqlite-explorer.desktop" } }