Repository navigation
Agent suggestions
Public previewDescription
Describe the bug
On Windows, gh extension upgrade, gh extension remove and gh extension install delete the installed extension's folder before doing anything else. If any file in it is in use, gh stops half-way and leaves an extension that gh extension upgrade can no longer repair.
The cause is in pkg/cmd/extension/manager.go. installBin, installGit, InstallLocal and Remove all call cleanExtensionUpdateDir(name) first:
func (m *Manager) cleanExtensionUpdateDir(name string) error {
if err := os.RemoveAll(m.UpdateDir(name)); err != nil {
return fmt.Errorf("failed to remove previous extension update state: %w", err)
}
return nil
}UpdateDir(name) is config.StateDir()/extensions/<name> and the install folder is config.DataDir()/extensions/<name>. On Linux and macOS these are different folders. On Windows, go-gh/pkg/config resolves both StateDir() and DataDir() to %LOCALAPPDATA%\GitHub CLI, so the "update state" folder is the installed extension itself.
os.RemoveAll deletes what it can, including manifest.yml, and then fails on whatever is locked. Windows locks the extension's .exe while it runs, and it locks a folder that is any process's working directory. Without manifest.yml the extension is listed with no repo or version, and every later gh extension upgrade fails with unable to retrieve latest version for extension "<name>". Only remove + install recovers.
This came in with #9934, which added the update-state folder. github/gh-aw ran into the same thing and traced it to the same lines (github/gh-aw#27269). They worked around it on their side, but this repo never got an issue.
Affected version
gh version 2.102.0 (2026-09-30)
Also reproduced with 2.92.0 and 2.103.3. cleanExtensionUpdateDir is unchanged on trunk.
Steps to reproduce the behavior
On Windows, with any binary extension (gh-foo below):
gh extension install owner/gh-foo- Keep the extension running, e.g. a long-running
gh foo <command>in one terminal. A terminal opened with%LOCALAPPDATA%\GitHub CLI\extensions\gh-fooas its working directory has the same effect. - In a second terminal:
gh extension remove foo.gh extension upgrade footakes the same path whenever an update is available. gh extension list
Expected vs actual behavior
Expected: gh only touches its own update state. An upgrade while the extension runs either succeeds (a running .exe can be renamed aside) or fails before changing the install, and the installed extension stays usable either way.
Actual (upgrade prints the same error after [foo]: and then some extensions failed to upgrade):
failed to remove previous extension update state: unlinkat C:\Users\<user>\AppData\Local\GitHub CLI\extensions\gh-foo: The process cannot access the file because it is being used by another process.
gh extension list then shows gh foo with no repo and no version, manifest.yml is gone, and gh extension upgrade foo fails from then on with unable to retrieve latest version until gh extension remove foo && gh extension install owner/gh-foo.
Possible fixes, in increasing scope:
- Delete only the state file inside
UpdateDir(name)instead of the folder, or skipcleanExtensionUpdateDirwhenUpdateDir(name)equals the install folder. - Use a separate state folder on Windows, e.g.
%LOCALAPPDATA%\GitHub CLI\state. Existing state would need a migration.
Logs
The error above is the only output. The command is local file handling, so GH_DEBUG=api adds nothing past the release lookup.
Activity
cli-triage commentedon Oct 29, 2026
No duplicates found — searches for
cleanExtensionUpdateDir, "extension update directory Windows", and "unable to retrieve latest version" turned up nothing else tracking this.I'm suggesting
bug,priority-2, andgh-extension. The report's root-cause analysis checks out against current source:UpdateDiris built fromconfig.StateDir(), while the install directory is built fromconfig.DataDir()(same file, ~line 51-52). On most platforms these differ, but on Windows go-gh resolves both to%LOCALAPPDATA%\GitHub CLI, making them identical.cleanExtensionUpdateDirthen callsos.RemoveAllon that shared path, which is called frominstallBin,installGit,InstallLocal, andRemovebefore any other work is done — so on Windows this removes the live, installed extension directory rather than just update-check metadata. If any file inside (e.g. a running.exe, or a directory in use as a cwd) is locked,RemoveAllpartially succeeds, deletingmanifest.ymlwhile leaving the locked file behind, which matches the reported broken state. I'd suggestpriority-2since it affects Windows users of binary extensions broadly and has no fix without manual remove+reinstall, but doesn't cause true data loss or affect non-Windows platforms.