diff --git a/.gitignore b/.gitignore index 6efe7d4..f741d6f 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ coverage/ # Build artifacts that should never be committed *.tar.gz *.zip +/dist/ app.*.symbols app.*.map.json diff --git a/apps/desktop/lib/src/update/update_controller.dart b/apps/desktop/lib/src/update/update_controller.dart index a94548e..88648ea 100644 --- a/apps/desktop/lib/src/update/update_controller.dart +++ b/apps/desktop/lib/src/update/update_controller.dart @@ -107,7 +107,8 @@ class UpdateController { final root = _root; if (root != null) await cleanupUpdateLeftovers(root); try { - final m = await fetchManifest(url: manifestUrl); + final m = await fetchLatestManifest( + currentVersion: currentVersion, url: manifestUrl); if (!isNewerVersion(currentVersion, m.version)) { _log.info('up to date ($currentVersion, latest ${m.version})'); lastCheck.value = 'Up to date'; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0eaf0dc..9714645 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1144,7 +1144,11 @@ does the same from the app binary. In the app, `UpdateController` indicator, which opens the update card, and restarts the app ("Update and restart", closing Hive first). Settings has Check now and an auto-check toggle. The manifest is `updates/manifest.json` in -this repo, written by `tool/release.sh`. +this repo, written by `tool/release.sh`. Test builds (`X.Y.Z-dev.N`, +`isDevBuild`) also read `devManifestUrl`, the `manifest.json` of the `dev` +prerelease (`tool/dev_release.sh`), and take the newer of the two +(`fetchLatestManifest`); the release X.Y.Z outranks its test builds, so +testers land back on stable. `0.0.0-dev` (a dev run) checks nothing. ## Decisions diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 64127ff..1cc877a 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -94,6 +94,16 @@ User pushes tags; CI does the rest. release if the other job hasn't yet and uploads (or replaces) its asset. By hand: package, then the same `dart tool/release_publish.dart` line. +Test builds (Linux, no tag, no CI): `tool/dev_release.sh [--no-publish]` +(token: `RELEASE_TOKEN` or `~/.config/watch-together-release-token`, a +Gitea access token with repository read/write) builds the working tree here as the next patch's +`-dev.N` (N one past the published one) and replaces the assets of the +`dev` prerelease with the tarball and its `manifest.json`. Dev builds +update from it ([Updater](ARCHITECTURE.md#updater)). A stable install +moves onto it once with the command the script prints (run with the app +open: it finds the install from the running process and copies the new +files over it). + Tokens come from the environment (`RELEASE_TOKEN`, or Actions-provided `GITHUB_TOKEN`/`GITEA_TOKEN`). The manifest has no `sha256` by default; the updater skips the check when absent. Workflows live in `.gitea/workflows` diff --git a/packages/core/lib/src/updater.dart b/packages/core/lib/src/updater.dart index 13bbe4a..d1a7781 100644 --- a/packages/core/lib/src/updater.dart +++ b/packages/core/lib/src/updater.dart @@ -28,6 +28,11 @@ final _log = createLogger('update'); const updateManifestUrl = 'https://git.senkochan.moe/Foxyz/sync_replay_project/raw/branch/main/updates/manifest.json'; +/// Test builds (`tool/dev_release.sh`): one Gitea prerelease, tag `dev`, +/// whose assets each run replaces. Only dev builds read it. +const devManifestUrl = + 'https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/dev/manifest.json'; + const appVersion = String.fromEnvironment('APP_VERSION', defaultValue: '0.0.0-dev'); @@ -161,6 +166,34 @@ Future fetchManifest( } } +/// A test build (`1.1.3-dev.4`); `0.0.0-dev` (a dev run) is not one. +bool isDevBuild(String version) => version.contains('-dev.'); + +/// The manifest to update from: the stable one, and for a [isDevBuild] +/// also the dev one, whichever is newer (either alone if the other fails). +/// A release outranks its own test builds, so testers return to stable. +Future fetchLatestManifest( + {HttpClient? http, + String currentVersion = appVersion, + String url = updateManifestUrl, + String devUrl = devManifestUrl}) async { + final stable = fetchManifest(http: http, url: url); + if (!isDevBuild(currentVersion)) return stable; + stable.ignore(); // awaited below; don't let an early failure go uncaught + final dev = await fetchManifest(http: http, url: devUrl) + .then((m) => m, onError: (Object e) { + _log.warning('dev manifest: $e'); + return null; + }); + if (dev == null) return stable; + try { + final s = await stable; + return isNewerVersion(s.version, dev.version) ? dev : s; + } catch (_) { + return dev; + } +} + typedef DownloadProgress = void Function(int received, int? total); Future downloadFile(String url, File dest, @@ -538,7 +571,8 @@ Future runUpgrade( stdout.writeln('$program $currentVersion (proto ${RelayConstants.protoVersion})'); late final UpdateManifest manifest; try { - manifest = await fetchManifest(http: http, url: manifestUrl); + manifest = await fetchLatestManifest( + http: http, currentVersion: currentVersion, url: manifestUrl); } catch (e) { stdout.writeln('update check failed: $e'); return 1; diff --git a/packages/core/test/updater_test.dart b/packages/core/test/updater_test.dart index cdd07df..db24f49 100644 --- a/packages/core/test/updater_test.dart +++ b/packages/core/test/updater_test.dart @@ -208,6 +208,35 @@ void main() { } }); + test('dev builds also read the dev manifest, the newer wins', () async { + final versions = {'/stable': '1.1.2', '/dev': '1.1.3-dev.2'}; + final http = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + http.listen((req) async { + final v = versions[req.uri.path]; + if (v == null) { + req.response.statusCode = 404; + } else { + req.response.write(jsonEncode( + {'version': v, 'protocol': 1, 'url': 'http://x/$v.tar.gz'})); + } + await req.response.close(); + }); + final base = 'http://127.0.0.1:${http.port}'; + Future latest(String current) async => (await fetchLatestManifest( + currentVersion: current, url: '$base/stable', devUrl: '$base/dev')) + .version; + try { + expect(await latest('1.1.2'), '1.1.2'); // stable builds never see dev + expect(await latest('1.1.3-dev.1'), '1.1.3-dev.2'); + versions['/stable'] = '1.1.3'; // the release outranks its test builds + expect(await latest('1.1.3-dev.2'), '1.1.3'); + versions.remove('/stable'); + expect(await latest('1.1.3-dev.1'), '1.1.3-dev.2'); + } finally { + await http.close(force: true); + } + }); + group('upgrade flow', () { late Directory work; late _Server server; diff --git a/tool/dev_release.sh b/tool/dev_release.sh new file mode 100755 index 0000000..9878f3e --- /dev/null +++ b/tool/dev_release.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Build a Linux test build here and publish it to the `dev` prerelease on +# Gitea, where dev builds update from (see Updater in docs/ARCHITECTURE.md). +# No tag push, no CI, no commit: the working tree is built as it is. +# Usage: tool/dev_release.sh [--no-publish] +# Version: the next patch after the pubspec's, `-dev.N`, N one past the +# published dev build of that patch (1.1.2 -> 1.1.3-dev.1, -dev.2, ...). +# Token: RELEASE_TOKEN, else the file ~/.config/watch-together-release-token +# (a Gitea access token with repository read/write; keep the file 0600). +# Prints the one-time command that moves a stable install onto test builds. +set -euo pipefail + +PUBLISH=1 +for arg in "$@"; do + case "$arg" in + --no-publish) PUBLISH=0 ;; + *) echo "usage: $0 [--no-publish]" >&2; exit 1 ;; + esac +done +TOKEN_FILE="$HOME/.config/watch-together-release-token" +if [[ -z "${RELEASE_TOKEN:-}" && -f "$TOKEN_FILE" ]]; then + RELEASE_TOKEN="$(tr -d '[:space:]' <"$TOKEN_FILE")"; export RELEASE_TOKEN +fi +if [[ "$PUBLISH" == "1" && -z "${RELEASE_TOKEN:-}" ]]; then + echo "error: set RELEASE_TOKEN or write $TOKEN_FILE (or pass --no-publish)" >&2; exit 1 +fi + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BASE_URL="https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/dev" + +PUB="$(sed -n 's/^version: \([0-9.]*\).*/\1/p' "$ROOT/apps/desktop/pubspec.yaml")" +IFS=. read -r MA MI PA <<<"$PUB" +NEXT="$MA.$MI.$((PA + 1))" +LAST="$(curl -fsSL "$BASE_URL/manifest.json" 2>/dev/null | + python3 -I -c 'import json,sys; print(json.load(sys.stdin)["version"])' 2>/dev/null || true)" +N=1 +if [[ "$LAST" == "$NEXT-dev."* ]]; then N=$(( ${LAST##*-dev.} + 1 )); fi +export VERSION="$NEXT-dev.$N" +echo "building $VERSION (last published: ${LAST:-none})" + +"$ROOT/tool/package_linux.sh" --no-upload +TARBALL="$ROOT/dist/watch-together-$VERSION-linux-x64.tar.gz" +[[ -f "$TARBALL" ]] || { echo "error: $TARBALL missing" >&2; exit 1; } + +NOTES="$(git -C "$ROOT" log -1 --format=%s)" +[[ -n "$(git -C "$ROOT" status --porcelain --untracked-files=no)" ]] && NOTES="$NOTES (+ uncommitted changes)" +MANIFEST="$ROOT/dist/manifest.json" +MANIFEST_VER="$VERSION" \ +MANIFEST_PROTO="$(sed -n 's/.*protoVersion = \([0-9]*\).*/\1/p' "$ROOT/packages/core/lib/src/constants.dart")" \ +MANIFEST_URL="$BASE_URL/$(basename "$TARBALL")" \ +MANIFEST_SHA="$(sha256sum "$TARBALL" | cut -d' ' -f1)" \ +MANIFEST_NOTES="Test build: $NOTES" \ +python3 -I - "$MANIFEST" <<'EOF' +import json, os, sys +e = os.environ +url = e['MANIFEST_URL'] +with open(sys.argv[1], 'w') as f: + json.dump({'version': e['MANIFEST_VER'], 'protocol': int(e['MANIFEST_PROTO']), + 'url': url, 'sha256': e['MANIFEST_SHA'], 'notes': e['MANIFEST_NOTES'], + 'assets': {'linux-x64': url}}, f, indent=2) +EOF + +if [[ "$PUBLISH" != "1" ]]; then + echo "built $TARBALL (not published)"; exit 0 +fi +dart "$ROOT/tool/release_publish.dart" dev "$TARBALL" "$MANIFEST" + +cat <&2; exit 1; fi if [[ "$SKIP_BUILD" != "1" ]]; then diff --git a/tool/release_publish.dart b/tool/release_publish.dart index e37484e..2624961 100644 --- a/tool/release_publish.dart +++ b/tool/release_publish.dart @@ -6,24 +6,31 @@ // asset URLs), so this never touches git history beyond reading notes. // Usage: dart tool/release_publish.dart vX.Y.Z // (run after tool/package_linux.sh or tool/package_windows.ps1) +// dart tool/release_publish.dart dev +// (tool/dev_release.sh): the `dev` prerelease, its assets all replaced. // Env: RELEASE_TOKEN (preferred) or the Actions-provided GITHUB_TOKEN / // GITEA_TOKEN. dart: imports only — runs without `pub get`. import 'dart:convert'; import 'dart:io'; Future main(List args) async { - if (args.length != 2) { - _die('usage: dart tool/release_publish.dart vX.Y.Z '); + final dev = args.isNotEmpty && args[0] == 'dev'; + if (args.length != (dev ? 3 : 2)) { + _die('usage: dart tool/release_publish.dart vX.Y.Z \n' + ' dart tool/release_publish.dart dev '); } final tag = args[0]; - final archive = File(args[1]); + final files = [for (final a in args.skip(1)) File(a)]; + for (final f in files) { + if (!f.existsSync()) _die('missing: ${f.path}'); + } + final root = File.fromUri(Platform.script).parent.parent.path; + if (dev) return _publishDev(root, files); + final archive = files.single; final ver = tag.startsWith('v') ? tag.substring(1) : tag; if (!RegExp(r'^\d+\.\d+\.\d+$').hasMatch(ver)) { _die('tag must look like v0.2.0, got $tag'); } - if (!archive.existsSync()) _die('archive missing: ${archive.path}'); - - final root = File.fromUri(Platform.script).parent.parent.path; final pubspec = File('$root/apps/desktop/pubspec.yaml').readAsStringSync(); final pub = RegExp(r'^version: ([0-9.]+)', multiLine: true) .firstMatch(pubspec) @@ -32,18 +39,8 @@ Future main(List args) async { _die('tag $tag != desktop pubspec $pub (run tool/release.sh first)'); } - final env = Platform.environment; - final token = [env['RELEASE_TOKEN'], env['GITHUB_TOKEN'], env['GITEA_TOKEN']] - .firstWhere((t) => t != null && t.isNotEmpty, - orElse: () => _die('set RELEASE_TOKEN (or run in Actions)'))!; - - final origin = _git(root, ['remote', 'get-url', 'origin']); - final server = RegExp(r'^(https?://[^/]+)/').firstMatch(origin)?.group(1) ?? - env['GITHUB_SERVER_URL'] ?? - _die('cannot derive server from origin $origin'); - final repo = - RegExp(r'([^/:]+/[^/]+?)(\.git)?$').firstMatch(origin)!.group(1)!; - final api = '$server/api/v1/repos/$repo'; + final token = _token(); + final api = _apiBase(root); final prev = _git(root, ['describe', '--tags', '--abbrev=0', '$tag^'], allowFail: true); @@ -62,7 +59,7 @@ Future main(List args) async { final c = _Api(http, token); var rel = await c.get('$api/releases/tags/$tag'); if (rel == null) { - stdout.writeln('creating release $tag on $repo ...'); + stdout.writeln('creating release $tag on $api ...'); rel = await c.post('$api/releases', { 'tag_name': tag, 'name': tag, @@ -95,6 +92,61 @@ Future main(List args) async { } } +String _token() { + final env = Platform.environment; + return [env['RELEASE_TOKEN'], env['GITHUB_TOKEN'], env['GITEA_TOKEN']] + .firstWhere((t) => t != null && t.isNotEmpty, + orElse: () => _die('set RELEASE_TOKEN (or run in Actions)'))!; +} + +// The repo's API URL, from origin. +String _apiBase(String root) { + final origin = _git(root, ['remote', 'get-url', 'origin']); + final server = RegExp(r'^(https?://[^/]+)/').firstMatch(origin)?.group(1) ?? + Platform.environment['GITHUB_SERVER_URL'] ?? + _die('cannot derive server from origin $origin'); + final repo = + RegExp(r'([^/:]+/[^/]+?)(\.git)?$').firstMatch(origin)!.group(1)!; + return '$server/api/v1/repos/$repo'; +} + +// The `dev` prerelease (created at main's head on first use): every old +// asset deleted, then [files] uploaded. Its manifest's URL is fixed, so +// test builds find each new one. +Future _publishDev(String root, List files) async { + final token = _token(); + final api = _apiBase(root); + final http = HttpClient(); + try { + final c = _Api(http, token); + final rel = await c.get('$api/releases/tags/dev') ?? + await c.post('$api/releases', { + 'tag_name': 'dev', + 'target_commitish': 'main', + 'name': 'Test build', + 'body': 'Latest test build; dev builds update from here.', + 'draft': false, + 'prerelease': true, + }) ?? + _die('could not create or find release dev'); + final id = rel['id']; + for (final a in (rel['assets'] as List? ?? const [])) { + if (a is Map) await c.delete('$api/releases/$id/assets/${a['id']}'); + } + for (final f in files) { + final name = f.uri.pathSegments.last; + stdout.writeln('uploading $name ...'); + await c.upload( + '$api/releases/$id/assets?name=${Uri.encodeQueryComponent(name)}', + f, + name); + } + stdout.writeln('published to dev.'); + } finally { + http.close(); + } +} + Never _die(String msg) { stderr.writeln('error: $msg'); exit(1);