From 1f460d39198f872198e1edc5c497e5fca1a74ae7 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Wed, 12 Aug 2026 09:00:04 +1200 Subject: [PATCH 1/7] Target the Fallout 10.4.0 GA release instead of preview packages Fallout 10.4.0 shipped to nuget.org, so the dogfood build no longer needs the private GitHub Packages feed or its token: - Pin Fallout.Common and the CLI tool to 10.4.0. The tool's package id changed on GA (fallout.globaltools -> fallout.globaltool). - Drop the github package source, its credentials, and the source mapping from nuget.config; nuget.org is now the only feed. Verified that Fallout.Common 10.4.0 resolves from api.nuget.org. - Drop FALLOUT_PACKAGES_TOKEN from the publish workflow. Nerdbank.GitVersioning stamps stable builds with a four-component version (10.4.0.15+f16e0f1441), so FrameworkVersion() now picks the height out of the fourth component rather than assuming a prerelease counter. Returning the release verbatim would emit 10.4.0.15, which is not valid semver and fails marketplace validation. The extension version therefore moves from calendar versioning to tracking the framework release line: 10.4.15 against Fallout 10.4. Co-Authored-By: Claude Opus 5 (1M context) --- .config/dotnet-tools.json | 4 ++-- .github/workflows/publish.yml | 18 +++++++++--------- CHANGELOG.md | 7 +++++-- README.md | 4 ++-- build/Build.cs | 23 ++++++++++++++++------- build/_build.csproj | 2 +- nuget.config | 22 ++++------------------ package.json | 2 +- src/model.ts | 4 ++-- 9 files changed, 42 insertions(+), 44 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 4543a82..a8483d3 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -2,8 +2,8 @@ "version": 1, "isRoot": true, "tools": { - "fallout.globaltools": { - "version": "2026.1.0-preview.104.g6583d78d2c", + "fallout.globaltool": { + "version": "10.4.0", "commands": [ "fallout" ] diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d4565d1..55d3077 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,23 +1,25 @@ # Publishes the extension to the VS Marketplace and Open VSX, and attaches the -# .vsix to the GitHub Release. Fires on a version tag (e.g. v2026.1.0). +# .vsix to the GitHub Release. Fires on a version tag matching the version the +# build stamps from the pinned framework (e.g. v10.4.15 for Fallout 10.4.0). # # Dogfood: the actual build/package/publish is a Fallout build (build/Build.cs), # invoked here via ./build.sh. This workflow only provisions the toolchains and # passes the marketplace tokens through the environment. # # Required repository secrets: -# VSCE_PAT - Azure DevOps PAT, scope Marketplace > Manage (VS Marketplace) -# OVSX_TOKEN - Open VSX access token (Eclipse Foundation account) -# FALLOUT_PACKAGES_TOKEN - token with read:packages, to restore Fallout -preview -# packages (the build framework + CLI) from GitHub Packages +# VSCE_PAT - Azure DevOps PAT, scope Marketplace > Manage (VS Marketplace) +# OVSX_TOKEN - Open VSX access token (Eclipse Foundation account) +# +# Fallout itself restores from nuget.org since 10.4.0 went GA, so no package-feed +# token is needed. name: publish on: push: tags: - 'v*' - # Manual dry-run: restore (incl. Fallout -preview from GitHub Packages) + compile + - # package, WITHOUT publishing. Validates the toolchain and secrets before tagging. + # Manual dry-run: restore + compile + package, WITHOUT publishing. Validates the + # toolchain and secrets before tagging. workflow_dispatch: permissions: @@ -39,8 +41,6 @@ jobs: - name: 'Fallout build' run: ./build.sh ${{ github.event_name == 'push' && 'Publish' || 'Pack' }} env: - # Restores Fallout -preview (framework + CLI tool) from GitHub Packages. - FALLOUT_PACKAGES_TOKEN: ${{ secrets.FALLOUT_PACKAGES_TOKEN }} VSCE_PAT: ${{ secrets.VSCE_PAT }} # ovsx reads OVSX_PAT from the environment; our secret is named OVSX_TOKEN. OVSX_PAT: ${{ secrets.OVSX_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a75e4d..46e8397 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,11 @@ # Changelog -## 2026.1.0 +## 10.4.15 -Initial release. +Initial release, built against Fallout 10.4.0 — the release that emits +`build-graph.json` on build initialization, which everything below depends on. +The patch component is the framework's release-branch height, so the extension +version reads as 10.4.x against the Fallout 10.4 line. - **Targets view** in the activity bar — lists build targets from `build-graph.json`, showing the default target, unlisted targets, and each target's `depends on` / `runs after` / `triggered by` / `triggers` relations as expandable children. - **Run Target** — runs a target in an integrated `Fallout` terminal via `./build.ps1` (Windows) or `./build.sh`. diff --git a/README.md b/README.md index 6c00e77..2abdd5c 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,11 @@ Explore, run, and visualize your [Fallout](https://github.com/Fallout-build/Fall ## Requirements -The extension reads a `build-graph.json` emitted by the Fallout build into `.fallout/temp/` (or the legacy `.nuke/temp/`). Run the build once (e.g. `./build.ps1 --plan`) to generate it. +**Fallout 10.4.0 or later.** The extension reads a `build-graph.json` that the Fallout build writes into `.fallout/temp/` (or the legacy `.nuke/temp/`) on every build initialization — the emission landed in 10.4.0, so older framework versions produce no graph at all. Run the build once (e.g. `./build.ps1 --plan`) to generate it. ## Versioning -The extension's `major.minor` track the Fallout framework version it targets; the patch moves independently. A mismatch between the extension and the framework your workspace builds with surfaces as a non-blocking warning. +The extension's `major.minor` track the Fallout framework release line it targets — 10.4.x builds against Fallout 10.4 — while the patch moves independently. A mismatch between the extension and the framework your workspace builds with surfaces as a non-blocking warning. ## License diff --git a/build/Build.cs b/build/Build.cs index 2614f78..225f333 100644 --- a/build/Build.cs +++ b/build/Build.cs @@ -61,8 +61,15 @@ class Build : FalloutBuild // The extension version tracks the Fallout framework it was built against — the pinned // Fallout.Common package, read back from the loaded assembly. Marketplaces accept only - // three integers, so a preview (X.Y.Z-preview.N) maps to X.Y.N and is marked pre-release; - // a stable release (X.Y.Z) is used verbatim. + // three integers, so major.minor come from the framework release line and the third is + // whatever counter moves within it: + // + // 10.4.0.15+f16e0f1441 -> 10.4.15 (stable) third = the git height + // 10.4.0-rc.5 -> 10.4.5 (pre-release) third = the prerelease counter + // + // Nerdbank.GitVersioning stamps stable builds with a four-component version, so the + // height has to be picked out explicitly — returning the release verbatim would emit + // 10.4.0.15, which is not valid semver and fails marketplace validation. static (string Version, bool PreRelease) FrameworkVersion() { var info = typeof(FalloutBuild).Assembly @@ -71,13 +78,15 @@ class Build : FalloutBuild var core = info.Split('+')[0]; var dash = core.IndexOf('-'); - if (dash < 0) - return (core, false); + var preRelease = dash >= 0; - var parts = core[..dash].Split('.'); + var parts = (preRelease ? core[..dash] : core).Split('.'); var major = parts.ElementAtOrDefault(0) ?? "0"; var minor = parts.ElementAtOrDefault(1) ?? "0"; - var height = core[(dash + 1)..].Split('.').FirstOrDefault(p => int.TryParse(p, out _)) ?? "0"; - return ($"{major}.{minor}.{height}", true); + var height = (preRelease + ? core[(dash + 1)..].Split('.').FirstOrDefault(p => int.TryParse(p, out _)) + : parts.ElementAtOrDefault(3)) ?? "0"; + + return ($"{major}.{minor}.{height}", preRelease); } } diff --git a/build/_build.csproj b/build/_build.csproj index 91152fc..dbb7b01 100644 --- a/build/_build.csproj +++ b/build/_build.csproj @@ -13,7 +13,7 @@ - + diff --git a/nuget.config b/nuget.config index 17e11e2..e80b5a2 100644 --- a/nuget.config +++ b/nuget.config @@ -1,25 +1,11 @@ + - - - - - - - - - - - - - - - - - - diff --git a/package.json b/package.json index a27ed56..52ac80b 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "fallout", "displayName": "Fallout", "description": "Explore, run, and visualize your Fallout (NUKE successor) build targets from VS Code.", - "version": "2026.1.0", + "version": "10.4.15", "publisher": "fallout", "license": "MIT", "icon": "media/icon.png", diff --git a/src/model.ts b/src/model.ts index b65c907..3a33dff 100644 --- a/src/model.ts +++ b/src/model.ts @@ -39,8 +39,8 @@ const warned = new Set(); /** * Versioning contract: the extension's major.minor track the Fallout framework - * (calendar versioning, major = year); the patch moves independently. The schema - * `version` is the hard gate, the major.minor comparison a drift warning. + * release line (e.g. 10.4.x against Fallout 10.4); the patch moves independently. + * The schema `version` is the hard gate, the major.minor comparison a drift warning. */ export function checkCompatibility(graph: BuildGraph, extensionVersion: string): void { let message: string | undefined; From a1efb99c8afd8ef0d0c4651ece4c9bf548b1fb54 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Wed, 12 Aug 2026 09:05:36 +1200 Subject: [PATCH 2/7] Support release candidates, and gate the marketplace publish The VS Marketplace rejects semver prerelease versions outright, so an RC cannot be expressed as 10.4.15-rc.1. Instead an RC is an ordinary three-integer version whose VSIX manifest carries a pre-release bit (Microsoft.VisualStudio.Code.PreRelease), set at package time. The rc suffix lives only on the git tag and the GitHub release. - Add a PreRelease build parameter, independent of the framework's own version state so the extension can cut an RC while Fallout is GA. It ORs with the framework-derived prerelease flag. - Split the workflow into package / prerelease / publish. An rc tag now routes to a GitHub pre-release with the .vsix attached and never reaches a marketplace, which the previous 'v*' trigger would have done. - Narrow the tag patterns so a stable tag and an rc tag cannot match each other's pattern, and anything else triggers nothing at all. - Gate the stable publish on the 'marketplace' environment, so a tag push alone cannot publish without an approval. Verified locally: PreRelease=true packages 10.4.15 with Microsoft.VisualStudio.Code.PreRelease="true" in extension.vsixmanifest; the default still packages it as a stable release. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/publish.yml | 115 +++++++++++++++++++++++++++++----- build/Build.cs | 14 ++++- 2 files changed, 114 insertions(+), 15 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 55d3077..cd2d9ab 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,11 +1,24 @@ -# Publishes the extension to the VS Marketplace and Open VSX, and attaches the -# .vsix to the GitHub Release. Fires on a version tag matching the version the -# build stamps from the pinned framework (e.g. v10.4.15 for Fallout 10.4.0). +# Builds the extension, and depending on how it was triggered, releases it. # # Dogfood: the actual build/package/publish is a Fallout build (build/Build.cs), # invoked here via ./build.sh. This workflow only provisions the toolchains and # passes the marketplace tokens through the environment. # +# Three paths, deliberately separated so a release candidate can never reach a +# marketplace: +# +# workflow_dispatch -> package only, plus a credential check. Dry-run. +# tag v1.2.3-rc.4 -> package, attach the .vsix to a GitHub PRE-release. +# Nothing is published to any marketplace. +# tag v1.2.3 -> package, publish to VS Marketplace + Open VSX, +# attach the .vsix to a GitHub release. Gated on the +# 'marketplace' environment, so it needs an approval. +# +# On RC versions: the VS Marketplace rejects semver prerelease versions outright +# (vsce throws on '10.4.15-rc.1'), so the .vsix an RC tag produces is versioned +# with plain integers and carries a pre-release bit in its manifest instead. The +# rc suffix lives only on the git tag and the GitHub release. +# # Required repository secrets: # VSCE_PAT - Azure DevOps PAT, scope Marketplace > Manage (VS Marketplace) # OVSX_TOKEN - Open VSX access token (Eclipse Foundation account) @@ -17,7 +30,10 @@ name: publish on: push: tags: - - 'v*' + # Patterns must match the whole ref, so a stable tag cannot match the rc + # pattern or vice versa. Anything else (v10.4, v10.4.15-beta) triggers nothing. + - 'v[0-9]+.[0-9]+.[0-9]+' + - 'v[0-9]+.[0-9]+.[0-9]+-rc.[0-9]+' # Manual dry-run: restore + compile + package, WITHOUT publishing. Validates the # toolchain and secrets before tagging. workflow_dispatch: @@ -26,8 +42,11 @@ permissions: contents: write jobs: - publish: + # Always runs. Produces the .vsix every downstream job consumes. + package: runs-on: ubuntu-latest + outputs: + is-rc: ${{ steps.classify.outputs.is-rc }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -37,13 +56,23 @@ jobs: - uses: actions/setup-dotnet@v4 with: global-json-file: global.json - # Tag push -> Publish (both registries). Manual dispatch -> Pack only (dry-run). - - name: 'Fallout build' - run: ./build.sh ${{ github.event_name == 'push' && 'Publish' || 'Pack' }} + + - name: Classify the trigger + id: classify + run: | + if [[ "${{ github.ref_type }}" == "tag" && "${{ github.ref_name }}" == *-rc.* ]]; then + echo 'is-rc=true' >> "$GITHUB_OUTPUT" + else + echo 'is-rc=false' >> "$GITHUB_OUTPUT" + fi + + # An rc tag marks the package as a marketplace pre-release. Fallout resolves + # parameters from the environment, so PreRelease is passed that way. + - name: 'Fallout build (Pack)' + run: ./build.sh Pack env: - VSCE_PAT: ${{ secrets.VSCE_PAT }} - # ovsx reads OVSX_PAT from the environment; our secret is named OVSX_TOKEN. - OVSX_PAT: ${{ secrets.OVSX_TOKEN }} + PreRelease: ${{ steps.classify.outputs.is-rc }} + # Dry-run only: prove the marketplace credentials + publisher/namespace align # before a real tag, without publishing anything. - name: 'Verify publish credentials' @@ -54,8 +83,66 @@ jobs: run: | npx vsce verify-pat fallout npx ovsx verify-pat fallout - - name: Attach .vsix to the GitHub Release - if: github.event_name == 'push' + + - uses: actions/upload-artifact@v4 + with: + name: fallout-vsix + path: fallout.vsix + if-no-files-found: error + + # Release candidate: a GitHub pre-release only. No marketplace involvement, so the + # version number stays free for the eventual stable publish. + prerelease: + needs: package + if: github.ref_type == 'tag' && needs.package.outputs.is-rc == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: fallout-vsix + - name: 'Create GitHub pre-release' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.ref_name }} + run: | + gh release create "$TAG" \ + --prerelease \ + --verify-tag \ + --title "$TAG" \ + --notes 'Release candidate. Not published to the VS Marketplace or Open VSX — install the attached .vsix via "Extensions: Install from VSIX...".' \ + fallout.vsix \ + || gh release upload "$TAG" fallout.vsix --clobber + + # Stable release. Requires an approval on the 'marketplace' environment, so a tag + # push alone cannot publish. Re-runs the Fallout chain so the published bits are + # built fresh from the tag. + publish: + needs: package + if: github.ref_type == 'tag' && needs.package.outputs.is-rc == 'false' + runs-on: ubuntu-latest + environment: marketplace + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: 'Fallout build (Publish)' + run: ./build.sh Publish + env: + VSCE_PAT: ${{ secrets.VSCE_PAT }} + # ovsx reads OVSX_PAT from the environment; our secret is named OVSX_TOKEN. + OVSX_PAT: ${{ secrets.OVSX_TOKEN }} + + - name: 'Create GitHub release' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release upload "${GITHUB_REF_NAME}" fallout.vsix --clobber + TAG: ${{ github.ref_name }} + run: | + gh release create "$TAG" --verify-tag --title "$TAG" --generate-notes fallout.vsix \ + || gh release upload "$TAG" fallout.vsix --clobber diff --git a/build/Build.cs b/build/Build.cs index 225f333..024a0f9 100644 --- a/build/Build.cs +++ b/build/Build.cs @@ -17,6 +17,16 @@ class Build : FalloutBuild { public static int Main() => Execute(x => x.Pack); + // Marks the packaged extension as a marketplace pre-release. The VS Marketplace rejects + // semver prerelease versions outright — `vsce` throws on `10.4.15-rc.1` — so a release + // candidate is an ordinary three-integer version carrying a pre-release bit in the VSIX + // manifest (Microsoft.VisualStudio.Code.PreRelease), set at package time. + // + // Deliberately independent of the framework's own version state: the extension needs to + // be able to cut an RC while the Fallout it targets is already GA. + [Parameter("Mark the packaged extension as a marketplace pre-release")] + readonly bool PreRelease; + Target Restore => _ => _ .Executes(() => { @@ -34,7 +44,9 @@ class Build : FalloutBuild .DependsOn(Compile) .Executes(() => { - var (version, preRelease) = FrameworkVersion(); + var (version, frameworkPreRelease) = FrameworkVersion(); + // Either an explicitly requested RC, or a build against a not-yet-GA framework. + var preRelease = PreRelease || frameworkPreRelease; Serilog.Log.Information("Packaging extension as {Version} (pre-release: {PreRelease})", version, preRelease); Npm( $"run package -- {version} --no-update-package-json --no-git-tag-version" From e4dff2d89dfc8c98dfb3614f0950a1d45dc97dfc Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Wed, 12 Aug 2026 09:05:49 +1200 Subject: [PATCH 3/7] Regenerate the build schema with the PreRelease parameter Emitted by the Fallout build itself when the parameter was added. Co-Authored-By: Claude Opus 5 (1M context) --- .fallout/build.schema.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.fallout/build.schema.json b/.fallout/build.schema.json index 46496eb..4978c39 100644 --- a/.fallout/build.schema.json +++ b/.fallout/build.schema.json @@ -108,7 +108,14 @@ } }, "allOf": [ - {}, + { + "properties": { + "PreRelease": { + "type": "boolean", + "description": "Mark the packaged extension as a marketplace pre-release" + } + } + }, { "$ref": "#/definitions/FalloutBuild" } From 92f83c6a6a74bf4a2f1a432a6551cf5b514d9b05 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Thu, 13 Aug 2026 14:07:46 +1200 Subject: [PATCH 4/7] Move the vsce toolchain into a Fallout plugin, version with NB.GV MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes that belong together, because the plugin is what makes the version mapping expressible. plugins/Fallout.Vsce — the vsce/ovsx toolchain as a Fallout plugin rather than an in-tree framework component. This is the pilot for the plugin shape ADR-0001 sketches for Octopus: tool wrappers in the same form the Tooling.Generator emits (ToolTasks + [Command] ToolOptions + [Argument] properties + fluent [Builder] setters), plus IPackVsix / IPublishVsix components mirroring Fallout.Components' IPack / IPublish. Registries are declared as data (VsixPublishTarget, the VSIX analogue of PublishTarget) and narrowed per-run with --publish-vsix-to, so adding Open VSX was a list entry rather than a new target. Versioning now matches the framework: Nerdbank.GitVersioning over version.json, replacing the hand-rolled parse of the pinned assembly's informational version. version.json pins the 10.4 line and the build asserts it still matches the referenced Fallout.Common, so the two cannot drift apart silently — the extension's major.minor is a documented promise about which framework line it targets. MarketplaceVersion holds the one rule neither NB.GV nor the marketplaces will enforce for us: three integers, no prerelease. It is where the four-component stable-build case and the rc case both get normalised. Also fixes two things this surfaced: - .vscodeignore did not exclude plugins/, so a package built after adding it shipped 30 files of C# source and build output to the marketplace. - package.json's version is now 0.0.0. The build stamps the real one at package time, and a hardcoded number next to it was a second source of truth guaranteed to drift. The npm package/publish scripts are gone for the same reason — the plugin owns that path now. Co-Authored-By: Claude Opus 5 (1M context) --- .fallout/build.schema.json | 18 +- .gitignore | 6 +- .vscodeignore | 2 + build/Build.cs | 139 ++++++------- build/_build.csproj | 10 + package.json | 7 +- plugins/Fallout.Vsce/Fallout.Vsce.csproj | 37 ++++ plugins/Fallout.Vsce/IPackVsix.cs | 98 +++++++++ plugins/Fallout.Vsce/IPublishVsix.cs | 130 ++++++++++++ plugins/Fallout.Vsce/MarketplaceVersion.cs | 73 +++++++ plugins/Fallout.Vsce/OvsxTasks.cs | 134 ++++++++++++ plugins/Fallout.Vsce/VsceTasks.cs | 231 +++++++++++++++++++++ plugins/Fallout.Vsce/VsixPublishTarget.cs | 41 ++++ version.json | 32 +++ 14 files changed, 866 insertions(+), 92 deletions(-) create mode 100644 plugins/Fallout.Vsce/Fallout.Vsce.csproj create mode 100644 plugins/Fallout.Vsce/IPackVsix.cs create mode 100644 plugins/Fallout.Vsce/IPublishVsix.cs create mode 100644 plugins/Fallout.Vsce/MarketplaceVersion.cs create mode 100644 plugins/Fallout.Vsce/OvsxTasks.cs create mode 100644 plugins/Fallout.Vsce/VsceTasks.cs create mode 100644 plugins/Fallout.Vsce/VsixPublishTarget.cs create mode 100644 version.json diff --git a/.fallout/build.schema.json b/.fallout/build.schema.json index 4978c39..05c35ae 100644 --- a/.fallout/build.schema.json +++ b/.fallout/build.schema.json @@ -24,12 +24,11 @@ "ExecutableTarget": { "type": "string", "enum": [ - "Compile", - "Pack", - "Publish", - "PublishMarketplace", - "PublishOpenVsx", - "Restore" + "CompileVsix", + "PackVsix", + "PublishVsix", + "RestoreVsix", + "VerifyVsixCredentials" ] }, "Verbosity": { @@ -113,6 +112,13 @@ "PreRelease": { "type": "boolean", "description": "Mark the packaged extension as a marketplace pre-release" + }, + "PublishVsixTo": { + "type": "array", + "description": "Publish only to these named registries (default: all configured VsixPublishTargets)", + "items": { + "type": "string" + } } } }, diff --git a/.gitignore b/.gitignore index ec73a56..a0343ba 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ out/ *.vsix .vscode-test/ -# Fallout build +# Fallout build (build/ is the build project, plugins/ the Fallout plugins it consumes) .fallout/temp/ -build/bin/ -build/obj/ +bin/ +obj/ diff --git a/.vscodeignore b/.vscodeignore index d1357a5..d67cb73 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -11,9 +11,11 @@ tsconfig.json # Fallout build scaffold — build/publish tooling, not part of the shipped extension. .fallout/** build/** +plugins/** .config/** global.json nuget.config +version.json build.ps1 build.sh diff --git a/build/Build.cs b/build/Build.cs index 024a0f9..47d24cb 100644 --- a/build/Build.cs +++ b/build/Build.cs @@ -1,104 +1,87 @@ -using System.Linq; +using System.Collections.Generic; using System.Reflection; using Fallout.Common; -using Fallout.Common.IO; -using static Fallout.Common.Tools.Npm.NpmTasks; +using Fallout.Common.Tools.NerdbankGitVersioning; +using Fallout.Vsce; -// Dogfood: the extension's own build/publish pipeline is a Fallout build. It drives the -// Node toolchain (npm scripts wrapping tsc / vsce / ovsx) rather than reimplementing it — -// Fallout as a general orchestrator, not just a .NET build tool. +// Dogfood: the extension's own build/publish pipeline is a Fallout build, and the vsce/ovsx +// toolchain it drives is a Fallout plugin (plugins/Fallout.Vsce) rather than anything baked +// into the framework. Fallout as a general orchestrator, not just a .NET build tool. // -// dotnet fallout Pack -> produces fallout.vsix -// dotnet fallout Publish -> publishes to VS Marketplace + Open VSX +// dotnet fallout PackVsix -> produces fallout.vsix +// dotnet fallout VerifyVsixCredentials -> proves the tokens work, publishes nothing +// dotnet fallout PublishVsix -> publishes to every configured registry +// dotnet fallout PublishVsix --publish-vsix-to open-vsx -> just that one // -// Marketplace tokens are read from the environment by vsce (VSCE_PAT) and ovsx (OVSX_PAT); -// this build never handles them directly. -class Build : FalloutBuild +// Registry tokens are read from the environment by the CLIs themselves (VSCE_PAT / OVSX_PAT); +// this build never handles them, so they stay out of process argument lists and logs. +class Build : FalloutBuild, IPublishVsix { - public static int Main() => Execute(x => x.Pack); + public static int Main() => Execute(x => ((IPackVsix)x).PackVsix); - // Marks the packaged extension as a marketplace pre-release. The VS Marketplace rejects - // semver prerelease versions outright — `vsce` throws on `10.4.15-rc.1` — so a release - // candidate is an ordinary three-integer version carrying a pre-release bit in the VSIX - // manifest (Microsoft.VisualStudio.Code.PreRelease), set at package time. - // - // Deliberately independent of the framework's own version state: the extension needs to - // be able to cut an RC while the Fallout it targets is already GA. + /// Publisher on the VS Marketplace and namespace on Open VSX. + const string Publisher = "fallout"; + + /// + /// Marks this build's package as a marketplace pre-release. Kept independent of the + /// version because a marketplace version cannot express prerelease-ness: a release + /// candidate is a plain triple plus a manifest bit. Set by the rc path in CI. + /// [Parameter("Mark the packaged extension as a marketplace pre-release")] readonly bool PreRelease; - Target Restore => _ => _ - .Executes(() => - { - Npm("ci", workingDirectory: RootDirectory); - }); + // Versioning matches the framework's own: Nerdbank.GitVersioning over version.json. + [NerdbankGitVersioning] readonly NerdbankGitVersioning Versioning; - Target Compile => _ => _ - .DependsOn(Restore) - .Executes(() => - { - Npm("run compile", workingDirectory: RootDirectory); - }); + bool IPackVsix.VsixPreRelease => PreRelease; - Target Pack => _ => _ - .DependsOn(Compile) - .Executes(() => + string IPackVsix.VsixVersion + { + get { - var (version, frameworkPreRelease) = FrameworkVersion(); - // Either an explicitly requested RC, or a build against a not-yet-GA framework. - var preRelease = PreRelease || frameworkPreRelease; - Serilog.Log.Information("Packaging extension as {Version} (pre-release: {PreRelease})", version, preRelease); - Npm( - $"run package -- {version} --no-update-package-json --no-git-tag-version" - + (preRelease ? " --pre-release" : ""), - workingDirectory: RootDirectory); - }); + var (version, _) = MarketplaceVersion.FromNerdbankGitVersioning(Versioning, PreRelease); + AssertFrameworkLineMatches(version); + return version; + } + } - Target PublishMarketplace => _ => _ - .DependsOn(Pack) - .Executes(() => + IEnumerable IPublishVsix.VsixPublishTargets => + [ + new VsixPublishTarget { - Npm("run publish:vsce", workingDirectory: RootDirectory); - }); - - Target PublishOpenVsx => _ => _ - .DependsOn(Pack) - .Executes(() => + Name = "vs-marketplace", + Registry = VsixRegistry.VisualStudioMarketplace, + Publisher = Publisher + }, + new VsixPublishTarget { - Npm("run publish:ovsx", workingDirectory: RootDirectory); - }); - - Target Publish => _ => _ - .DependsOn(PublishMarketplace, PublishOpenVsx); + Name = "open-vsx", + Registry = VsixRegistry.OpenVsx, + Publisher = Publisher + } + ]; - // The extension version tracks the Fallout framework it was built against — the pinned - // Fallout.Common package, read back from the loaded assembly. Marketplaces accept only - // three integers, so major.minor come from the framework release line and the third is - // whatever counter moves within it: - // - // 10.4.0.15+f16e0f1441 -> 10.4.15 (stable) third = the git height - // 10.4.0-rc.5 -> 10.4.5 (pre-release) third = the prerelease counter - // - // Nerdbank.GitVersioning stamps stable builds with a four-component version, so the - // height has to be picked out explicitly — returning the release verbatim would emit - // 10.4.0.15, which is not valid semver and fails marketplace validation. - static (string Version, bool PreRelease) FrameworkVersion() + /// + /// The extension's major.minor are a promise about which Fallout release line it targets — + /// README documents it, and model.ts warns the user at runtime when the workspace's + /// framework drifts from it. version.json states that line; the pinned Fallout.Common + /// package is what the extension was actually built against. Nothing keeps the two in step + /// automatically, so bumping one and forgetting the other would ship a version that lies. + /// + void AssertFrameworkLineMatches(string version) { var info = typeof(FalloutBuild).Assembly .GetCustomAttribute() ?.InformationalVersion ?? "0.0.0"; - var core = info.Split('+')[0]; - var dash = core.IndexOf('-'); - var preRelease = dash >= 0; + var framework = MarketplaceVersion.Normalize(info); - var parts = (preRelease ? core[..dash] : core).Split('.'); - var major = parts.ElementAtOrDefault(0) ?? "0"; - var minor = parts.ElementAtOrDefault(1) ?? "0"; - var height = (preRelease - ? core[(dash + 1)..].Split('.').FirstOrDefault(p => int.TryParse(p, out _)) - : parts.ElementAtOrDefault(3)) ?? "0"; + var declared = version.Split('.'); + var actual = framework.Split('.'); - return ($"{major}.{minor}.{height}", preRelease); + Assert.True(declared[0] == actual[0] && declared[1] == actual[1], + $"Version line mismatch: version.json declares {declared[0]}.{declared[1]}.x but the build " + + $"references Fallout {framework}. Update version.json's \"version\" and the pinned " + + "Fallout.Common together, or the published extension misstates which framework it targets."); } } diff --git a/build/_build.csproj b/build/_build.csproj index dbb7b01..0f51d85 100644 --- a/build/_build.csproj +++ b/build/_build.csproj @@ -14,6 +14,16 @@ + + + + + + + + diff --git a/package.json b/package.json index 52ac80b..925eb11 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "fallout", "displayName": "Fallout", "description": "Explore, run, and visualize your Fallout (NUKE successor) build targets from VS Code.", - "version": "10.4.15", + "version": "0.0.0", "publisher": "fallout", "license": "MIT", "icon": "media/icon.png", @@ -132,10 +132,7 @@ "scripts": { "vscode:prepublish": "npm run compile", "compile": "tsc -p ./", - "watch": "tsc -watch -p ./", - "package": "vsce package --out fallout.vsix", - "publish:vsce": "vsce publish --packagePath fallout.vsix", - "publish:ovsx": "ovsx publish fallout.vsix" + "watch": "tsc -watch -p ./" }, "dependencies": { "mermaid": "^11.4.1" diff --git a/plugins/Fallout.Vsce/Fallout.Vsce.csproj b/plugins/Fallout.Vsce/Fallout.Vsce.csproj new file mode 100644 index 0000000..e53647c --- /dev/null +++ b/plugins/Fallout.Vsce/Fallout.Vsce.csproj @@ -0,0 +1,37 @@ + + + + + net10.0 + latest + enable + false + true + false + false + + CS0649;CA1050;CA1822 + + Fallout.Vsce + Fallout build components and tool wrappers for the VS Code extension toolchain (vsce, ovsx). + Fallout.build + MIT + https://github.com/Fallout-build/fallout-vscode + true + + + + + + + + diff --git a/plugins/Fallout.Vsce/IPackVsix.cs b/plugins/Fallout.Vsce/IPackVsix.cs new file mode 100644 index 0000000..d863626 --- /dev/null +++ b/plugins/Fallout.Vsce/IPackVsix.cs @@ -0,0 +1,98 @@ +using Fallout.Common; +using Fallout.Common.IO; +using Fallout.Common.Tooling; +using Fallout.Common.Utilities; +using static Fallout.Common.Tools.Npm.NpmTasks; + +namespace Fallout.Vsce; + +/// +/// Locates the VS Code extension in the repository and the toolchain that builds it. +/// +/// vsce and ovsx are conventionally dev dependencies rather than global +/// installs, so the default tool paths point at node_modules/.bin and only fall back +/// to PATH when the local binary is absent. +/// +public interface IHasVsix : IFalloutBuild +{ + /// Directory holding package.json. Defaults to the repository root. + AbsolutePath VsixDirectory => RootDirectory; + + /// Path of the packaged .vsix. + AbsolutePath VsixFile => VsixDirectory / "fallout.vsix"; + + /// Locally-installed CLI directory. + AbsolutePath NodeModulesBinDirectory => VsixDirectory / "node_modules" / ".bin"; + + /// Resolves a locally-installed npm CLI, falling back to PATH. + sealed string ResolveNodeTool(string name) + { + // npm writes a .cmd shim next to the extensionless shell script on Windows; invoking + // the extensionless one there starts a shell, not the tool. + var local = NodeModulesBinDirectory / (EnvironmentInfo.IsWin ? $"{name}.cmd" : name); + return local.FileExists() ? local.ToString() : name; + } + + /// Path to the vsce CLI. + string VsceToolPath => ResolveNodeTool(VsceTasks.PathExecutable); + + /// Path to the ovsx CLI. + string OvsxToolPath => ResolveNodeTool(OvsxTasks.PathExecutable); +} + +/// +/// Restores, compiles, and packages a VS Code extension into a .vsix. +/// +/// Compilation is delegated to the project's own npm scripts rather than reimplemented — the +/// TypeScript toolchain is already described in package.json, and duplicating it in C# +/// would create a second source of truth that drifts. +/// +public interface IPackVsix : IHasVsix +{ + /// Version stamped into the package. Must be three integers — see . + string VsixVersion { get; } + + /// + /// Marks the package as a marketplace pre-release. Independent of , + /// because a marketplace version cannot itself express prerelease-ness. + /// + bool VsixPreRelease => false; + + /// npm script that compiles the extension. + string CompileScript => "compile"; + + /// Installs the Node toolchain from the lockfile. + Target RestoreVsix => _ => _ + .Executes(() => Npm("ci", workingDirectory: VsixDirectory)); + + /// Compiles the extension via its own npm script. + Target CompileVsix => _ => _ + .DependsOn(RestoreVsix) + .Executes(() => Npm($"run {CompileScript}", workingDirectory: VsixDirectory)); + + /// Packages the compiled extension into a .vsix. + Target PackVsix => _ => _ + .DependsOn(CompileVsix) + .Executes(() => + { + // Not .Requires(): that is for injected parameters, and VsixVersion is computed by + // the consuming build (from Nerdbank.GitVersioning, a constant, whatever it likes). + Assert.True(!VsixVersion.IsNullOrWhiteSpace(), + "IPackVsix.VsixVersion resolved to nothing — the consuming build must supply a version."); + + var version = MarketplaceVersion.Normalize(VsixVersion); + Serilog.Log.Information( + "Packaging {File} as {Version} (pre-release: {PreRelease})", VsixFile.Name, version, VsixPreRelease); + + VsceTasks.VscePackage(_ => _ + .SetProcessToolPath(VsceToolPath) + .SetProcessWorkingDirectory(VsixDirectory) + .SetVersion(version) + .SetOutput(VsixFile) + // package.json stays the source of truth for everything but the version, which + // the build computes; writing it back would dirty the tree on every CI run. + .EnableNoUpdatePackageJson() + .EnableNoGitTagVersion() + .SetPreRelease(VsixPreRelease ? true : (bool?)null)); + }); +} diff --git a/plugins/Fallout.Vsce/IPublishVsix.cs b/plugins/Fallout.Vsce/IPublishVsix.cs new file mode 100644 index 0000000..3d70e6e --- /dev/null +++ b/plugins/Fallout.Vsce/IPublishVsix.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Fallout.Common; +using Fallout.Common.Tooling; +using Fallout.Common.Utilities; +using Fallout.Common.Utilities.Collections; + +namespace Fallout.Vsce; + +/// +/// Fans one packaged .vsix out across extension registries. +/// +/// Mirrors Fallout.Components.IPublish: destinations are declared as data +/// (), a CLI selector narrows them for a given run +/// (), and every selected target is validated before anything is +/// pushed — a missing credential is a configuration error knowable up front, not something to +/// discover after half the registries have already accepted the release. +/// +public interface IPublishVsix : IPackVsix +{ + /// Registries this build publishes to. Override to add or re-route destinations. + IEnumerable VsixPublishTargets => Array.Empty(); + + /// + /// Names of the configured to push to this run; empty + /// selects all. Wire from the CLI as --publish-vsix-to vs-marketplace. + /// + [Parameter("Publish only to these named registries (default: all configured VsixPublishTargets).")] + string[] PublishVsixTo => TryGetValue(() => PublishVsixTo) ?? Array.Empty(); + + /// Resolves the selector against the configured targets, asserting the result is usable. + sealed IReadOnlyList SelectedVsixPublishTargets() + { + var configured = VsixPublishTargets.ToList(); + var selection = PublishVsixTo; + + var targets = selection.Length == 0 + ? configured + : configured.Where(x => selection.Contains(x.Name, StringComparer.OrdinalIgnoreCase)).ToList(); + + Assert.True(targets.Count > 0, + selection.Length == 0 + ? "No publish targets are configured — override IPublishVsix.VsixPublishTargets." + : $"--publish-vsix-to [{selection.JoinComma()}] matched none of the configured targets [{configured.Select(x => x.Name).JoinComma()}]."); + + return targets; + } + + /// + /// Proves each selected registry would accept a publish, without publishing. Worth running + /// before a tag: a token that expired since the last release otherwise surfaces halfway + /// through the real release, with some registries already updated. + /// + Target VerifyVsixCredentials => _ => _ + .Executes(() => + { + foreach (var target in SelectedVsixPublishTargets()) + { + Assert.True(!target.Publisher.IsNullOrWhiteSpace(), + $"Publish target '{target.Name}' has no Publisher — required to verify credentials."); + + Serilog.Log.Information("Verifying credentials for {Target} ({Publisher}).", target.Name, target.Publisher); + + switch (target.Registry) + { + case VsixRegistry.VisualStudioMarketplace: + VsceTasks.VsceVerifyPat(_ => _ + .SetProcessToolPath(VsceToolPath) + .SetProcessWorkingDirectory(VsixDirectory) + .SetPublisher(target.Publisher!) + .When(target.Pat is not null, _ => _.SetPat(target.Pat!))); + break; + + case VsixRegistry.OpenVsx: + OvsxTasks.OvsxVerifyPat(_ => _ + .SetProcessToolPath(OvsxToolPath) + .SetProcessWorkingDirectory(VsixDirectory) + .SetNamespace(target.Publisher!) + .When(target.Pat is not null, _ => _.SetPat(target.Pat!))); + break; + + default: + throw new NotSupportedException($"Unknown registry '{target.Registry}'."); + } + } + }); + + /// Publishes the packaged .vsix to every selected registry. + Target PublishVsix => _ => _ + .DependsOn(PackVsix) + .Executes(() => + { + var targets = SelectedVsixPublishTargets(); + Assert.FileExists(VsixFile); + + foreach (var target in targets) + { + Serilog.Log.Information("Publish target {Target}: pushing {File} → {Registry}.", + target.Name, VsixFile.Name, target.Registry); + + switch (target.Registry) + { + case VsixRegistry.VisualStudioMarketplace: + VsceTasks.VscePublish(_ => _ + .SetProcessToolPath(VsceToolPath) + .SetProcessWorkingDirectory(VsixDirectory) + .SetPackagePath(VsixFile) + .When(target.Pat is not null, _ => _.SetPat(target.Pat!)) + .When(target.SkipDuplicate, _ => _.SetSkipDuplicate(true)) + // Only an assertion against the manifest on a --packagePath publish; + // the bit baked in at package time is what actually decides. + .SetPreRelease(VsixPreRelease ? true : (bool?)null)); + break; + + case VsixRegistry.OpenVsx: + OvsxTasks.OvsxPublish(_ => _ + .SetProcessToolPath(OvsxToolPath) + .SetProcessWorkingDirectory(VsixDirectory) + .SetExtensionFile(VsixFile) + .When(target.Pat is not null, _ => _.SetPat(target.Pat!)) + .When(target.SkipDuplicate, _ => _.SetSkipDuplicate(true))); + break; + + default: + throw new NotSupportedException($"Unknown registry '{target.Registry}'."); + } + } + }); +} diff --git a/plugins/Fallout.Vsce/MarketplaceVersion.cs b/plugins/Fallout.Vsce/MarketplaceVersion.cs new file mode 100644 index 0000000..2a12ea0 --- /dev/null +++ b/plugins/Fallout.Vsce/MarketplaceVersion.cs @@ -0,0 +1,73 @@ +using System; +using System.Linq; +using Fallout.Common.Tools.NerdbankGitVersioning; + +namespace Fallout.Vsce; + +/// +/// Maps a semantic version onto what the extension marketplaces actually accept. +/// +/// Both registries take exactly three integers and nothing else. vsce refuses a +/// semver prerelease outright — semver.prerelease(manifest.version) throws +/// "The VS Marketplace doesn't support prerelease versions" — so 10.4.16-rc.1 +/// can never be published. A release candidate is therefore an ordinary triple carrying a +/// pre-release bit in the VSIX manifest, and the -rc.N suffix lives only on the git +/// tag and the GitHub release. +/// +/// The consequence worth knowing before you publish: a given version is pre-release +/// or stable and can never be both. Publishing 10.4.16 as a pre-release burns +/// that number — the stable release then has to be 10.4.17. This is why the release +/// pipeline keeps release candidates on GitHub and off the marketplaces entirely. +/// +public static class MarketplaceVersion +{ + /// + /// Derives the marketplace version from Nerdbank.GitVersioning, matching how the Fallout + /// framework itself versions. forces the pre-release bit on + /// independently of the computed version — an extension RC is a decision about this + /// release, not a property of the version string, precisely because the version string + /// cannot carry it. + /// + public static (string Version, bool PreRelease) FromNerdbankGitVersioning( + NerdbankGitVersioning versioning, + bool preRelease = false) + { + if (versioning is null) + throw new ArgumentNullException(nameof(versioning)); + + var source = !string.IsNullOrWhiteSpace(versioning.SimpleVersion) + ? versioning.SimpleVersion + : versioning.Version; + + return (Normalize(source), + preRelease || !string.IsNullOrEmpty(versioning.PrereleaseVersion)); + } + + /// + /// Reduces any version string to the three-integer form the marketplaces accept. + /// + /// Nerdbank.GitVersioning stamps stable builds with four components + /// (10.4.0.15), which is not valid semver and fails marketplace validation, so the + /// fourth is dropped. Prerelease and build-metadata suffixes are stripped for the same + /// reason. Missing components are zero-filled: 10.4 becomes 10.4.0. + /// + public static string Normalize(string version) + { + if (string.IsNullOrWhiteSpace(version)) + throw new ArgumentException("Version must not be empty.", nameof(version)); + + // Strip build metadata (+g) then any prerelease tag (-rc.1). + var core = version.Split('+')[0].Split('-')[0]; + + var parts = core + .Split('.') + .TakeWhile(x => int.TryParse(x, out _)) + .Take(3) + .ToArray(); + + if (parts.Length == 0) + throw new ArgumentException($"Cannot derive a marketplace version from '{version}'.", nameof(version)); + + return string.Join(".", Enumerable.Range(0, 3).Select(i => parts.ElementAtOrDefault(i) ?? "0")); + } +} diff --git a/plugins/Fallout.Vsce/OvsxTasks.cs b/plugins/Fallout.Vsce/OvsxTasks.cs new file mode 100644 index 0000000..2feadd0 --- /dev/null +++ b/plugins/Fallout.Vsce/OvsxTasks.cs @@ -0,0 +1,134 @@ +// 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; + +namespace Fallout.Vsce; + +/// +///

ovsx — the Open VSX registry CLI, the +/// vendor-neutral marketplace used by VS Code forks (VSCodium, Gitpod, Cursor).

+///

Publishing a prepackaged .vsix reads the pre-release bit from the VSIX manifest; +/// --pre-release is ignored in that mode, so package it correctly rather than relying +/// on the publish flag.

+///
+[ExcludeFromCodeCoverage] +[PathTool(Executable = PathExecutable)] +public partial class OvsxTasks : ToolTasks +{ + /// Executable name looked up on PATH when no explicit tool path is set. + public const string PathExecutable = "ovsx"; + + /// Resolved path to the ovsx executable. + public static string OvsxPath + { + get => new OvsxTasks().GetToolPathInternal(); + set => new OvsxTasks().SetToolPath(value); + } + + /// Invokes ovsx with raw arguments. + public static IReadOnlyCollection Ovsx( + ArgumentStringHandler arguments, + string? workingDirectory = null, + IReadOnlyDictionary? environmentVariables = null, + int? timeout = null, + bool? logOutput = null, + bool? logInvocation = null, + Action? logger = null, + Func? exitHandler = null) + => new OvsxTasks().Run(arguments, workingDirectory, environmentVariables, timeout, logOutput, logInvocation, logger, exitHandler); + + /// Publishes a packaged .vsix to Open VSX. + public static IReadOnlyCollection OvsxPublish(OvsxPublishSettings? options = null) + => new OvsxTasks().Run(options ?? new OvsxPublishSettings()); + + /// + public static IReadOnlyCollection OvsxPublish(Configure configurator) + => new OvsxTasks().Run(configurator.Invoke(new OvsxPublishSettings())); + + /// Verifies a PAT against a namespace, without publishing. + public static IReadOnlyCollection OvsxVerifyPat(OvsxVerifyPatSettings? options = null) + => new OvsxTasks().Run(options ?? new OvsxVerifyPatSettings()); + + /// + public static IReadOnlyCollection OvsxVerifyPat(Configure configurator) + => new OvsxTasks().Run(configurator.Invoke(new OvsxVerifyPatSettings())); +} + +#region OvsxPublishSettings + +/// +[ExcludeFromCodeCoverage] +[Command(Type = typeof(OvsxTasks), Command = nameof(OvsxTasks.OvsxPublish), Arguments = "publish")] +public partial class OvsxPublishSettings : ToolOptions +{ + /// Path to the .vsix to publish (positional). + [Argument(Format = "{value}", Position = 1)] public string? ExtensionFile => Get(() => ExtensionFile); + + /// Open VSX access token. Also readable from OVSX_PAT. + [Argument(Format = "--pat {value}", Secret = true)] public string? Pat => Get(() => Pat); + + /// Succeeds instead of failing when the version is already published. + [Argument(Format = "--skip-duplicate")] public bool? SkipDuplicate => Get(() => SkipDuplicate); + + /// Registry URL, for self-hosted Open VSX instances. + [Argument(Format = "--registryUrl {value}")] public string? RegistryUrl => Get(() => RegistryUrl); + + /// Target platform for a platform-specific publish. + [Argument(Format = "--target {value}")] public string? Target => Get(() => Target); +} + +/// +[ExcludeFromCodeCoverage] +public static class OvsxPublishSettingsExtensions +{ + /// + [Builder(Type = typeof(OvsxPublishSettings), Property = nameof(OvsxPublishSettings.ExtensionFile))] + public static T SetExtensionFile(this T o, string v) where T : OvsxPublishSettings => o.Modify(b => b.Set(() => o.ExtensionFile, v)); + + /// + [Builder(Type = typeof(OvsxPublishSettings), Property = nameof(OvsxPublishSettings.Pat))] + public static T SetPat(this T o, string v) where T : OvsxPublishSettings => o.Modify(b => b.Set(() => o.Pat, v)); + + /// + [Builder(Type = typeof(OvsxPublishSettings), Property = nameof(OvsxPublishSettings.SkipDuplicate))] + public static T SetSkipDuplicate(this T o, bool? v) where T : OvsxPublishSettings => o.Modify(b => b.Set(() => o.SkipDuplicate, v)); + + /// + [Builder(Type = typeof(OvsxPublishSettings), Property = nameof(OvsxPublishSettings.RegistryUrl))] + public static T SetRegistryUrl(this T o, string v) where T : OvsxPublishSettings => o.Modify(b => b.Set(() => o.RegistryUrl, v)); +} + +#endregion + +#region OvsxVerifyPatSettings + +/// +[ExcludeFromCodeCoverage] +[Command(Type = typeof(OvsxTasks), Command = nameof(OvsxTasks.OvsxVerifyPat), Arguments = "verify-pat")] +public partial class OvsxVerifyPatSettings : ToolOptions +{ + /// Namespace the token is checked against. + [Argument(Format = "{value}", Position = 1)] public string? Namespace => Get(() => Namespace); + + /// Open VSX access token. Also readable from OVSX_PAT. + [Argument(Format = "--pat {value}", Secret = true)] public string? Pat => Get(() => Pat); +} + +/// +[ExcludeFromCodeCoverage] +public static class OvsxVerifyPatSettingsExtensions +{ + /// + [Builder(Type = typeof(OvsxVerifyPatSettings), Property = nameof(OvsxVerifyPatSettings.Namespace))] + public static T SetNamespace(this T o, string v) where T : OvsxVerifyPatSettings => o.Modify(b => b.Set(() => o.Namespace, v)); + + /// + [Builder(Type = typeof(OvsxVerifyPatSettings), Property = nameof(OvsxVerifyPatSettings.Pat))] + public static T SetPat(this T o, string v) where T : OvsxVerifyPatSettings => o.Modify(b => b.Set(() => o.Pat, v)); +} + +#endregion diff --git a/plugins/Fallout.Vsce/VsceTasks.cs b/plugins/Fallout.Vsce/VsceTasks.cs new file mode 100644 index 0000000..05ee828 --- /dev/null +++ b/plugins/Fallout.Vsce/VsceTasks.cs @@ -0,0 +1,231 @@ +// Hand-written tool wrapper. The in-tree Fallout tools are emitted by +// Fallout.Tooling.Generator from a .json spec; that generator is a repo-internal +// build step, so a plugin outside the repo writes the same shape by hand. The shape is +// deliberately identical to Npm.Generated.cs: a ToolTasks subclass, [Command]-annotated +// ToolOptions per subcommand, [Argument]-annotated properties, and fluent [Builder] +// setters — so a consumer cannot tell the difference at the call site. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Fallout.Common; +using Fallout.Common.Tooling; + +namespace Fallout.Vsce; + +/// +///

vsce — the VS Code Extension +/// manager. Packages an extension into a .vsix and publishes it to the Visual Studio +/// Marketplace.

+///

Normally installed as a dev dependency rather than globally, so callers usually set +/// to the local node_modules/.bin binary — +/// see .

+///
+[ExcludeFromCodeCoverage] +[PathTool(Executable = PathExecutable)] +public partial class VsceTasks : ToolTasks +{ + /// Executable name looked up on PATH when no explicit tool path is set. + public const string PathExecutable = "vsce"; + + /// Resolved path to the vsce executable. + public static string VscePath + { + get => new VsceTasks().GetToolPathInternal(); + set => new VsceTasks().SetToolPath(value); + } + + /// Invokes vsce with raw arguments. + public static IReadOnlyCollection Vsce( + ArgumentStringHandler arguments, + string? workingDirectory = null, + IReadOnlyDictionary? environmentVariables = null, + int? timeout = null, + bool? logOutput = null, + bool? logInvocation = null, + Action? logger = null, + Func? exitHandler = null) + => new VsceTasks().Run(arguments, workingDirectory, environmentVariables, timeout, logOutput, logInvocation, logger, exitHandler); + + /// Packages the extension into a .vsix. + public static IReadOnlyCollection VscePackage(VscePackageSettings? options = null) + => new VsceTasks().Run(options ?? new VscePackageSettings()); + + /// + public static IReadOnlyCollection VscePackage(Configure configurator) + => new VsceTasks().Run(configurator.Invoke(new VscePackageSettings())); + + /// Publishes a packaged .vsix to the Visual Studio Marketplace. + public static IReadOnlyCollection VscePublish(VscePublishSettings? options = null) + => new VsceTasks().Run(options ?? new VscePublishSettings()); + + /// + public static IReadOnlyCollection VscePublish(Configure configurator) + => new VsceTasks().Run(configurator.Invoke(new VscePublishSettings())); + + /// Verifies that a PAT can publish for the given publisher, without publishing. + public static IReadOnlyCollection VsceVerifyPat(VsceVerifyPatSettings? options = null) + => new VsceTasks().Run(options ?? new VsceVerifyPatSettings()); + + /// + public static IReadOnlyCollection VsceVerifyPat(Configure configurator) + => new VsceTasks().Run(configurator.Invoke(new VsceVerifyPatSettings())); +} + +#region VscePackageSettings + +/// +[ExcludeFromCodeCoverage] +[Command(Type = typeof(VsceTasks), Command = nameof(VsceTasks.VscePackage), Arguments = "package")] +public partial class VscePackageSettings : ToolOptions +{ + /// + /// Version to stamp into the package. Must be three integers — the Marketplace rejects + /// semver prerelease versions outright, so 1.2.3-rc.1 is not packageable. Mark a + /// release candidate with instead. + /// + [Argument(Format = "{value}", Position = 1)] public string? Version => Get(() => Version); + + /// Target path for the produced .vsix. + [Argument(Format = "--out {value}")] public string? Output => Get(() => Output); + + /// + /// Marks the package as a pre-release, written into the VSIX manifest as + /// Microsoft.VisualStudio.Code.PreRelease. This is the only way to express an RC; + /// a version is pre-release or stable and can never be both. + /// + [Argument(Format = "--pre-release")] public bool? PreRelease => Get(() => PreRelease); + + /// Leaves package.json's version untouched when a version is supplied. + [Argument(Format = "--no-update-package-json")] public bool? NoUpdatePackageJson => Get(() => NoUpdatePackageJson); + + /// Suppresses the git tag npm would otherwise create for the version bump. + [Argument(Format = "--no-git-tag-version")] public bool? NoGitTagVersion => Get(() => NoGitTagVersion); + + /// Skips the dependency detection step. + [Argument(Format = "--no-dependencies")] public bool? NoDependencies => Get(() => NoDependencies); + + /// Skips the prepublish script. + [Argument(Format = "--no-prepublish")] public bool? NoPrePublish => Get(() => NoPrePublish); + + /// Target platform for a platform-specific package (e.g. win32-x64). + [Argument(Format = "--target {value}")] public string? Target => Get(() => Target); +} + +/// +[ExcludeFromCodeCoverage] +public static class VscePackageSettingsExtensions +{ + /// + [Builder(Type = typeof(VscePackageSettings), Property = nameof(VscePackageSettings.Version))] + public static T SetVersion(this T o, string v) where T : VscePackageSettings => o.Modify(b => b.Set(() => o.Version, v)); + + /// + [Builder(Type = typeof(VscePackageSettings), Property = nameof(VscePackageSettings.Output))] + public static T SetOutput(this T o, string v) where T : VscePackageSettings => o.Modify(b => b.Set(() => o.Output, v)); + + /// + [Builder(Type = typeof(VscePackageSettings), Property = nameof(VscePackageSettings.PreRelease))] + public static T SetPreRelease(this T o, bool? v) where T : VscePackageSettings => o.Modify(b => b.Set(() => o.PreRelease, v)); + + /// + [Builder(Type = typeof(VscePackageSettings), Property = nameof(VscePackageSettings.PreRelease))] + public static T EnablePreRelease(this T o) where T : VscePackageSettings => o.Modify(b => b.Set(() => o.PreRelease, true)); + + /// + [Builder(Type = typeof(VscePackageSettings), Property = nameof(VscePackageSettings.NoUpdatePackageJson))] + public static T EnableNoUpdatePackageJson(this T o) where T : VscePackageSettings => o.Modify(b => b.Set(() => o.NoUpdatePackageJson, true)); + + /// + [Builder(Type = typeof(VscePackageSettings), Property = nameof(VscePackageSettings.NoGitTagVersion))] + public static T EnableNoGitTagVersion(this T o) where T : VscePackageSettings => o.Modify(b => b.Set(() => o.NoGitTagVersion, true)); + + /// + [Builder(Type = typeof(VscePackageSettings), Property = nameof(VscePackageSettings.NoDependencies))] + public static T EnableNoDependencies(this T o) where T : VscePackageSettings => o.Modify(b => b.Set(() => o.NoDependencies, true)); + + /// + [Builder(Type = typeof(VscePackageSettings), Property = nameof(VscePackageSettings.Target))] + public static T SetTarget(this T o, string v) where T : VscePackageSettings => o.Modify(b => b.Set(() => o.Target, v)); +} + +#endregion + +#region VscePublishSettings + +/// +[ExcludeFromCodeCoverage] +[Command(Type = typeof(VsceTasks), Command = nameof(VsceTasks.VscePublish), Arguments = "publish")] +public partial class VscePublishSettings : ToolOptions +{ + /// Path to an already-packaged .vsix to publish. + [Argument(Format = "--packagePath {value}")] public string? PackagePath => Get(() => PackagePath); + + /// Azure DevOps PAT with Marketplace > Manage scope. Also readable from VSCE_PAT. + [Argument(Format = "--pat {value}", Secret = true)] public string? Pat => Get(() => Pat); + + /// + /// Asserts the package was built as a pre-release. On a publish this + /// only validates against the VSIX manifest — the manifest bit set at package time is what + /// actually determines pre-release status. + /// + [Argument(Format = "--pre-release")] public bool? PreRelease => Get(() => PreRelease); + + /// Succeeds instead of failing when the version is already published. + [Argument(Format = "--skip-duplicate")] public bool? SkipDuplicate => Get(() => SkipDuplicate); + + /// Target platform for a platform-specific publish. + [Argument(Format = "--target {value}")] public string? Target => Get(() => Target); +} + +/// +[ExcludeFromCodeCoverage] +public static class VscePublishSettingsExtensions +{ + /// + [Builder(Type = typeof(VscePublishSettings), Property = nameof(VscePublishSettings.PackagePath))] + public static T SetPackagePath(this T o, string v) where T : VscePublishSettings => o.Modify(b => b.Set(() => o.PackagePath, v)); + + /// + [Builder(Type = typeof(VscePublishSettings), Property = nameof(VscePublishSettings.Pat))] + public static T SetPat(this T o, string v) where T : VscePublishSettings => o.Modify(b => b.Set(() => o.Pat, v)); + + /// + [Builder(Type = typeof(VscePublishSettings), Property = nameof(VscePublishSettings.PreRelease))] + public static T SetPreRelease(this T o, bool? v) where T : VscePublishSettings => o.Modify(b => b.Set(() => o.PreRelease, v)); + + /// + [Builder(Type = typeof(VscePublishSettings), Property = nameof(VscePublishSettings.SkipDuplicate))] + public static T SetSkipDuplicate(this T o, bool? v) where T : VscePublishSettings => o.Modify(b => b.Set(() => o.SkipDuplicate, v)); +} + +#endregion + +#region VsceVerifyPatSettings + +/// +[ExcludeFromCodeCoverage] +[Command(Type = typeof(VsceTasks), Command = nameof(VsceTasks.VsceVerifyPat), Arguments = "verify-pat")] +public partial class VsceVerifyPatSettings : ToolOptions +{ + /// Publisher the PAT is checked against. + [Argument(Format = "{value}", Position = 1)] public string? Publisher => Get(() => Publisher); + + /// Azure DevOps PAT. Also readable from VSCE_PAT. + [Argument(Format = "--pat {value}", Secret = true)] public string? Pat => Get(() => Pat); +} + +/// +[ExcludeFromCodeCoverage] +public static class VsceVerifyPatSettingsExtensions +{ + /// + [Builder(Type = typeof(VsceVerifyPatSettings), Property = nameof(VsceVerifyPatSettings.Publisher))] + public static T SetPublisher(this T o, string v) where T : VsceVerifyPatSettings => o.Modify(b => b.Set(() => o.Publisher, v)); + + /// + [Builder(Type = typeof(VsceVerifyPatSettings), Property = nameof(VsceVerifyPatSettings.Pat))] + public static T SetPat(this T o, string v) where T : VsceVerifyPatSettings => o.Modify(b => b.Set(() => o.Pat, v)); +} + +#endregion diff --git a/plugins/Fallout.Vsce/VsixPublishTarget.cs b/plugins/Fallout.Vsce/VsixPublishTarget.cs new file mode 100644 index 0000000..320b9d8 --- /dev/null +++ b/plugins/Fallout.Vsce/VsixPublishTarget.cs @@ -0,0 +1,41 @@ +namespace Fallout.Vsce; + +/// Which CLI, and therefore which registry, a publishes through. +public enum VsixRegistry +{ + /// The Visual Studio Marketplace, via vsce. + VisualStudioMarketplace, + + /// The Open VSX registry, via ovsx. + OpenVsx +} + +/// +/// A routable publish destination for a packaged .vsix — deliberately the same shape as +/// Fallout.Components.PublishTarget, so fanning one Pack across several registries +/// reads the same whether the artifact is a .nupkg or a .vsix. +/// +public sealed class VsixPublishTarget +{ + /// Logical name, used by the --publish-vsix-to selector (e.g. vs-marketplace). + public required string Name { get; init; } + + /// Registry this target publishes to, which selects the CLI used. + public required VsixRegistry Registry { get; init; } + + /// + /// Access token. Both CLIs also read one from the environment (VSCE_PAT / OVSX_PAT); + /// leaving this null falls back to that, which is what CI normally wants so the token never + /// passes through a process argument list. + /// + public string? Pat { get; init; } + + /// + /// Publisher (VS Marketplace) or namespace (Open VSX) this target owns. Used by the + /// credential check; not needed for the publish itself. + /// + public string? Publisher { get; init; } + + /// Pass --skip-duplicate so re-running a partially-failed publish is idempotent. + public bool SkipDuplicate { get; init; } = true; +} diff --git a/version.json b/version.json new file mode 100644 index 0000000..d599743 --- /dev/null +++ b/version.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", + + // major.minor track the Fallout framework release line this extension targets; the third + // component is the git height and moves on its own. The build asserts this stays in step + // with the pinned Fallout.Common — see Build.cs. + // + // Unlike the framework, prerelease-ness is NOT expressed here. Both marketplaces reject + // semver prerelease versions outright, so a release candidate is a plain triple carrying a + // pre-release bit in the VSIX manifest, requested per-build via --pre-release. + "version": "10.4", + + // main produces the releasable version directly; there is no separate preview lane, because + // the pre-stage is a GitHub pre-release of the same artifact rather than a different feed. + "publicReleaseRefSpec": [ + "^refs/heads/main$", + "^refs/heads/release/v\\d+\\.\\d+$" + ], + + // The extension shipped its changelog entry as 10.4.15 before versioning moved to NB.GV. + // Height restarts from this file's introduction, so without an offset the next build would + // compute 10.4.1 and go backwards. Marketplace versions must increase monotonically. + "versionHeightOffset": 15, + + "pathFilters": [ + ":/src", + ":/build", + ":/plugins", + ":/package.json", + ":/version.json" + ] +} From f5f0f81476293004a41d89f3c267c326b459d643 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Thu, 13 Aug 2026 14:15:34 +1200 Subject: [PATCH 5/7] Adopt Fallout's release-channel model and label-driven changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the pipeline in line with the framework repo so both are read with one mental model. Changelog by PR label: .github/release.yml mirrors Fallout's taxonomy (breaking-change / enhancement / bug / security / dependencies / documentation, plus skip-changelog), and releases are cut with --generate-notes so the label on a PR is what lands in the notes. The four missing labels now exist on the repo with the same colours. CHANGELOG.md stays hand-written and separate: it ships inside the .vsix and is what the marketplace renders on the extension page. Channels as environments, mirroring publish-packages-release.yml: validate-ref -> pack -> three channel jobs, each in its own environment. GitHub Releases is the ungated pre-stage; vs-marketplace and open-vsx are promotion targets behind an opt-in workflow_dispatch flag AND an approval gate, the same two layers Fallout puts in front of nuget.org. A tag push can no longer reach a marketplace at all, which the previous single-gate shape allowed. The old 'marketplace' environment is replaced by the three. The promotion jobs publish the exact artifact the pack job produced (--skip PackVsix) rather than rebuilding, so what gets approved is what ships. The workflow_dispatch boolean is compared against both true and 'true' — the REST API can only send strings, and a bare == true would silently skip every CLI-triggered promotion while reporting success. RELEASING.md documents the runbook, including why a release candidate is not -rc.N and why RCs deliberately stop at GitHub: publishing a version as a marketplace pre-release burns that number for the GA. Co-Authored-By: Claude Opus 5 (1M context) --- .github/PULL_REQUEST_TEMPLATE.md | 10 ++ .github/release.yml | 52 +++++++ .github/workflows/publish.yml | 240 ++++++++++++++++++++++--------- .vscodeignore | 2 + CHANGELOG.md | 6 +- README.md | 2 + RELEASING.md | 92 ++++++++++++ plugins/Fallout.Vsce/README.md | 52 +++++++ 8 files changed, 384 insertions(+), 72 deletions(-) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/release.yml create mode 100644 RELEASING.md create mode 100644 plugins/Fallout.Vsce/README.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..2692835 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..135cb50 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,52 @@ +# Source of truth for the PR label taxonomy, mirroring the Fallout repo's own +# .github/release.yml so contributors and tooling meet one convention across both. +# +# GitHub reads this to auto-generate release notes, grouping merged PRs into the +# sections below by label. Releases are cut with `gh release create --generate-notes`, +# so the label you put on a PR is what appears in the release. +# +# When raising a PR: apply the one category label that matches the change. A PR with +# no category label falls through to "Other Changes" — prefer a real category. +# +# CHANGELOG.md is a separate, curated artifact: it ships inside the .vsix and is what +# the marketplace renders on the extension page, so it stays hand-written and +# user-facing. These notes are the per-release commit-level record. +changelog: + exclude: + labels: + # skip-changelog: housekeeping with no consumer-facing note (CI, tooling, + # internal refactors). Keeps the notes signal-heavy. + - skip-changelog + # dependencies: dependency bumps — folded out of the notes as noise. Also a + # category below, so the label is documented once; the exclude wins. + - dependencies + authors: + - dependabot + categories: + # breaking-change: removes/renames a command ID, setting, or view ID; drops + # support for a VS Code or Fallout version; changes the build-graph contract. + - title: "⚠️ Breaking Changes" + labels: + - breaking-change + # enhancement: a new feature or user-facing capability. + - title: "✨ New Features" + labels: + - enhancement + # bug: fixes incorrect behavior. + - title: "🐛 Bug Fixes" + labels: + - bug + # security: fixes a vulnerability or hardens a security-sensitive surface. + - title: "🔒 Security" + labels: + - security + # dependencies: dependency version bumps (excluded above — kept for completeness). + - title: "📦 Dependencies" + labels: + - dependencies + # documentation: docs, comments, and agent-instruction changes only. + - title: "📖 Documentation" + labels: + - documentation + # Fallback for PRs carrying none of the labels above — don't rely on it. + - title: "Other Changes" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index cd2d9ab..d1a1f73 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,54 +1,96 @@ -# Builds the extension, and depending on how it was triggered, releases it. +# Release pipeline for the extension. Shape deliberately mirrors the Fallout repo's +# publish-packages-release.yml, so the two repos are read with one mental model. # -# Dogfood: the actual build/package/publish is a Fallout build (build/Build.cs), -# invoked here via ./build.sh. This workflow only provisions the toolchains and -# passes the marketplace tokens through the environment. +# Dogfood: the build, package and publish steps are a Fallout build (build/Build.cs) +# driving the Fallout.Vsce plugin (plugins/Fallout.Vsce). This workflow only provisions +# toolchains, routes channels, and carries the tokens. # -# Three paths, deliberately separated so a release candidate can never reach a -# marketplace: +# validate-ref -> pack -> fan out to three channel jobs, each its own GH Environment # -# workflow_dispatch -> package only, plus a credential check. Dry-run. -# tag v1.2.3-rc.4 -> package, attach the .vsix to a GitHub PRE-release. -# Nothing is published to any marketplace. -# tag v1.2.3 -> package, publish to VS Marketplace + Open VSX, -# attach the .vsix to a GitHub release. Gated on the -# 'marketplace' environment, so it needs an approval. +# Channel routing — GitHub is the pre-stage, the marketplaces are promoted into: # -# On RC versions: the VS Marketplace rejects semver prerelease versions outright -# (vsce throws on '10.4.15-rc.1'), so the .vsix an RC tag produces is versioned -# with plain integers and carries a pre-release bit in its manifest instead. The -# rc suffix lives only on the git tag and the GitHub release. +# | Channel | Trigger | Gating | +# |--------------------------------|----------------------------------|---------------------| +# | github-releases (pre-stage) | 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 a conscious act: dispatch with +# publish-to-marketplaces=true, then approve the environment. Two independent layers, +# matching how Fallout gates nuget.org. +# +# gh workflow run publish.yml -f tag=v10.4.16 -f publish-to-marketplaces=true +# +# On release candidates: both registries reject semver prerelease versions, so an rc tag +# produces an ordinary three-integer .vsix carrying a pre-release bit in its manifest, and +# is published only as a GitHub pre-release. The -rc.N suffix lives on the tag alone. +# Because an rc never reaches a marketplace, its version number stays free for the GA. # # Required repository secrets: # VSCE_PAT - Azure DevOps PAT, scope Marketplace > Manage (VS Marketplace) # OVSX_TOKEN - Open VSX access token (Eclipse Foundation account) -# -# Fallout itself restores from nuget.org since 10.4.0 went GA, so no package-feed -# token is needed. name: publish on: push: tags: - # Patterns must match the whole ref, so a stable tag cannot match the rc - # pattern or vice versa. Anything else (v10.4, v10.4.15-beta) triggers nothing. + # Patterns must match the whole ref, so a stable tag cannot match the rc pattern + # or vice versa. Anything else (v10.4, v10.4.16-beta) triggers nothing. - 'v[0-9]+.[0-9]+.[0-9]+' - 'v[0-9]+.[0-9]+.[0-9]+-rc.[0-9]+' - # Manual dry-run: restore + compile + package, WITHOUT publishing. Validates the - # toolchain and secrets before tagging. + # Manual path, for two scenarios: + # 1. Re-running a partial release after a transient failure (pick the tag, leave + # publish-to-marketplaces at its default). + # 2. Promoting a released version to the marketplaces (pick the tag, set the flag). workflow_dispatch: + inputs: + tag: + description: 'Existing tag to (re-)release (e.g. v10.4.16)' + required: true + publish-to-marketplaces: + description: 'Publish to VS Marketplace + Open VSX? Default false — opt-in for promotion only.' + required: false + type: boolean + default: false permissions: - contents: write + contents: read jobs: - # Always runs. Produces the .vsix every downstream job consumes. - package: + validate-ref: + name: validate ref runs-on: ubuntu-latest + if: github.event_name == 'push' + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: 'Verify tag is reachable from a release branch' + run: | + set -euo pipefail + REACHABLE=$(git branch -r --contains "${{ github.sha }}" \ + | grep -E 'origin/(main|release/v[0-9]+\.[0-9]+)$' || true) + if [ -z "$REACHABLE" ]; then + echo "::error::Tag ${GITHUB_REF_NAME} is not reachable from main or a release/vX.Y branch." + exit 1 + fi + echo "Tag ${GITHUB_REF_NAME} validated. Reachable from:" + echo "$REACHABLE" + + pack: + name: pack + runs-on: ubuntu-latest + needs: [validate-ref] + # Runs when validate-ref succeeded (tag push) OR was skipped (workflow_dispatch). + if: always() && (needs.validate-ref.result == 'success' || needs.validate-ref.result == 'skipped') outputs: is-rc: ${{ steps.classify.outputs.is-rc }} + tag: ${{ steps.classify.outputs.tag }} steps: - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag || github.ref }} + fetch-depth: 0 # Nerdbank.GitVersioning needs full history - uses: actions/setup-node@v4 with: node-version: 20 @@ -60,70 +102,102 @@ jobs: - name: Classify the trigger id: classify run: | - if [[ "${{ github.ref_type }}" == "tag" && "${{ github.ref_name }}" == *-rc.* ]]; then + set -euo pipefail + TAG="${{ inputs.tag || github.ref_name }}" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + if [[ "$TAG" == *-rc.* ]]; then echo 'is-rc=true' >> "$GITHUB_OUTPUT" else echo 'is-rc=false' >> "$GITHUB_OUTPUT" fi - # An rc tag marks the package as a marketplace pre-release. Fallout resolves - # parameters from the environment, so PreRelease is passed that way. - - name: 'Fallout build (Pack)' - run: ./build.sh Pack + # An rc marks the package as a marketplace pre-release. Fallout resolves parameters + # from the environment, so PreRelease is passed that way. + # + # PublicRelease: we check out a tag above, so HEAD is detached and matches none of + # version.json's publicReleaseRefSpec entries (all branch refs — nbgv never matches + # that spec against refs/tags/*). Without this, NB.GV stamps a git-height suffix and + # the marketplace version would not be a clean triple. + - name: 'Fallout build (PackVsix)' + run: ./build.sh PackVsix env: PreRelease: ${{ steps.classify.outputs.is-rc }} + PublicRelease: true - # Dry-run only: prove the marketplace credentials + publisher/namespace align - # before a real tag, without publishing anything. - - name: 'Verify publish credentials' + # Prove the tokens still work before a promotion needs them. Publishes nothing. + - name: 'Verify marketplace credentials' if: github.event_name == 'workflow_dispatch' + run: ./build.sh VerifyVsixCredentials env: VSCE_PAT: ${{ secrets.VSCE_PAT }} OVSX_PAT: ${{ secrets.OVSX_TOKEN }} - run: | - npx vsce verify-pat fallout - npx ovsx verify-pat fallout - uses: actions/upload-artifact@v4 with: name: fallout-vsix path: fallout.vsix + retention-days: 7 if-no-files-found: error - # Release candidate: a GitHub pre-release only. No marketplace involvement, so the - # version number stays free for the eventual stable publish. - prerelease: - needs: package - if: github.ref_type == 'tag' && needs.package.outputs.is-rc == 'true' + # Pre-stage. Every release tag lands here first, and this is the only channel a + # release candidate ever reaches. + publish-github-releases: + name: publish → GitHub Releases runs-on: ubuntu-latest + needs: [pack] + if: always() && needs.pack.result == 'success' + permissions: + contents: write + environment: + name: github-releases + url: https://github.com/Fallout-build/fallout-vscode/releases steps: - uses: actions/checkout@v4 - uses: actions/download-artifact@v4 with: name: fallout-vsix - - name: 'Create GitHub pre-release' + - name: 'Create or update the GitHub release' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG: ${{ github.ref_name }} + TAG: ${{ needs.pack.outputs.tag }} + IS_RC: ${{ needs.pack.outputs.is-rc }} run: | - gh release create "$TAG" \ - --prerelease \ - --verify-tag \ - --title "$TAG" \ - --notes 'Release candidate. Not published to the VS Marketplace or Open VSX — install the attached .vsix via "Extensions: Install from VSIX...".' \ - fallout.vsix \ - || gh release upload "$TAG" fallout.vsix --clobber + set -euo pipefail + if gh release view "$TAG" > /dev/null 2>&1; then + echo "Release $TAG already exists — uploading asset with --clobber." + gh release upload "$TAG" fallout.vsix --clobber + else + # --generate-notes groups merged PRs by the label taxonomy in .github/release.yml. + ARGS=(--title "$TAG" --generate-notes --verify-tag) + if [ "$IS_RC" = "true" ]; then + ARGS+=(--prerelease) + fi + gh release create "$TAG" "${ARGS[@]}" fallout.vsix + fi - # Stable release. Requires an approval on the 'marketplace' environment, so a tag - # push alone cannot publish. Re-runs the Fallout chain so the published bits are - # built fresh from the tag. - publish: - needs: package - if: github.ref_type == 'tag' && needs.package.outputs.is-rc == 'false' + # Promotion tier. Opt-in flag AND environment approval, and never for a release + # candidate. See the header for why the condition tolerates a string 'true': a + # type: boolean input only arrives as a real boolean from the Actions UI, while the + # REST API — and therefore `gh workflow run` — can only send strings. Comparing a + # string to a boolean casts both to numbers, so a bare `== true` would silently skip + # every CLI-triggered promotion while still reporting success. + publish-vs-marketplace: + name: publish → VS Marketplace runs-on: ubuntu-latest - environment: marketplace + needs: [pack] + if: >- + always() && needs.pack.result == 'success' + && needs.pack.outputs.is-rc == 'false' + && github.event_name == 'workflow_dispatch' + && (inputs.publish-to-marketplaces == true || inputs.publish-to-marketplaces == 'true') + environment: + name: vs-marketplace + url: https://marketplace.visualstudio.com/items?itemName=fallout.fallout steps: - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag }} + fetch-depth: 0 - uses: actions/setup-node@v4 with: node-version: 20 @@ -131,18 +205,46 @@ jobs: - uses: actions/setup-dotnet@v4 with: global-json-file: global.json - - - name: 'Fallout build (Publish)' - run: ./build.sh Publish + - uses: actions/download-artifact@v4 + with: + name: fallout-vsix + # --skip PackVsix publishes the exact artifact that was packed and validated, + # rather than rebuilding it here and publishing bits nothing has seen. + - name: 'Fallout build (PublishVsix → vs-marketplace)' + run: ./build.sh PublishVsix --publish-vsix-to vs-marketplace --skip PackVsix env: VSCE_PAT: ${{ secrets.VSCE_PAT }} - # ovsx reads OVSX_PAT from the environment; our secret is named OVSX_TOKEN. - OVSX_PAT: ${{ secrets.OVSX_TOKEN }} + PublicRelease: true - - name: 'Create GitHub release' + publish-open-vsx: + name: publish → Open VSX + runs-on: ubuntu-latest + needs: [pack] + if: >- + always() && needs.pack.result == 'success' + && needs.pack.outputs.is-rc == 'false' + && github.event_name == 'workflow_dispatch' + && (inputs.publish-to-marketplaces == true || inputs.publish-to-marketplaces == 'true') + environment: + name: open-vsx + url: https://open-vsx.org/extension/fallout/fallout + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag }} + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + - uses: actions/download-artifact@v4 + with: + name: fallout-vsix + - name: 'Fallout build (PublishVsix → open-vsx)' + run: ./build.sh PublishVsix --publish-vsix-to open-vsx --skip PackVsix env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG: ${{ github.ref_name }} - run: | - gh release create "$TAG" --verify-tag --title "$TAG" --generate-notes fallout.vsix \ - || gh release upload "$TAG" fallout.vsix --clobber + OVSX_PAT: ${{ secrets.OVSX_TOKEN }} + PublicRelease: true diff --git a/.vscodeignore b/.vscodeignore index d67cb73..303531f 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -18,6 +18,8 @@ nuget.config version.json build.ps1 build.sh +# Maintainer-facing; the marketplace page renders README.md + CHANGELOG.md only. +RELEASING.md # Ship only Mermaid's self-contained UMD bundle (loaded by the graph webview via a # classic