From 5b96cbf3b7b73a5655406b9e8e667930100e3e80 Mon Sep 17 00:00:00 2001 From: Shewatipa Tseisi Date: Tue, 20 Oct 2026 21:24:21 +0200 Subject: [PATCH 01/24] ci: publish to NuGet with Trusted Publishing release.yml pushed with a long-lived NUGET_API_KEY secret. The job now runs in the release environment with id-token: write, and NuGet/login exchanges the OIDC token for a key that expires after an hour, as ShellDocs and ShellUI Native already do. docs/RELEASING.md has the one-time GitHub and nuget.org setup and the release checklist. Refs #59 --- .github/workflows/release.yml | 25 +++++++++---- VERSIONING_STRATEGY.md | 2 +- docs/CONTRIBUTING.md | 2 +- docs/RELEASING.md | 70 +++++++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 9 deletions(-) create mode 100644 docs/RELEASING.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e1644d9..a3e9d1a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,9 +5,6 @@ on: tags: - 'v*' -permissions: - contents: write - concurrency: group: release-${{ github.ref }} cancel-in-progress: false @@ -16,6 +13,11 @@ jobs: publish: runs-on: ubuntu-latest if: github.event.repository.fork == false + # Must match the environment in the nuget.org Trusted Publishing policy (see docs/RELEASING.md). + environment: release + permissions: + contents: write # GitHub Release + id-token: write # OIDC token exchanged for a short-lived NuGet key steps: - uses: actions/checkout@v4 @@ -74,16 +76,25 @@ jobs: - name: Pack NuGet packages run: dotnet pack ShellUI.slnx --no-build --configuration Release -p:ContinuousIntegrationBuild=true + # Trusted Publishing: no stored key; the key this returns expires after an hour. + - name: NuGet login + id: nuget-login + uses: NuGet/login@v1 + with: + user: ${{ secrets.NUGET_USER }} + # Core and Templates are internal; only the CLI and Components are published. - name: Publish to NuGet + env: + NUGET_API_KEY: ${{ steps.nuget-login.outputs.NUGET_API_KEY }} run: | - dotnet nuget push src/ShellUI.CLI/bin/Release/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate + dotnet nuget push src/ShellUI.CLI/bin/Release/*.nupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate if ls src/ShellUI.CLI/bin/Release/*.snupkg 1> /dev/null 2>&1; then - dotnet nuget push src/ShellUI.CLI/bin/Release/*.snupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate + dotnet nuget push src/ShellUI.CLI/bin/Release/*.snupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate fi - dotnet nuget push src/ShellUI.Components/bin/Release/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate + dotnet nuget push src/ShellUI.Components/bin/Release/*.nupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate if ls src/ShellUI.Components/bin/Release/*.snupkg 1> /dev/null 2>&1; then - dotnet nuget push src/ShellUI.Components/bin/Release/*.snupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate + dotnet nuget push src/ShellUI.Components/bin/Release/*.snupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate fi - name: Create GitHub Release diff --git a/VERSIONING_STRATEGY.md b/VERSIONING_STRATEGY.md index cea7c31..1ae1715 100644 --- a/VERSIONING_STRATEGY.md +++ b/VERSIONING_STRATEGY.md @@ -113,7 +113,7 @@ Release tags use the `v` prefix, for example `v0.3.0-rc.2`. Pushing a `v*` tag r 3. Merge, then on an up-to-date `main` run `pwsh ./prepare-release.ps1 -Version -Suffix -DryRun` (builds, tests and checks the notes section). 4. Tag the merged commit and push the tag: `git tag v` then `git push origin v`. -The release workflow fails before publishing when the tag does not match the version in `Directory.Build.props` or when the notes section is missing. NuGet versions cannot be deleted, only unlisted, so the version must be merged before tagging. +The release workflow publishes with NuGet Trusted Publishing; the one-time GitHub and nuget.org setup, and the full release checklist, are in [docs/RELEASING.md](docs/RELEASING.md). The release workflow fails before publishing when the tag does not match the version in `Directory.Build.props` or when the notes section is missing. NuGet versions cannot be deleted, only unlisted, so the version must be merged before tagging. The verification commands are: diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 0caca59..deb7fcc 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing to ShellUI -ShellUI targets .NET 10 and Tailwind CSS `4.3.2`. This guide describes the contribution and verification workflow; releases are covered in [VERSIONING_STRATEGY.md](../VERSIONING_STRATEGY.md). +ShellUI targets .NET 10 and Tailwind CSS `4.3.2`. This guide describes the contribution and verification workflow; releases are covered in [VERSIONING_STRATEGY.md](../VERSIONING_STRATEGY.md) and [RELEASING.md](RELEASING.md). ## Contribution Policy diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..41a52af --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,70 @@ +# Releasing ShellUI + +How `ShellUI.CLI` and `ShellUI.Components` get to nuget.org. Pushing a `v*` tag runs `.github/workflows/release.yml`: it checks the version, rebuilds the precompiled CSS bundle, builds, tests, packs, publishes both packages and creates the GitHub release from `docs/RELEASE_NOTES.md`. A tag with a `-` (`v0.4.0-alpha.3`) is published as a prerelease. + +## One-time setup: NuGet Trusted Publishing + +The workflow doesn't store a NuGet API key. On each run it exchanges a GitHub OIDC token for a key that's valid for one hour ([NuGet docs](https://learn.microsoft.com/nuget/nuget-org/trusted-publishing)). This needs three things, set up once: + +1. **GitHub environment.** Repository → Settings → Environments → New environment, named exactly `release`. Nothing to configure inside (add required reviewers if you want a manual approval before each publish). +2. **`NUGET_USER` secret.** Repository → Settings → Secrets and variables → Actions → New repository secret: name `NUGET_USER`, value your **nuget.org profile name** (the name in `nuget.org/profiles/`, not your email). +3. **Trusted Publishing policy on nuget.org.** Sign in → your username → **Trusted Publishing** → **Add**, choose the account that owns the `ShellUI.*` packages, and fill in: + - Repository Owner: `shellui-dev` + - Repository: `shellui` + - Workflow File: `release.yml` (the file name only, no `.github/workflows/`) + - Environment: `release` + +Then run one release. When it has published, finish the switch: + +4. **Delete the old `NUGET_API_KEY` secret** from the repository, and **revoke that key** on nuget.org (your username → API Keys). + +The `release/v0.3` branch has its own copy of `release.yml`, and a tag runs the workflow from the tagged commit. Bring the same change to `release/v0.3` before deleting the old key, or the next 0.3.x tag can't publish. + +If a run fails at **NuGet login**, check in this order: the `NUGET_USER` secret is missing or wrong; the policy's Workflow File has a path in it; the policy's Environment doesn't match `environment: release`. + +## Cutting a release + +The version rules and the `prepare-release.ps1` dry run are in [VERSIONING_STRATEGY.md](../VERSIONING_STRATEGY.md#release-workflow). In short: + +### 1. Version + +`Directory.Build.props` sets the version for every project: + +```xml +0.4.0 +alpha.3 +``` + +Also update the version mentioned in `README.md`, `VERSIONING_STRATEGY.md`, `docs/CLI_INSTALLATION.md`, `docs/CLI_SYNTAX.md`, `docs/FAQ.md`, `docs/QUICKSTART.md`, and the fallback in `src/ShellUI.Templates/VersionHelper.cs` and `src/ShellUI.Core/Models/ComponentMetadata.cs`. `git grep` the previous version to find them all. + +### 2. Release notes + +Add a `# ShellUI v0.4.0-alpha.3` section at the top of `docs/RELEASE_NOTES.md`: what's new, fixes, breaking changes, and how to upgrade. Each section describes only its own release. Check that the workflow will find it: + +```bash +bash scripts/extract-release-notes.sh 0.4.0-alpha.3 +``` + +### 3. Check + +- If component CSS changed, regenerate the safelist (`dotnet run --project tools/ShellUI.SafelistGenerator -- …`, full command in VERSIONING_STRATEGY.md). +- `pwsh ./prepare-release.ps1 -Version 0.4.0 -Suffix alpha.3 -DryRun` builds, tests and checks the notes section. +- Pack both projects to a local folder, install the CLI from it into a tool path, and run `shellui init` and `shellui add` in fresh apps on .NET 8, 9 and 10 (all direct targets together, app named `my-app`). +- Use the packed `ShellUI.Components` in a fresh app and click through the components the release changed, in light and dark mode. +- Delete the local test packages from `~/.nuget/packages` afterwards, so later restores fetch the published ones. + +### 4. Merge and tag + +Open a `chore(release): v0.4.0-alpha.3` pull request, merge it, then tag the merge commit: + +```bash +git switch main && git pull --ff-only +git tag v0.4.0-alpha.3 +git push origin v0.4.0-alpha.3 +``` + +Watch the **Release** workflow in the Actions tab. If it fails before **Publish to NuGet**, fix it, delete the tag (`git push --delete origin v0.4.0-alpha.3`, `git tag -d v0.4.0-alpha.3`) and tag again. Once a version is on nuget.org it can't be reused, only unlisted. + +## 0.3.x patches + +0.3 lives on `release/v0.3`, which is never merged into `main`. For a patch: branch `fix/v0.3.N` off `release/v0.3`, cherry-pick the fixes from `main`, bump the version and add a `# ShellUI v0.3.N` section to that branch's release notes, open the pull request **into `release/v0.3`**, and tag the merge commit there. The same workflow publishes it as a stable release. From c4ef1ad6083e03b82e8f5b23ebbe7b7606b3c78f Mon Sep 17 00:00:00 2001 From: Shewatipa Tseisi Date: Tue, 13 Oct 2026 10:19:12 +0200 Subject: [PATCH 02/24] fix: keep local changes and install new dependencies on update update rewrote every file it was given, including blocks apps are meant to edit, and never installed dependencies a component gained since it was installed, so projects stopped building (BreadcrumbList, TabItem). shellui.json now records a hash of each file the CLI writes; update skips files that changed unless --force is passed, and installs missing dependencies the way add does. Entries without a hash keep the editable blocks when they differ from the template. --- ShellUI.Tests/UpdateTests.cs | 86 +++++++++++ docs/ARCHITECTURE.md | 2 +- docs/CLI_SYNTAX.md | 10 +- docs/FAQ.md | 4 +- docs/QUICKSTART.md | 2 +- src/ShellUI.CLI/Program.cs | 11 +- .../Services/ComponentInstaller.cs | 40 +++-- src/ShellUI.CLI/Services/ComponentManager.cs | 144 ++++++++++++++---- src/ShellUI.Core/Models/ShellUIConfig.cs | 5 + 9 files changed, 259 insertions(+), 45 deletions(-) create mode 100644 ShellUI.Tests/UpdateTests.cs diff --git a/ShellUI.Tests/UpdateTests.cs b/ShellUI.Tests/UpdateTests.cs new file mode 100644 index 0000000..07c846a --- /dev/null +++ b/ShellUI.Tests/UpdateTests.cs @@ -0,0 +1,86 @@ +using System.Collections.Generic; +using System.IO; +using ShellUI.CLI.Services; +using ShellUI.Core.Models; +using ShellUI.Templates; +using Xunit; + +namespace ShellUI.Tests; + +public class UpdateDependencyTests +{ + [Fact] + public void MissingDependencies_FindsDependenciesAddedSinceInstall() + { + // An older install of tabs and dialog, before tab-models and the dialog parts were dependencies. + var installed = new List { "tabs", "tabs-list", "tabs-trigger", "tabs-content", "dialog", "dialog-trigger" }; + + var missing = ComponentManager.MissingDependencies(new[] { "tabs", "dialog" }, installed); + + Assert.Contains("tab-models", missing); + Assert.Contains("dialog-content", missing); + Assert.Contains("dialog-close", missing); + Assert.DoesNotContain("tabs-list", missing); + Assert.Equal(missing.Count, new HashSet(missing).Count); + } + + [Fact] + public void MissingDependencies_IsEmptyWhenEverythingIsInstalled() + { + var tabs = ComponentRegistry.GetMetadata("tabs")!; + var installed = new List(tabs.Dependencies) { "tabs" }; + + Assert.Empty(ComponentManager.MissingDependencies(new[] { "tabs" }, installed)); + } +} + +public class UpdateLocalChangesTests +{ + private const string Template = "
@ChildContent
\n"; + + private static InstalledComponent Entry(string name, string? hash) => new() { Name = name, Version = "0.4.0", Hash = hash }; + + [Fact] + public void FileMatchingTheRecordedHash_HasNoLocalChanges() + { + var entry = Entry("button", ComponentInstaller.ContentHash(Template)); + + Assert.False(ComponentManager.HasLocalChanges(entry, ComponentRegistry.GetMetadata("button")!, Template, Template)); + } + + [Fact] + public void EditedFile_HasLocalChanges() + { + var entry = Entry("button", ComponentInstaller.ContentHash(Template)); + + Assert.True(ComponentManager.HasLocalChanges(entry, ComponentRegistry.GetMetadata("button")!, Template + "", Template)); + } + + [Fact] + public void LineEndingChangesAreNotLocalChanges() + { + var entry = Entry("button", ComponentInstaller.ContentHash(Template)); + + Assert.False(ComponentManager.HasLocalChanges(entry, ComponentRegistry.GetMetadata("button")!, Template.Replace("\n", "\r\n"), Template)); + } + + [Theory] + [InlineData("app-sidebar")] + [InlineData("dashboard-02")] + public void OlderInstall_EditableBlockThatDiffersFromTheTemplate_HasLocalChanges(string name) + { + Assert.True(ComponentManager.HasLocalChanges(Entry(name, null), ComponentRegistry.GetMetadata(name)!, "edited", Template)); + } + + [Fact] + public void OlderInstall_EditableBlockMatchingTheTemplate_HasNoLocalChanges() + { + Assert.False(ComponentManager.HasLocalChanges(Entry("app-sidebar", null), ComponentRegistry.GetMetadata("app-sidebar")!, Template, Template)); + } + + [Fact] + public void OlderInstall_OrdinaryComponentIsUpdatedAsBefore() + { + Assert.False(ComponentManager.HasLocalChanges(Entry("button", null), ComponentRegistry.GetMetadata("button")!, "edited", Template)); + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 03a1e51..f73ffcf 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -80,7 +80,7 @@ The implemented command tree is: | `shellui add ` | Install one or more direct targets and recursively install their registry dependencies. | | `shellui list` | List direct targets, with installed/available filtering. | | `shellui remove ` | Remove selected installed source files and update `shellui.json`. | -| `shellui update [components...]` | Rewrite selected installed source files, or all installed components with `--all`. | +| `shellui update [components...]` | Rewrite selected installed source files, or all installed components with `--all`; skips files changed since install unless `--force`, and installs new dependencies. | | `shellui theme init ` | Initialize a project and apply a fetched theme in one operation. | | `shellui theme apply ` | Apply a theme to `wwwroot/input.css` or emit a standalone override. | | `shellui theme update` | Re-fetch the theme recorded in `shellui.theme.lock`. | diff --git a/docs/CLI_SYNTAX.md b/docs/CLI_SYNTAX.md index c633d29..52e8a00 100644 --- a/docs/CLI_SYNTAX.md +++ b/docs/CLI_SYNTAX.md @@ -20,7 +20,7 @@ shellui init [--force] [--style