Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 21 additions & 12 deletions docs/RELEASES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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.
4 changes: 3 additions & 1 deletion docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 `<prefix>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 |
77 changes: 50 additions & 27 deletions server/internal/release/source.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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"),
}
}

Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
Comment on lines +225 to +226

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the prerelease cache as the version floor

When a deployment that previously cached a prerelease restarts with RELEASE_INCLUDE_PRERELEASE disabled, this rejection leaves s.current nil. The ensuing refresh therefore skips both monotonicity checks and can download a lower stable tag with a lower versionCode, replacing the newer cached artifact despite the documented no-downgrade guarantee. Keep the rejected prerelease's version metadata as a non-serving monotonic floor, or otherwise prevent a lower stable release from overwriting it.

Useful? React with 👍 / 👎.

}
apkName := meta.APK
if apkName == "" {
// Backward compatibility for caches written before immutable artifact
Expand Down Expand Up @@ -249,30 +263,33 @@ 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 {
s.warnFailed(err)
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)
Expand All @@ -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
}

Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading