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

+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"
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 [[ "$SKIP_BUILD" != "1" ]]; then
+71 -19
View File
@@ -6,24 +6,31 @@
// asset URLs), so this never touches git history beyond reading notes.
// Usage: dart tool/release_publish.dart vX.Y.Z <archive>
// (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 /
// GITEA_TOKEN. dart: imports only — runs without `pub get`.
import 'dart:convert';
import 'dart:io';
Future<void> main(List<String> args) async {
if (args.length != 2) {
_die('usage: dart tool/release_publish.dart vX.Y.Z <archive>');
final dev = args.isNotEmpty && args[0] == 'dev';
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 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<void> main(List<String> 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<void> main(List<String> 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<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) {
stderr.writeln('error: $msg');
exit(1);