Add --latest-pre-release and --pin flags to gh extension upgrade - #13982
Conversation
This comment was marked as outdated.
This comment was marked as outdated.
There was a problem hiding this comment.
Pull request overview
Adds new upgrade behaviors for gh extension upgrade to support selecting pre-releases and pinning binary extensions to a specific release tag, aligning upgrade behavior with extension development workflows.
Changes:
- Introduces
extensions.UpgradeOptionsand threads it through theExtensionManager.UpgradeAPI. - Adds
--latest-pre-releaseand--pinflags, with corresponding manager logic for binary extensions. - Implements
fetchLatestPrerelease(version-ordered selection) and adds unit tests for new HTTP + manager behaviors.
Show a summary per file
| File | Description |
|---|---|
| pkg/extensions/manager_mock.go | Updates generated mock to match new Upgrade(name, opts) signature. |
| pkg/extensions/extension.go | Adds UpgradeOptions and updates the ExtensionManager interface signature. |
| pkg/cmd/extension/manager.go | Implements option-aware upgrade logic, including pinning and prerelease selection for binaries. |
| pkg/cmd/extension/manager_test.go | Adds/updates tests to cover pinning and latest-pre-release upgrade behavior. |
| pkg/cmd/extension/http.go | Extends release model and adds fetchLatestPrerelease with version ordering. |
| pkg/cmd/extension/http_test.go | New tests for fetchLatestPrerelease selection behavior. |
| pkg/cmd/extension/command.go | Wires new flags into gh extension upgrade and validates incompatible combinations. |
| pkg/cmd/extension/command_test.go | Updates command tests for new UpgradeOptions signature and new flags. |
| cmd/gen-docs/main.go | Updates docs generator’s ExtensionManager stub implementation to new signature. |
Review details
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Files not reviewed (1)
- pkg/extensions/manager_mock.go: Generated file
- Files reviewed: 8/9 changed files
- Comments generated: 3
- Review effort level: Low
| // Force upgrades the extension even when it is pinned or already up to date. | ||
| Force bool |
| // Note that if the latest prerelease is not on the first page of 100, it is | ||
| // possible that this will not find it; for performance reasons in busy | ||
| // repositories it is not safe or efficient to iterate over every page of | ||
| // release. In those cases, the user should specify a tag with --pin |
| // Release-based selection is only meaningful for binary extensions. | ||
| if opts.LatestPreRelease || opts.PinVersion != "" { | ||
| return errors.New("the --pin and --latest-pre-release flags are only supported for binary extensions") | ||
| } |
BagToad
left a comment
There was a problem hiding this comment.
Submitting a first pass review to get some commentary out there. There is some surprising behavior in this diff alongside a breaking change/bug:
- Breaking:
gh ext upgrade --allexit code changed. Now command fails if one extension fails to fetch. - Surprising:
gh ext upgrade --latest-pre-releasecan upgrade to a release which is not tagged as a pre-release.
| } | ||
| v, verr := version.NewVersion(r.Tag) | ||
| if verr != nil { | ||
| continue |
There was a problem hiding this comment.
❗ v1.0.0.beta.2 (for example, the tag from the linked issue) isn't valid semver, so version.NewVersion errors and we would continue past it.
With a mix of parseable and unparseable tags we keep the highest parseable one, which can be older than the release the user wants.
Instead, can we return an error when the newest release won't parse and point at --pin?
This is also a unit test gap.
There was a problem hiding this comment.
How do you define "newest release" in this case? Do you mean just "the first release returned in the API" because if the version won't parse then I don't know how else you'd define "newest". It's inherently not ordered as a version if it's unparseable.
I look at this and I think that this is the best of possible options; it might be reasonable to issue a warning if we can't parse a version, but I think we should only fail if we can't parse any version. Which, I think, is what's happening here.
There was a problem hiding this comment.
I've pushed an update with a skipped test that expresses this; feedback is welcome.
| cmd.Flags().BoolVar(&flagAll, "all", false, "Upgrade all extensions") | ||
| cmd.Flags().BoolVar(&flagForce, "force", false, "Force upgrade extension") | ||
| cmd.Flags().BoolVar(&flagDryRun, "dry-run", false, "Only display upgrades") | ||
| cmd.Flags().BoolVar(&flagLatestPreRelease, "latest-pre-release", false, "Upgrade to the latest release, including pre-releases (binary extensions only)") |
There was a problem hiding this comment.
💭 gh release uses --prerelease (create/edit) and --exclude-pre-releases (list), so either spelling has precedent.
| }) | ||
| }, | ||
| } | ||
| cmd.Flags().BoolVar(&flagAll, "all", false, "Upgrade all extensions") |
There was a problem hiding this comment.
💅 upgrade has no Example , an oversight from the past, but we feel it here. We should add one, and at least add some examples for the new flags, but I'm sure it's nothing to have copilot generate the full proper examples.
|
|
||
| // Release-based selection is only meaningful for binary extensions. | ||
| if opts.LatestPreRelease || opts.PinVersion != "" { | ||
| return errors.New("the --pin and --latest-pre-release flags are only supported for binary extensions") |
There was a problem hiding this comment.
💭 Feels a bit off potentially including a flag in an error message that the user didn't provide. I think it would be more user friendly to indicate what they did wrong more specifically.
| if len(args) > 1 { | ||
| return cmdutil.FlagErrorf("too many arguments") | ||
| } | ||
| if flagLatestPreRelease && flagAll { |
There was a problem hiding this comment.
💅 These three pairwise checks should be replaced withcmdutil.MutuallyExclusive. Validation also usually lives in RunE, not Args , but that's a sin made in the past and doesn't need fixing here.
| if ext.CurrentVersion() == opts.PinVersion && ext.IsPinned() { | ||
| return upToDateError | ||
| } | ||
| if err := m.installBin(repo, opts.PinVersion); err != nil { |
There was a problem hiding this comment.
💅A missing pinned tag returns the bare releaseNotFoundErr, so the user sees release not found with no echo of the tag they typed. install --pinhas precedent for this:
// command.go, install RunE
if errors.Is(err, releaseNotFoundErr) {
return fmt.Errorf("%s Could not find a release of %s for %s",
cs.FailureIcon(), args[0], cs.Cyan(pinFlag))
}Can we align with it?
| } | ||
|
|
||
| if ext.IsBinary() { | ||
| return m.upgradeBinExtension(ext, opts) |
There was a problem hiding this comment.
❗Binary extensions appear to skip the cached UpdateAvailable() check and always hit the network now. For example,upgrade --all already fetched latest versions in list(), so this is a second lookup per extension.
Two things:
- Up-to-date extensions cost an extra request each
- A fetch error now turns
already up to dateinto a command failure
The if !ext.UpdateAvailable() on L545 is the likely problem.
| // possible that this will not find it; for performance reasons in busy | ||
| // repositories it is not safe or efficient to iterate over every page of | ||
| // release. In those cases, the user should specify a tag with --pin | ||
| func fetchLatestPrerelease(httpClient *http.Client, baseRepo ghrepo.Interface) (*release, error) { |
There was a problem hiding this comment.
❗The behavior here feels quite surprising in that we could get a release installed which is not a prerelease when using --latest-pre-release.
IsPrerelease is decoded but never read, and selection is pure version-max, so --latest-pre-release can install any release type. The name implies you get a prerelease, and that was my expectation too when this was discussed.
My take: we should install the highest prerelease (actually use IsPrerelease), then warn when the latest stable beats it on either:
- higher semver, or
- published more recently
No prereleases in the repo should also error rather than silently falling back to stable, e.g. no pre-releases found for gh-foo
| path := fmt.Sprintf("repos/%s/%s/releases?per_page=100", baseRepo.RepoOwner(), baseRepo.RepoName()) | ||
| url := ghinstance.RESTPrefix(baseRepo.RepoHost()) + path | ||
| req, err := http.NewRequest("GET", url, nil) | ||
| if err != nil { | ||
| return nil, err | ||
| } |
There was a problem hiding this comment.
💅I recognize this is not a pattern you introduced, but new API methods should use a cli/go-gh APIClient type, not a raw HTTP client, where it is possible. This aligns it more with newer implementations in the codebase.
| // repositories it is not safe or efficient to iterate over every page of | ||
| // release. In those cases, the user should specify a tag with --pin | ||
| func fetchLatestPrerelease(httpClient *http.Client, baseRepo ghrepo.Interface) (*release, error) { | ||
| path := fmt.Sprintf("repos/%s/%s/releases?per_page=100", baseRepo.RepoOwner(), baseRepo.RepoName()) |
There was a problem hiding this comment.
💅 These are probably safe but it's probably best they be path encoded anyway.
|
There are a lot of comments on here; can you clarify which ones are blockers versus commentary? Ah, I think I understand your use of emoji. |
21abdcf to
538b468
Compare
- address PR comment changes from @BagToad
538b468 to
a560bea
Compare
Add some gh extension upgrade support for prereleases:
--latest-pre-releaseto make it easier to keep abreast of extension development--pinto work with an explicit version, prerelease or otherwise, of an extensionFixes #13968