From 32848aaf71a216a2cc85e58b76c3b208e9e8aa31 Mon Sep 17 00:00:00 2001 From: foxyz Date: Sun, 13 Sep 2026 03:03:48 +0300 Subject: [PATCH] Simplify release DX: local manifest, optional hash, slimmer publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit release.sh writes updates/manifest.json at release time (predictable asset URL) — no CI commit-back, no push-token problem, no detached checkout dance. release_publish.sh is create-release + upload only. Manifest sha256 now optional; updater skips verification with a note when absent (HTTPS + gzip CRC cover the realistic cases). Fixed the repo-URL sed keeping a .git suffix (caught by a worktree dry run). Updater tests: 8 (sha-less manifest flow added). --- .gitea/workflows/release.yml | 2 +- AGENTS.md | 4 +- HANDOFF.md | 6 ++- apps/desktop/lib/src/update/updater.dart | 21 +++++---- apps/desktop/test/updater_test.dart | 40 ++++++++++++++++- docs/ROADMAP.md | 6 +-- tool/release.sh | 47 +++++++++++++++++--- tool/release_publish.sh | 55 +++++------------------- updates/manifest.example.json | 1 - 9 files changed, 111 insertions(+), 71 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index b081667..c27a2ae 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -34,7 +34,7 @@ jobs: name: linux-tarball path: dist/*.tar.gz - - name: Publish release + manifest + - name: Publish release if: startsWith(github.ref, 'refs/tags/v') env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md index 27fc976..8c6cdc2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,8 +27,8 @@ pre-commit check. Tests run only when the code under test changed. Release flow: `tool/release.sh [patch|minor|major]` derives the version (no typing), bumps all pubspecs, commits, tags → review → `git push --follow-tags` → `.gitea/workflows/release.yml` -builds, publishes the Forgejo release, commits -`updates/manifest.json` back. `tool/release_publish.sh` is the same +builds and publishes the Forgejo release (manifest already written +by release.sh — predictable asset URL, no CI roundtrip). `tool/release_publish.sh` is the same flow by hand. Workflows live in `.gitea/workflows` (this forge reads `.gitea` first, `.github` is ignored when it exists). Never commit secrets: tokens arrive via env (`DISCORD_WEBHOOK_URL`, diff --git a/HANDOFF.md b/HANDOFF.md index 60ac031..65bd3d2 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -289,8 +289,10 @@ remain in session_screen.dart. (wired in `tool/package_linux.sh`; dev runs report `0.0.0-dev`). Publishing is CI-driven: tag `vX.Y.Z` → `.gitea/workflows/release.yml` builds, creates the Forgejo - release, uploads the tarball, commits the manifest back - (`tool/release_publish.sh` does the same by hand). + release, uploads the tarball. Manifest is written locally by + `release.sh` (predictable asset URL, no CI roundtrip); + sha256 is optional — skipped with a note when absent + (HTTPS + gzip CRC cover the realistic cases). Manual trigger only: bump desktop pubspec in a release commit first (script enforces tag == pubspec). No silent proto jumps: updater warns when manifest protocol != local. diff --git a/apps/desktop/lib/src/update/updater.dart b/apps/desktop/lib/src/update/updater.dart index 81f0b0f..f397399 100644 --- a/apps/desktop/lib/src/update/updater.dart +++ b/apps/desktop/lib/src/update/updater.dart @@ -24,34 +24,32 @@ class UpdateManifest { final String version; final int protocol; final String url; - final String sha256; + final String? sha256; final String notes; const UpdateManifest( {required this.version, required this.protocol, required this.url, - required this.sha256, + this.sha256, required this.notes}); factory UpdateManifest.fromJson(Map json) { final version = json['version']; final protocol = json['protocol']; final url = json['url']; - final sha = json['sha256']; if (version is! String || version.isEmpty || protocol is! int || url is! String || - url.isEmpty || - sha is! String || - sha.isEmpty) { - throw const FormatException('update manifest missing version/protocol/url/sha256'); + url.isEmpty) { + throw const FormatException('update manifest missing version/protocol/url'); } + final sha = json['sha256']; return UpdateManifest( version: version, protocol: protocol, url: url, - sha256: sha, + sha256: sha is String && sha.isNotEmpty ? sha : null, notes: (json['notes'] as String?) ?? ''); } } @@ -220,7 +218,12 @@ Future runUpgrade( final file = File('${tmp.path}/update.tar.gz'); stdout.writeln('downloading ${manifest.url} ...'); await downloadFile(manifest.url, file, http: http); - await verifySha256(file, manifest.sha256); + if (manifest.sha256 != null) { + await verifySha256(file, manifest.sha256!); + } else { + stdout.writeln('no checksum in manifest — skipping verification ' + '(HTTPS transit + gzip CRC still apply).'); + } stdout.writeln('verified, applying over ${root.path} ...'); await applyTarball(file, root); } catch (e) { diff --git a/apps/desktop/test/updater_test.dart b/apps/desktop/test/updater_test.dart index 273d3d6..349267e 100644 --- a/apps/desktop/test/updater_test.dart +++ b/apps/desktop/test/updater_test.dart @@ -16,15 +16,20 @@ void main() { expect(isNewerVersion('0.2.0', '0.1.9'), isFalse); }); - test('manifest rejects missing fields', () { + test('manifest rejects missing fields, sha256 is optional', () { expect(() => UpdateManifest.fromJson({}), throwsFormatException); expect( () => UpdateManifest.fromJson({ 'version': '0.2.0', 'protocol': 4, - 'url': 'https://x/y.tar.gz', }), throwsFormatException); + final noSha = UpdateManifest.fromJson({ + 'version': '0.2.0', + 'protocol': 4, + 'url': 'https://x/y.tar.gz', + }); + expect(noSha.sha256, isNull); }); test('findInstallRoot honors bundle layout', () async { @@ -162,6 +167,37 @@ void main() { 'binary-v1'); }); + test('manifest without sha256 still applies', () async { + await server.close(force: true); + server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + server.listen((req) async { + if (req.uri.path == '/manifest.json') { + req.response + ..statusCode = 200 + ..write(jsonEncode({ + 'version': '0.2.0', + 'protocol': 4, + 'url': + 'http://127.0.0.1:${server.port}/watch-together-0.2.0.tar.gz', + 'notes': '', + })); + } else { + req.response + ..statusCode = 200 + ..add(tarball); + } + await req.response.close(); + }); + final root = await fakeRoot(); + final rc = await runUpgrade( + currentVersion: '0.1.0', + manifestUrl: manifestUrl(), + installRoot: root); + expect(rc, 0); + expect(await File('${root.path}/watch_together').readAsString(), + 'binary-v2'); + }); + test('unknown layout asks for manual update', () async { final rc = await runUpgrade( currentVersion: '0.1.0', diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 426453a..1416200 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -25,9 +25,9 @@ desktop 17 + 7 updater. ## Backlog (priority order, each one sitting-sized) 1. First real release: `tool/release.sh` (derives the version, - no typing) → review → `git push --follow-tags` → CI - (`release.yml`) builds the tarball, publishes the Forgejo - release, and commits the manifest back. Verifies the whole + writes the manifest, no typing) → review → + `git push --follow-tags` → CI (`release.yml`) builds the + tarball and publishes the Forgejo release. Verifies the whole `--upgrade` chain end to end. Manual fallback is `tool/package_linux.sh` + `tool/release_publish.sh `. 2. Jumpy seek: live repro with timeline log first, then fix the diff --git a/tool/release.sh b/tool/release.sh index ab4265d..1b5f364 100755 --- a/tool/release.sh +++ b/tool/release.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash # Cut a release without typing a version anywhere: derives the next # version (patch by default), bumps all three pubspecs in lockstep, -# commits, and tags. Review, then `git push --follow-tags` — CI takes -# it from there (build, Forgejo release, manifest commit-back). +# writes updates/manifest.json (predictable asset URL — no CI roundtrip +# needed), commits, and tags. Review, then `git push --follow-tags` — +# CI takes it from there (build, Forgejo release, asset upload). # Usage: tool/release.sh [patch|minor|major] [--dry-run] set -euo pipefail @@ -46,11 +47,45 @@ fi for f in $PUBSPECS; do sed -i -E "s/^version: .*/version: $VER/" "$ROOT/$f" done -git -C "$ROOT" add $PUBSPECS -if git -C "$ROOT" diff --cached --quiet; then - echo "pubspecs already at $VER — tagging only" + +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 - git -C "$ROOT" commit -m "Release v$VER" -- $PUBSPECS + NOTES="Watch together $VER." +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" +python3 - <<'EOF' +import json, os +path = os.environ['MANIFEST_FILE'] +try: + with open(path) as f: + manifest = json.load(f) +except (FileNotFoundError, ValueError): + manifest = {} +manifest.update({ + 'version': os.environ['MANIFEST_VER'], + 'protocol': int(os.environ['MANIFEST_PROTO']), + 'url': os.environ['MANIFEST_URL'], + 'notes': os.environ['MANIFEST_NOTES'].splitlines()[0] if os.environ['MANIFEST_NOTES'] else '', +}) +manifest.pop('sha256', None) +with open(path, 'w') as f: + json.dump(manifest, f, indent=2) + f.write('\n') +EOF + +git -C "$ROOT" add $PUBSPECS updates/manifest.json +if git -C "$ROOT" diff --cached --quiet; then + echo "everything already at $VER — tagging only" +else + git -C "$ROOT" commit -m "Release v$VER" -- $PUBSPECS updates/manifest.json fi git -C "$ROOT" tag -a -m "Release v$VER" "v$VER" echo "review with: git show v$VER — then: git push --follow-tags" diff --git a/tool/release_publish.sh b/tool/release_publish.sh index 283309f..896ce1b 100755 --- a/tool/release_publish.sh +++ b/tool/release_publish.sh @@ -1,12 +1,12 @@ #!/usr/bin/env bash -# Publish a release to Forgejo: create the release, upload the Linux -# tarball built by tool/package_linux.sh, and commit the updated -# updates/manifest.json back to main so --upgrade clients see the new -# version at the stable raw URL. +# Publish a release to Gitea: create the release and upload the Linux +# tarball built by tool/package_linux.sh. The manifest is written by +# tool/release.sh at release time (predictable asset URL), so this +# script never touches git — no push-back, no write-token problem +# beyond the release API itself. # Usage: tool/release_publish.sh vX.Y.Z (run after package_linux.sh) # Env: RELEASE_TOKEN (preferred) or the Actions-provided GITHUB_TOKEN / -# GITEA_TOKEN (needs repo write or the final push is denied — then set a -# RELEASE_TOKEN secret with write access). +# GITEA_TOKEN. set -euo pipefail TAG="${1:?usage: tool/release_publish.sh vX.Y.Z}" @@ -18,16 +18,14 @@ TARBALL="$ROOT/dist/watch-together-$VER-linux-x64.tar.gz" [[ -f "$TARBALL" ]] || { echo "error: run tool/package_linux.sh first ($TARBALL missing)" >&2; exit 1; } PUB="$(sed -n 's/^version: \([0-9.]*\).*/\1/p' "$ROOT/apps/desktop/pubspec.yaml")" -[[ "$PUB" == "$VER" ]] || { echo "error: tag $TAG != desktop pubspec $PUB (bump it in a release commit first)" >&2; exit 1; } +[[ "$PUB" == "$VER" ]] || { echo "error: tag $TAG != desktop pubspec $PUB (run tool/release.sh first)" >&2; exit 1; } ORIGIN="$(git -C "$ROOT" remote get-url origin)" SERVER="$(echo "$ORIGIN" | sed -E 's#(https?://[^/]+)/.*#\1#')" -REPO="$(echo "$ORIGIN" | sed -E 's#.*[:/]([^/]+/[^/]+?)(\.git)?$#\1#')" +REPO="$(echo "${ORIGIN%.git}" | sed -E 's#.*[:/]([^/]+/[^/]+)$#\1#')" TOKEN="${RELEASE_TOKEN:-${GITHUB_TOKEN:-${GITEA_TOKEN:-}}}" -[[ -n "$TOKEN" ]] || { echo "error: set RELEASE_TOKEN (or run in Actions with a write token)" >&2; exit 1; } +[[ -n "$TOKEN" ]] || { echo "error: set RELEASE_TOKEN (or run in Actions)" >&2; exit 1; } -PROTO="$(sed -n 's/.*protoVersion = \([0-9]*\).*/\1/p' "$ROOT/packages/core/lib/src/constants.dart")" -SHA="$(sha256sum "$TARBALL" | cut -d' ' -f1)" ASSET="$(basename "$TARBALL")" PREV="$(git -C "$ROOT" describe --tags --abbrev=0 HEAD 2>/dev/null || true)" if [[ -n "$PREV" && "$PREV" != "$TAG" ]]; then @@ -48,37 +46,4 @@ echo "uploading $ASSET ..." curl -fsS -H "Authorization: token $TOKEN" -H 'Content-Type: application/gzip' \ --data-binary "@$TARBALL" \ "$SERVER/api/v1/repos/$REPO/releases/$REL_ID/assets?name=$ASSET" > /dev/null - -export MANIFEST_FILE="$ROOT/updates/manifest.json" MANIFEST_VER="$VER" \ - MANIFEST_PROTO="$PROTO" MANIFEST_SHA="$SHA" \ - MANIFEST_URL="$SERVER/$REPO/releases/download/$TAG/$ASSET" -python3 - <<'EOF' -import json, os -path = os.environ['MANIFEST_FILE'] -try: - with open(path) as f: - manifest = json.load(f) -except (FileNotFoundError, ValueError): - manifest = {} -manifest.update({ - 'version': os.environ['MANIFEST_VER'], - 'protocol': int(os.environ['MANIFEST_PROTO']), - 'url': os.environ['MANIFEST_URL'], - 'sha256': os.environ['MANIFEST_SHA'], - 'notes': os.environ['REL_NOTES'].splitlines()[0] if os.environ['REL_NOTES'] else '', -}) -with open(path, 'w') as f: - json.dump(manifest, f, indent=2) - f.write('\n') -EOF - -echo "committing manifest back to main ..." -git -C "$ROOT" fetch origin main 2>/dev/null || true -git -C "$ROOT" checkout -B main origin/main -git -C "$ROOT" add updates/manifest.json -git -C "$ROOT" -c user.name='watch-together-release' -c user.email='release@localhost' \ - commit -m "Release $TAG manifest" -- updates/manifest.json -if ! git -C "$ROOT" push -o ci.skip origin main 2>/dev/null; then - git -C "$ROOT" push origin main -fi -echo "published $TAG — manifest live at updates/manifest.json on main." +echo "published $TAG." diff --git a/updates/manifest.example.json b/updates/manifest.example.json index 5e47177..490f958 100644 --- a/updates/manifest.example.json +++ b/updates/manifest.example.json @@ -2,6 +2,5 @@ "version": "0.2.0", "protocol": 4, "url": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v0.2.0/watch-together-0.2.0-linux-x64.tar.gz", - "sha256": "", "notes": "One-line highlight of what changed." }