CI: Windows build + release on yuuka-windows
ci / core (push) Successful in 32s
ci / server (push) Successful in 44s
ci / updater-windows (push) Successful in 1m25s

tool/package_windows.ps1 builds watch-together-<ver>-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 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-07 22:27:38 +03:00
1 parent 2c21cfde11
commit 50bcc2e1a2
10 files changed
+342 -21

No files matched your search

+21
View File
@@ -36,3 +36,24 @@ jobs:
working-directory: packages/server working-directory: packages/server
- run: build/watch-together-server --help - run: build/watch-together-server --help
working-directory: packages/server 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
+39 -1
View File
@@ -36,4 +36,42 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
RELEASE_TOKEN: ${{ secrets.RELEASE_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 }
+10 -5
View File
@@ -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 (`sync-relay` is the working name). It appears in file names and the
update manifest until that is settled. 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. - No transcoding. Everyone plays the original file.
- Friends trust model: no accounts, no roles. A blank password means an open server. - 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 ## Install and update
Download the latest `watch-together-<version>-linux-x64.tar.gz` from the From the Releases page of this repo:
Releases page of this repo, extract it, run `watch_together`.
- Linux: `watch-together-<version>-linux-x64.tar.gz`. Extract, run
`watch_together`. Needs mpv's library installed (`mpv` or `libmpv2`).
- Windows: `watch-together-<version>-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 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, 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 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: 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 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 must run the same protocol version, and a mismatch is refused with a message
telling you to update. 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. - "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. - 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. - 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), For contributors: [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md),
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), [docs/PROTOCOL.md](docs/PROTOCOL.md). [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), [docs/PROTOCOL.md](docs/PROTOCOL.md).
+3 -1
View File
@@ -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 rename works). A packaged install is detected by `data/` and `lib/` next to
the binary; anything else exits with "update manually". A manifest whose the binary; anything else exits with "update manually". A manifest whose
protocol is newer than the local one is a required update. Windows apply 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 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 checks). `watch_together --upgrade` (or the shipped `update.sh`) runs it from
the CLI; `UpdateController` runs the same code for Home's update indicator, the CLI; `UpdateController` runs the same code for Home's update indicator,
+9 -4
View File
@@ -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 pub get && flutter run -d linux
cd apps/desktop && flutter build linux --release --dart-define=APP_VERSION=<x.y.z> cd apps/desktop && flutter build linux --release --dart-define=APP_VERSION=<x.y.z>
tool/package_linux.sh [--skip-build] [--noupload] # dist/*.tar.gz 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 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 1. `tool/release.sh [patch|minor|major] [--dry-run]`: derives the next
version from the latest tag, bumps the three pubspecs in lockstep, writes 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. makes an annotated tag.
2. Review, then `git push --follow-tags`. 2. Review, then `git push --follow-tags`.
3. `.gitea/workflows/release.yml` builds the Linux tarball and runs 3. `.gitea/workflows/release.yml` runs one job per platform: Linux builds
`tool/release_publish.sh vX.Y.Z`, which creates the release and uploads it. with `tool/package_linux.sh`, Windows (runner `yuuka-windows`, host mode,
By hand: `tool/package_linux.sh` then `tool/release_publish.sh vX.Y.Z`. PowerShell only) with `tool/package_windows.ps1`. Each then runs
`dart tool/release_publish.dart vX.Y.Z <archive>`, 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 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
+6 -7
View File
@@ -6,13 +6,12 @@ off by deleting it in the same commit as the code. Wire changes also edit
## Backlog ## Backlog
1. **Windows build + CI.** There is no Windows runner. Try 1. **Windows: first real install.** CI builds and publishes
`ubuntu2610/flutter_build` (LLVM-MinGW + Wine `gen_snapshot`) on the Linux `watch-together-<ver>-windows-x64.zip` (libmpv bundled) and runs the
runner, fallback a GitHub Actions mirror. The manifest needs per-platform updater tests on NTFS, but nobody has launched the app on Windows yet.
URLs and `release_publish.sh` must handle a second platform. The updater's Check: playback, fullscreen controls, hosting (firewall prompt), in-app
Windows apply path exists but is untested. The tarball's update from one release to the next. The rest of the desktop tests are
`watch-together-server` needs a Windows `dart compile exe` too. Desktop not in CI.
tests are not in CI either.
2. **Jellyfin** (epic, below). 2. **Jellyfin** (epic, below).
+55
View File
@@ -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-<version>-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
+10 -3
View File
@@ -52,7 +52,6 @@ ORIGIN="$(git -C "$ROOT" remote get-url origin)"
SERVER="$(echo "$ORIGIN" | sed -E 's#(https?://[^/]+)/.*#\1#')" SERVER="$(echo "$ORIGIN" | sed -E 's#(https?://[^/]+)/.*#\1#')"
REPO="$(echo "${ORIGIN%.git}" | sed -E 's#.*[:/]([^/]+/[^/]+)$#\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")" 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 if [[ -n "$LATEST" && "$LATEST" != "v$VER" ]]; then
NOTES="$(git -C "$ROOT" log "$LATEST..HEAD" --format='- %s' | head -n 20)" NOTES="$(git -C "$ROOT" log "$LATEST..HEAD" --format='- %s' | head -n 20)"
else else
@@ -60,7 +59,7 @@ else
fi fi
export MANIFEST_FILE="$ROOT/updates/manifest.json" MANIFEST_VER="$VER" \ export MANIFEST_FILE="$ROOT/updates/manifest.json" MANIFEST_VER="$VER" \
MANIFEST_PROTO="$PROTO" MANIFEST_NOTES="$NOTES" \ 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' python3 - <<'EOF'
import json, os import json, os
path = os.environ['MANIFEST_FILE'] path = os.environ['MANIFEST_FILE']
@@ -69,10 +68,18 @@ try:
manifest = json.load(f) manifest = json.load(f)
except (FileNotFoundError, ValueError): except (FileNotFoundError, ValueError):
manifest = {} 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({ manifest.update({
'version': os.environ['MANIFEST_VER'], 'version': os.environ['MANIFEST_VER'],
'protocol': int(os.environ['MANIFEST_PROTO']), '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 '', 'notes': os.environ['MANIFEST_NOTES'].splitlines()[0] if os.environ['MANIFEST_NOTES'] else '',
}) })
manifest.pop('sha256', None) manifest.pop('sha256', None)
+174
View File
@@ -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 <archive>
// (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<void> main(List<String> args) async {
if (args.length != 2) {
_die('usage: dart tool/release_publish.dart vX.Y.Z <archive>');
}
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<String> 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<int>? 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<Map<String, Object?>?> 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<String, Object?>;
}
// null on 409 (already exists).
Future<Map<String, Object?>?> 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<String, Object?>;
}
Future<void> 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<void> upload(String url, File file, String name) async {
final boundary = 'wt-${DateTime.now().microsecondsSinceEpoch}';
final body = <int>[
...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}');
}
}
}
+15
View File
@@ -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 }