From b0cc57c55fa068d136c2bdefe89d075bb6f1178c Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Thu, 13 Aug 2026 21:22:16 +1200 Subject: [PATCH 1/3] Add a rolling preview channel, and InstallVsix for sideloading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There was no preview build: build.yml is PR-only with PublishArtifacts false, and nothing ran on push to main, so a .vsix existed only for tagged releases. preview.yml builds every push to main and replaces the asset on a rolling `preview` GitHub pre-release. This is the counterpart to the framework's per-commit -preview packages, reshaped because the extension can't reuse that mechanism: GitHub Packages does not speak the VS Code gallery protocol, and neither marketplace accepts a semver prerelease version. A rolling release rather than workflow artifacts, because the download URL is then stable — installing is two commands with no run ID to hunt and no expiry. The tag is `preview`, deliberately not matching `v*`, so it neither triggers publish.yml nor falls under the v* protection ruleset, which matters because the workflow force-moves it on every push. Concurrency queues rather than cancels: a cancelled run could leave the release holding a half-uploaded asset. CodeTasks wraps the editor's `code` CLI, and IPackVsix gains InstallVsix to sideload the freshly built package — the way to dogfood something that hasn't been published. --force is passed because installing over the same version is otherwise a no-op, which would make an iterate-and-reinstall loop silently reinstall nothing. Node's stderr diagnostics are demoted via [LogLevelPattern] so a successful install stops reporting an [ERR] about url.parse(), the same treatment NpmTasks gives npm's notices. Documented in RELEASING.md, including why this is not auto-updating: VS Code only tracks versions for extensions it got from a gallery. The two real auto-update options (marketplace pre-release channel, self-hosted Open VSX) are written up with their costs. Co-Authored-By: Claude Opus 5 (1M context) --- .fallout/build.schema.json | 1 + .github/workflows/preview.yml | 137 ++++++++++++++++++++++++++++++ RELEASING.md | 23 +++++ plugins/Fallout.Vsce/CodeTasks.cs | 106 +++++++++++++++++++++++ plugins/Fallout.Vsce/IPackVsix.cs | 27 ++++++ plugins/Fallout.Vsce/README.md | 6 +- 6 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/preview.yml create mode 100644 plugins/Fallout.Vsce/CodeTasks.cs diff --git a/.fallout/build.schema.json b/.fallout/build.schema.json index 05c35ae..af58dd8 100644 --- a/.fallout/build.schema.json +++ b/.fallout/build.schema.json @@ -25,6 +25,7 @@ "type": "string", "enum": [ "CompileVsix", + "InstallVsix", "PackVsix", "PublishVsix", "RestoreVsix", diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 0000000..1c57068 --- /dev/null +++ b/.github/workflows/preview.yml @@ -0,0 +1,137 @@ +# Hand-written (not auto-generated). The "preview" channel — per-commit builds of main. +# +# Counterpart to the framework repo's publish-packages-preview.yml, which pushes a +# -preview prerelease to GitHub Packages on every commit to main. The extension can't +# do the same thing: GitHub Packages does not speak the VS Code gallery protocol, and +# neither marketplace accepts a semver prerelease version. So the preview channel here +# is a ROLLING GitHub pre-release whose asset is replaced on every push. +# +# Why a rolling release rather than workflow artifacts: +# - the download URL is stable, so installing is one command with no run-hunting: +# +# gh release download preview -R Fallout-build/Fallout.Extensions.VSCode -p '*.vsix' --clobber +# code --install-extension fallout.vsix +# +# or, from a clone: dotnet fallout InstallVsix +# - workflow artifacts expire and need the run ID to fetch. +# +# The tag is `preview`, deliberately NOT matching `v*`. Two consequences, both wanted: +# publish.yml (which triggers on v* only) does not fire, and the v* tag-protection +# ruleset does not apply to a tag this workflow force-moves on every push. +# +# The .vsix is marked as a marketplace pre-release, so if it is ever sideloaded next to +# a stable build VS Code shows it as pre-release rather than silently as a release. +# +# NOTE: this publishes NOTHING to any marketplace. It is the GitHub pre-stage only. +name: preview + +on: + push: + branches: + - main + paths-ignore: + - '**/*.md' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + # Never cancel in progress: a cancelled run could leave the rolling release holding a + # half-uploaded asset. Queue instead, so the newest push wins by finishing last. + group: preview + cancel-in-progress: false + +jobs: + preview: + name: preview + runs-on: ubuntu-latest + permissions: + contents: write + environment: + name: github-releases + url: https://github.com/Fallout-build/Fallout.Extensions.VSCode/releases/tag/preview + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 # Nerdbank.GitVersioning needs full history + - uses: actions/setup-node@v6 + with: + node-version: 20 + cache: npm + - uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + + # PublicRelease: main IS in version.json's publicReleaseRefSpec, so this is only + # belt-and-braces for the workflow_dispatch case on a detached checkout. + - name: 'Fallout build (PackVsix)' + run: ./build.sh PackVsix + env: + PreRelease: true + PublicRelease: true + + - name: 'Read the packaged version' + id: version + run: | + set -euo pipefail + # Take it from the VSIX manifest rather than recomputing — this reports what was + # actually built, so a version bug shows up here instead of being masked. + # + # Scoped to the element on purpose: the FIRST Version= attribute in the + # manifest is , the manifest schema version, so a + # naive "first match" reports 2.0.0 for every build. + unzip -p fallout.vsix extension.vsixmanifest > manifest.xml + VERSION=$(grep -o ']*' manifest.xml | grep -o 'Version="[^"]*"' | cut -d'"' -f2) + if [ -z "$VERSION" ]; then + echo "::error::Could not read the version from the VSIX manifest." + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Packaged version: $VERSION" + + - name: 'Update the rolling preview release' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + + NOTES=$(cat < /dev/null 2>&1; then + gh release edit preview --target "$GITHUB_SHA" --notes "$NOTES" --prerelease + gh release upload preview fallout.vsix --clobber + else + gh release create preview \ + --title 'Preview (rolling)' \ + --target "$GITHUB_SHA" \ + --notes "$NOTES" \ + --prerelease \ + fallout.vsix + fi diff --git a/RELEASING.md b/RELEASING.md index f3fe702..1581068 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -32,12 +32,35 @@ GitHub is the pre-stage; each marketplace is a promotion target with its own env | Channel | Trigger | Gating | |---|---|---| +| `preview` (rolling) | every push to `main` | none | | `github-releases` | any release tag | none | | `vs-marketplace` | `workflow_dispatch` opt-in flag | flag + approval | | `open-vsx` | `workflow_dispatch` opt-in flag | flag + approval | A tag push **never** reaches a marketplace. Promotion is deliberate: set the flag, then approve the environment — two independent layers, matching how Fallout gates nuget.org. +## The preview channel + +Every push to `main` builds a `.vsix` and replaces the asset on a rolling `preview` GitHub pre-release (`preview.yml`). It's the counterpart to the framework's per-commit `-preview` packages — reshaped because GitHub Packages doesn't speak the VS Code gallery protocol and neither marketplace accepts a semver prerelease. + +The `preview` tag deliberately doesn't match `v*`, so it neither triggers `publish.yml` nor falls under the `v*` tag-protection ruleset — which matters, because the workflow force-moves it on every push. + +### Installing a preview + +```bash +gh release download preview -R Fallout-build/Fallout.Extensions.VSCode -p '*.vsix' --clobber +code --install-extension fallout.vsix --force +``` + +The download URL is stable, so that pair of commands is the whole update story — no run IDs to hunt, no expiry. From a clone, `dotnet fallout InstallVsix` builds your working tree and installs that instead. + +### Why this isn't auto-updating + +VS Code will not auto-update a sideloaded extension — it only tracks versions for extensions it got from a gallery. Two ways to get real automatic updates, both with a cost: + +- **Marketplace pre-release channel.** The native mechanism: VS Code offers *"Switch to Pre-Release Version"* and updates it like anything else. Requires an actual marketplace presence. Worth noting the version-burning concern doesn't apply here — the patch is a git height, so every build has a unique number and stable is always a later height than any preview. +- **Self-hosted Open VSX**, with `product.json`'s `extensionsGallery` repointed at it. Genuinely works, but it's Postgres + Elasticsearch + the server, and `product.json` lives in a version-hashed directory that every VS Code update replaces. + ## Cutting a release candidate ```bash diff --git a/plugins/Fallout.Vsce/CodeTasks.cs b/plugins/Fallout.Vsce/CodeTasks.cs new file mode 100644 index 0000000..22f7fd4 --- /dev/null +++ b/plugins/Fallout.Vsce/CodeTasks.cs @@ -0,0 +1,106 @@ +// See VsceTasks.cs for why this wrapper is hand-written rather than generated. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Fallout.Common; +using Fallout.Common.Tooling; +using Serilog.Events; + +namespace Fallout.Vsce; + +/// +/// The code CLI that ships with VS Code — used here to sideload a locally built +/// .vsix, which is how you dogfood a build that hasn't been published anywhere. +/// +/// Sideloading is a one-shot install, not a subscription: VS Code will not auto-update an +/// extension it did not get from a gallery. Re-run the install to move to a newer build. +/// Automatic updates require a real gallery — the marketplace pre-release channel, or a +/// self-hosted Open VSX pointed at by product.json's extensionsGallery. +/// +// The code CLI writes Node's own diagnostics to stderr, which the default logger surfaces +// as errors and lists under "Errors & Warnings" — so a successful install reports an +// [ERR] line about url.parse() that has nothing to do with the build. Demoted rather than +// hidden: same approach NpmTasks takes to npm's notices. +[ExcludeFromCodeCoverage] +// Node's deprecation notice spans two lines — the "(Use `Code --trace-deprecation …`)" +// continuation needs demoting as well, or it survives on its own as a bare [ERR]. +[LogLevelPattern(LogEventLevel.Debug, @"^\(node:\d+\)")] +[LogLevelPattern(LogEventLevel.Debug, @"^\(Use `")] +[LogLevelPattern(LogEventLevel.Warning, "^Failed to install")] +[PathTool(Executable = PathExecutable)] +public partial class CodeTasks : ToolTasks +{ + /// Executable name looked up on PATH when no explicit tool path is set. + public const string PathExecutable = "code"; + + /// Resolved path to the code executable. + public static string CodePath + { + get => new CodeTasks().GetToolPathInternal(); + set => new CodeTasks().SetToolPath(value); + } + + /// Invokes code with raw arguments. + public static IReadOnlyCollection Code( + ArgumentStringHandler arguments, + string? workingDirectory = null, + IReadOnlyDictionary? environmentVariables = null, + int? timeout = null, + bool? logOutput = null, + bool? logInvocation = null, + Action? logger = null, + Func? exitHandler = null) + => new CodeTasks().Run(arguments, workingDirectory, environmentVariables, timeout, logOutput, logInvocation, logger, exitHandler); + + /// Installs an extension from a .vsix path or a marketplace id. + public static IReadOnlyCollection CodeInstallExtension(CodeInstallExtensionSettings? options = null) + => new CodeTasks().Run(options ?? new CodeInstallExtensionSettings()); + + /// + public static IReadOnlyCollection CodeInstallExtension(Configure configurator) + => new CodeTasks().Run(configurator.Invoke(new CodeInstallExtensionSettings())); +} + +#region CodeInstallExtensionSettings + +/// +[ExcludeFromCodeCoverage] +[Command(Type = typeof(CodeTasks), Command = nameof(CodeTasks.CodeInstallExtension))] +public partial class CodeInstallExtensionSettings : ToolOptions +{ + /// Path to a .vsix, or a publisher.name marketplace id. + [Argument(Format = "--install-extension {value}")] public string? Extension => Get(() => Extension); + + /// Replaces an already-installed version of the same extension. + [Argument(Format = "--force")] public bool? Force => Get(() => Force); + + /// Install into an isolated extensions directory rather than the user's. + [Argument(Format = "--extensions-dir {value}")] public string? ExtensionsDirectory => Get(() => ExtensionsDirectory); + + /// Prefer the pre-release version when installing by marketplace id. + [Argument(Format = "--pre-release")] public bool? PreRelease => Get(() => PreRelease); +} + +/// +[ExcludeFromCodeCoverage] +public static class CodeInstallExtensionSettingsExtensions +{ + /// + [Builder(Type = typeof(CodeInstallExtensionSettings), Property = nameof(CodeInstallExtensionSettings.Extension))] + public static T SetExtension(this T o, string v) where T : CodeInstallExtensionSettings => o.Modify(b => b.Set(() => o.Extension, v)); + + /// + [Builder(Type = typeof(CodeInstallExtensionSettings), Property = nameof(CodeInstallExtensionSettings.Force))] + public static T EnableForce(this T o) where T : CodeInstallExtensionSettings => o.Modify(b => b.Set(() => o.Force, true)); + + /// + [Builder(Type = typeof(CodeInstallExtensionSettings), Property = nameof(CodeInstallExtensionSettings.ExtensionsDirectory))] + public static T SetExtensionsDirectory(this T o, string v) where T : CodeInstallExtensionSettings => o.Modify(b => b.Set(() => o.ExtensionsDirectory, v)); + + /// + [Builder(Type = typeof(CodeInstallExtensionSettings), Property = nameof(CodeInstallExtensionSettings.PreRelease))] + public static T EnablePreRelease(this T o) where T : CodeInstallExtensionSettings => o.Modify(b => b.Set(() => o.PreRelease, true)); +} + +#endregion diff --git a/plugins/Fallout.Vsce/IPackVsix.cs b/plugins/Fallout.Vsce/IPackVsix.cs index d863626..1ba4891 100644 --- a/plugins/Fallout.Vsce/IPackVsix.cs +++ b/plugins/Fallout.Vsce/IPackVsix.cs @@ -38,6 +38,13 @@ sealed string ResolveNodeTool(string name) /// Path to the ovsx CLI. string OvsxToolPath => ResolveNodeTool(OvsxTasks.PathExecutable); + + /// + /// Path to the code CLI. Not a node tool — it ships with the editor, so this is a + /// plain PATH lookup. Override for a fork (codium, cursor) or for an + /// install that isn't on PATH. + /// + string CodeToolPath => CodeTasks.PathExecutable; } /// @@ -95,4 +102,24 @@ public interface IPackVsix : IHasVsix .EnableNoGitTagVersion() .SetPreRelease(VsixPreRelease ? true : (bool?)null)); }); + + /// + /// Sideloads the freshly packaged .vsix into the local editor — the way to dogfood a + /// build that hasn't been published. VS Code does not auto-update a sideloaded extension, + /// so re-run this to move to a newer build. + /// + Target InstallVsix => _ => _ + .DependsOn(PackVsix) + .Executes(() => + { + Serilog.Log.Information("Installing {File} into the local editor.", VsixFile.Name); + CodeTasks.CodeInstallExtension(_ => _ + .SetProcessToolPath(CodeToolPath) + .SetProcessWorkingDirectory(VsixDirectory) + .SetExtension(VsixFile) + // Without --force, installing over the same version is a no-op, which makes an + // iterate-and-reinstall loop silently reinstall nothing. + .EnableForce()); + Serilog.Log.Information("Installed. Reload the window (Developer: Reload Window) to pick it up."); + }); } diff --git a/plugins/Fallout.Vsce/README.md b/plugins/Fallout.Vsce/README.md index 76ac697..1f68db4 100644 --- a/plugins/Fallout.Vsce/README.md +++ b/plugins/Fallout.Vsce/README.md @@ -32,11 +32,14 @@ class Build : FalloutBuild, IPublishVsix ```bash dotnet fallout PackVsix +dotnet fallout InstallVsix # sideload into the local editor dotnet fallout VerifyVsixCredentials # proves the tokens, publishes nothing dotnet fallout PublishVsix dotnet fallout PublishVsix --publish-vsix-to open-vsx ``` +**`CodeTasks`** wraps the `code` CLI that ships with the editor, which is what `InstallVsix` uses to sideload a build that hasn't been published. Point `IHasVsix.CodeToolPath` at `codium` or `cursor` for a fork. + **`MarketplaceVersion`** — the one rule neither the versioning tool nor the registries will enforce for you: three integers, no prerelease. `vsce` throws on `10.4.16-rc.1`, and Nerdbank.GitVersioning stamps stable builds with four components, so both cases get normalised here. ## Things worth knowing @@ -44,7 +47,8 @@ dotnet fallout PublishVsix --publish-vsix-to open-vsx - **A version is pre-release or stable, never both.** The bit lives in the VSIX manifest (`Microsoft.VisualStudio.Code.PreRelease`) and is set at *package* time. Publishing `1.2.3` as a pre-release burns that number for good. - **`ovsx` ignores `--pre-release` for a prepackaged `.vsix`** — it reads the manifest. Package it correctly; don't rely on the publish flag. - **`vsce publish --pre-release` on a `--packagePath` is only an assertion** against the package, not what sets the status. -- **Tool resolution** prefers `node_modules/.bin` over `PATH`, since both CLIs are conventionally dev dependencies. Override via `IHasVsix.VsceToolPath` / `OvsxToolPath`. +- **Tool resolution** prefers `node_modules/.bin` over `PATH` for `vsce`/`ovsx`, since both are conventionally dev dependencies. `code` is a plain `PATH` lookup — it ships with the editor. Override via `IHasVsix.VsceToolPath` / `OvsxToolPath` / `CodeToolPath`. +- **A sideloaded extension never auto-updates.** VS Code only tracks versions for extensions it got from a gallery, so `InstallVsix` is a one-shot install — re-run it to move to a newer build. - **Tokens** are left to the CLIs' own environment variables (`VSCE_PAT`, `OVSX_PAT`) unless you set `VsixPublishTarget.Pat`, so they stay out of process argument lists. ## Status From 05ab746658738b5bebc83a638f5c087688d61705 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Thu, 13 Aug 2026 21:33:33 +1200 Subject: [PATCH 2/3] Drop InstallVsix: packaging is the build's job, installing is not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the sideload target and the CodeTasks wrapper from the previous commit. The build has no business reaching into the developer's editor, and a wrapper with no remaining caller is dead code. Distribution stays: the .vsix is produced, uploaded as a per-run workflow artifact, and attached to the rolling preview release. Downloading and installing is a manual step. The per-run artifact is worth having alongside the release asset: the rolling asset is replaced on the next push, so it cannot be the fixed record of what a given commit produced. Two ideas filed rather than built: - Chrison-Homelab/Homelab#409 — self-host a gallery, comparing coder/code-marketplace (Go, AGPL, single binary) against self-hosted Open VSX (full-featured, Postgres + Elasticsearch). - #6 — publish to it at gallery.fallout.build. Registries are already modelled as data in IPublishVsix, so that is a target entry, not a new pipeline. Recorded in both: microsoft/vsmarketplace is NOT a self-hostable marketplace — it is the issue tracker for Microsoft's hosted service. Microsoft ships no self-hostable gallery. Co-Authored-By: Claude Opus 5 (1M context) --- .fallout/build.schema.json | 1 - .github/workflows/preview.yml | 16 ++++- RELEASING.md | 12 ++-- plugins/Fallout.Vsce/CodeTasks.cs | 106 ------------------------------ plugins/Fallout.Vsce/IPackVsix.cs | 27 -------- plugins/Fallout.Vsce/README.md | 7 +- 6 files changed, 23 insertions(+), 146 deletions(-) delete mode 100644 plugins/Fallout.Vsce/CodeTasks.cs diff --git a/.fallout/build.schema.json b/.fallout/build.schema.json index af58dd8..05c35ae 100644 --- a/.fallout/build.schema.json +++ b/.fallout/build.schema.json @@ -25,7 +25,6 @@ "type": "string", "enum": [ "CompileVsix", - "InstallVsix", "PackVsix", "PublishVsix", "RestoreVsix", diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 1c57068..381fbed 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -71,6 +71,16 @@ jobs: PreRelease: true PublicRelease: true + # Per-run copy, alongside the rolling release asset below. The release is the thing + # you install from; this is the fixed record of what a specific commit produced, + # which the rolling asset can't be since it is replaced on the next push. + - uses: actions/upload-artifact@v7 + with: + name: fallout-vsix + path: fallout.vsix + retention-days: 14 + if-no-files-found: error + - name: 'Read the packaged version' id: version run: | @@ -111,11 +121,11 @@ jobs: \`\`\`bash gh release download preview -R ${GITHUB_REPOSITORY} -p '*.vsix' --clobber - code --install-extension fallout.vsix + code --install-extension fallout.vsix --force \`\`\` - From a clone of the repo, \`dotnet fallout InstallVsix\` builds and installs your - working tree instead. + Installing is manual by design — VS Code only auto-updates extensions it got from a + gallery, and there is no gallery in this channel. See RELEASING.md. EOF ) diff --git a/RELEASING.md b/RELEASING.md index 1581068..a781321 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -52,14 +52,18 @@ gh release download preview -R Fallout-build/Fallout.Extensions.VSCode -p '*.vsi code --install-extension fallout.vsix --force ``` -The download URL is stable, so that pair of commands is the whole update story — no run IDs to hunt, no expiry. From a clone, `dotnet fallout InstallVsix` builds your working tree and installs that instead. +The download URL is stable, so that pair of commands is the whole update story — no run IDs to hunt, no expiry. Each run also uploads the same `.vsix` as a workflow artifact, which is the fixed record of what a given commit produced; the rolling asset can't be, since the next push replaces it. + +Building locally, `dotnet fallout PackVsix` produces the `.vsix` and you install it the same way. Installing is deliberately a manual step — the build never touches your editor. ### Why this isn't auto-updating -VS Code will not auto-update a sideloaded extension — it only tracks versions for extensions it got from a gallery. Two ways to get real automatic updates, both with a cost: +VS Code will not auto-update a manually installed extension — it only tracks versions for extensions it got from a gallery, and this channel has no gallery. Manual download-and-install is the deliberate trade-off; the build never touches your editor. + +Two ways to get real automatic updates, if that ever becomes worth the cost: -- **Marketplace pre-release channel.** The native mechanism: VS Code offers *"Switch to Pre-Release Version"* and updates it like anything else. Requires an actual marketplace presence. Worth noting the version-burning concern doesn't apply here — the patch is a git height, so every build has a unique number and stable is always a later height than any preview. -- **Self-hosted Open VSX**, with `product.json`'s `extensionsGallery` repointed at it. Genuinely works, but it's Postgres + Elasticsearch + the server, and `product.json` lives in a version-hashed directory that every VS Code update replaces. +- **Marketplace pre-release channel.** The native mechanism: VS Code offers *"Switch to Pre-Release Version"* and updates it like anything else. Requires an actual marketplace presence. Note the version-burning concern doesn't apply to a preview stream — the patch is a git height, so every build has a unique number and stable is always a later height. +- **A self-hosted gallery at `gallery.fallout.build`** — tracked as [#6](https://github.com/Fallout-build/Fallout.Extensions.VSCode/issues/6), with the hosting options and their trade-offs in [Chrison-Homelab/Homelab#409](https://github.com/Chrison-Homelab/Homelab/issues/409). Since registries are already modelled as data in `IPublishVsix`, adding one is a target entry rather than a new pipeline. ## Cutting a release candidate diff --git a/plugins/Fallout.Vsce/CodeTasks.cs b/plugins/Fallout.Vsce/CodeTasks.cs deleted file mode 100644 index 22f7fd4..0000000 --- a/plugins/Fallout.Vsce/CodeTasks.cs +++ /dev/null @@ -1,106 +0,0 @@ -// See VsceTasks.cs for why this wrapper is hand-written rather than generated. - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using Fallout.Common; -using Fallout.Common.Tooling; -using Serilog.Events; - -namespace Fallout.Vsce; - -/// -/// The code CLI that ships with VS Code — used here to sideload a locally built -/// .vsix, which is how you dogfood a build that hasn't been published anywhere. -/// -/// Sideloading is a one-shot install, not a subscription: VS Code will not auto-update an -/// extension it did not get from a gallery. Re-run the install to move to a newer build. -/// Automatic updates require a real gallery — the marketplace pre-release channel, or a -/// self-hosted Open VSX pointed at by product.json's extensionsGallery. -/// -// The code CLI writes Node's own diagnostics to stderr, which the default logger surfaces -// as errors and lists under "Errors & Warnings" — so a successful install reports an -// [ERR] line about url.parse() that has nothing to do with the build. Demoted rather than -// hidden: same approach NpmTasks takes to npm's notices. -[ExcludeFromCodeCoverage] -// Node's deprecation notice spans two lines — the "(Use `Code --trace-deprecation …`)" -// continuation needs demoting as well, or it survives on its own as a bare [ERR]. -[LogLevelPattern(LogEventLevel.Debug, @"^\(node:\d+\)")] -[LogLevelPattern(LogEventLevel.Debug, @"^\(Use `")] -[LogLevelPattern(LogEventLevel.Warning, "^Failed to install")] -[PathTool(Executable = PathExecutable)] -public partial class CodeTasks : ToolTasks -{ - /// Executable name looked up on PATH when no explicit tool path is set. - public const string PathExecutable = "code"; - - /// Resolved path to the code executable. - public static string CodePath - { - get => new CodeTasks().GetToolPathInternal(); - set => new CodeTasks().SetToolPath(value); - } - - /// Invokes code with raw arguments. - public static IReadOnlyCollection Code( - ArgumentStringHandler arguments, - string? workingDirectory = null, - IReadOnlyDictionary? environmentVariables = null, - int? timeout = null, - bool? logOutput = null, - bool? logInvocation = null, - Action? logger = null, - Func? exitHandler = null) - => new CodeTasks().Run(arguments, workingDirectory, environmentVariables, timeout, logOutput, logInvocation, logger, exitHandler); - - /// Installs an extension from a .vsix path or a marketplace id. - public static IReadOnlyCollection CodeInstallExtension(CodeInstallExtensionSettings? options = null) - => new CodeTasks().Run(options ?? new CodeInstallExtensionSettings()); - - /// - public static IReadOnlyCollection CodeInstallExtension(Configure configurator) - => new CodeTasks().Run(configurator.Invoke(new CodeInstallExtensionSettings())); -} - -#region CodeInstallExtensionSettings - -/// -[ExcludeFromCodeCoverage] -[Command(Type = typeof(CodeTasks), Command = nameof(CodeTasks.CodeInstallExtension))] -public partial class CodeInstallExtensionSettings : ToolOptions -{ - /// Path to a .vsix, or a publisher.name marketplace id. - [Argument(Format = "--install-extension {value}")] public string? Extension => Get(() => Extension); - - /// Replaces an already-installed version of the same extension. - [Argument(Format = "--force")] public bool? Force => Get(() => Force); - - /// Install into an isolated extensions directory rather than the user's. - [Argument(Format = "--extensions-dir {value}")] public string? ExtensionsDirectory => Get(() => ExtensionsDirectory); - - /// Prefer the pre-release version when installing by marketplace id. - [Argument(Format = "--pre-release")] public bool? PreRelease => Get(() => PreRelease); -} - -/// -[ExcludeFromCodeCoverage] -public static class CodeInstallExtensionSettingsExtensions -{ - /// - [Builder(Type = typeof(CodeInstallExtensionSettings), Property = nameof(CodeInstallExtensionSettings.Extension))] - public static T SetExtension(this T o, string v) where T : CodeInstallExtensionSettings => o.Modify(b => b.Set(() => o.Extension, v)); - - /// - [Builder(Type = typeof(CodeInstallExtensionSettings), Property = nameof(CodeInstallExtensionSettings.Force))] - public static T EnableForce(this T o) where T : CodeInstallExtensionSettings => o.Modify(b => b.Set(() => o.Force, true)); - - /// - [Builder(Type = typeof(CodeInstallExtensionSettings), Property = nameof(CodeInstallExtensionSettings.ExtensionsDirectory))] - public static T SetExtensionsDirectory(this T o, string v) where T : CodeInstallExtensionSettings => o.Modify(b => b.Set(() => o.ExtensionsDirectory, v)); - - /// - [Builder(Type = typeof(CodeInstallExtensionSettings), Property = nameof(CodeInstallExtensionSettings.PreRelease))] - public static T EnablePreRelease(this T o) where T : CodeInstallExtensionSettings => o.Modify(b => b.Set(() => o.PreRelease, true)); -} - -#endregion diff --git a/plugins/Fallout.Vsce/IPackVsix.cs b/plugins/Fallout.Vsce/IPackVsix.cs index 1ba4891..d863626 100644 --- a/plugins/Fallout.Vsce/IPackVsix.cs +++ b/plugins/Fallout.Vsce/IPackVsix.cs @@ -38,13 +38,6 @@ sealed string ResolveNodeTool(string name) /// Path to the ovsx CLI. string OvsxToolPath => ResolveNodeTool(OvsxTasks.PathExecutable); - - /// - /// Path to the code CLI. Not a node tool — it ships with the editor, so this is a - /// plain PATH lookup. Override for a fork (codium, cursor) or for an - /// install that isn't on PATH. - /// - string CodeToolPath => CodeTasks.PathExecutable; } /// @@ -102,24 +95,4 @@ public interface IPackVsix : IHasVsix .EnableNoGitTagVersion() .SetPreRelease(VsixPreRelease ? true : (bool?)null)); }); - - /// - /// Sideloads the freshly packaged .vsix into the local editor — the way to dogfood a - /// build that hasn't been published. VS Code does not auto-update a sideloaded extension, - /// so re-run this to move to a newer build. - /// - Target InstallVsix => _ => _ - .DependsOn(PackVsix) - .Executes(() => - { - Serilog.Log.Information("Installing {File} into the local editor.", VsixFile.Name); - CodeTasks.CodeInstallExtension(_ => _ - .SetProcessToolPath(CodeToolPath) - .SetProcessWorkingDirectory(VsixDirectory) - .SetExtension(VsixFile) - // Without --force, installing over the same version is a no-op, which makes an - // iterate-and-reinstall loop silently reinstall nothing. - .EnableForce()); - Serilog.Log.Information("Installed. Reload the window (Developer: Reload Window) to pick it up."); - }); } diff --git a/plugins/Fallout.Vsce/README.md b/plugins/Fallout.Vsce/README.md index 1f68db4..9f43957 100644 --- a/plugins/Fallout.Vsce/README.md +++ b/plugins/Fallout.Vsce/README.md @@ -32,14 +32,11 @@ class Build : FalloutBuild, IPublishVsix ```bash dotnet fallout PackVsix -dotnet fallout InstallVsix # sideload into the local editor dotnet fallout VerifyVsixCredentials # proves the tokens, publishes nothing dotnet fallout PublishVsix dotnet fallout PublishVsix --publish-vsix-to open-vsx ``` -**`CodeTasks`** wraps the `code` CLI that ships with the editor, which is what `InstallVsix` uses to sideload a build that hasn't been published. Point `IHasVsix.CodeToolPath` at `codium` or `cursor` for a fork. - **`MarketplaceVersion`** — the one rule neither the versioning tool nor the registries will enforce for you: three integers, no prerelease. `vsce` throws on `10.4.16-rc.1`, and Nerdbank.GitVersioning stamps stable builds with four components, so both cases get normalised here. ## Things worth knowing @@ -47,8 +44,8 @@ dotnet fallout PublishVsix --publish-vsix-to open-vsx - **A version is pre-release or stable, never both.** The bit lives in the VSIX manifest (`Microsoft.VisualStudio.Code.PreRelease`) and is set at *package* time. Publishing `1.2.3` as a pre-release burns that number for good. - **`ovsx` ignores `--pre-release` for a prepackaged `.vsix`** — it reads the manifest. Package it correctly; don't rely on the publish flag. - **`vsce publish --pre-release` on a `--packagePath` is only an assertion** against the package, not what sets the status. -- **Tool resolution** prefers `node_modules/.bin` over `PATH` for `vsce`/`ovsx`, since both are conventionally dev dependencies. `code` is a plain `PATH` lookup — it ships with the editor. Override via `IHasVsix.VsceToolPath` / `OvsxToolPath` / `CodeToolPath`. -- **A sideloaded extension never auto-updates.** VS Code only tracks versions for extensions it got from a gallery, so `InstallVsix` is a one-shot install — re-run it to move to a newer build. +- **Tool resolution** prefers `node_modules/.bin` over `PATH`, since both CLIs are conventionally dev dependencies. Override via `IHasVsix.VsceToolPath` / `OvsxToolPath`. +- **Installing is out of scope on purpose.** This plugin packages and publishes; it does not touch the local editor. A manually installed `.vsix` never auto-updates anyway, since VS Code only tracks versions for extensions it got from a gallery. - **Tokens** are left to the CLIs' own environment variables (`VSCE_PAT`, `OVSX_PAT`) unless you set `VsixPublishTarget.Pat`, so they stay out of process argument lists. ## Status From 6a7eb7ec43d00dfeceabe1de56e086bca9ab7850 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Thu, 13 Aug 2026 21:42:22 +1200 Subject: [PATCH 3/3] Correct the self-hosted gallery options: Microsoft ships one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I claimed Microsoft has no self-hostable marketplace, judging microsoft/vsmarketplace by its repo description ("issue tracker") without looking inside it. Wrong: privatemarketplace/ ships a real one. mcr.microsoft.com/vsmarketplace/vscode-private-marketplace:1.0.57 It is the best engineering fit of the three candidates — stateless with no external database, extensions from a mounted volume or Azure Artifacts, and optional upstreaming so it can proxy the public marketplace rather than being a second gallery to switch between. Most importantly it is configured through supported device-management policy (ExtensionGalleryServiceUrl via Windows Group Policy or a macOS .mobileconfig), so the product.json objection I raised against the alternatives does not apply to it — that patch is what does not survive a VS Code update. The catch is commercial, not technical: every consumer must sign in with a GitHub Copilot Enterprise/Business or GitHub Enterprise subscription. That is enforced client-side by the accessSKUs list already present in VS Code's product.json, which is what that list is for. Issues updated with all three options and the constraints that actually decide between them. Co-Authored-By: Claude Opus 5 (1M context) --- RELEASING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASING.md b/RELEASING.md index a781321..41f17d9 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -63,7 +63,7 @@ VS Code will not auto-update a manually installed extension — it only tracks v Two ways to get real automatic updates, if that ever becomes worth the cost: - **Marketplace pre-release channel.** The native mechanism: VS Code offers *"Switch to Pre-Release Version"* and updates it like anything else. Requires an actual marketplace presence. Note the version-burning concern doesn't apply to a preview stream — the patch is a git height, so every build has a unique number and stable is always a later height. -- **A self-hosted gallery at `gallery.fallout.build`** — tracked as [#6](https://github.com/Fallout-build/Fallout.Extensions.VSCode/issues/6), with the hosting options and their trade-offs in [Chrison-Homelab/Homelab#409](https://github.com/Chrison-Homelab/Homelab/issues/409). Since registries are already modelled as data in `IPublishVsix`, adding one is a target entry rather than a new pipeline. +- **A self-hosted gallery at `gallery.fallout.build`** — tracked as [#6](https://github.com/Fallout-build/Fallout.Extensions.VSCode/issues/6), with hosting options and trade-offs in [Chrison-Homelab/Homelab#409](https://github.com/Chrison-Homelab/Homelab/issues/409). Candidates are Microsoft's own [Private Marketplace](https://github.com/microsoft/vsmarketplace/blob/main/privatemarketplace/latest/README.md) (stateless container, configured by supported device-management policy, but every consumer needs a Copilot Business/Enterprise seat), `coder/code-marketplace`, or self-hosted Open VSX. Since registries are already modelled as data in `IPublishVsix`, adding one is a target entry rather than a new pipeline. ## Cutting a release candidate