From 50bcc2e1a28935b56b6563dc917546ddc76757e2 Mon Sep 17 00:00:00 2001 From: foxyz Date: Wed, 7 Oct 2026 22:27:38 +0300 Subject: [PATCH] CI: Windows build + release on yuuka-windows tool/package_windows.ps1 builds watch-together--windows-x64.zip (same layout as the Linux tarball, libmpv bundled, update.bat and watch-together-server.exe). The release workflow gets a windows job; ci runs the updater tests on Windows. Publishing moves to tool/release_publish.dart (dart: only, no python/bash on Windows), which tolerates both jobs racing to create the release and replaces an asset on re-runs. release.sh writes both asset URLs into the manifest. Windows jobs check out with plain git (the host's cached actions/checkout clone was corrupt). Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/ci.yml | 21 ++++ .gitea/workflows/release.yml | 40 +++++++- README.md | 15 ++- docs/ARCHITECTURE.md | 4 +- docs/DEVELOPMENT.md | 13 ++- docs/ROADMAP.md | 13 ++- tool/package_windows.ps1 | 55 +++++++++++ tool/release.sh | 13 ++- tool/release_publish.dart | 174 +++++++++++++++++++++++++++++++++ tool/setup_flutter_windows.ps1 | 15 +++ 10 files changed, 342 insertions(+), 21 deletions(-) create mode 100644 tool/package_windows.ps1 create mode 100644 tool/release_publish.dart create mode 100644 tool/setup_flutter_windows.ps1 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index e012fc1..d4bbc2c 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -36,3 +36,24 @@ jobs: working-directory: packages/server - run: build/watch-together-server --help working-directory: packages/server + + # The updater's Windows apply path (running exe parked as .old) only + # means something on real NTFS. Runner notes: see release.yml. + updater-windows: + runs-on: windows-latest + steps: + - name: Checkout + shell: powershell + run: | + git init -q . + git remote add origin "$env:GITHUB_SERVER_URL/$env:GITHUB_REPOSITORY.git" + git fetch -q origin "$env:GITHUB_SHA" + if ($LASTEXITCODE) { exit 1 } + git checkout -q --force FETCH_HEAD + - name: Setup Flutter + shell: powershell + run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/setup_flutter_windows.ps1 + - name: Updater tests + shell: powershell + working-directory: apps/desktop + run: flutter test test/updater_test.dart diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 605e728..c5b0dbc 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -36,4 +36,42 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} - run: tool/release_publish.sh "${{ github.ref_name }}" + run: | + VER="${GITHUB_REF_NAME#v}" + dart tool/release_publish.dart "$GITHUB_REF_NAME" "dist/watch-together-$VER-linux-x64.tar.gz" + + windows: + # yuuka-windows: host-mode runner (no containers), runs as SYSTEM, has + # git + VS 2022 Build Tools but no bash/python — PowerShell and Dart + # only. Checkout is plain git: that host's cached actions/checkout + # clone was corrupt, and no `uses:` means no action cache to rot. + runs-on: windows-latest + steps: + - name: Checkout + shell: powershell + run: | + git init -q . + git remote add origin "$env:GITHUB_SERVER_URL/$env:GITHUB_REPOSITORY.git" + git fetch -q --tags origin "$env:GITHUB_SHA" + if ($LASTEXITCODE) { exit 1 } + git checkout -q --force FETCH_HEAD + + - name: Setup Flutter + shell: powershell + run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/setup_flutter_windows.ps1 + + - name: Build zip + shell: powershell + run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/package_windows.ps1 + + - name: Publish release + if: startsWith(github.ref, 'refs/tags/v') + shell: powershell + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} + run: | + $Ver = $env:GITHUB_REF_NAME.TrimStart('v') + dart tool/release_publish.dart $env:GITHUB_REF_NAME "dist/watch-together-$Ver-windows-x64.zip" + if ($LASTEXITCODE) { exit 1 } diff --git a/README.md b/README.md index d9107fe..ad2db37 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Name: `watch-together` is a placeholder and the final name is undecided (`sync-relay` is the working name). It appears in file names and the update manifest until that is settled. -- Desktop only (Linux now, Windows planned), always mpv. No web clients. +- Desktop only (Linux and Windows), always mpv. No web clients. - No transcoding. Everyone plays the original file. - Friends trust model: no accounts, no roles. A blank password means an open server. @@ -19,14 +19,19 @@ How it fits together: [docs/HOW-IT-WORKS.md](docs/HOW-IT-WORKS.md). ## Install and update -Download the latest `watch-together--linux-x64.tar.gz` from the -Releases page of this repo, extract it, run `watch_together`. +From the Releases page of this repo: + +- Linux: `watch-together--linux-x64.tar.gz`. Extract, run + `watch_together`. Needs mpv's library installed (`mpv` or `libmpv2`). +- Windows: `watch-together--windows-x64.zip`. Extract, run + `watch_together.exe`. mpv is bundled. New and not yet tried on a real + Windows desktop; reports welcome. Update from the app: when a newer version exists, an update indicator appears in Home's top bar and opens the update card. "Update and restart" downloads, verifies, applies in place and restarts; the install folder must be writable by you. Settings has Check now and an auto-check toggle. From a terminal: -`watch_together --upgrade` or `update.sh` (shipped in the tarball). A +`watch_together --upgrade`, or the shipped `update.sh` / `update.bat`. A release with a newer protocol is a required update: everyone on a server must run the same protocol version, and a mismatch is refused with a message telling you to update. @@ -214,7 +219,7 @@ fails to load shows a toast and switches shaders off. - "host runs a different version": update the app on both sides. - Picture frozen but sound fine: Ctrl+R reloads the video; the app also does it automatically. - Everyone waits at "waiting for X": X has not buffered 4 seconds yet or has not pressed Space. -- No Windows build yet. +- Windows: the build is new; if something is off there, say so. For contributors: [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md), [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), [docs/PROTOCOL.md](docs/PROTOCOL.md). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a29b29c..888a0b9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -547,7 +547,9 @@ with rollback on failure (writing the running executable fails with ETXTBSY; rename works). A packaged install is detected by `data/` and `lib/` next to the binary; anything else exits with "update manually". A manifest whose protocol is newer than the local one is a required update. Windows apply -(`*.old` rename, swept on next start) exists but is untested. The version is +renames in-use files to `*.old` (swept on next start); CI tests it against a +running exe on NTFS. The manifest's `assets` maps `linux-x64` / +`windows-x64` to archive URLs; top-level `url` (Linux) is for builds <= 0.2.1. The version is stamped with `--dart-define=APP_VERSION` (dev runs report `0.0.0-dev` and skip checks). `watch_together --upgrade` (or the shipped `update.sh`) runs it from the CLI; `UpdateController` runs the same code for Home's update indicator, diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 5152e86..19a99fa 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -12,6 +12,7 @@ mpv libraries come from `media_kit_libs_video`. cd apps/desktop && flutter pub get && flutter run -d linux cd apps/desktop && flutter build linux --release --dart-define=APP_VERSION= tool/package_linux.sh [--skip-build] [--noupload] # dist/*.tar.gz +powershell -File tool/package_windows.ps1 [-SkipBuild] # dist/*.zip (on Windows) cd packages/server && dart compile exe bin/server.dart -o watch-together-server ``` @@ -77,12 +78,16 @@ User pushes tags; CI does the rest. 1. `tool/release.sh [patch|minor|major] [--dry-run]`: derives the next version from the latest tag, bumps the three pubspecs in lockstep, writes - `updates/manifest.json` (asset URL is predictable), commits `Release vX.Y.Z`, + `updates/manifest.json` (per-platform `assets` URLs are predictable; `url` + stays the Linux tarball for builds <= 0.2.1), commits `Release vX.Y.Z`, makes an annotated tag. 2. Review, then `git push --follow-tags`. -3. `.gitea/workflows/release.yml` builds the Linux tarball and runs - `tool/release_publish.sh vX.Y.Z`, which creates the release and uploads it. - By hand: `tool/package_linux.sh` then `tool/release_publish.sh vX.Y.Z`. +3. `.gitea/workflows/release.yml` runs one job per platform: Linux builds + with `tool/package_linux.sh`, Windows (runner `yuuka-windows`, host mode, + PowerShell only) with `tool/package_windows.ps1`. Each then runs + `dart tool/release_publish.dart vX.Y.Z `, which creates the + 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. Tokens come from the environment (`RELEASE_TOKEN`, or Actions-provided `GITHUB_TOKEN`/`GITEA_TOKEN`). The manifest has no `sha256` by default; the diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2471235..57cc3fd 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -6,13 +6,12 @@ off by deleting it in the same commit as the code. Wire changes also edit ## Backlog -1. **Windows build + CI.** There is no Windows runner. Try - `ubuntu2610/flutter_build` (LLVM-MinGW + Wine `gen_snapshot`) on the Linux - runner, fallback a GitHub Actions mirror. The manifest needs per-platform - URLs and `release_publish.sh` must handle a second platform. The updater's - Windows apply path exists but is untested. The tarball's - `watch-together-server` needs a Windows `dart compile exe` too. Desktop - tests are not in CI either. +1. **Windows: first real install.** CI builds and publishes + `watch-together--windows-x64.zip` (libmpv bundled) and runs the + updater tests on NTFS, but nobody has launched the app on Windows yet. + Check: playback, fullscreen controls, hosting (firewall prompt), in-app + update from one release to the next. The rest of the desktop tests are + not in CI. 2. **Jellyfin** (epic, below). diff --git a/tool/package_windows.ps1 b/tool/package_windows.ps1 new file mode 100644 index 0000000..872bbe7 --- /dev/null +++ b/tool/package_windows.ps1 @@ -0,0 +1,55 @@ +# Package the Windows release bundle into a versioned zip. +# Usage: powershell -File tool/package_windows.ps1 [-SkipBuild] +# Output: dist/watch-together--windows-x64.zip containing one +# top-level folder with watch_together.exe, its DLLs and data/ as built, +# plus update.bat (runs the exe with --upgrade) and +# watch-together-server.exe (dedicated server, `dart compile exe` of +# packages/server/bin/server.dart). Same layout as tool/package_linux.sh; +# the in-app updater strips the top-level folder. +# Needs: flutter on PATH, tar.exe (ships with Windows 10+, writes zip). +param([switch]$SkipBuild) +$ErrorActionPreference = 'Stop' + +$Root = Split-Path -Parent $PSScriptRoot +$App = Join-Path $Root 'apps/desktop' +$Name = 'watch-together' +$Arch = 'x64' + +$Version = (Select-String -Path "$App/pubspec.yaml" -Pattern '^version: ([0-9.]+)').Matches[0].Groups[1].Value +if (-not $Version) { throw "version not found in $App/pubspec.yaml" } + +function Invoke-Checked([string]$What, [scriptblock]$Cmd) { + & $Cmd + if ($LASTEXITCODE -ne 0) { throw "$What failed (exit $LASTEXITCODE)" } +} + +if (-not $SkipBuild) { + Push-Location $App + try { + Invoke-Checked 'flutter build windows' { flutter build windows --release "--dart-define=APP_VERSION=$Version" } + } finally { Pop-Location } +} + +$Bundle = Join-Path $App "build/windows/$Arch/runner/Release" +if (-not (Test-Path "$Bundle/watch_together.exe")) { throw "bundle missing (build first): $Bundle" } + +$Stage = Join-Path $App 'build/package-stage' +$Dist = Join-Path $Root 'dist' +$Folder = "$Name-$Version" +$Zip = Join-Path $Dist "$Folder-windows-$Arch.zip" +if (Test-Path $Stage) { Remove-Item -Recurse -Force $Stage } +New-Item -ItemType Directory -Force "$Stage/$Folder", $Dist | Out-Null +Copy-Item -Recurse "$Bundle/*" "$Stage/$Folder/" +Copy-Item "$App/lib/src/update/bundle/update.bat" "$Stage/$Folder/update.bat" + +Push-Location (Join-Path $Root 'packages/server') +try { + Invoke-Checked 'dart pub get' { dart pub get | Out-Null } + Invoke-Checked 'dart compile exe' { dart compile exe bin/server.dart -o "$Stage/$Folder/watch-together-server.exe" } +} finally { Pop-Location } + +if (Test-Path $Zip) { Remove-Item -Force $Zip } +# bsdtar writes forward-slash entry names (Compress-Archive on 5.1 doesn't). +Invoke-Checked 'tar' { tar.exe -a -c -f $Zip -C $Stage $Folder } +Remove-Item -Recurse -Force $Stage +Get-Item $Zip | Format-Table Name, Length diff --git a/tool/release.sh b/tool/release.sh index 1b5f364..9250592 100755 --- a/tool/release.sh +++ b/tool/release.sh @@ -52,7 +52,6 @@ ORIGIN="$(git -C "$ROOT" remote get-url origin)" SERVER="$(echo "$ORIGIN" | sed -E 's#(https?://[^/]+)/.*#\1#')" REPO="$(echo "${ORIGIN%.git}" | sed -E 's#.*[:/]([^/]+/[^/]+)$#\1#')" PROTO="$(sed -n 's/.*protoVersion = \([0-9]*\).*/\1/p' "$ROOT/packages/core/lib/src/constants.dart")" -ASSET="watch-together-$VER-linux-x64.tar.gz" if [[ -n "$LATEST" && "$LATEST" != "v$VER" ]]; then NOTES="$(git -C "$ROOT" log "$LATEST..HEAD" --format='- %s' | head -n 20)" else @@ -60,7 +59,7 @@ else fi export MANIFEST_FILE="$ROOT/updates/manifest.json" MANIFEST_VER="$VER" \ MANIFEST_PROTO="$PROTO" MANIFEST_NOTES="$NOTES" \ - MANIFEST_URL="$SERVER/$REPO/releases/download/v$VER/$ASSET" + MANIFEST_BASE="$SERVER/$REPO/releases/download/v$VER/watch-together-$VER" python3 - <<'EOF' import json, os path = os.environ['MANIFEST_FILE'] @@ -69,10 +68,18 @@ try: manifest = json.load(f) except (FileNotFoundError, ValueError): manifest = {} +base = os.environ['MANIFEST_BASE'] +# Asset names must match tool/package_linux.sh / package_windows.ps1. +# `url` stays the Linux tarball for builds that predate `assets` (<= 0.2.1). +assets = { + 'linux-x64': base + '-linux-x64.tar.gz', + 'windows-x64': base + '-windows-x64.zip', +} manifest.update({ 'version': os.environ['MANIFEST_VER'], 'protocol': int(os.environ['MANIFEST_PROTO']), - 'url': os.environ['MANIFEST_URL'], + 'url': assets['linux-x64'], + 'assets': assets, 'notes': os.environ['MANIFEST_NOTES'].splitlines()[0] if os.environ['MANIFEST_NOTES'] else '', }) manifest.pop('sha256', None) diff --git a/tool/release_publish.dart b/tool/release_publish.dart new file mode 100644 index 0000000..e37484e --- /dev/null +++ b/tool/release_publish.dart @@ -0,0 +1,174 @@ +// Publish one platform's archive to the Gitea release for a tag: create +// the release if it doesn't exist yet, then upload (or replace) the asset. +// The Linux and Windows CI jobs both run this, possibly at the same time, +// so a lost create race falls back to the release the other job made. +// The manifest is written by tool/release.sh at release time (predictable +// 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) +// 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 tag = args[0]; + final archive = File(args[1]); + 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) + ?.group(1); + if (pub != ver) { + _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 prev = _git(root, ['describe', '--tags', '--abbrev=0', '$tag^'], + allowFail: true); + final notes = prev.isEmpty + ? 'Watch together $ver.' + : _git(root, [ + 'log', + '$prev..$tag', + '--format=- %s', + '--invert-grep', + '--grep=^Release v' + ]).split('\n').take(20).join('\n'); + + final http = HttpClient(); + try { + final c = _Api(http, token); + var rel = await c.get('$api/releases/tags/$tag'); + if (rel == null) { + stdout.writeln('creating release $tag on $repo ...'); + rel = await c.post('$api/releases', { + 'tag_name': tag, + 'name': tag, + 'body': 'Watch together $ver\n\n$notes', + 'draft': false, + 'prerelease': false, + }); + // The other platform's job won the race. + rel ??= await c.get('$api/releases/tags/$tag') ?? + _die('could not create or find release $tag'); + } + final id = rel['id']; + stdout.writeln('release id: $id'); + + final name = archive.uri.pathSegments.last; + for (final a in (rel['assets'] as List? ?? const [])) { + if (a is Map && a['name'] == name) { + stdout.writeln('replacing existing $name ...'); + await c.delete('$api/releases/$id/assets/${a['id']}'); + } + } + stdout.writeln('uploading $name ...'); + await c.upload( + '$api/releases/$id/assets?name=${Uri.encodeQueryComponent(name)}', + archive, + name); + stdout.writeln('published $name to $tag.'); + } finally { + http.close(); + } +} + +Never _die(String msg) { + stderr.writeln('error: $msg'); + exit(1); +} + +String _git(String root, List args, {bool allowFail = false}) { + final r = Process.runSync('git', ['-C', root, ...args]); + if (r.exitCode != 0) { + if (allowFail) return ''; + _die('git ${args.join(' ')}: ${r.stderr}'.trim()); + } + return (r.stdout as String).trim(); +} + +class _Api { + final HttpClient http; + final String token; + _Api(this.http, this.token); + + Future<(int, String)> _send(String method, String url, + {List? body, String? type}) async { + final req = await http.openUrl(method, Uri.parse(url)); + req.headers.set('Authorization', 'token $token'); + req.headers.set('Accept', 'application/json'); + if (body != null) { + req.headers.set('Content-Type', type!); + req.contentLength = body.length; + req.add(body); + } + final res = await req.close(); + return (res.statusCode, await res.transform(utf8.decoder).join()); + } + + // null on 404. + Future?> get(String url) async { + final (code, text) = await _send('GET', url); + if (code == 404) return null; + _check('GET', url, code, text); + return jsonDecode(text) as Map; + } + + // null on 409 (already exists). + Future?> post(String url, Object json) async { + final (code, text) = await _send('POST', url, + body: utf8.encode(jsonEncode(json)), type: 'application/json'); + if (code == 409) return null; + _check('POST', url, code, text); + return jsonDecode(text) as Map; + } + + Future delete(String url) async { + final (code, text) = await _send('DELETE', url); + _check('DELETE', url, code, text); + } + + // Gitea's asset API takes a multipart form with an `attachment` field. + Future upload(String url, File file, String name) async { + final boundary = 'wt-${DateTime.now().microsecondsSinceEpoch}'; + final body = [ + ...utf8.encode('--$boundary\r\n' + 'Content-Disposition: form-data; name="attachment"; filename="$name"\r\n' + 'Content-Type: application/octet-stream\r\n\r\n'), + ...await file.readAsBytes(), + ...utf8.encode('\r\n--$boundary--\r\n'), + ]; + final (code, text) = await _send('POST', url, + body: body, type: 'multipart/form-data; boundary=$boundary'); + _check('upload', url, code, text); + } + + void _check(String what, String url, int code, String text) { + if (code < 200 || code >= 300) { + _die( + '$what $url → HTTP $code: ${text.length > 300 ? text.substring(0, 300) : text}'); + } + } +} diff --git a/tool/setup_flutter_windows.ps1 b/tool/setup_flutter_windows.ps1 new file mode 100644 index 0000000..7866cc3 --- /dev/null +++ b/tool/setup_flutter_windows.ps1 @@ -0,0 +1,15 @@ +# CI only (yuuka-windows, host mode): put Flutter stable on PATH for the +# following steps. The SDK is cached on the host in +# %ProgramData%\flutter-ci and fast-forwarded each run; first run clones. +$ErrorActionPreference = 'Stop' +$F = "$env:ProgramData\flutter-ci" +if (Test-Path "$F\.git") { + git -C $F fetch -q --depth 1 origin stable; if ($LASTEXITCODE) { exit 1 } + git -C $F reset -q --hard FETCH_HEAD; if ($LASTEXITCODE) { exit 1 } +} else { + git clone -q https://github.com/flutter/flutter.git -b stable --depth 1 $F; if ($LASTEXITCODE) { exit 1 } +} +Add-Content $env:GITHUB_PATH "$F\bin" +& "$F\bin\flutter.bat" config --no-analytics | Out-Null +& "$F\bin\flutter.bat" --version +if ($LASTEXITCODE) { exit 1 }