Test builds: tool/dev_release.sh builds Linux here and publishes to a dev prerelease that dev builds update from

X.Y.Z-dev.N builds read the dev prerelease's manifest as well as the
stable one and take the newer, so a tester gets each test build through
the normal update and lands back on stable at the release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-11 02:29:40 +03:00
1 parent 1b0a3db439
commit ab133dd82f
9 files changed
+228 -23

No files matched your search

+1
View File
@@ -17,6 +17,7 @@ coverage/
# Build artifacts that should never be committed # Build artifacts that should never be committed
*.tar.gz *.tar.gz
*.zip *.zip
/dist/
app.*.symbols app.*.symbols
app.*.map.json app.*.map.json
@@ -107,7 +107,8 @@ class UpdateController {
final root = _root; final root = _root;
if (root != null) await cleanupUpdateLeftovers(root); if (root != null) await cleanupUpdateLeftovers(root);
try { try {
final m = await fetchManifest(url: manifestUrl); final m = await fetchLatestManifest(
currentVersion: currentVersion, url: manifestUrl);
if (!isNewerVersion(currentVersion, m.version)) { if (!isNewerVersion(currentVersion, m.version)) {
_log.info('up to date ($currentVersion, latest ${m.version})'); _log.info('up to date ($currentVersion, latest ${m.version})');
lastCheck.value = 'Up to date'; lastCheck.value = 'Up to date';
+5 -1
View File
@@ -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 indicator, which opens the update card, and restarts the app ("Update and
restart", closing Hive first). restart", closing Hive first).
Settings has Check now and an auto-check toggle. The manifest is `updates/manifest.json` in 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 ## Decisions
+10
View File
@@ -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. 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. 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 Tokens come from the environment (`RELEASE_TOKEN`, or Actions-provided
`GITHUB_TOKEN`/`GITEA_TOKEN`). The manifest has no `sha256` by default; the `GITHUB_TOKEN`/`GITEA_TOKEN`). The manifest has no `sha256` by default; the
updater skips the check when absent. Workflows live in `.gitea/workflows` updater skips the check when absent. Workflows live in `.gitea/workflows`
+35 -1
View File
@@ -28,6 +28,11 @@ final _log = createLogger('update');
const updateManifestUrl = const updateManifestUrl =
'https://git.senkochan.moe/Foxyz/sync_replay_project/raw/branch/main/updates/manifest.json'; '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', const appVersion = String.fromEnvironment('APP_VERSION',
defaultValue: '0.0.0-dev'); defaultValue: '0.0.0-dev');
@@ -161,6 +166,34 @@ Future<UpdateManifest> 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<UpdateManifest> 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<UpdateManifest?>((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); typedef DownloadProgress = void Function(int received, int? total);
Future<void> downloadFile(String url, File dest, Future<void> downloadFile(String url, File dest,
@@ -538,7 +571,8 @@ Future<int> runUpgrade(
stdout.writeln('$program $currentVersion (proto ${RelayConstants.protoVersion})'); stdout.writeln('$program $currentVersion (proto ${RelayConstants.protoVersion})');
late final UpdateManifest manifest; late final UpdateManifest manifest;
try { try {
manifest = await fetchManifest(http: http, url: manifestUrl); manifest = await fetchLatestManifest(
http: http, currentVersion: currentVersion, url: manifestUrl);
} catch (e) { } catch (e) {
stdout.writeln('update check failed: $e'); stdout.writeln('update check failed: $e');
return 1; return 1;
+29
View File
@@ -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<String> 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', () { group('upgrade flow', () {
late Directory work; late Directory work;
late _Server server; late _Server server;
+73
View File
@@ -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 <<EOF
Published $VERSION. Dev builds pick it up with their next update check.
A stable install moves onto test builds once: with the app open, run (bash)
p=\$(pgrep -xo watch_together) && d=\$(dirname "\$(readlink /proc/\$p/exe)") && t=\$(mktemp -d) && curl -fsSL $BASE_URL/$(basename "$TARBALL") | tar -xz -C "\$t" --strip-components=1 && cp -rf --remove-destination "\$t"/. "\$d"/ && rm -rf "\$t" && echo "Done: restart the app." || echo "Failed: is the app open?"
EOF
+2 -1
View File
@@ -31,7 +31,8 @@ APP="$ROOT/apps/desktop"
NAME="watch-together" NAME="watch-together"
ARCH="x64" ARCH="x64"
VERSION="$(sed -n 's/^version: \([0-9.]*\).*/\1/p' "$APP/pubspec.yaml")" # VERSION in the environment overrides it (tool/dev_release.sh).
VERSION="${VERSION:-$(sed -n 's/^version: \([0-9.]*\).*/\1/p' "$APP/pubspec.yaml")}"
if [[ -z "$VERSION" ]]; then echo "error: version not found in $APP/pubspec.yaml" >&2; exit 1; fi if [[ -z "$VERSION" ]]; then echo "error: version not found in $APP/pubspec.yaml" >&2; exit 1; fi
if [[ "$SKIP_BUILD" != "1" ]]; then if [[ "$SKIP_BUILD" != "1" ]]; then
+71 -19
View File
@@ -6,24 +6,31 @@
// asset URLs), so this never touches git history beyond reading notes. // asset URLs), so this never touches git history beyond reading notes.
// Usage: dart tool/release_publish.dart vX.Y.Z <archive> // Usage: dart tool/release_publish.dart vX.Y.Z <archive>
// (run after tool/package_linux.sh or tool/package_windows.ps1) // (run after tool/package_linux.sh or tool/package_windows.ps1)
// dart tool/release_publish.dart dev <archive> <manifest.json>
// (tool/dev_release.sh): the `dev` prerelease, its assets all replaced.
// Env: RELEASE_TOKEN (preferred) or the Actions-provided GITHUB_TOKEN / // Env: RELEASE_TOKEN (preferred) or the Actions-provided GITHUB_TOKEN /
// GITEA_TOKEN. dart: imports only — runs without `pub get`. // GITEA_TOKEN. dart: imports only — runs without `pub get`.
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
Future<void> main(List<String> args) async { Future<void> main(List<String> args) async {
if (args.length != 2) { final dev = args.isNotEmpty && args[0] == 'dev';
_die('usage: dart tool/release_publish.dart vX.Y.Z <archive>'); if (args.length != (dev ? 3 : 2)) {
_die('usage: dart tool/release_publish.dart vX.Y.Z <archive>\n'
' dart tool/release_publish.dart dev <archive> <manifest.json>');
} }
final tag = args[0]; 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; final ver = tag.startsWith('v') ? tag.substring(1) : tag;
if (!RegExp(r'^\d+\.\d+\.\d+$').hasMatch(ver)) { if (!RegExp(r'^\d+\.\d+\.\d+$').hasMatch(ver)) {
_die('tag must look like v0.2.0, got $tag'); _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 pubspec = File('$root/apps/desktop/pubspec.yaml').readAsStringSync();
final pub = RegExp(r'^version: ([0-9.]+)', multiLine: true) final pub = RegExp(r'^version: ([0-9.]+)', multiLine: true)
.firstMatch(pubspec) .firstMatch(pubspec)
@@ -32,18 +39,8 @@ Future<void> main(List<String> args) async {
_die('tag $tag != desktop pubspec $pub (run tool/release.sh first)'); _die('tag $tag != desktop pubspec $pub (run tool/release.sh first)');
} }
final env = Platform.environment; final token = _token();
final token = [env['RELEASE_TOKEN'], env['GITHUB_TOKEN'], env['GITEA_TOKEN']] final api = _apiBase(root);
.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 prev = _git(root, ['describe', '--tags', '--abbrev=0', '$tag^'], final prev = _git(root, ['describe', '--tags', '--abbrev=0', '$tag^'],
allowFail: true); allowFail: true);
@@ -62,7 +59,7 @@ Future<void> main(List<String> args) async {
final c = _Api(http, token); final c = _Api(http, token);
var rel = await c.get('$api/releases/tags/$tag'); var rel = await c.get('$api/releases/tags/$tag');
if (rel == null) { if (rel == null) {
stdout.writeln('creating release $tag on $repo ...'); stdout.writeln('creating release $tag on $api ...');
rel = await c.post('$api/releases', { rel = await c.post('$api/releases', {
'tag_name': tag, 'tag_name': tag,
'name': tag, 'name': tag,
@@ -95,6 +92,61 @@ Future<void> main(List<String> 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<void> _publishDev(String root, List<File> 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) { Never _die(String msg) {
stderr.writeln('error: $msg'); stderr.writeln('error: $msg');
exit(1); exit(1);