From 77fe4f6f9ccb1c37de5c86fa1f987f7f8ccfdf39 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 20 Jul 2026 20:32:58 -0400 Subject: [PATCH 1/3] feat(updates): discover FVM releases from GitHub Replace pub.dev update lookup with stable GitHub release discovery while preserving throttling, configuration safety, and command exit codes. Add major-version migration guidance and document the staged binary-only FVM 5 release plan. --- internal/fvm-5-binary-migration-plan.md | 515 ++++++++++++++++++ lib/src/runner.dart | 82 +-- lib/src/services/fvm_release_service.dart | 179 ++++++ lib/src/utils/constants.dart | 10 + lib/src/utils/context.dart | 2 + pubspec.lock | 24 - pubspec.yaml | 1 - test/src/runner_test.dart | 259 ++++++++- .../services/fvm_release_service_test.dart | 211 +++++++ test/testing_utils.dart | 2 + 10 files changed, 1213 insertions(+), 72 deletions(-) create mode 100644 internal/fvm-5-binary-migration-plan.md create mode 100644 lib/src/services/fvm_release_service.dart create mode 100644 test/src/services/fvm_release_service_test.dart diff --git a/internal/fvm-5-binary-migration-plan.md b/internal/fvm-5-binary-migration-plan.md new file mode 100644 index 000000000..900a6f8d3 --- /dev/null +++ b/internal/fvm-5-binary-migration-plan.md @@ -0,0 +1,515 @@ +# FVM 5 binary-first migration plan + +> Move FVM 5 to a standalone-CLI distribution without stranding existing +> pub.dev users, then validate every supported release channel before the +> stable cutover. + +- Status: Checkpoint A implemented; release and platform milestones proposed + for review +- Base: `origin/release/5.0` at `0453eb80` +- Working branch: `fvm-5-binary-migration` +- Reviewed: 2026-07-20 +- Checkpoint A environment: Dart `3.10.7` on macOS arm64 + +## Objective + +- Ship the FVM 5 CLI as a standalone binary. Users must not need a Dart SDK to + run the released CLI. +- Use the latest stable Dart SDK as the FVM 5 source and build floor. As of this + review, that is Dart `3.12.2`, so the initial FVM 5 constraint is + `>=3.12.2 <4.0.0` and release jobs are pinned to `3.12.2`. +- Publish one final FVM 4 bridge release to pub.dev. It keeps the FVM 4 Dart + compatibility floor, discovers updates from GitHub Releases, and directs + users to the standalone installation path when FVM 5 becomes stable. +- Preserve FVM configuration, Flutter SDK caches, project references, command + exit codes, and command behavior while changing update discovery and + distribution. +- Treat the CLI as the minimum FVM 5 deliverable. The separately versioned + `fvm_mcp` package remains compatible with the CLI but does not block the CLI + migration or get embedded into `fvm` in this plan. + +### Out of scope + +- A pub.dev bootstrap or wrapper package for FVM 5. +- An in-place `fvm update` command or automatic binary replacement. Package + managers and the install scripts remain responsible for installation. +- New Flutter SDK-management behavior, cache layouts, Git operations, or + project configuration formats. +- Embedding the MCP server into the CLI. +- Replacing `cli_pkg` merely for cleanup. Its archive-building behavior can be + retained while the workflow around it is made deterministic. + +## Decisions already made + +1. **Binary-only FVM 5:** the root package will use `publish_to: none`; there is + no Dart-dependent wrapper. +2. **Staged bridge:** a final FVM 4 release is published before FVM 5 stable. +3. **CLI-first execution:** application code and tests are migrated first. The + GitHub-release, installer, package-manager, and per-platform checks are a + later mandatory gate, not part of the first implementation slice. +4. **Stable updates only:** automatic checks ignore drafts and prereleases. A + prerelease is installed only when the user explicitly chooses it. +5. **Inform, do not mutate:** update checks print guidance and never modify the + installed executable. + +## Verified current state + +| Area | Evidence | Planning consequence | +| --- | --- | --- | +| Root package | `pubspec.yaml` is `4.1.2`, requires Dart `>=3.6.0 <4.0.0`, declares `fvm: main`, and depends on `pub_updater`. | The GitHub client must land while the old Dart floor is still intact so it can be reused by the final FVM 4 release. Keep the executable declaration because `cli_pkg` reads it. | +| Update behavior | `FvmCommandRunner._checkForUpdates()` in `lib/src/runner.dart` uses pub.dev, the `disableUpdateCheck` and `lastUpdateCheck` fields, a one-day interval, debug-only failure reporting, and a check that runs alongside the requested command. | Preserve opt-out, daily cadence, non-fatal failures, concurrency, and command exit codes. Replace only the release source and notification content. | +| Update state | `LocalAppConfig.read(requireValid: true)` protects malformed configuration from overwrite; `test/src/runner_test.dart` locks this down. | Keep the strict read before persisting an attempt timestamp and retain the malformed-config regression test. No config schema change is needed. | +| Release build | `.github/workflows/release.yml` creates the GitHub release, uploads Linux archives, publishes to pub.dev, then runs Windows, macOS/Homebrew, and Docker jobs. `tool/release_tool/tool/grind.dart` configures `cli_pkg`. | FVM 5 must remove the pub step. Before that, asset creation and validation must complete before a release becomes the latest stable release. | +| Dart toolchains | Root CI uses Dart `3.10.0`; release jobs use `3.9.0`; the prepare action defaults to `3.10.0`; the release tool declares `>=3.8.0`. | FVM 5 needs one explicit `3.12.2` CLI/release toolchain. The final FVM 4 bridge retains the older floor and is tested separately. | +| Current public release | GitHub redirects the project to `conceptadev/fvm`. Release `4.1.2` uses the unprefixed tag `4.1.2` and has 12 binary archives: Linux arm/arm64/riscv64/x64 in glibc and musl variants, macOS arm64/x64, and Windows arm64/x64. No `SHA256SUMS` asset is present. | Preserve the public asset names for the first FVM 5 release, add a checksum manifest, and validate all 12 archives before publication. | +| Repository identity | Live code and docs contain `leoafarias/fvm`, `fluttertools/fvm`, and the canonical `conceptadev/fvm`. GitHub currently redirects old repository URLs after the transfer. | Canonicalize live updater, installer, workflow, and package metadata URLs. Do not rely on redirects for release infrastructure. Historical changelog links need not be rewritten. | +| Installers | `docs/public/install.sh` already installs GitHub archives for macOS/Linux. `scripts/install.ps1` uses a different repository slug, saves a ZIP as `fvm.tar.gz`, extracts it as gzip, and requires admin rights. | Keep the shell installer as the Unix baseline; replace or substantially harden the PowerShell path before Windows is declared supported for FVM 5. | +| Release precedent | `.github/workflows/release-fvm-mcp.yml` builds first, gathers artifacts, generates `SHA256SUMS`, and publishes after all builds succeed. | Reuse this release shape for the CLI while retaining `cli_pkg` as the archive builder. | +| Migration coverage | `test/integration/migration_from_v3_test.dart`, `scripts/manual-migration-test.sh`, and `docs/pages/documentation/guides/manual-smoke-test.md` already exercise isolated config, cache, links, and command flows. | Extend these patterns to cover final-FVM-4-to-FVM-5 installation without touching a developer's real home or cache. | + +Current external facts were checked against the [Dart SDK documentation](https://dart.dev/tools/sdk), +the [GitHub Releases API contract](https://docs.github.com/en/rest/releases/releases), +and GitHub's [repository-transfer guidance](https://docs.github.com/en/repositories/creating-and-managing-repositories/transferring-a-repository). +Recheck the latest stable Dart patch immediately before the FVM 5 constraint is +committed; a pubspec cannot express “whatever is latest.” + +## Recommended architecture + +Add a small **FvmReleaseService** under the existing service layer and make +`FvmCommandRunner` depend on it. The service performs one unauthenticated GitHub +API request per eligible check, parses only FVM CLI release tags matching an +optional `v` followed by semantic version text, excludes drafts and +prereleases, and returns the greatest stable `pub_semver.Version`. + +Do not use GitHub's `/releases/latest` endpoint. GitHub defines “latest” using +release metadata rather than the greatest CLI semantic version, and this +repository can also contain independently versioned MCP releases. Query the +published release list with `per_page=100`, reject tags such as +`fvm-mcp-v...`, and choose the maximum valid FVM CLI version. A response with +no valid CLI releases is a non-fatal update-check failure. + +The runner owns policy: + +- when enabled, check at most once in 24 hours; +- make a first-run check eligible, which is required for the bridge to reach + users whose config has no timestamp; +- persist the attempt timestamp through the caller's `appConfigPath` before the + network call, preserving the current “do not retry every command” behavior; +- bound the network operation to five seconds; +- run it concurrently with the requested command and never replace that + command's exit code; +- notify only when `latest > current`, never for a downgrade or equal version; +- retain the existing compact message for same-major updates; +- for a greater major version, link to the FVM 5 migration guide and explain + that pub.dev no longer carries the new CLI; +- never download or execute release assets. + +The shared service and runner changes must compile on Dart 3.6 so they can be +cherry-picked into the final FVM 4 bridge. Dart-3.12-only syntax and dependency +updates begin only after that bridge is prepared. + +### Alternatives considered + +- **Hard cut directly to FVM 5:** rejected because existing FVM 4 installations + discover updates through pub.dev and would never learn about a GitHub-only + FVM 5 release. +- **Thin pub.dev bootstrap package:** rejected because it still requires Dart, + adds download/execution and supply-chain behavior, and creates two update + systems for one CLI. +- **Publish FVM 5 to both pub.dev and native channels:** rejected because it + keeps the Dart compatibility constraint and installation conflicts that the + binary-only decision is intended to remove. +- **Rewrite all release packaging immediately:** rejected for the first cut. + Keep the proven `cli_pkg` archive generation, but move release publication + after artifact validation and checksumming. + +## Compatibility invariants + +- The final FVM 4 bridge keeps `>=3.6.0 <4.0.0` unless its current dependency + graph proves that floor is already invalid. It must be tested at the declared + minimum before publication. +- FVM 5's Dart `3.12.2` floor applies to source development and compilation. + Released standalone archives include what they need to run and must work on a + machine with no standalone Dart SDK on `PATH`. +- The global config path and JSON fields remain unchanged. Existing + `disableUpdateCheck` and `lastUpdateCheck` values continue to work. +- The cache root, Git cache, installed Flutter SDKs, global link, `.fvmrc`, + project SDK reference, VS Code settings, and Melos settings are neither moved + nor rewritten by this migration. +- `completion` and `api` retain their current update-check bypass, update + failures remain debug-only, and all CLI exit-code contracts remain intact. +- The first implementation milestone does not change `GitService`, + `FlutterService`, `EnsureCacheWorkflow`, or project-reference workflows. +- `fvm_mcp` keeps its own version and source compatibility declaration for the + CLI-first milestone. It is tested against FVM 5, but raising its declared Dart + floor requires its own release decision. + +## Work breakdown + +### Milestone A — shared application code (start here) + +- [x] **Task 1: Add stable GitHub release discovery** + - Dependencies: none + - Files: add the planned paths + lib/src/services/fvm_release_service.dart and + test/src/services/fvm_release_service_test.dart; register the service in + `lib/src/utils/context.dart`; add canonical repository/API constants to + `lib/src/utils/constants.dart`. + - Behavior: send the recommended GitHub API headers and an FVM user agent; + accept `X.Y.Z` and `vX.Y.Z`; skip drafts, prereleases, malformed tags, and + non-CLI tags; select the greatest stable semantic version independent of + response order; expose the release page URL for the notice. + - Test cases: empty response, out-of-order versions, optional `v`, an MCP tag, + draft, prerelease, malformed JSON, non-2xx response, timeout, and a valid + response mixed with invalid entries. + - Acceptance: the service makes no filesystem changes and returns a typed + release or a typed failure that the runner can suppress. + - Verification: + + ```bash + dart test test/src/services/fvm_release_service_test.dart + ``` + + - Scope: S + +- [x] **Task 2: Replace `pub_updater` without changing command contracts** + - Dependencies: Task 1 + - Files: update `lib/src/runner.dart`, `test/src/runner_test.dart`, + `pubspec.yaml`, and `pubspec.lock`. + - Behavior: inject **FvmReleaseService**; remove `pub_updater`; make a null + timestamp eligible; persist through `context.appConfigPath`; enforce the + five-second bound; compare with `pub_semver`; keep opt-out, 24-hour + throttling, concurrency, debug-only failures, and existing bypasses. + - Notice: same-major updates keep the current version arrow; greater-major + updates link to the migration guide and state that FVM 5 is installed as a + standalone CLI. Do not print installation commands that assume one package + manager. + - Regression cases: disabled checks, recent timestamp, first run, older + timestamp, equal/lower/latest versions, same-major update, major migration, + network failure, timeout, malformed global config, `--version`, API and + completion commands, and preservation of the requested command's non-zero + exit code. + - Acceptance: the following search returns no production or test references; + update checks issue at most one GitHub lookup per eligible invocation; no + update path invokes a process. + + ```bash + rg pub_updater lib test pubspec.yaml + ``` + + - Verification: + + ```bash + dart test test/src/runner_test.dart test/src/services/fvm_release_service_test.dart + ``` + + - Scope: M + +- [x] **Checkpoint A — requested first slice:** run the targeted tests plus full + analysis and unit tests under the current FVM 4-compatible SDK. Review the + diff specifically for config writes, network failure handling, output, and + exit codes. Stop here before changing publication, installers, or platform + release jobs. + + Completed on 2026-07-20. `dart analyze --fatal-infos` and `dcm analyze lib` + reported no issues; the focused service/runner suite passed 22 tests; the + full `dart test` run passed 522 tests; generated-code validation succeeded; + and the `pub_updater` acceptance search returned no matches. Milestones B-E + remain intentionally unimplemented. + +### Milestone B — GitHub and platform release gate (later, but mandatory) + +- [ ] **Task 3: Make the CLI release atomic and verifiable** + - Dependencies: Checkpoint A + - Files: `.github/workflows/release.yml`, + `tool/release_tool/tool/grind.dart`, + `tool/release_tool/test/grind_test.dart`, and + `.github/workflows/README.md`. + - Retain `cli_pkg` archive building, but upload Actions artifacts first. Only + create or publish the GitHub release after every expected archive exists, + archive contents pass inspection, and `SHA256SUMS` is generated. + - Keep public release and asset versions unprefixed (`X.Y.Z`) for installer + compatibility. If a `vX.Y.Z` trigger tag is retained, create or verify the + unprefixed tag at the exact same commit before publishing. + - Validate that the tag, `pubspec.yaml`, generated + `lib/src/version.dart`, changelog heading, release target SHA, and archive + filenames all contain the same version. + - Expected archive matrix for the initial cut: + - Linux: `arm`, `arm64`, `riscv64`, and `x64`, each with glibc and musl + archives. + - macOS: `arm64` and `x64`. + - Windows: `arm64` and `x64`. + - Publish Homebrew, Chocolatey, and Docker only after the GitHub release and + checksum verification succeed. + - Acceptance: no partially populated release can become the highest stable + CLI version seen by the updater. + - Verification: release-tool analysis/tests, a dry-run artifact build on all + three runner OSes, archive listing checks, SHA-256 verification, and + `fvm --version` from each runnable native artifact. + - Scope: M + +- [ ] **Task 4: Canonicalize and validate every installation channel** + - Dependencies: Task 3 + - Files: `docs/public/install.sh`, `scripts/install.ps1`, + `.github/workflows/test-install.yml`, `scripts/test-install.sh`, + `scripts/test-install-arch.sh`, `test/install_script_validation_test.dart`, + `tool/fvm.template.rb`, `fvm.nuspec`, `.docker/Dockerfile`, + `.docker/alpine/Dockerfile`, and live repository links in maintainer/install + documentation. + - Use `conceptadev/fvm` for live release infrastructure. Leave historical + changelog links alone. + - Unix installer: preserve user-local installation and cache-preserving + uninstall behavior; download and verify `SHA256SUMS`; test every advertised + OS/architecture/libc mapping with stubbed metadata before a live smoke. + - PowerShell installer: use the canonical repository, normalize optional `v`, + download a ZIP as a ZIP, create an isolated temporary directory, verify its + checksum, extract with the correct primitive, install user-locally, update + or clearly explain `PATH`, verify the binary, and clean up on failure. The + Chocolatey path remains separate. + - Homebrew/Chocolatey/Docker: verify metadata URLs, checksums, version output, + and installation from the candidate GitHub release. Pin Docker's Dart build + image for reproducible source builds. + - Acceptance: each advertised channel installs the requested version without + an existing Dart SDK, and a reinstall does not delete FVM's Flutter cache. + - Verification: shellcheck, Bash fixture tests, Windows PowerShell tests, + Homebrew formula test, Chocolatey package test, Docker build/smoke, and the + `test-install.yml` OS matrix against a candidate release. + - Scope: M + +- **Checkpoint B:** use a prerelease or private candidate release to run the + entire asset and installer matrix. Do not publish the final FVM 4 bridge or + change `publish_to` until this checkpoint passes. + +### Milestone C — final FVM 4 bridge + +- [ ] **Task 5: Publish one last pub.dev migration release** + - Dependencies: Checkpoint B + - Branching: prepare a maintenance branch from the current FVM 4 release line + and cherry-pick the compatibility-safe updater/release commits. Do not + cherry-pick FVM-5-only Dart-floor or `publish_to` changes. + - Version: recommend `4.2.0` because the GitHub update source and migration + notice are user-visible features; confirm the final number during release + preparation. + - Keep the FVM 4 Dart constraint and pub.dev deployment. Add release notes and + a migration guide covering pub deactivation, standalone installation, + package-manager choices, cache preservation, and rollback to FVM 4. + - Publish to pub.dev and all native channels. Initially, the GitHub client + should resolve the new FVM 4 release as current and print no migration + notice. Once FVM 5 stable exists, the same binary must discover FVM 5 and + print the major-version guidance without another FVM 4 publication. + - Acceptance: using the recommended version, `dart pub global activate fvm + 4.2.0` works on the declared minimum Dart SDK; if release preparation picks + a different final 4.x number, substitute that exact version in the same + check. Update checks no longer contact pub.dev, and all FVM 4 + config/cache/project smoke tests pass. + - Verification: minimum-SDK and latest-SDK CI jobs, pub publish dry run, + post-publish pub activation in an isolated `PUB_CACHE`, Checkpoint B's + release matrix, and the repository's manual branch smoke test. + - Scope: M + +### Milestone D — FVM 5 source and publication cutover + +- [ ] **Task 6: Apply the FVM-5-only Dart and pub changes** + - Dependencies: Task 5 is published and verified + - Files: `pubspec.yaml`, `pubspec.lock`, `lib/src/version.dart`, + `CHANGELOG.md`, `.pubignore`, `tool/release_tool/pubspec.yaml`, + `tool/release_tool/pubspec.lock`, `.github/actions/prepare/action.yml`, + `.github/workflows/test.yml`, `.github/workflows/release.yml`, standalone + deploy workflows, `README.md`, `AGENTS.md`, and maintainer documentation. + - Set the root SDK constraint to `>=3.12.2 <4.0.0`, after rechecking the + then-current stable Dart patch. Set the CLI/release CI pins and release-tool + floor to the same version. Test `fvm_mcp` on that SDK but do not raise its + declared floor in this CLI-only change. + - Add `publish_to: none`, remove pub credentials and the pub deployment job, + remove publication-only `.pubignore`, and add a CI assertion that a tagged + FVM 5 release cannot invoke `dart pub publish`. + - Keep the root `executables` map because release packaging uses it. Remove + the obsolete `bin/compile.dart` helper rather than retain its hard-coded + `/usr/local/bin` move and pub-global deactivation behavior. + - Set the first FVM 5 prerelease version and regenerate + `lib/src/version.dart`. Run build generation after every version or + mappable-model change and commit only intentional generated output. + - Acceptance: source validation uses Dart 3.12.2; the compiled CLI runs with + Dart absent from `PATH`; no FVM 5 workflow or package metadata can publish + the root package to pub.dev. + - Verification: root, release-tool, and MCP dependency resolution; analyzers; + DCM; full unit tests; build verification; native compile; and execution in + a Dart-free container/shell environment. + - Scope: M + +- [ ] **Task 7: Cut documentation and channel metadata over to binary-first** + - Dependencies: Task 6 and Checkpoint B + - Files: `README.md`, + `docs/pages/documentation/getting-started/installation.mdx`, + `docs/pages/documentation/getting-started/faq.md`, + `docs/pages/documentation/guides/workflows.mdx`, + `docs/pages/documentation/guides/global-configuration.mdx`, + `.github/workflows/README.md`, Homebrew/Chocolatey metadata, and the new FVM + 4-to-5 migration guide. + - Make the standalone script/package-manager path primary, remove FVM 5 pub + activation instructions and pub badges, and clearly separate “Dart needed + to build from source” from “Dart not needed to run FVM.” + - Keep the final FVM 4 migration instructions available at a stable URL used + by the CLI notice. + - Acceptance: every documented command corresponds to a tested installation + path and no page implies that FVM 5 is published on pub.dev. + - Verification: docs build, link check, command copy/paste review, and + candidate-install smoke tests from the rendered instructions. + - Scope: S + +### Milestone E — rollout + +- [ ] **Task 8: Rehearse, publish, observe, and discontinue pub** + - Dependencies: Tasks 6 and 7 + - Publish an FVM 5 prerelease first. Because prereleases are excluded from the + bridge updater, this cannot redirect stable FVM 4 users. + - Run Checkpoint B against the actual prerelease assets and verify config, + cache, global link, project link, VS Code, Melos, and command proxy behavior + in an isolated home. + - Publish FVM 5 stable only after every required asset, checksum, installer, + package manager, Docker image, and migration instruction is ready. + - From a clean isolated pub installation of the final FVM 4 release, confirm + the next eligible update check discovers FVM 5 and prints the migration URL. + Then install FVM 5 and verify that the same isolated FVM state still works. + - Observe the stable release before marking the pub.dev package discontinued. + When discontinuing it, link pub.dev to the migration guide; do not remove + old versions. + - Acceptance: the GitHub release is the only FVM 5 source of truth, all + supported installation paths resolve the same stable version, and legacy + users receive actionable migration guidance. + - Scope: M + +## Test strategy + +### Unit and contract tests + +- test/src/services/fvm_release_service_test.dart: HTTP/JSON/tag filtering, + semantic ordering, headers, error handling, and timeout. +- `test/src/runner_test.dart`: cadence, opt-out, first run, config persistence, + same-major and major notices, no downgrade notice, non-fatal failures, + bypasses, and exit-code preservation. +- `tool/release_tool/test/grind_test.dart`: canonical repository, version/tag + consistency, expected asset manifest, release target SHA, and GitHub failures. +- Installer tests: version normalization, exact asset selection, checksum + verification, archive safety, cleanup, cache preservation, and PATH guidance. + +### Required commands + +Run these with the SDK appropriate to the checkpoint; FVM 5 runs use Dart +`3.12.2`. + +```bash +dart pub get +dart format --output=none --set-exit-if-changed . +dart analyze --fatal-infos +dcm analyze lib +dart test +dart run build_runner build --delete-conflicting-outputs + +(cd tool/release_tool && dart pub get && dart analyze && dart test) +(cd fvm_mcp && dart pub get && dart analyze && dart test) + +shellcheck docs/public/install.sh scripts/test-install.sh scripts/test-install-arch.sh +./scripts/test-install.sh +./scripts/test-install-arch.sh +git diff --check origin/release/5.0... +``` + +After build generation, verify that `git diff` contains only expected mapper and +version output. Before final release, also run the pre-commit checks and the +manual smoke test in +`docs/pages/documentation/guides/manual-smoke-test.md` with isolated `HOME`, +`FVM_CACHE_PATH`, and `FVM_GIT_CACHE_PATH` values. + +### Migration smoke + +The end-to-end migration smoke must prove all of the following without using a +developer's real state: + +1. Activate the final FVM 4 package into an isolated `PUB_CACHE`. +2. Seed global config, a global Flutter version, a project `.fvmrc`, a cached + release/channel, VS Code settings, and Melos settings under an isolated home. +3. Make the update check eligible and confirm it resolves the FVM 5 stable + GitHub release without changing CLI state or command exit codes. +4. Deactivate the pub package, install the platform FVM 5 archive, and ensure + executable resolution now selects the standalone binary. +5. Run `fvm list`, `fvm doctor`, `fvm use`, `fvm global`, `fvm flutter + --version`, and `fvm dart --version`; confirm all seeded state and project + references remain valid. +6. Remove only the sandbox state created by the test. + +## Risks and mitigations + +- **GitHub API rate limits or outages:** an unauthenticated lookup can fail for + users behind a shared network. Mitigation: one lookup at most daily, a + five-second total bound, debug-only failure, and no effect on the requested + command. If prerelease telemetry or issue reports show this is insufficient, + publish a small signed/static release manifest as a separately reviewed + follow-up rather than adding fallback parsing to the first bridge. +- **Old pub executable shadows the new binary:** a successful install can still + resolve the pub-cache shim first. Mitigation: the migration guide explicitly + deactivates the pub package, installers print the selected path, and the + migration smoke asserts executable resolution before and after cutover. +- **Trigger and public tags diverge:** current releases use both `vX.Y.Z` and + `X.Y.Z`. Mitigation: the release workflow resolves both to the same immutable + SHA before creating assets and fails on any mismatch. +- **A non-CLI release pollutes update discovery:** MCP releases share the + repository. Mitigation: strict CLI tag parsing plus draft/prerelease and + semantic-version tests; never trust release ordering or name alone. +- **A new Dart stable patch appears during implementation:** “latest” can drift + between plan approval and cutover. Mitigation: recheck the official Dart SDK + page at the FVM-5-only checkpoint and update the constraint, lockfiles, CI, + and release-tool pins together. +- **Cross-built archives behave differently from native archives:** `cli_pkg` + can package a runtime and snapshot for non-host targets. Mitigation: retain + the current artifact names, inspect archive contents, execute every available + native artifact on its target runner, and do not advertise a target whose + candidate cannot run. + +## Release order and stop conditions + +Release order is strict: + +1. Merge and verify the compatibility-safe GitHub updater. +2. Pass the GitHub/platform release gate. +3. Publish and verify the final FVM 4 bridge on pub.dev and native channels. +4. Apply FVM-5-only Dart and no-pub changes. +5. Publish and validate an FVM 5 prerelease. +6. Publish FVM 5 stable. +7. Verify the final FVM 4-to-5 notification and state-preserving migration. +8. Update remaining channel metadata and discontinue the pub.dev package. + +Stop the rollout if any of these is true: + +- the final FVM 4 package cannot run on its declared minimum Dart SDK; +- a GitHub request can select an MCP, draft, prerelease, malformed, or lower + version as an FVM CLI update; +- the release tag, target SHA, pubspec version, generated version, changelog, or + archive version disagree; +- any required platform archive or `SHA256SUMS` is absent or invalid; +- a documented installer needs an undeclared runtime, deletes cache state, or + installs a different version; +- a standalone binary fails with Dart removed from `PATH`; +- the final FVM 4 bridge does not discover FVM 5 stable; +- config/cache/project-reference smoke behavior differs from the FVM 4 + baseline. + +## Rollback posture + +- Before FVM 5 stable, leave the final FVM 4 release as the latest stable CLI + release and fix the prerelease; no stable users are redirected. +- If an FVM 5 release is published prematurely, make it non-latest/unpublished + while preserving its artifacts for investigation, restore the final FVM 4 + release as latest, and pause package-manager promotion. +- Do not publish FVM 5 to pub.dev as a rollback. Fix forward with a corrected + GitHub release after the full release gate passes. +- No data rollback is expected because this plan does not migrate config or + cache formats. If the smoke test detects a state mutation, stop and design a + separate reversible state migration before continuing. + +## Review result + +This plan deliberately separates the low-risk application-code seam from the +high-risk distribution cutover. Milestone A is the next implementation scope. +Milestones B–E remain required before FVM 5 stable and explicitly cover the +GitHub-release and platform-specific work requested for later review. diff --git a/lib/src/runner.dart b/lib/src/runner.dart index 811f0e80a..e4b01afe4 100644 --- a/lib/src/runner.dart +++ b/lib/src/runner.dart @@ -6,7 +6,7 @@ import 'package:args/command_runner.dart'; import 'package:cli_completion/cli_completion.dart'; import 'package:io/ansi.dart'; import 'package:io/io.dart'; -import 'package:pub_updater/pub_updater.dart'; +import 'package:pub_semver/pub_semver.dart'; import 'commands/api_command.dart'; import 'commands/config_command.dart'; @@ -27,6 +27,7 @@ import 'commands/spawn_command.dart'; import 'commands/use_command.dart'; import 'models/config_model.dart'; import 'models/log_level_model.dart'; +import 'services/fvm_release_service.dart'; import 'services/logger_service.dart'; import 'utils/constants.dart'; import 'utils/context.dart'; @@ -36,11 +37,12 @@ import 'version.dart'; /// Command Runner for FVM class FvmCommandRunner extends CompletionCommandRunner { final FvmContext context; - final PubUpdater _pubUpdater; + final FvmReleaseService _releaseService; + static const _updateCheckInterval = Duration(days: 1); /// Constructor - FvmCommandRunner(this.context, {PubUpdater? pubUpdater}) - : _pubUpdater = pubUpdater ?? PubUpdater(), + FvmCommandRunner(this.context, {FvmReleaseService? releaseService}) + : _releaseService = releaseService ?? context.get(), super(kPackageName, kDescription) { argParser ..addFlag('verbose', help: 'Print verbose output.', negatable: false) @@ -69,49 +71,55 @@ class FvmCommandRunner extends CompletionCommandRunner { addCommand(IntegrationTestCommand(context)); } - /// Checks if the current version (set by the build runner on the - /// version.dart file) is the most recent one. If not, show a prompt to the - /// user. - Future _checkForUpdates() async { + /// Returns a notice when a newer stable FVM release is available. + Future _checkForUpdates() async { try { - final lastUpdateCheck = context.lastUpdateCheck ?? DateTime.now(); if (context.updateCheckDisabled) return null; - final oneDay = lastUpdateCheck.add(const Duration(days: 1)); - if (DateTime.now().isBefore(oneDay)) { + final now = DateTime.now(); + final lastUpdateCheck = context.lastUpdateCheck; + if (lastUpdateCheck != null && + now.isBefore(lastUpdateCheck.add(_updateCheckInterval))) { return null; } LocalAppConfig.read(path: context.appConfigPath, requireValid: true) - ..lastUpdateCheck = DateTime.now() + ..lastUpdateCheck = now ..save(path: context.appConfigPath); - final isUpToDate = await _pubUpdater.isUpToDate( - packageName: kPackageName, - currentVersion: packageVersion, - ); + final latestRelease = await _releaseService.getLatestStableRelease(); + final currentVersion = Version.parse(packageVersion); + if (latestRelease.version.compareTo(currentVersion) <= 0) return null; - if (isUpToDate) return null; + return () => _showUpdateNotice(latestRelease, currentVersion); + } catch (_) { + return () => logger.debug('Failed to check for updates.'); + } + } - final latestVersion = await _pubUpdater.getLatestVersion(kPackageName); + void _showUpdateNotice(FvmRelease latestRelease, Version currentVersion) { + final updateAvailableLabel = lightYellow.wrap('Update available!'); + final currentVersionLabel = lightCyan.wrap(packageVersion); + final latestVersionLabel = lightCyan.wrap( + latestRelease.version.toString(), + ); - return () { - final updateAvailableLabel = lightYellow.wrap('Update available!'); - final currentVersionLabel = lightCyan.wrap(packageVersion); - final latestVersionLabel = lightCyan.wrap(latestVersion); + logger + ..info() + ..info( + '$updateAvailableLabel $currentVersionLabel \u2192 $latestVersionLabel', + ) + ..info(); - logger - ..info() - ..info( - '$updateAvailableLabel $currentVersionLabel \u2192 $latestVersionLabel', - ) - ..info(); - }; - } catch (_) { - return () { - logger.debug("Failed to check for updates."); - }; - } + if (latestRelease.version.major <= currentVersion.major) return; + + logger + ..info( + 'FVM ${latestRelease.version.major} is distributed as a standalone ' + 'CLI and is no longer published to pub.dev.', + ) + ..info('Migration guide: $kFvmMigrationGuideUrl') + ..info(); } /// Checks for deprecated environment variables and shows warnings @@ -281,7 +289,7 @@ class FvmCommandRunner extends CompletionCommandRunner { // Check for deprecated environment variables _checkDeprecatedEnvironmentVariables(); - final checkingForUpdate = _checkForUpdates(); + final updateCheck = _checkForUpdates(); // Run the command or show version final int? exitCode; @@ -292,8 +300,8 @@ class FvmCommandRunner extends CompletionCommandRunner { exitCode = await super.runCommand(topLevelResults); } - final logOutput = await checkingForUpdate; - logOutput?.call(); + final updateNotice = await updateCheck; + updateNotice?.call(); return exitCode; } diff --git a/lib/src/services/fvm_release_service.dart b/lib/src/services/fvm_release_service.dart new file mode 100644 index 000000000..bee49d3a3 --- /dev/null +++ b/lib/src/services/fvm_release_service.dart @@ -0,0 +1,179 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:pub_semver/pub_semver.dart'; + +import '../utils/constants.dart'; +import '../version.dart'; +import 'base_service.dart'; + +typedef FvmReleaseRequest = Future Function( + Uri uri, + Map headers, +); + +class FvmReleaseException implements Exception { + final String message; + + const FvmReleaseException(this.message); + + @override + String toString() => message; +} + +class FvmReleaseHttpResponse { + final int statusCode; + final String body; + + const FvmReleaseHttpResponse({ + required this.statusCode, + required this.body, + }); +} + +class FvmRelease { + final Version version; + final Uri url; + + const FvmRelease({required this.version, required this.url}); +} + +class FvmReleaseService extends ContextualService { + static final _defaultReleasesUri = Uri.https( + 'api.github.com', + '/repos/$kFvmRepository/releases', + {'per_page': '100'}, + ); + static const _requestHeaders = { + 'Accept': 'application/vnd.github+json', + 'User-Agent': 'fvm/$packageVersion', + 'X-GitHub-Api-Version': '2022-11-28', + }; + + final FvmReleaseRequest _request; + final Uri _releasesUri; + final Duration _timeout; + + FvmReleaseService( + super.context, { + FvmReleaseRequest? request, + Uri? releasesUri, + Duration timeout = const Duration(seconds: 5), + }) : _request = request ?? _httpRequest, + _releasesUri = releasesUri ?? _defaultReleasesUri, + _timeout = timeout; + + static Never _throwReleaseException(String message, StackTrace stackTrace) { + Error.throwWithStackTrace(FvmReleaseException(message), stackTrace); + } + + static List _decodeReleaseList(String body) { + try { + final decoded = jsonDecode(body); + + return decoded is List ? decoded : const []; + } on FormatException catch (_, stackTrace) { + _throwReleaseException( + 'GitHub release response contained invalid JSON.', + stackTrace, + ); + } + } + + static FvmRelease? _parseRelease(Object? entry) { + if (entry is! Map || + entry['draft'] != false || + entry['prerelease'] != false) { + return null; + } + + final tag = entry['tag_name']; + final url = entry['html_url']; + if (tag is! String || url is! String) return null; + + final releaseUrl = Uri.tryParse(url); + if (releaseUrl == null || + releaseUrl.scheme != 'https' || + releaseUrl.host != 'github.com' || + !releaseUrl.path.startsWith('/$kFvmRepository/releases/tag/')) { + return null; + } + + final normalizedTag = tag.startsWith('v') ? tag.substring(1) : tag; + try { + final version = Version.parse(normalizedTag); + if (version.isPreRelease) return null; + + return FvmRelease(version: version, url: releaseUrl); + } on FormatException { + return null; + } + } + + static Future _httpRequest( + Uri uri, + Map headers, + ) async { + final client = HttpClient(); + try { + final request = await client.getUrl(uri); + headers.forEach(request.headers.set); + final response = await request.close(); + final body = await response.transform(utf8.decoder).join(); + + return FvmReleaseHttpResponse( + statusCode: response.statusCode, + body: body, + ); + } finally { + client.close(force: true); + } + } + + Future getLatestStableRelease() async { + final FvmReleaseHttpResponse response; + try { + response = + await _request(_releasesUri, _requestHeaders).timeout(_timeout); + } on TimeoutException catch (_, stackTrace) { + _throwReleaseException( + 'GitHub release request timed out after ' + '${_timeout.inSeconds} seconds.', + stackTrace, + ); + } on FvmReleaseException { + rethrow; + } catch (error, stackTrace) { + _throwReleaseException( + 'GitHub release request failed: $error', + stackTrace, + ); + } + + if (response.statusCode != 200) { + throw FvmReleaseException( + 'GitHub release request failed with status ${response.statusCode}.', + ); + } + + final releases = _decodeReleaseList(response.body); + FvmRelease? latest; + + for (final entry in releases) { + final release = _parseRelease(entry); + if (release != null && + (latest == null || release.version > latest.version)) { + latest = release; + } + } + + if (latest == null) { + throw const FvmReleaseException( + 'GitHub response did not contain a stable FVM CLI release.', + ); + } + + return latest; + } +} diff --git a/lib/src/utils/constants.dart b/lib/src/utils/constants.dart index 8a9faf197..968ee4510 100644 --- a/lib/src/utils/constants.dart +++ b/lib/src/utils/constants.dart @@ -32,6 +32,16 @@ const kDefaultFlutterUrl = 'https://github.com/flutter/flutter.git'; /// This file contains the configuration settings for FVM. const kFvmConfigFileName = '.fvmrc'; +/// The canonical GitHub repository for FVM releases and source code. +const kFvmRepository = 'conceptadev/fvm'; + +/// The canonical GitHub URL for FVM. +const kFvmRepositoryUrl = 'https://github.com/$kFvmRepository'; + +/// Migration guidance for standalone FVM releases. +const kFvmMigrationGuideUrl = + '$kFvmDocsUrl/documentation/getting-started/installation'; + /// The legacy FVM configuration file name, used for backward compatibility. const kFvmLegacyConfigFileName = 'fvm_config.json'; diff --git a/lib/src/utils/context.dart b/lib/src/utils/context.dart index efcbc0b1e..156218019 100644 --- a/lib/src/utils/context.dart +++ b/lib/src/utils/context.dart @@ -10,6 +10,7 @@ import '../services/app_config_service.dart'; import '../services/base_service.dart'; import '../services/cache_service.dart'; import '../services/flutter_service.dart'; +import '../services/fvm_release_service.dart'; import '../services/git_service.dart'; import '../services/logger_service.dart'; import '../services/process_service.dart'; @@ -248,6 +249,7 @@ const _defaultGenerators = { ProjectService: ProjectService.new, CacheService: CacheService.new, FlutterReleaseClient: FlutterReleaseClient.new, + FvmReleaseService: FvmReleaseService.new, FlutterService: FlutterService.new, ApiService: ApiService.new, GitService: GitService.new, diff --git a/pubspec.lock b/pubspec.lock index b65fa6409..a8a77916e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -513,14 +513,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" - platform: - dependency: transitive - description: - name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" - url: "https://pub.dev" - source: hosted - version: "3.1.6" pool: dependency: transitive description: @@ -529,14 +521,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.1" - process: - dependency: transitive - description: - name: process - sha256: "107d8be718f120bbba9dcd1e95e3bd325b1b4a4f07db64154635ba03f2567a0d" - url: "https://pub.dev" - source: hosted - version: "5.0.3" pub_semver: dependency: "direct main" description: @@ -545,14 +529,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" - pub_updater: - dependency: "direct main" - description: - name: pub_updater - sha256: "54e8dc865349059ebe7f163d6acce7c89eb958b8047e6d6e80ce93b13d7c9e60" - url: "https://pub.dev" - source: hosted - version: "0.4.0" pubspec_parse: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index d46c9e7f1..c0275e6e9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,7 +20,6 @@ dependencies: meta: ^1.10.0 path: ^1.9.0 pub_semver: ^2.1.4 - pub_updater: ^0.4.0 scope: ^5.1.0 yaml: ^3.1.2 yaml_edit: ^2.2.0 diff --git a/test/src/runner_test.dart b/test/src/runner_test.dart index 41999d451..62d5c7441 100644 --- a/test/src/runner_test.dart +++ b/test/src/runner_test.dart @@ -4,9 +4,12 @@ import 'package:args/command_runner.dart'; import 'package:fvm/fvm.dart'; import 'package:fvm/src/commands/api_command.dart'; import 'package:fvm/src/runner.dart'; +import 'package:fvm/src/services/fvm_release_service.dart'; +import 'package:fvm/src/services/logger_service.dart'; +import 'package:fvm/src/version.dart'; import 'package:io/io.dart'; import 'package:path/path.dart' as p; -import 'package:pub_updater/pub_updater.dart'; +import 'package:pub_semver/pub_semver.dart'; import 'package:test/test.dart'; import '../testing_utils.dart'; @@ -38,16 +41,180 @@ void main() { isTest: true, ); configFile.writeAsStringSync(malformedConfig); - final updater = _TrackingPubUpdater(); + final releaseService = _TrackingReleaseService( + context, + release: _release(_nextPatchVersion), + ); final result = await FvmCommandRunner( context, - pubUpdater: updater, + releaseService: releaseService, ).run(['--version']); expect(result, ExitCode.success.code); expect(configFile.readAsStringSync(), malformedConfig); - expect(updater.calls, 0); + expect(releaseService.calls, 0); + }); + + group('GitHub release update checks', () { + test('checks on first run and records the attempt', () async { + final fixture = _updateFixture( + release: _release(_nextPatchVersion), + ); + + final result = await fixture.runner.run(['--version']); + + expect(result, ExitCode.success.code); + expect(fixture.service.calls, 1); + expect( + LocalAppConfig.read( + path: fixture.context.appConfigPath, + ).lastUpdateCheck, + isNotNull, + ); + expect( + fixture.context.get().outputs.join('\n'), + allOf( + contains('Update available!'), + contains(packageVersion), + contains(_nextPatchVersion.toString()), + isNot(contains(kFvmMigrationGuideUrl)), + ), + ); + }); + + test('honors the update-check opt-out', () async { + final fixture = _updateFixture( + release: _release(_nextPatchVersion), + disableUpdateCheck: true, + ); + + final result = await fixture.runner.run(['--version']); + + expect(result, ExitCode.success.code); + expect(fixture.service.calls, 0); + expect( + LocalAppConfig.read( + path: fixture.context.appConfigPath, + ).lastUpdateCheck, + isNull, + ); + }); + + test('does not check again within one day', () async { + final recentCheck = DateTime.now().subtract(const Duration(hours: 1)); + final fixture = _updateFixture( + release: _release(_nextPatchVersion), + lastUpdateCheck: recentCheck, + ); + + final result = await fixture.runner.run(['--version']); + + expect(result, ExitCode.success.code); + expect(fixture.service.calls, 0); + final persistedCheck = LocalAppConfig.read( + path: fixture.context.appConfigPath, + ).lastUpdateCheck; + expect(persistedCheck, isNotNull); + expect(persistedCheck!.isAtSameMomentAs(recentCheck), isTrue); + }); + + test('checks again after one day', () async { + final oldCheck = DateTime(2000); + final fixture = _updateFixture( + release: _release(_currentVersion), + lastUpdateCheck: oldCheck, + ); + + final result = await fixture.runner.run(['--version']); + + expect(result, ExitCode.success.code); + expect(fixture.service.calls, 1); + final persistedCheck = LocalAppConfig.read( + path: fixture.context.appConfigPath, + ).lastUpdateCheck; + expect(persistedCheck, isNotNull); + expect(persistedCheck!.isAfter(oldCheck), isTrue); + }); + + test('prints standalone migration guidance for a newer major', () async { + final nextMajor = Version(_currentVersion.major + 1, 0, 0); + final fixture = _updateFixture( + release: _release(nextMajor), + ); + + final result = await fixture.runner.run(['--version']); + + expect(result, ExitCode.success.code); + expect( + fixture.context.get().outputs.join('\n'), + allOf( + contains('FVM ${nextMajor.major} is distributed as a standalone CLI'), + contains('no longer published to pub.dev'), + contains(kFvmMigrationGuideUrl), + ), + ); + }); + + test('does not suggest an equal or older release', () async { + for (final latestVersion in [_currentVersion, Version(0, 0, 0)]) { + final fixture = _updateFixture( + release: _release(latestVersion), + ); + + final result = await fixture.runner.run(['--version']); + + expect(result, ExitCode.success.code); + expect(fixture.service.calls, 1); + expect( + fixture.context.get().outputs.join('\n'), + isNot(contains('Update available!')), + ); + } + }); + + test('keeps release lookup failures debug-only', () async { + final fixture = _updateFixture( + failure: const FvmReleaseException('offline'), + ); + + final result = await fixture.runner.run(['--version']); + + expect(result, ExitCode.success.code); + expect(fixture.service.calls, 1); + expect( + fixture.context.get().outputs, + contains('Failed to check for updates.'), + ); + }); + + test('preserves a command non-zero exit code', () async { + final fixture = _updateFixture( + release: _release(_nextPatchVersion), + ); + fixture.runner.addCommand(_ExitCommand(42)); + + final result = await fixture.runner.run([_ExitCommand.commandName]); + + expect(result, 42); + expect(fixture.service.calls, 1); + }); + + test('does not check for API commands', () async { + final fixture = _updateFixture( + release: _release(_nextPatchVersion), + ); + final apiCommand = fixture.runner.commands['api'] as APICommand; + apiCommand.addSubcommand(_ExitCommand(42)); + + final result = await fixture.runner.run([ + 'api', + _ExitCommand.commandName, + ]); + + expect(result, 42); + expect(fixture.service.calls, 0); + }); }); group('TestCommandRunner.runOrThrow', () { @@ -90,15 +257,87 @@ class _ExitCommand extends Command { int run() => code; } -class _TrackingPubUpdater extends PubUpdater { +final _currentVersion = Version.parse(packageVersion); + +final _nextPatchVersion = Version( + _currentVersion.major, + _currentVersion.minor, + _currentVersion.patch + 1, +); + +typedef _UpdateFixture = ({ + FvmContext context, + _TrackingReleaseService service, + FvmCommandRunner runner, +}); + +_UpdateFixture _updateFixture({ + FvmRelease? release, + Object? failure, + bool? disableUpdateCheck, + DateTime? lastUpdateCheck, +}) { + final context = _updateContext( + disableUpdateCheck: disableUpdateCheck, + lastUpdateCheck: lastUpdateCheck, + ); + final service = _TrackingReleaseService( + context, + release: release, + failure: failure, + ); + + return ( + context: context, + service: service, + runner: FvmCommandRunner(context, releaseService: service), + ); +} + +FvmContext _updateContext({ + bool? disableUpdateCheck, + DateTime? lastUpdateCheck, +}) { + final tempDir = createTempDir('runner-update-check'); + final configFile = File(p.join(tempDir.path, 'config.json')); + if (disableUpdateCheck != null || lastUpdateCheck != null) { + LocalAppConfig( + disableUpdateCheck: disableUpdateCheck, + lastUpdateCheck: lastUpdateCheck, + ).save(path: configFile.path); + } + + return FvmContext.create( + appConfigPath: configFile.path, + workingDirectoryOverride: tempDir.path, + isTest: true, + ); +} + +FvmRelease _release(Version version) { + return FvmRelease( + version: version, + url: Uri.parse('$kFvmRepositoryUrl/releases/tag/$version'), + ); +} + +class _TrackingReleaseService extends FvmReleaseService { int calls = 0; + final FvmRelease? release; + final Object? failure; + + _TrackingReleaseService( + super.context, { + this.release, + this.failure, + }); @override - Future isUpToDate({ - required String packageName, - required String currentVersion, - }) async { + Future getLatestStableRelease() async { calls++; - return true; + final failure = this.failure; + if (failure != null) throw failure; + + return release!; } } diff --git a/test/src/services/fvm_release_service_test.dart b/test/src/services/fvm_release_service_test.dart new file mode 100644 index 000000000..22b2eae99 --- /dev/null +++ b/test/src/services/fvm_release_service_test.dart @@ -0,0 +1,211 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:fvm/src/services/fvm_release_service.dart'; +import 'package:fvm/src/version.dart'; +import 'package:pub_semver/pub_semver.dart'; +import 'package:test/test.dart'; + +import '../../testing_utils.dart'; + +void main() { + test('is available from the FVM context', () { + final context = TestFactory.fastContext(); + + expect(context.get(), isA()); + }); + + test('returns the greatest stable FVM CLI release', () async { + late Uri requestedUri; + late Map requestedHeaders; + final service = FvmReleaseService( + TestFactory.fastContext(), + request: (uri, headers) async { + requestedUri = uri; + requestedHeaders = headers; + + return FvmReleaseHttpResponse( + statusCode: 200, + body: jsonEncode([ + _releaseJson('4.2.0'), + _releaseJson('fvm-mcp-v9.0.0'), + _releaseJson('5.1.0-beta.1', prerelease: true), + _releaseJson('9.0.0', draft: true), + _releaseJson('v5.0.0'), + _releaseJson('not-a-version'), + {..._releaseJson('99.0.0'), 'html_url': '/relative-release-url'}, + ]), + ); + }, + ); + + final release = await service.getLatestStableRelease(); + + expect(release.version, Version(5, 0, 0)); + expect( + release.url, + Uri.parse('https://github.com/conceptadev/fvm/releases/tag/5.0.0'), + ); + expect(requestedUri.host, 'api.github.com'); + expect(requestedUri.path, '/repos/conceptadev/fvm/releases'); + expect(requestedUri.queryParameters['per_page'], '100'); + expect(requestedHeaders['Accept'], 'application/vnd.github+json'); + expect(requestedHeaders['X-GitHub-Api-Version'], '2022-11-28'); + expect(requestedHeaders['User-Agent'], 'fvm/$packageVersion'); + }); + + test('rejects a non-successful GitHub response', () async { + final service = FvmReleaseService( + TestFactory.fastContext(), + request: (_, __) async => FvmReleaseHttpResponse( + statusCode: 500, + body: jsonEncode([_releaseJson('99.0.0')]), + ), + ); + + expect( + service.getLatestStableRelease, + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('status 500'), + ), + ), + ); + }); + + test('reports malformed GitHub JSON as a release failure', () async { + final service = FvmReleaseService( + TestFactory.fastContext(), + request: (_, __) async => const FvmReleaseHttpResponse( + statusCode: 200, + body: 'not-json', + ), + ); + + expect( + service.getLatestStableRelease, + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('invalid JSON'), + ), + ), + ); + }); + + test('rejects a response without a stable FVM CLI release', () async { + final service = FvmReleaseService( + TestFactory.fastContext(), + request: (_, __) async => FvmReleaseHttpResponse( + statusCode: 200, + body: jsonEncode([ + _releaseJson('fvm-mcp-v1.0.0'), + _releaseJson('5.0.0-beta.1'), + ]), + ), + ); + + expect( + service.getLatestStableRelease, + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('stable FVM CLI release'), + ), + ), + ); + }); + + test('rejects an empty GitHub release list', () async { + final service = FvmReleaseService( + TestFactory.fastContext(), + request: (_, __) async => const FvmReleaseHttpResponse( + statusCode: 200, + body: '[]', + ), + ); + + expect( + service.getLatestStableRelease, + throwsA(isA()), + ); + }); + + test('reports a bounded request timeout as a release failure', () async { + final pendingResponse = Completer(); + final service = FvmReleaseService( + TestFactory.fastContext(), + request: (_, __) => pendingResponse.future, + timeout: Duration.zero, + ); + + expect( + service.getLatestStableRelease, + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('timed out'), + ), + ), + ); + }); + + test('reports transport errors as release failures', () async { + final service = FvmReleaseService( + TestFactory.fastContext(), + request: (_, __) async => throw const SocketException('offline'), + ); + + expect( + service.getLatestStableRelease, + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('request failed'), + ), + ), + ); + }); + + test('uses the default HTTP transport', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + server.listen((request) async { + request.response + ..statusCode = HttpStatus.ok + ..write(jsonEncode([_releaseJson('5.0.0')])) + ..close(); + }); + final service = FvmReleaseService( + TestFactory.fastContext(), + releasesUri: Uri.parse( + 'http://${server.address.host}:${server.port}/releases?per_page=100', + ), + ); + + final release = await service.getLatestStableRelease(); + + expect(release.version, Version(5, 0, 0)); + }); +} + +Map _releaseJson( + String tag, { + bool draft = false, + bool prerelease = false, +}) { + return { + 'tag_name': tag, + 'html_url': + 'https://github.com/conceptadev/fvm/releases/tag/${tag.replaceFirst('v', '')}', + 'draft': draft, + 'prerelease': prerelease, + }; +} diff --git a/test/testing_utils.dart b/test/testing_utils.dart index 4ebf9855c..0ea4abbc6 100644 --- a/test/testing_utils.dart +++ b/test/testing_utils.dart @@ -457,6 +457,8 @@ class TestFactory { gitCachePath: _sharedGitCacheDir.path, privilegedAccess: privilegedAccess, useGitCache: true, + // Unit tests must not contact the external update source. + disableUpdateCheck: true, forks: globalConfig.forks, ); From a3360404409a467de32ecfa1f6dc07305352c82f Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Thu, 23 Jul 2026 11:36:17 -0400 Subject: [PATCH 2/3] ci: gate matrix checks behind initial validation --- .github/workflows/test.yml | 33 +++++++++++------------ fvm_mcp/lib/src/process_runner.dart | 36 ++++++++++++-------------- fvm_mcp/lib/src/server.dart | 18 ++++++------- fvm_mcp/test/process_runner_test.dart | 27 ++++++++++--------- fvm_mcp/test/server_test.dart | 23 ++++++++-------- lib/src/commands/doctor_command.dart | 7 +++-- test/commands/doctor_command_test.dart | 9 +++++-- 7 files changed, 80 insertions(+), 73 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 54785067c..fd424ade0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -52,12 +52,15 @@ concurrency: jobs: # ───────────────────────────────────────────────────────────────────────────── - # Unit Tests - Run first for fast feedback + # Initial quality gate - all matrix jobs wait for this job to pass # ───────────────────────────────────────────────────────────────────────────── test: name: Test timeout-minutes: 30 runs-on: ubuntu-latest + env: + DCM_CI_KEY: ${{ secrets.DCM_CI_KEY }} + DCM_EMAIL: ${{ secrets.DCM_EMAIL }} steps: - name: Checkout uses: actions/checkout@v4 @@ -71,26 +74,22 @@ jobs: working-directory: fvm_mcp run: dart pub get - - uses: invertase/github-action-dart-analyzer@v1 - with: - fatal-infos: false + - name: Analyze + run: dart analyze --fatal-infos + # DCM >=1.38.2 refuses to run on CI without a license (DCM_CI_KEY and + # DCM_EMAIL). Skip the steps when the repo has no license secrets; the + # pre-commit and pre-push hooks still run DCM locally. - name: Install DCM + if: env.DCM_CI_KEY != '' && env.DCM_EMAIL != '' uses: CQLabs/setup-dcm@v1 - continue-on-error: true with: github_token: ${{ secrets.GITHUB_TOKEN }} - - name: Ensure DCM available - run: | - if ! command -v dcm >/dev/null 2>&1; then - dart pub global activate dart_code_metrics - echo "$HOME/.pub-cache/bin" >> "$GITHUB_PATH" - fi - - name: Run DCM - run: dcm analyze lib fvm_mcp/lib - + if: env.DCM_CI_KEY != '' && env.DCM_EMAIL != '' + run: dcm analyze lib + - name: Install lcov run: sudo apt-get install lcov @@ -141,6 +140,7 @@ jobs: name: Test on ${{ matrix.os }} timeout-minutes: 45 runs-on: ${{ matrix.os }} + needs: test strategy: fail-fast: true matrix: @@ -174,12 +174,12 @@ jobs: shell: bash # ───────────────────────────────────────────────────────────────────────────── - # Integration Tests - Run in parallel with unit tests + # Matrix checks - fan out after the initial quality gate # ───────────────────────────────────────────────────────────────────────────── integration-test: name: Integration Tests on ${{ matrix.os }} runs-on: ${{ matrix.os }} - needs: [test, test-os] + needs: test timeout-minutes: 45 # Skip heavy integration tests for the fvm-mcp PR branch (MCP-only changes). if: github.event_name != 'pull_request' || github.event.pull_request.head.ref != 'fvm-mcp' @@ -213,6 +213,7 @@ jobs: migration-test: name: Migration Test on ${{ matrix.os }} runs-on: ${{ matrix.os }} + needs: test timeout-minutes: 60 strategy: diff --git a/fvm_mcp/lib/src/process_runner.dart b/fvm_mcp/lib/src/process_runner.dart index cf841d403..9e9751c14 100644 --- a/fvm_mcp/lib/src/process_runner.dart +++ b/fvm_mcp/lib/src/process_runner.dart @@ -20,18 +20,16 @@ class ProcessRunner { ProcessStartMode? startMode, bool? runInShell, ProcessManager? processManager, - }) : startMode = startMode ?? ProcessStartMode.normal, - runInShell = runInShell ?? Platform.isWindows, - processManager = processManager ?? const LocalProcessManager(); + }) : startMode = startMode ?? ProcessStartMode.normal, + runInShell = runInShell ?? Platform.isWindows, + processManager = processManager ?? const LocalProcessManager(); String _formatCommand(List args) { - return [exe, ...args] - .map((part) { - final escaped = part.replaceAll('"', r'\"'); + return [exe, ...args].map((part) { + final escaped = part.replaceAll('"', r'\"'); - return escaped.contains(' ') ? '"$escaped"' : escaped; - }) - .join(' '); + return escaped.contains(' ') ? '"$escaped"' : escaped; + }).join(' '); } String _formatDuration(Duration duration) { @@ -125,9 +123,8 @@ class ProcessRunner { progressToken: meta!.progressToken!, progress: 100, total: 100, - message: timedOut - ? '$progressLabel timed out' - : '$progressLabel done', + message: + timedOut ? '$progressLabel timed out' : '$progressLabel done', ), ); } @@ -177,13 +174,14 @@ class ProcessRunner { Duration timeout = const Duration(minutes: 2), String? progressLabel, MetaWithProgressToken? meta, - }) => _runCore( - args, - cwd: cwd, - timeout: timeout, - progressLabel: progressLabel, - meta: meta, - ); + }) => + _runCore( + args, + cwd: cwd, + timeout: timeout, + progressLabel: progressLabel, + meta: meta, + ); Future run( List args, { diff --git a/fvm_mcp/lib/src/server.dart b/fvm_mcp/lib/src/server.dart index 1c33e974d..852cc9d32 100644 --- a/fvm_mcp/lib/src/server.dart +++ b/fvm_mcp/lib/src/server.dart @@ -43,15 +43,15 @@ base class FvmMcpServer extends MCPServer with ToolsSupport { required StreamChannel channel, required this.fvm, required ProcessRunner runner, - }) : _runner = runner, - super.fromStreamChannel( - channel, - implementation: implementation, - instructions: - 'Use tools under the fvm.* namespace. Read-only JSON via fvm api; ' - 'mutations are non-interactive where supported. ' - 'Server v${implementation.version}. Detected FVM: ${fvm.raw}.', - ); + }) : _runner = runner, + super.fromStreamChannel( + channel, + implementation: implementation, + instructions: + 'Use tools under the fvm.* namespace. Read-only JSON via fvm api; ' + 'mutations are non-interactive where supported. ' + 'Server v${implementation.version}. Detected FVM: ${fvm.raw}.', + ); /// Create, detect FVM version, then return a ready server. static Future start({ diff --git a/fvm_mcp/test/process_runner_test.dart b/fvm_mcp/test/process_runner_test.dart index b9df6fc7c..fac82a364 100644 --- a/fvm_mcp/test/process_runner_test.dart +++ b/fvm_mcp/test/process_runner_test.dart @@ -19,13 +19,16 @@ void main() { final outName = Platform.isWindows ? 'fake_fvm.exe' : 'fake_fvm'; final outPath = '${tmp.path}/$outName'; - final result = await Process.run(Platform.resolvedExecutable, [ - 'compile', - 'exe', - script, - '-o', - outPath, - ], runInShell: Platform.isWindows); + final result = await Process.run( + Platform.resolvedExecutable, + [ + 'compile', + 'exe', + script, + '-o', + outPath, + ], + runInShell: Platform.isWindows); if (result.exitCode != 0) { throw StateError( 'Failed to compile fake_fvm:\n${result.stdout}\n${result.stderr}', @@ -35,11 +38,11 @@ void main() { } ProcessRunner runner({required bool hasSkipInput}) => ProcessRunner( - exe: fakeFvmExe, - hasSkipInput: hasSkipInput, - startMode: ProcessStartMode.normal, - runInShell: false, - ); + exe: fakeFvmExe, + hasSkipInput: hasSkipInput, + startMode: ProcessStartMode.normal, + runInShell: false, + ); String textFrom(CallToolResult result) { final contents = result.content.whereType(); diff --git a/fvm_mcp/test/server_test.dart b/fvm_mcp/test/server_test.dart index 362077f46..164fa9cc5 100644 --- a/fvm_mcp/test/server_test.dart +++ b/fvm_mcp/test/server_test.dart @@ -12,16 +12,18 @@ void main() { late String fakeFvm; setUpAll(() async { tempDir = await Directory.systemTemp.createTemp('fvm_mcp_server_'); - fakeFvm = - '${tempDir.path}${Platform.pathSeparator}' + fakeFvm = '${tempDir.path}${Platform.pathSeparator}' '${Platform.isWindows ? 'fvm.exe' : 'fvm'}'; - final result = await Process.run(Platform.resolvedExecutable, [ - 'compile', - 'exe', - 'test/bin/fake_fvm.dart', - '-o', - fakeFvm, - ], runInShell: Platform.isWindows); + final result = await Process.run( + Platform.resolvedExecutable, + [ + 'compile', + 'exe', + 'test/bin/fake_fvm.dart', + '-o', + fakeFvm, + ], + runInShell: Platform.isWindows); if (result.exitCode != 0) { fail('Failed to compile fake FVM:\n${result.stdout}\n${result.stderr}'); } @@ -99,8 +101,7 @@ void main() { ); expect(invalidFlutterCall.isError, isTrue); - final missingDirectory = - '${Directory.systemTemp.path}' + final missingDirectory = '${Directory.systemTemp.path}' '${Platform.pathSeparator}fvm_mcp_missing_${DateTime.now().microsecondsSinceEpoch}'; final failure = await harness.connection.callTool( CallToolRequest(name: flutter.name, arguments: {'cwd': missingDirectory}), diff --git a/lib/src/commands/doctor_command.dart b/lib/src/commands/doctor_command.dart index 22ee6ea5c..8068ebeda 100644 --- a/lib/src/commands/doctor_command.dart +++ b/lib/src/commands/doctor_command.dart @@ -90,12 +90,11 @@ class DoctorCommand extends BaseFvmCommand { ); final sdkPath = settings['dart.flutterSdkPath']; + final matchesPinnedVersion = sdkPath is String && + convertToPosixPath(sdkPath) == expectedSdkPath; table.insertRow(['dart.flutterSdkPath', sdkPath ?? 'None']); - table.insertRow([ - 'Matches pinned version:', - sdkPath == expectedSdkPath, - ]); + table.insertRow(['Matches pinned version:', matchesPinnedVersion]); } on FormatException catch (_, stackTrace) { logger ..err('Error parsing Vscode settings.json on ${settingsFile.path}') diff --git a/test/commands/doctor_command_test.dart b/test/commands/doctor_command_test.dart index 66708ea9a..28773eb00 100644 --- a/test/commands/doctor_command_test.dart +++ b/test/commands/doctor_command_test.dart @@ -148,7 +148,7 @@ void main() { } test( - 'recognizes the absolute VS Code SDK path without privileged access', + 'recognizes an absolute VS Code SDK path with Windows separators', () async { final testDir = tempDirs.create(); createPubspecYaml(testDir); @@ -161,7 +161,12 @@ void main() { final vscodeDir = Directory(p.join(testDir.path, '.vscode')) ..createSync(); File(p.join(vscodeDir.path, 'settings.json')).writeAsStringSync( - jsonEncode({'dart.flutterSdkPath': project.localVersionSymlinkPath}), + jsonEncode({ + 'dart.flutterSdkPath': project.localVersionSymlinkPath.replaceAll( + '/', + r'\', + ), + }), ); final exitCode = await TestCommandRunner( From d5f9783e0d367a77c4550d20f50004eb7f97eb30 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Thu, 23 Jul 2026 11:40:31 -0400 Subject: [PATCH 3/3] fix(ci): use Linux formatter output for MCP --- fvm_mcp/lib/src/process_runner.dart | 36 ++++++++++++++------------- fvm_mcp/lib/src/server.dart | 18 +++++++------- fvm_mcp/test/process_runner_test.dart | 27 +++++++++----------- fvm_mcp/test/server_test.dart | 23 ++++++++--------- 4 files changed, 51 insertions(+), 53 deletions(-) diff --git a/fvm_mcp/lib/src/process_runner.dart b/fvm_mcp/lib/src/process_runner.dart index 9e9751c14..cf841d403 100644 --- a/fvm_mcp/lib/src/process_runner.dart +++ b/fvm_mcp/lib/src/process_runner.dart @@ -20,16 +20,18 @@ class ProcessRunner { ProcessStartMode? startMode, bool? runInShell, ProcessManager? processManager, - }) : startMode = startMode ?? ProcessStartMode.normal, - runInShell = runInShell ?? Platform.isWindows, - processManager = processManager ?? const LocalProcessManager(); + }) : startMode = startMode ?? ProcessStartMode.normal, + runInShell = runInShell ?? Platform.isWindows, + processManager = processManager ?? const LocalProcessManager(); String _formatCommand(List args) { - return [exe, ...args].map((part) { - final escaped = part.replaceAll('"', r'\"'); + return [exe, ...args] + .map((part) { + final escaped = part.replaceAll('"', r'\"'); - return escaped.contains(' ') ? '"$escaped"' : escaped; - }).join(' '); + return escaped.contains(' ') ? '"$escaped"' : escaped; + }) + .join(' '); } String _formatDuration(Duration duration) { @@ -123,8 +125,9 @@ class ProcessRunner { progressToken: meta!.progressToken!, progress: 100, total: 100, - message: - timedOut ? '$progressLabel timed out' : '$progressLabel done', + message: timedOut + ? '$progressLabel timed out' + : '$progressLabel done', ), ); } @@ -174,14 +177,13 @@ class ProcessRunner { Duration timeout = const Duration(minutes: 2), String? progressLabel, MetaWithProgressToken? meta, - }) => - _runCore( - args, - cwd: cwd, - timeout: timeout, - progressLabel: progressLabel, - meta: meta, - ); + }) => _runCore( + args, + cwd: cwd, + timeout: timeout, + progressLabel: progressLabel, + meta: meta, + ); Future run( List args, { diff --git a/fvm_mcp/lib/src/server.dart b/fvm_mcp/lib/src/server.dart index 852cc9d32..1c33e974d 100644 --- a/fvm_mcp/lib/src/server.dart +++ b/fvm_mcp/lib/src/server.dart @@ -43,15 +43,15 @@ base class FvmMcpServer extends MCPServer with ToolsSupport { required StreamChannel channel, required this.fvm, required ProcessRunner runner, - }) : _runner = runner, - super.fromStreamChannel( - channel, - implementation: implementation, - instructions: - 'Use tools under the fvm.* namespace. Read-only JSON via fvm api; ' - 'mutations are non-interactive where supported. ' - 'Server v${implementation.version}. Detected FVM: ${fvm.raw}.', - ); + }) : _runner = runner, + super.fromStreamChannel( + channel, + implementation: implementation, + instructions: + 'Use tools under the fvm.* namespace. Read-only JSON via fvm api; ' + 'mutations are non-interactive where supported. ' + 'Server v${implementation.version}. Detected FVM: ${fvm.raw}.', + ); /// Create, detect FVM version, then return a ready server. static Future start({ diff --git a/fvm_mcp/test/process_runner_test.dart b/fvm_mcp/test/process_runner_test.dart index fac82a364..b9df6fc7c 100644 --- a/fvm_mcp/test/process_runner_test.dart +++ b/fvm_mcp/test/process_runner_test.dart @@ -19,16 +19,13 @@ void main() { final outName = Platform.isWindows ? 'fake_fvm.exe' : 'fake_fvm'; final outPath = '${tmp.path}/$outName'; - final result = await Process.run( - Platform.resolvedExecutable, - [ - 'compile', - 'exe', - script, - '-o', - outPath, - ], - runInShell: Platform.isWindows); + final result = await Process.run(Platform.resolvedExecutable, [ + 'compile', + 'exe', + script, + '-o', + outPath, + ], runInShell: Platform.isWindows); if (result.exitCode != 0) { throw StateError( 'Failed to compile fake_fvm:\n${result.stdout}\n${result.stderr}', @@ -38,11 +35,11 @@ void main() { } ProcessRunner runner({required bool hasSkipInput}) => ProcessRunner( - exe: fakeFvmExe, - hasSkipInput: hasSkipInput, - startMode: ProcessStartMode.normal, - runInShell: false, - ); + exe: fakeFvmExe, + hasSkipInput: hasSkipInput, + startMode: ProcessStartMode.normal, + runInShell: false, + ); String textFrom(CallToolResult result) { final contents = result.content.whereType(); diff --git a/fvm_mcp/test/server_test.dart b/fvm_mcp/test/server_test.dart index 164fa9cc5..362077f46 100644 --- a/fvm_mcp/test/server_test.dart +++ b/fvm_mcp/test/server_test.dart @@ -12,18 +12,16 @@ void main() { late String fakeFvm; setUpAll(() async { tempDir = await Directory.systemTemp.createTemp('fvm_mcp_server_'); - fakeFvm = '${tempDir.path}${Platform.pathSeparator}' + fakeFvm = + '${tempDir.path}${Platform.pathSeparator}' '${Platform.isWindows ? 'fvm.exe' : 'fvm'}'; - final result = await Process.run( - Platform.resolvedExecutable, - [ - 'compile', - 'exe', - 'test/bin/fake_fvm.dart', - '-o', - fakeFvm, - ], - runInShell: Platform.isWindows); + final result = await Process.run(Platform.resolvedExecutable, [ + 'compile', + 'exe', + 'test/bin/fake_fvm.dart', + '-o', + fakeFvm, + ], runInShell: Platform.isWindows); if (result.exitCode != 0) { fail('Failed to compile fake FVM:\n${result.stdout}\n${result.stderr}'); } @@ -101,7 +99,8 @@ void main() { ); expect(invalidFlutterCall.isError, isTrue); - final missingDirectory = '${Directory.systemTemp.path}' + final missingDirectory = + '${Directory.systemTemp.path}' '${Platform.pathSeparator}fvm_mcp_missing_${DateTime.now().microsecondsSinceEpoch}'; final failure = await harness.connection.callTool( CallToolRequest(name: flutter.name, arguments: {'cwd': missingDirectory}),