From b8e86c28b7c788ecd5c697c62cce378bf962494a Mon Sep 17 00:00:00 2001 From: Turin-dev Date: Wed, 26 Aug 2026 22:47:28 +0900 Subject: [PATCH] feat: opt into prerelease APK serving --- docs/RELEASES.md | 33 +++++--- docs/USAGE.md | 4 +- server/internal/release/source.go | 77 +++++++++++------ server/internal/release/source_test.go | 109 ++++++++++++++++++++++--- 4 files changed, 170 insertions(+), 53 deletions(-) diff --git a/docs/RELEASES.md b/docs/RELEASES.md index 6351490..7878fc5 100644 --- a/docs/RELEASES.md +++ b/docs/RELEASES.md @@ -19,15 +19,21 @@ be exactly `MAJOR.MINOR.PATCH`. The prefix is configurable with `RELEASE_TAG_PREFIX`, but defaults to `agent-v`, preventing an old or unrelated APK from becoming the current agent by accident. -The public version server ignores drafts, prereleases, malformed channel tags, -and releases without an `.apk` asset. It selects the highest compatible tag by -semantic version rather than GitHub release creation order. Once a rewrite APK -is cached, a replacement must have both a greater tag version and a strictly -greater Android `versionCode`; lower or equal versions are never downloaded as -an update. Both downloaded and cached APKs are rejected when their embedded -`versionName` differs from the tag suffix. Downloaded, cached, and `APK_PATH` -override files are capped at 128 MiB; inflated `AndroidManifest.xml` data is -bounded separately while parsing. +By default, the public version server ignores drafts, prereleases, malformed +channel tags, and releases without an `.apk` asset. It selects the highest +compatible stable tag by semantic version rather than GitHub release creation +order. A preview deployment can explicitly set +`RELEASE_INCLUDE_PRERELEASE=true`; that opt-in includes published prereleases +but still excludes drafts and retains all APK/version validation. The preview +flag is intended for controlled testing and must not be confused with stable +release acceptance. + +Once a rewrite APK is cached, a replacement must have both a greater tag +version and a strictly greater Android `versionCode`; lower or equal versions +are never downloaded as an update. Both downloaded and cached APKs are +rejected when their embedded `versionName` differs from the tag suffix. +Downloaded, cached, and `APK_PATH` override files are capped at 128 MiB; +inflated `AndroidManifest.xml` data is bounded separately while parsing. The paginated release scan is capped at 1,000 entries. Reaching that bound before a final short page rejects the entire refresh, so the server never @@ -46,7 +52,9 @@ The npm package `rish-mcp-setup` has its own independent semantic version. A CLI package version is not an Android agent version and must not be used to select an APK. -No signed rewrite APK has been published yet. +`agent-v0.1.0` is currently published as a signed GitHub prerelease for +controlled preview testing. It is not a stable release because the real-device +pairing, relay, command, and upgrade gates below are still outstanding. ## Publication gates @@ -63,5 +71,6 @@ An `agent-vX.Y.Z` release is ready only after all of the following are recorded: a real supported Android device. 7. Upgrade behavior from any supported prior rewrite release is verified. -Build success alone is not release acceptance. Until these gates are met, use -a locally built debug APK and do not advertise it as an official release. +Build success alone is not stable release acceptance. A signed preview may be +used for controlled testing, but until these gates are met it must not be +advertised as a stable official release. diff --git a/docs/USAGE.md b/docs/USAGE.md index 2d56aca..118d729 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -265,7 +265,8 @@ curl -sO https://dl.example.com/agent.apk # no token needed It lists stable GitHub releases in the configured channel and selects the highest semantic version, rather than trusting GitHub creation order or the -repository-wide `latest` release. A channel tag must be exactly +repository-wide `latest` release. Set `RELEASE_INCLUDE_PRERELEASE=true` only +for an explicitly configured preview deployment. A channel tag must be exactly `RELEASE_TAG_PREFIX` + `MAJOR.MINOR.PATCH`; the default prefix is `agent-v`, so a rewrite release is tagged, for example, `agent-v0.1.0`. This separate channel intentionally excludes historical `v0.2`–`v0.5` releases, which contain the @@ -348,5 +349,6 @@ Connection query params on `GET /agent`: `token`, `deviceId`, `name`, `sdk`, | `RELEASE_TAG_PREFIX` | | `agent-v` | Rewrite release channel; accepted tags are exactly `MAJOR.MINOR.PATCH`, and the highest compatible version is selected | | `RELEASE_CACHE_DIR` | | `/var/cache/rish-mcp` | Where the fetched APK is cached | | `RELEASE_POLL_MS` | | `900000` | How often to check GitHub for a newer release | +| `RELEASE_INCLUDE_PRERELEASE` | | `false` | Preview-only opt-in to include published GitHub prereleases; drafts remain excluded | | `GITHUB_API_BASE` | | `https://api.github.com` | Overridable for testing | | `APK_PATH` | | — | Serve this local APK (maximum 128 MiB) instead of fetching a release; disables GitHub polling | diff --git a/server/internal/release/source.go b/server/internal/release/source.go index 8804187..5b98deb 100644 --- a/server/internal/release/source.go +++ b/server/internal/release/source.go @@ -82,6 +82,10 @@ type SourceOptions struct { // TagPrefix is the release channel. Only GitHub releases whose tag starts // with this prefix can be downloaded or restored from the cache. TagPrefix string + // IncludePrerelease opts into serving GitHub prereleases. Keep this false + // for the stable channel; it exists for explicitly configured preview + // deployments and is never enabled by default. + IncludePrerelease bool // LocalAPK is a dev/test escape hatch: serve this file and never call GitHub. LocalAPK string } @@ -94,12 +98,13 @@ func SourceOptionsFromEnv() SourceOptions { } } return SourceOptions{ - Repo: envOr("GITHUB_REPO", "turin-dev/rish-mcp"), - CacheDir: envOr("RELEASE_CACHE_DIR", "/var/cache/rish-mcp"), - APIBase: envOr("GITHUB_API_BASE", "https://api.github.com"), - PollEvery: time.Duration(pollMs) * time.Millisecond, - TagPrefix: envOr("RELEASE_TAG_PREFIX", defaultReleaseTagPrefix), - LocalAPK: os.Getenv("APK_PATH"), + Repo: envOr("GITHUB_REPO", "turin-dev/rish-mcp"), + CacheDir: envOr("RELEASE_CACHE_DIR", "/var/cache/rish-mcp"), + APIBase: envOr("GITHUB_API_BASE", "https://api.github.com"), + PollEvery: time.Duration(pollMs) * time.Millisecond, + TagPrefix: envOr("RELEASE_TAG_PREFIX", defaultReleaseTagPrefix), + IncludePrerelease: envBool("RELEASE_INCLUDE_PRERELEASE"), + LocalAPK: os.Getenv("APK_PATH"), } } @@ -110,6 +115,11 @@ func envOr(key, def string) string { return def } +func envBool(key string) bool { + v, err := strconv.ParseBool(os.Getenv(key)) + return err == nil && v +} + // Source polls GitHub for the highest published release in its configured tag // channel, downloads and caches its .apk asset, and serves whatever compatible // release it has — even a stale cache — rather than going empty because of a @@ -191,9 +201,10 @@ func (s *Source) loadCache() { } type cacheMetadata struct { - Tag string `json:"tag"` - APK string `json:"apk,omitempty"` - FetchedAt string `json:"fetchedAt,omitempty"` + Tag string `json:"tag"` + APK string `json:"apk,omitempty"` + Prerelease bool `json:"prerelease,omitempty"` + FetchedAt string `json:"fetchedAt,omitempty"` } func (s *Source) readCachedRelease() (*Release, error) { @@ -211,6 +222,9 @@ func (s *Source) readCachedRelease() (*Release, error) { if _, ok := parseReleaseVersion(meta.Tag, s.opts.TagPrefix); !ok { return nil, fmt.Errorf("cached release tag %q is not a valid %sMAJOR.MINOR.PATCH tag", meta.Tag, s.opts.TagPrefix) } + if meta.Prerelease && !s.opts.IncludePrerelease { + return nil, fmt.Errorf("cached release %q is a prerelease but RELEASE_INCLUDE_PRERELEASE is disabled", meta.Tag) + } apkName := meta.APK if apkName == "" { // Backward compatibility for caches written before immutable artifact @@ -249,9 +263,10 @@ func (s *Source) isCompatibleTag(tag string) bool { return strings.HasPrefix(tag, s.opts.TagPrefix) && len(tag) > len(s.opts.TagPrefix) } -// refresh checks GitHub for the highest stable release in the configured tag -// channel and downloads it. Errors are swallowed: a failed refresh just means -// "keep serving whatever compatible release we already have". +// refresh checks GitHub for the highest release in the configured tag channel +// and downloads it. Prereleases are considered only when explicitly enabled. +// Errors are swallowed: a failed refresh just means "keep serving whatever +// compatible release we already have". func (s *Source) refresh(ctx context.Context) { releases, err := s.fetchReleases(ctx) if err != nil { @@ -259,20 +274,22 @@ func (s *Source) refresh(ctx context.Context) { return } - // GitHub returns releases newest-first. Ignore drafts and prereleases to - // preserve the old implicit-latest endpoint's stable-channel behaviour, - // then choose the highest compatible semantic version that has an APK. + // GitHub returns releases newest-first. Ignore drafts and, unless the + // preview flag is enabled, prereleases to preserve the stable-channel + // behaviour. Then choose the highest compatible semantic version that has + // an APK. // Selecting by tag version, rather than API creation order, prevents a // later-created lower tag from downgrading the published agent. var ( - selectedTag string - selectedVersion releaseVersion - assetURL string - assetName string - haveSelection bool + selectedTag string + selectedVersion releaseVersion + assetURL string + assetName string + selectedPrerelease bool + haveSelection bool ) for _, candidate := range releases { - if candidate.Draft || candidate.Prerelease || !s.isCompatibleTag(candidate.TagName) { + if candidate.Draft || (!s.opts.IncludePrerelease && candidate.Prerelease) || !s.isCompatibleTag(candidate.TagName) { continue } candidateVersion, ok := parseReleaseVersion(candidate.TagName, s.opts.TagPrefix) @@ -295,11 +312,16 @@ func (s *Source) refresh(ctx context.Context) { selectedVersion = candidateVersion assetURL = candidateAssetURL assetName = candidateAssetName + selectedPrerelease = candidate.Prerelease haveSelection = true } } if !haveSelection { - s.warnFailed(fmt.Errorf("no stable release with tag prefix %q and a .apk asset", s.opts.TagPrefix)) + releaseKind := "stable" + if s.opts.IncludePrerelease { + releaseKind = "stable or prerelease" + } + s.warnFailed(fmt.Errorf("no %s release with tag prefix %q and a .apk asset", releaseKind, s.opts.TagPrefix)) return } @@ -318,7 +340,7 @@ func (s *Source) refresh(ctx context.Context) { } log.Printf("[release] fetching %s from %s", assetName, selectedTag) - if err := s.downloadAndPublish(ctx, assetURL, selectedTag); err != nil { + if err := s.downloadAndPublish(ctx, assetURL, selectedTag, selectedPrerelease); err != nil { s.warnFailed(err) } } @@ -371,7 +393,7 @@ func (s *Source) fetchJSON(ctx context.Context, url string, timeout time.Duratio return body, nil } -func (s *Source) downloadAndPublish(ctx context.Context, assetURL, tag string) error { +func (s *Source) downloadAndPublish(ctx context.Context, assetURL, tag string, prerelease bool) error { version, ok := parseReleaseVersion(tag, s.opts.TagPrefix) if !ok { return fmt.Errorf("release tag %q is not a valid %sMAJOR.MINOR.PATCH tag", tag, s.opts.TagPrefix) @@ -444,9 +466,10 @@ func (s *Source) downloadAndPublish(ctx context.Context, assetURL, tag string) e apkName := immutableAPKName(info) apkPath := filepath.Join(s.opts.CacheDir, apkName) metaBytes, err := json.Marshal(cacheMetadata{ - Tag: tag, - APK: apkName, - FetchedAt: time.Now().UTC().Format(time.RFC3339), + Tag: tag, + APK: apkName, + Prerelease: prerelease, + FetchedAt: time.Now().UTC().Format(time.RFC3339), }) if err != nil { return fmt.Errorf("encode release metadata: %w", err) diff --git a/server/internal/release/source_test.go b/server/internal/release/source_test.go index 60ef8bf..84a9505 100644 --- a/server/internal/release/source_test.go +++ b/server/internal/release/source_test.go @@ -352,6 +352,70 @@ func TestSourceSelectsHighestStableReleaseInTagChannel(t *testing.T) { } } +func TestSourceIncludesPrereleaseOnlyWhenConfigured(t *testing.T) { + previewAPK := buildTestApkBytes(t, 2, "0.2.0") + var srv *httptest.Server + mux := http.NewServeMux() + mux.HandleFunc("/repos/test/repo/releases", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode([]map[string]any{{ + "tag_name": "agent-v0.2.0", + "prerelease": true, + "assets": []map[string]string{{ + "name": "preview.apk", + "browser_download_url": srv.URL + "/download/preview.apk", + }}, + }}) + }) + mux.HandleFunc("/download/preview.apk", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(previewAPK) + }) + srv = httptest.NewServer(mux) + defer srv.Close() + + cacheDir := t.TempDir() + stable := NewSource(SourceOptions{ + Repo: "test/repo", + CacheDir: cacheDir, + APIBase: srv.URL, + PollEvery: time.Hour, + }) + stable.refresh(context.Background()) + if rel := stable.Get(); rel != nil { + t.Fatalf("stable channel served prerelease without opt-in: %+v", rel) + } + + preview := NewSource(SourceOptions{ + Repo: "test/repo", + CacheDir: cacheDir, + APIBase: srv.URL, + PollEvery: time.Hour, + IncludePrerelease: true, + }) + preview.refresh(context.Background()) + rel := preview.Get() + if rel == nil || rel.Tag != "agent-v0.2.0" || rel.VersionName != "0.2.0" { + t.Fatalf("preview channel release = %+v, want agent-v0.2.0", rel) + } + + metaBytes, err := os.ReadFile(filepath.Join(cacheDir, "release.json")) + if err != nil { + t.Fatalf("read preview metadata: %v", err) + } + var meta cacheMetadata + if err := json.Unmarshal(metaBytes, &meta); err != nil { + t.Fatalf("decode preview metadata: %v", err) + } + if !meta.Prerelease { + t.Fatal("preview metadata did not record prerelease state") + } + + stableCache := NewSource(SourceOptions{CacheDir: cacheDir}) + stableCache.loadCache() + if rel := stableCache.Get(); rel != nil { + t.Fatalf("stable channel restored a prerelease cache: %+v", rel) + } +} + func TestSourceScansLaterReleasePagesBeforeSelectingHighest(t *testing.T) { var ( srv *httptest.Server @@ -616,7 +680,7 @@ func TestDownloadAndPublishCleansUpTmpOnFailure(t *testing.T) { t.Fatal("expected no tmp file before download") } - err := s.downloadAndPublish(ctx, srv.URL+"/bad.apk", "agent-v1.0.0") + err := s.downloadAndPublish(ctx, srv.URL+"/bad.apk", "agent-v1.0.0", false) if err == nil { t.Fatal("expected downloadAndPublish to fail with bad APK bytes") } @@ -642,7 +706,7 @@ func TestDownloadAndPublishCleansUpTmpOnFailure(t *testing.T) { })) defer srv.Close() - if err := s.downloadAndPublish(ctx, srv.URL+"/v1.apk", "agent-v1.0.0"); err != nil { + if err := s.downloadAndPublish(ctx, srv.URL+"/v1.apk", "agent-v1.0.0", false); err != nil { t.Fatalf("publish last-good release: %v", err) } good := s.Get() @@ -652,7 +716,7 @@ func TestDownloadAndPublishCleansUpTmpOnFailure(t *testing.T) { } tmpPath := filepath.Join(dir, "agent.apk.tmp") - err := s.downloadAndPublish(ctx, srv.URL+"/v2.apk", "agent-v2.0.0") + err := s.downloadAndPublish(ctx, srv.URL+"/v2.apk", "agent-v2.0.0", false) if err == nil { t.Fatal("expected stale backup to block the cache swap") } @@ -737,7 +801,7 @@ func TestDownloadAndPublishMetadataWriteFailurePreservesLastGood(t *testing.T) { })) defer v2Server.Close() - err = src.downloadAndPublish(context.Background(), v2Server.URL+"/agent.apk", "agent-v2.0.0") + err = src.downloadAndPublish(context.Background(), v2Server.URL+"/agent.apk", "agent-v2.0.0", false) if err == nil || !strings.Contains(err.Error(), "write release metadata") { t.Fatalf("expected metadata write error, got %v", err) } @@ -967,6 +1031,7 @@ func TestParseReleaseVersion(t *testing.T) { func TestSourceOptionsFromEnvDefaults(t *testing.T) { t.Setenv("RELEASE_TAG_PREFIX", "") + t.Setenv("RELEASE_INCLUDE_PRERELEASE", "") opts := SourceOptionsFromEnv() if opts.Repo != "turin-dev/rish-mcp" { t.Errorf("Repo = %q, want turin-dev/rish-mcp", opts.Repo) @@ -983,6 +1048,9 @@ func TestSourceOptionsFromEnvDefaults(t *testing.T) { if opts.TagPrefix != "agent-v" { t.Errorf("TagPrefix = %q, want agent-v", opts.TagPrefix) } + if opts.IncludePrerelease { + t.Error("IncludePrerelease = true, want false") + } if opts.LocalAPK != "" { t.Errorf("LocalAPK = %q, want empty", opts.LocalAPK) } @@ -994,6 +1062,7 @@ func TestSourceOptionsFromEnvCustomValues(t *testing.T) { t.Setenv("GITHUB_API_BASE", "https://my-gh-api.example.com") t.Setenv("RELEASE_POLL_MS", "5000") t.Setenv("RELEASE_TAG_PREFIX", "android-v") + t.Setenv("RELEASE_INCLUDE_PRERELEASE", "true") t.Setenv("APK_PATH", "/tmp/test.apk") opts := SourceOptionsFromEnv() @@ -1012,11 +1081,25 @@ func TestSourceOptionsFromEnvCustomValues(t *testing.T) { if opts.TagPrefix != "android-v" { t.Errorf("TagPrefix = %q", opts.TagPrefix) } + if !opts.IncludePrerelease { + t.Error("IncludePrerelease = false, want true") + } if opts.LocalAPK != "/tmp/test.apk" { t.Errorf("LocalAPK = %q", opts.LocalAPK) } } +func TestSourceOptionsFromEnvInvalidPrereleaseFlag(t *testing.T) { + for _, value := range []string{"not-a-bool", "false", "0"} { + t.Run(value, func(t *testing.T) { + t.Setenv("RELEASE_INCLUDE_PRERELEASE", value) + if opts := SourceOptionsFromEnv(); opts.IncludePrerelease { + t.Errorf("IncludePrerelease = true for %q, want false", value) + } + }) + } +} + func TestSourceOptionsFromEnvInvalidPollMs(t *testing.T) { for _, value := range []string{"not-a-number", "0", "-1"} { t.Run(value, func(t *testing.T) { @@ -1436,7 +1519,7 @@ func TestDownloadAndPublishErrors(t *testing.T) { defer srv.Close() src := NewSource(SourceOptions{CacheDir: t.TempDir()}) - err := src.downloadAndPublish(context.Background(), srv.URL+"/legacy.apk", "v0.5.0") + err := src.downloadAndPublish(context.Background(), srv.URL+"/legacy.apk", "v0.5.0", false) if err == nil || !strings.Contains(err.Error(), "is not a valid agent-vMAJOR.MINOR.PATCH tag") { t.Fatalf("expected release-channel error, got %v", err) } @@ -1447,14 +1530,14 @@ func TestDownloadAndPublishErrors(t *testing.T) { t.Run("bad url", func(t *testing.T) { src := NewSource(SourceOptions{}) - if err := src.downloadAndPublish(context.Background(), "://bad", "agent-v1.0.0"); err == nil { + if err := src.downloadAndPublish(context.Background(), "://bad", "agent-v1.0.0", false); err == nil { t.Fatal("expected error for malformed URL") } }) t.Run("connection refused", func(t *testing.T) { src := NewSource(SourceOptions{}) - if err := src.downloadAndPublish(context.Background(), "http://127.0.0.1:1/agent.apk", "agent-v1.0.0"); err == nil { + if err := src.downloadAndPublish(context.Background(), "http://127.0.0.1:1/agent.apk", "agent-v1.0.0", false); err == nil { t.Fatal("expected error when connection is refused") } }) @@ -1466,7 +1549,7 @@ func TestDownloadAndPublishErrors(t *testing.T) { defer srv.Close() src := NewSource(SourceOptions{}) - err := src.downloadAndPublish(context.Background(), srv.URL+"/agent.apk", "agent-v1.0.0") + err := src.downloadAndPublish(context.Background(), srv.URL+"/agent.apk", "agent-v1.0.0", false) if err == nil { t.Fatal("expected error for non-200 download") } @@ -1482,7 +1565,7 @@ func TestDownloadAndPublishErrors(t *testing.T) { defer srv.Close() src := NewSource(SourceOptions{CacheDir: t.TempDir()}) - err := src.downloadAndPublish(context.Background(), srv.URL+"/agent.apk", "agent-v1.0.0") + err := src.downloadAndPublish(context.Background(), srv.URL+"/agent.apk", "agent-v1.0.0", false) if err == nil || !strings.Contains(err.Error(), "too large") { t.Fatalf("expected oversized download rejection, got %v", err) } @@ -1496,7 +1579,7 @@ func TestDownloadAndPublishErrors(t *testing.T) { defer srv.Close() src := NewSource(SourceOptions{}) - err := src.downloadAndPublish(context.Background(), srv.URL+"/agent.apk", "agent-v1.0.0") + err := src.downloadAndPublish(context.Background(), srv.URL+"/agent.apk", "agent-v1.0.0", false) if err == nil { t.Fatal("expected error when body is truncated") } @@ -1513,7 +1596,7 @@ func TestDownloadAndPublishErrors(t *testing.T) { defer srv.Close() src := NewSource(SourceOptions{CacheDir: blocker}) - if err := src.downloadAndPublish(context.Background(), srv.URL+"/agent.apk", "agent-v1.0.0"); err == nil { + if err := src.downloadAndPublish(context.Background(), srv.URL+"/agent.apk", "agent-v1.0.0", false); err == nil { t.Fatal("expected error when the cache dir cannot be created") } }) @@ -1529,7 +1612,7 @@ func TestDownloadAndPublishErrors(t *testing.T) { defer srv.Close() src := NewSource(SourceOptions{CacheDir: cacheDir}) - if err := src.downloadAndPublish(context.Background(), srv.URL+"/agent.apk", "agent-v1.0.0"); err == nil { + if err := src.downloadAndPublish(context.Background(), srv.URL+"/agent.apk", "agent-v1.0.0", false); err == nil { t.Fatal("expected error when the tmp path is an existing directory") } }) @@ -1544,7 +1627,7 @@ func TestDownloadAndPublishVersionMismatch(t *testing.T) { cacheDir := t.TempDir() src := NewSource(SourceOptions{CacheDir: cacheDir}) - err := src.downloadAndPublish(context.Background(), srv.URL+"/agent.apk", "agent-v2.0.0") + err := src.downloadAndPublish(context.Background(), srv.URL+"/agent.apk", "agent-v2.0.0", false) if err == nil { t.Fatal("expected tag/APK version mismatch to be rejected") }