diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 2d687e158e..26dcbf81d2 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,5 +1,8 @@
#### Unreleased
+* DEPRECATION: `paket.exe` and `paket.bootstrapper.exe` are deprecated and will no longer be published from Paket 12.0 onwards. The `paket` .NET tool becomes the only supported way to run Paket: `dotnet tool install paket`. Both binaries now print a notice on every run unless `-s` is passed.
+* The bootstrapper shipped with this release never resolves a version past 11.x, so it keeps working once 12.0 ships without a `paket.exe` asset instead of failing the build. An explicitly pinned version is unaffected.
+
#### 11.0.0-alpha003 - 2026-09-27
* Attempt to republish now package permissions are in place
diff --git a/docs/content/bootstrapper.md b/docs/content/bootstrapper.md
index 5443baaa47..ede70791d8 100644
--- a/docs/content/bootstrapper.md
+++ b/docs/content/bootstrapper.md
@@ -1,5 +1,10 @@
# The Paket bootstrapper (paket.bootstrapper.exe)
+> **Deprecated.** `paket.bootstrapper.exe` and `paket.exe` will no longer be published from Paket
+> 12.0 onwards, and the bootstrapper shipped with Paket 11 will never resolve past the 11.x
+> releases. Install Paket as a [.NET tool](installation.html#Installation-on-NET-Core) instead:
+> `dotnet tool install paket`.
+
The bootstrapper downloads the latest stable `paket.exe`. By default, the
bootstrapper caches downloaded versions of `paket.exe` for the current user
across all projects. If the requested version is not present in the cache, it is
diff --git a/docs/content/get-started.md b/docs/content/get-started.md
index baae9785ad..8c1049d6ea 100644
--- a/docs/content/get-started.md
+++ b/docs/content/get-started.md
@@ -3,7 +3,7 @@
This guide shows how to get started with Paket in various ways, depending on your scenario:
* [Get started with .NET Core (preferred)](#net-core-preferred)
-* [Get started with the paket bootstrapper (legacy)](#install-the-paket-bootstrapper-legacy)
+* [Get started with the paket bootstrapper (deprecated)](#install-the-paket-bootstrapper-deprecated)
* [Convert from legacy NuGet](#convert-from-nuget)
## .NET Core (preferred)
@@ -49,7 +49,11 @@ paket-files/
Next, [learn how to use Paket](learn-how-to-use-paket.html)
-## Install the Paket bootstrapper (legacy)
+## Install the Paket bootstrapper (deprecated)
+
+> **Deprecated.** `paket.bootstrapper.exe` and `paket.exe` will no longer be published from Paket
+> 12.0 onwards, and the bootstrapper shipped with Paket 11 will never resolve past the 11.x
+> releases. Prefer the .NET tool above; `dotnet tool install paket` is the supported route.
If you're not using .NET Core, or you're stuck on .NET Core 2.2 or lower, you can use the paket bootstrapper.
diff --git a/docs/content/installation.md b/docs/content/installation.md
index 3e394dfb80..eaf24c4841 100644
--- a/docs/content/installation.md
+++ b/docs/content/installation.md
@@ -40,7 +40,11 @@ dotnet paket --help
## Installation per repository
-The most common use of Paket is as a command line tool inside your project
+> **Deprecated.** This route relies on `paket.bootstrapper.exe` and `paket.exe`, neither of which
+> will be published from Paket 12.0 onwards. Use the [local tool](#Local-tool) instead, which gives
+> you the same per-repository pinning.
+
+Paket used to be most commonly used as a command line tool inside your project
repository.
1. Create a `.paket` directory in the root of your solution.
@@ -112,7 +116,8 @@ utility.
### Installation on Windows
-Please [install per repository](installation.html#Installation-per-repository).
+Please install the [.NET tool](#Installation-on-NET-Core). The
+[per-repository](#Installation-per-repository) route still works, but it is deprecated.
### Post installation
diff --git a/src/Paket.Bootstrapper/BootstrapperHelper.cs b/src/Paket.Bootstrapper/BootstrapperHelper.cs
index bbf1e9d69a..230e768826 100644
--- a/src/Paket.Bootstrapper/BootstrapperHelper.cs
+++ b/src/Paket.Bootstrapper/BootstrapperHelper.cs
@@ -10,7 +10,17 @@ namespace Paket.Bootstrapper
{
internal static class BootstrapperHelper
{
- public static string HelpText = @"The paket.bootstrapper downloads the latest version of paket.
+ ///
+ /// Printed on every run that isn't silent. Paket 12.0 and later ship the .NET tool
+ /// only, and their releases carry no paket.exe asset for the bootstrapper to download.
+ ///
+ public const string DeprecationNotice =
+ "paket.bootstrapper.exe and paket.exe are deprecated and will no longer be published from Paket 12.0 onwards. " +
+ "The .NET tool is now the only supported way to run Paket: dotnet tool install paket";
+
+ public static string HelpText = DeprecationNotice + @"
+
+The paket.bootstrapper downloads the latest version of paket.
Usage for paket bootstrapper:
paket.bootstrapper [OPTIONS] [prerelease|]
diff --git a/src/Paket.Bootstrapper/DownloadStrategies/DownloadStrategy.cs b/src/Paket.Bootstrapper/DownloadStrategies/DownloadStrategy.cs
index d4d098b7f5..24d75d32c3 100644
--- a/src/Paket.Bootstrapper/DownloadStrategies/DownloadStrategy.cs
+++ b/src/Paket.Bootstrapper/DownloadStrategies/DownloadStrategy.cs
@@ -8,10 +8,55 @@ public abstract class DownloadStrategy : IDownloadStrategy
public abstract string Name { get; }
public abstract bool CanDownloadHashFile { get; }
+ ///
+ /// Paket 12.0 and later are published as a .NET tool only: their releases carry no
+ /// paket.exe asset and their NuGet package no longer holds tools/paket.exe. Resolving one
+ /// would end in a 404, then a FileNotFoundException on the NuGet fallback, and an exit
+ /// code of 1 wherever paket.exe isn't already on disk. Stay on the last release we can
+ /// actually download instead.
+ ///
+ internal const int LastSupportedMajorVersion = 11;
+
+ internal const string LastSupportedVersion = "11.0.0";
+
public IDownloadStrategy FallbackStrategy { get; set; }
public string GetLatestVersion(bool ignorePrerelease)
{
- return Wrap(() => GetLatestVersionCore(ignorePrerelease), "GetLatestVersion");
+ var version = Wrap(() => GetLatestVersionCore(ignorePrerelease), "GetLatestVersion");
+ return CapToLastSupportedVersion(version);
+ }
+
+ ///
+ /// Applied here rather than in the individual strategies because this method is the single
+ /// non-virtual entry point every strategy goes through, and the decorating strategies call
+ /// it on the strategy they wrap. That also covers the version sources that never touch the
+ /// network: the on-disk cache, the --max-file-age fast path and a local NuGet folder.
+ ///
+ internal static string CapToLastSupportedVersion(string version)
+ {
+ if (String.IsNullOrWhiteSpace(version))
+ return version;
+
+ SemVer parsed;
+ try
+ {
+ parsed = SemVer.Create(version);
+ }
+ catch (Exception)
+ {
+ // Never let an unparseable version break the bootstrapper; let the caller deal
+ // with it as it did before.
+ return version;
+ }
+
+ if (parsed.Major <= LastSupportedMajorVersion)
+ return version;
+
+ ConsoleImpl.WriteWarning(
+ "Paket {0} is available, but it is published as a .NET tool only and cannot be downloaded by the bootstrapper. Staying on {1}. To move on, run: dotnet tool install paket",
+ version, LastSupportedVersion);
+
+ return LastSupportedVersion;
}
public void DownloadVersion(string latestVersion, string target, PaketHashFile hashfile)
diff --git a/src/Paket.Bootstrapper/DownloadStrategies/NugetDownloadStrategy.cs b/src/Paket.Bootstrapper/DownloadStrategies/NugetDownloadStrategy.cs
index 3f81702915..c0e9f8a374 100644
--- a/src/Paket.Bootstrapper/DownloadStrategies/NugetDownloadStrategy.cs
+++ b/src/Paket.Bootstrapper/DownloadStrategies/NugetDownloadStrategy.cs
@@ -102,6 +102,10 @@ protected override string GetLatestVersionCore(bool ignorePrerelease)
var latestVersion = allVersions.
Select(SemVer.Create).
Where(x => !ignorePrerelease || (x.PreRelease == null)).
+ // This is the only strategy that sees every published version, so it can pick
+ // the best one we still support instead of falling back to the constant the
+ // base class clamps to.
+ Where(x => x.Major <= LastSupportedMajorVersion).
OrderBy(x => x).
LastOrDefault(x => !String.IsNullOrWhiteSpace(x.Original));
return latestVersion != null ? latestVersion.Original : String.Empty;
diff --git a/src/Paket.Bootstrapper/Program.cs b/src/Paket.Bootstrapper/Program.cs
index 97306193bf..852d84772e 100644
--- a/src/Paket.Bootstrapper/Program.cs
+++ b/src/Paket.Bootstrapper/Program.cs
@@ -60,6 +60,12 @@ static void Main(string[] args)
}
ConsoleImpl.Verbosity = options.Verbosity;
+
+ // WriteWarning, not WriteAlways: it goes to stdout, and under '-s' (which transparent
+ // magic mode adds on its own) that stream is parsed by Paket.Restore.targets
+ // ('show-conditions -s'), where any extra line breaks the build.
+ ConsoleImpl.WriteWarning(BootstrapperHelper.DeprecationNotice);
+
if (options.UnprocessedCommandArgs.Any())
ConsoleImpl.WriteWarning("Ignoring the following unknown argument(s): {0}", String.Join(", ", options.UnprocessedCommandArgs));
diff --git a/src/Paket/Paket.fsproj b/src/Paket/Paket.fsproj
index efc7ce8ec7..22a095e494 100644
--- a/src/Paket/Paket.fsproj
+++ b/src/Paket/Paket.fsproj
@@ -8,6 +8,10 @@
true
true
+
+
+ PAKET_LEGACY_EXE;$(DefineConstants)
+
diff --git a/src/Paket/Program.fs b/src/Paket/Program.fs
index ffbba31f47..dada8e42b4 100644
--- a/src/Paket/Program.fs
+++ b/src/Paket/Program.fs
@@ -934,6 +934,22 @@ let main() =
Environment.SetEnvironmentVariable ("PAKET_DISABLE_RUNTIME_RESOLUTION", "true")
use consoleTrace = Logging.event.Publish |> Observable.subscribe Logging.traceToConsole
+#if PAKET_LEGACY_EXE
+ // Only the .NET Framework build carries this: it is the paket.exe the bootstrapper downloads,
+ // and the one committed into .paket for magic mode. Users of the .NET tool are already on the
+ // supported path and must not see it.
+ // It has to sit here: earlier and the trace event has no subscriber yet, later and the restore
+ // and install fast routes below would skip it.
+ // Skipped under -s/--silent: Paket.Restore.targets runs `show-conditions -s` and parses every
+ // stdout line as an MSBuild condition, so the notice must not be written there. The arguments
+ // are scanned by hand because Argu only parses them further down.
+ let isSilent =
+ Environment.GetCommandLineArgs()
+ |> Array.exists (fun a -> a = "-s" || a = "--silent")
+ if not isSilent then
+ traceWarnfn "paket.exe is deprecated and will no longer be published from Paket 12.0 onwards. The .NET tool is now the only supported way to run Paket: dotnet tool install paket"
+#endif
+
try
let args = Environment.GetCommandLineArgs()
match args with
diff --git a/tests/Paket.Bootstrapper.Tests/DownloadStrategies/DownloadStrategyVersionCapTests.cs b/tests/Paket.Bootstrapper.Tests/DownloadStrategies/DownloadStrategyVersionCapTests.cs
new file mode 100644
index 0000000000..e514ccd35a
--- /dev/null
+++ b/tests/Paket.Bootstrapper.Tests/DownloadStrategies/DownloadStrategyVersionCapTests.cs
@@ -0,0 +1,124 @@
+using NUnit.Framework;
+using Paket.Bootstrapper.DownloadStrategies;
+
+namespace Paket.Bootstrapper.Tests.DownloadStrategies
+{
+ [TestFixture]
+ public class DownloadStrategyVersionCapTests
+ {
+ private FakeStrategy sut;
+
+ [SetUp]
+ public void Setup()
+ {
+ sut = new FakeStrategy();
+ }
+
+ [Test]
+ public void GetLatestVersion_CapsAVersionWeCanNoLongerDownload()
+ {
+ //arrange
+ sut.LatestVersion = "12.0.0";
+
+ //act
+ var result = sut.GetLatestVersion(true);
+
+ //assert
+ Assert.That(result, Is.EqualTo(DownloadStrategy.LastSupportedVersion));
+ }
+
+ [Test]
+ public void GetLatestVersion_CapsAPrereleaseOfAnUnsupportedMajor()
+ {
+ //arrange
+ sut.LatestVersion = "12.0.0-alpha001";
+
+ //act
+ var result = sut.GetLatestVersion(false);
+
+ //assert
+ Assert.That(result, Is.EqualTo(DownloadStrategy.LastSupportedVersion));
+ }
+
+ [Test]
+ public void GetLatestVersion_LeavesTheLastSupportedMajorAlone()
+ {
+ //arrange
+ sut.LatestVersion = "11.2.3";
+
+ //act
+ var result = sut.GetLatestVersion(true);
+
+ //assert
+ Assert.That(result, Is.EqualTo("11.2.3"));
+ }
+
+ [Test]
+ public void GetLatestVersion_LeavesAnOlderVersionAlone()
+ {
+ //arrange
+ sut.LatestVersion = "10.3.1";
+
+ //act
+ var result = sut.GetLatestVersion(true);
+
+ //assert
+ Assert.That(result, Is.EqualTo("10.3.1"));
+ }
+
+ [Test]
+ public void GetLatestVersion_PassesAnEmptyVersionThrough()
+ {
+ //arrange
+ // An empty string is what the strategies return when they find nothing at all, and
+ // SemVer.Create would throw on it.
+ sut.LatestVersion = "";
+
+ //act
+ var result = sut.GetLatestVersion(true);
+
+ //assert
+ Assert.That(result, Is.EqualTo(""));
+ }
+
+ [Test]
+ public void GetLatestVersion_PassesAnUnparseableVersionThrough()
+ {
+ //arrange
+ sut.LatestVersion = "not a version";
+
+ //act
+ var result = sut.GetLatestVersion(true);
+
+ //assert
+ Assert.That(result, Is.EqualTo("not a version"));
+ }
+
+ private class FakeStrategy : DownloadStrategy
+ {
+ public string LatestVersion { get; set; }
+
+ public override string Name { get { return "Fake"; } }
+
+ public override bool CanDownloadHashFile { get { return false; } }
+
+ protected override string GetLatestVersionCore(bool ignorePrerelease)
+ {
+ return LatestVersion;
+ }
+
+ protected override void DownloadVersionCore(string latestVersion, string target, PaketHashFile hashfile)
+ {
+ }
+
+ protected override void SelfUpdateCore(string latestVersion)
+ {
+ }
+
+ protected override PaketHashFile DownloadHashFileCore(string latestVersion)
+ {
+ return null;
+ }
+ }
+ }
+}
diff --git a/tests/Paket.Bootstrapper.Tests/DownloadStrategies/GitHubDownloadStrategyTest.cs b/tests/Paket.Bootstrapper.Tests/DownloadStrategies/GitHubDownloadStrategyTest.cs
index 76d73d66d5..00565ed627 100644
--- a/tests/Paket.Bootstrapper.Tests/DownloadStrategies/GitHubDownloadStrategyTest.cs
+++ b/tests/Paket.Bootstrapper.Tests/DownloadStrategies/GitHubDownloadStrategyTest.cs
@@ -39,6 +39,22 @@ public void GetLatestVersion()
mockWebProxy.Verify();
}
+ [Test]
+ public void GetLatestVersion_CapsAVersionWeCanNoLongerDownload()
+ {
+ //arrange
+ mockWebProxy.Setup(x => x.DownloadString(GitHubDownloadStrategy.Constants.PaketReleasesLatestUrl)).Returns("Release 12.0.0 ยท fsprojects/Paket").Verifiable();
+
+ //act
+ var result = sut.GetLatestVersion(true);
+
+ //assert
+ // The releases page only ever yields the single newest version, so there is no older
+ // one to fall back to here; the base class clamps to the constant instead.
+ Assert.That(result, Is.EqualTo(DownloadStrategy.LastSupportedVersion));
+ mockWebProxy.Verify();
+ }
+
[Test]
public void GetLatestVersion_Prerelease()
{
diff --git a/tests/Paket.Bootstrapper.Tests/DownloadStrategies/NugetDownloadStrategyTests.cs b/tests/Paket.Bootstrapper.Tests/DownloadStrategies/NugetDownloadStrategyTests.cs
index b84c3d37bf..7611fd2e47 100644
--- a/tests/Paket.Bootstrapper.Tests/DownloadStrategies/NugetDownloadStrategyTests.cs
+++ b/tests/Paket.Bootstrapper.Tests/DownloadStrategies/NugetDownloadStrategyTests.cs
@@ -47,6 +47,22 @@ public void DefaultApi_GetLatestVersion_NoPrerelease()
Assert.That(version, Is.EqualTo("2.57.1"));
}
+ [Test]
+ public void DefaultApi_GetLatestVersion_SkipsVersionsPastTheLastSupportedMajor()
+ {
+ //arrange
+ CreateSystemUnderTestWithDefaultApi();
+ mockWebRequestProxy.Setup(x => x.DownloadString(It.IsAny())).Returns("[\"12.0.0\",\"11.1.0\",\"10.3.1\"]");
+
+ //act
+ var version = sut.GetLatestVersion(true);
+
+ //assert
+ // This strategy sees the whole list, so it picks the best supported version rather
+ // than the constant the base class clamps to.
+ Assert.That(version, Is.EqualTo("11.1.0"));
+ }
+
[Test]
public void DefaultApi_GetLatestVersion_WithPrerelease_ChoosePrelease()
{
@@ -238,13 +254,13 @@ public void NugetFolder_DownloadVersion_NoVersionSpecified_GetsLatestVersion()
CreateSystemUnderTestWithNugetFolder();
mockFileProxy.Setup(
x => x.EnumerateFiles(It.IsAny(), "paket.*.nupkg", SearchOption.TopDirectoryOnly))
- .Returns(new[] { "paket.111.nupkg" });
+ .Returns(new[] { "paket.1.1.1.nupkg" });
//act
sut.DownloadVersion(null, "paket", null);
//assert
- mockFileProxy.Verify(x => x.CopyFile(It.Is(s => s.StartsWith("anyNugetFolder") && s.EndsWith("paket.111.nupkg")), It.Is(s => s.StartsWith("folder") && s.EndsWith("paket.latest.nupkg")), false));
+ mockFileProxy.Verify(x => x.CopyFile(It.Is(s => s.StartsWith("anyNugetFolder") && s.EndsWith("paket.1.1.1.nupkg")), It.Is(s => s.StartsWith("folder") && s.EndsWith("paket.latest.nupkg")), false));
mockFileProxy.Verify(x => x.ExtractToDirectory(It.Is(s => s.StartsWith("folder") && s.EndsWith("paket.latest.nupkg")), It.IsAny()));
mockFileProxy.Verify(x => x.CopyFile(It.Is(s => s.StartsWith("folder") && s.EndsWith("paket.exe")), "paket", true));