You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
FormCraft's release path was rewired to publish via NuGet Trusted Publishing (OIDC) in #173
(merged 2026-07-27). The workflow side is done and on dev today: .github/workflows/continuous.yml
declares id-token: write, runs NuGet/login pinned by digest (8d19675…, v1.2.0) gated on startsWith(github.ref, 'refs/tags/v'), and feeds the short-lived key to Nuke as NUGET_API_KEY.
The NUGET_USER secret is set, and the nuget.org policy was registered the same day.
But that path has never once executed, and the long-lived key it was meant to retire is still there.
gh run list filtered on event=push, headBranch starting v
Latest version on nuget.org
3.1.0 (both packages)
api.nuget.org/v3-flatcontainer/…/index.json
NUGET_API_KEY repo secret
still present, created 2026-02-23
gh secret list
NUGET_USER repo secret
present, created 2026-07-27
gh secret list
Org-scope secrets
n/a — phmatray is a user account (API returns 422)
gh api …/actions/organization-secrets
So the last tag predates the rewrite by six weeks. Every published version of FormCraft and FormCraft.ForMudBlazor went out under the old long-lived key, and the OIDC path is wired but
unproven. Two consequences, both bad:
The next release is the test. If the policy fields, the workflow filename, or the Nuke Publish chain are wrong, we find out at the exact moment we want to ship a ★61 flagship
package — and a half-published release on nuget.org cannot be undone (nuget.org delists, it
never deletes).
The credential the migration was supposed to eliminate is still live. Nothing references secrets.NUGET_API_KEY in the workflow any more, so it is inert, not armed — but an inert
long-lived push credential is still a long-lived push credential, and deleting the GitHub secret
does not revoke it on nuget.org.
There is also a latent trap in build/Build.cs:20-32: the [GitHubActions] attribute still declares ImportSecrets = ["NUGET_API_KEY"] and omits IdToken from WritePermissions. It is harmless today only because AutoGenerate = false. Anyone who flips that flag regenerates continuous.yml and
silently deletes the entire OIDC block.
Proposed solution
Finish the migration the way repo-audit/TRUSTED_PUBLISHING.md prescribes — policy first,
key deletion last — and prove each step by observation rather than by reading the YAML:
Re-verify the nuget.org policy and secret scope against the live repo (read-only gate).
Close the AutoGenerate trap in build/Build.cs and add a regression test that fails if the
OIDC wiring is ever removed from continuous.yml.
Rehearse with a prerelease tag (v3.1.1-rc.1), which exercises the exact publish path, and
confirm the package appears on the nuget.org index.
Ship a real release through the same path.
Only then delete the NUGET_API_KEY repo secret and revoke the key on nuget.org.
Alternatives considered
Wait for the next real release (no rehearsal). Burns no version number, but moves the risk onto
a real user-facing release. Rejected — see Brainstorm.
Migrate to release-please like Koine. Koine drives its release from release-please.yml;
FormCraft uses MinVer + hand-cut tags. Changing the version driver is a much larger, separate
decision and is explicitly out of scope here — the Koine reference this issue borrows is the
OIDC publish pattern, which FormCraft already matches.
A workflow_dispatch dry-run that logs in but does not push. Proves only half the path and
risks a false negative. See Brainstorm.
Area
CI / release infrastructure — .github/workflows/continuous.yml, build/Build.cs (Nuke Publish / PublishIfNeeded targets), and the nuget.org Trusted Publishing policy. No library code changes.
The migration to Trusted Publishing is 90% done and 0% proven. repo-audit/TRUSTED_PUBLISHING.md
(the canonical, replayable runbook for this portfolio-wide effort) lays out six steps; FormCraft has
completed steps 1–4 (policy created, NUGET_USER set, workflow migrated, PR merged green) and has
completed neither step 5 ("a real release, then verify the publication — a merge proves
nothing") nor step 6 (delete the key).
That runbook is emphatic about the ordering, and for a reason it paid for: "Never before. Deleting
first leaves no way back if the policy is wrong." It also carries the portfolio's most expensive
lesson — an earlier note concluded eleven repos "expose nothing" based on gh secret list, which
only returns repo secrets; an org-scoped NUGET_API_KEY existed and seven repos had an armed
push. The comment was then copied into workflow files, where it served as proof to everyone. That
does not apply to FormCraft (phmatray is a user account — the org-secrets endpoint returns 422, so
repo scope is the whole story here) but the discipline does: verify by API, not by comment.
What makes FormCraft's shape different from Koine's, and worth thinking about rather than
copy-pasting:
Koine packs and pushes in adjacent steps, seconds apart. FormCraft mints the OIDC key before ./build.cmd Continuous, which runs Test → Pack → Publish. The exchanged key lives ~1 hour, so
in principle a slow build could outlive its own credential. Measured: the five most recent continuous runs took 60–77 s. That is ~50× headroom. Not a
constraint — but it is now measured rather than assumed, and it is the reason not to restructure
the workflow to move login adjacent to the push.
continuous.yml fires on push to main/dev, on pull_request, and on v* tags. The
runbook's rule is that a tag-only workflow must fail loudly (no if: env.NUGET_USER != ''
guard), because a guard on a tag produces a green CI with an unpublished version that nobody
notices. FormCraft is the mixed case and gets this right already: the guard is on the trigger
(startsWith(github.ref, 'refs/tags/v')), not on the secret. A fork's PR cannot obtain this
repo's OIDC token, so gating on the tag is also what keeps fork PRs from failing. Do not
"improve" this into a NUGET_USER guard.
Approaches
A. Prerelease rehearsal tag, then the real release, then revoke.(recommended)
Tag v3.1.1-rc.1, let continuous.yml run the whole path, and probe the nuget.org index for the
result. IsOnVersionTag() (build/Build.cs:379) matches on ^v\d+\.\d+\.\d+ with no $
anchor, so a prerelease tag does trigger publish — the rehearsal exercises the identical code path,
not a simulation of it. Cost, stated plainly: it publishes a real prerelease version to nuget.org, permanently. nuget.org
delists but never deletes. The blast radius is small — dotnet add package will not resolve a
prerelease without --prerelease — and --skip-duplicate (already set via EnableSkipDuplicate()
in build/Build.cs:226) makes a re-run idempotent.
B. Skip the rehearsal; let the next real release be the test.
Burns no version number. But it moves an untested, irreversible path onto a user-facing release of a
★61 package, and a partial failure (first package pushed, second not) is discovered only after the
first one is public. The whole point of step 5 in the runbook is that a merge proves nothing;
choosing B means the proof and the risk arrive together.
C. A workflow_dispatch dry-run that performs the OIDC exchange without pushing.
Cheap and publishes nothing. Two problems: it proves the login half but not the push half (which is
where the Nuke Requires/OnlyWhenStatic chain actually lives, and that chain has never run on a
tag), and the OIDC token's ref claim from a dev dispatch differs from a tag ref — so a rejection
would be ambiguous between "policy is wrong" and "this is not how the policy is scoped". A false
negative here costs more than the rc version A burns.
Recommendation
A. The cost is one prerelease version number; the return is that the first real release through
OIDC is not also the first release of OIDC. B defers a known risk onto the worst possible moment,
and C's cheapness comes from not testing the part most likely to break.
Fold in one piece of C's instinct as cheap insurance that costs nothing: a regression test on the
workflow file, so the wiring cannot be silently undone later. The repo already has this reflex — FormCraft.UnitTests/Documentation/DocumentationSamplesTests exists precisely so documentation
"cannot silently drift away from the real public API again", and RenderPipelineParityTests keeps
the two render paths honest. A publish path that is exercised roughly four times a year is more
prone to silent drift than either, not less.
Assumptions
The nuget.org policy registered on 2026-07-27 names Package Ownerphmatray, repository phmatray/FormCraft, workflow file continuous.yml, environment empty. Task 1 re-verifies this
in the UI rather than trusting the runbook's record — nuget.org does not validate the workflow
filename, so a typo there is accepted silently and only fails at the first tag.
v3.1.1-rc.1 is an acceptable version to burn. If a different rehearsal version is preferred, any v<major>.<minor>.<patch>-<label> works identically.
Deleting the secret and revoking the key (Task 5) are destructive and owner-only — they are
written as an explicit human gate, not automated.
📋 Spec
Goal
The next FormCraft release publishes both packages to nuget.org through OIDC only, proven by
observing the run and the nuget.org index — after which the long-lived NUGET_API_KEY is deleted
from GitHub and revoked on nuget.org.
Scope
Re-verify the nuget.org policy, secret scope, and workflow filename against the live repo.
Close the AutoGenerate regression trap in build/Build.cs and guard the OIDC wiring with a test.
Rehearse the publish path with a prerelease tag and verify the result on the nuget.org index.
Ship a real release through the same path.
Retire the long-lived key: delete the GitHub secret, then revoke it on nuget.org.
Non-goals
Migrating to release-please. FormCraft versions with MinVer from git tags
(MinVerTagPrefix=v); there is no <Version> element anywhere. Changing the version driver is a
separate decision.
Changing package ids, package ownership, or the phmatray-vs-atypical account split.
Touching .github/workflows/release.yml (git-cliff + GitHub Release). It fires on the same v*
tag but does not push to nuget.org, and build/Build.cs:300-316 deliberately does not chain CreateGitHubRelease so the two cannot race for the same release.
Restructuring continuous.yml to move login adjacent to the push — the measured 60–77 s build
makes that unnecessary.
The path being proven
flowchart TD
T["git push origin v3.1.1-rc.1"] --> W["continuous.yml<br/>on: push tags v*"]
W --> G{"startsWith(github.ref,<br/>'refs/tags/v')"}
G -->|false: push to dev / PR| S["login skipped<br/>NUGET_API_KEY unset"]
G -->|true| L["NuGet/login@8d19675 (v1.2.0)<br/>user: secrets.NUGET_USER"]
L -->|OIDC token, id-token: write| N["nuget.org<br/>Trusted Publishing policy"]
N -->|short-lived key ~1h| E["env NUGET_API_KEY"]
E --> B["./build.cmd Continuous"]
B --> C["Continuous → Test, Pack<br/>Triggers PublishIfNeeded"]
C --> P{"OnlyWhenStatic<br/>IsOnVersionTag() && IsServerBuild"}
P -->|false| X["no publish"]
P -->|true| Q["Publish (DependsOn)<br/>Requires NuGetApiKey"]
Q --> R["DotNetNuGetPush --skip-duplicate<br/>FormCraft + FormCraft.ForMudBlazor"]
R --> V["verify: api.nuget.org<br/>flatcontainer index = 200"]
Loading
Key files
File
Role
Change
.github/workflows/continuous.yml
the OIDC login + build invocation
none — already correct; becomes the subject of a guard test
build/Build.cs:20-32
[GitHubActions] attribute
remove stale ImportSecrets, add IdToken, document AutoGenerate = false
IsOnVersionTag() is ^v\d+\.\d+\.\d+ with no $ anchor — prerelease tags publish. This is
what makes the rehearsal a true rehearsal; it is also a live trap, since a tag like v1.0.0-donotship would publish. Documented, not changed.
Publish carries .Requires(() => NuGetApiKey). On a non-tag run the key is never minted, and PublishIfNeeded is skipped by OnlyWhenStatic — which is why dev pushes and PRs are green
today. The rehearsal is the first time this evaluates with the requirement satisfied.
EnableSkipDuplicate() makes a re-run of the same version a no-op, so re-running a failed
release job is safe.
A job-levelpermissions: block replaces the workflow-level one. continuous.yml declares
permissions only at workflow level (contents: write, packages: write, id-token: write).
Adding a job-level block without re-declaring all three would break the OIDC exchange or the
artifact upload.
Edge cases
nuget.org does not validate the workflow filename in a policy — it is a free-text string. A
wrong value is accepted at creation and fails only at the first tag. Task 1 checks it character
for character against the file on dev.
Deleting the GitHub secret revokes nothing. The key remains valid on nuget.org until revoked
there. Both halves are required.
Two packages, one push.Publish globs *.nupkg/*.snupkg from artifacts/. Verification
must probe both ids — a policy misconfigured for only one id would publish one and fail the
other.
Probe the nuget.org index, never the local ~/.nuget/packages/ cache: the cache can contain a
package that exists nowhere (this is exactly how Phosphor was mistaken for green on 2026-07-26).
Assumptions
Package owner on nuget.org for both ids is phmatray (confirmed via the flatcontainer registration
metadata in repo-audit/nuget_publish_audit.json).
NUGET_USER holds the nuget.org profile name matching the policy's Package Owner, not the
repository owner. It is a secret by convention only, not a credential.
🛠️ Implementation plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Prove FormCraft's NuGet Trusted Publishing path end-to-end with a real published package, then delete and revoke the long-lived NUGET_API_KEY.
Architecture: Verify preconditions (read-only) → guard the wiring in code → rehearse with a prerelease tag → ship a real release → retire the key. The order is the runbook's and is not negotiable: the key is deleted last, only after a verified publication.
dotnet test --filter is inert — these test projects run on Microsoft.Testing.Platform, which prints MTP0001 and runs the whole suite anyway. Always run the full suite; never report a filtered result.
TreatWarningsAsErrors=true in Directory.Build.props is deliberate. Do not relax it to make a build pass.
SDK pinned to 10.0.302 (rollForward: latestFeature); multi-target net8.0;net10.0 — a build error can be TFM-specific.
Never delete or revoke the key before a verified publication (Task 5 gates on Task 4).
Tasks 1, 3, 4 and 5 contain owner-only steps (nuget.org UI, tag pushes, secret deletion). Do not automate them; stop and hand off.
Task 1: Verify the trusted-publishing preconditions against the live repo
Read-only gate. Everything downstream assumes these four facts; the runbook's own history shows what happens when they are assumed rather than measured.
Files: none modified. Output is a comment on this issue recording the measured state.
Interfaces: Produces the go/no-go for Task 3. If any check fails, fix it before tagging anything.
Step 1: Confirm the workflow file named by the policy exists on the default branch, character for character:
gh api "repos/phmatray/FormCraft/contents/.github/workflows/continuous.yml?ref=dev" --jq .name
Expected: continuous.yml. Anything else (404, a different name) means the policy names a file that does not exist and the first tag will fail.
Step 2: Confirm the secret inventory, on every scope that exists for a user account:
gh api repos/phmatray/FormCraft/actions/secrets --jq '[.secrets[].name]'
gh api repos/phmatray/FormCraft/actions/variables --jq '[.variables[].name]'
Expected: secrets ["NUGET_API_KEY","NUGET_USER"], variables []. The organization-secrets endpoint returns HTTP 422 here because phmatray is a user, not an org — that 422 is the evidence that repo scope is complete. Record it; do not re-derive it from gh secret list alone.
Step 3: Confirm the OIDC wiring is present on dev and that no reference to the long-lived secret survives:
Expected: hits for id-token: write, NuGet/login@8d196754b4036150537f80ac539e15c2f1028841, and secrets.NUGET_USER; zero hits for secrets.NUGET_API_KEY. (Decode the base64 from raw JSON — gh api --jq @tsv corrupts base64 content.)
Step 4 (owner-only): On nuget.org → Account settings → Trusted Publishing, confirm the FormCraft policy reads exactly: Package owner phmatray · Repository owner phmatray · Repository FormCraft · Workflow file continuous.yml · Environment empty. nuget.org does not validate the workflow filename, so read it rather than assume it.
Step 5: Post the four results as a comment on this issue, then proceed. If any check fails, stop and fix it first — do not tag.
Task 2: Guard the OIDC wiring so it cannot be silently regenerated away
build/Build.cs:20-32 still declares ImportSecrets = ["NUGET_API_KEY"] and omits IdToken from WritePermissions. Harmless only because AutoGenerate = false; flipping that flag regenerates continuous.yml and deletes the OIDC block. This task removes the stale declaration and adds a test that fails if the wiring ever disappears.
Modify: build/Build.cs:20-32 (the [GitHubActions] attribute)
Interfaces: Produces no public API. GitHubActionsPermissions.IdToken is confirmed present in Nuke.Common 10.1.0.
Step 1: Write the failing test. Create FormCraft.UnitTests/Ci/TrustedPublishingWorkflowTests.cs:
usingSystem.IO;namespaceFormCraft.UnitTests.Ci;/// <summary>/// Guards the NuGet Trusted Publishing wiring (#173). The publish path runs only on a version/// tag — a handful of times a year — so a regression here is invisible until a release breaks./// These tests fail if the OIDC exchange is removed from the workflow, or if build/Build.cs/// starts declaring the long-lived key again (which a future AutoGenerate = true would bake/// straight back into continuous.yml)./// </summary>publicclassTrustedPublishingWorkflowTests{privatestaticstringRepoRoot(){vardir=newDirectoryInfo(AppContext.BaseDirectory);while(diris not null&&!File.Exists(Path.Combine(dir.FullName,"FormCraft.sln")))dir=dir.Parent;dir.ShouldNotBeNull("could not locate FormCraft.sln above the test output directory");returndir!.FullName;}privatestaticstringReadWorkflow()=>File.ReadAllText(Path.Combine(RepoRoot(),".github","workflows","continuous.yml"));privatestaticstringReadBuildScript()=>File.ReadAllText(Path.Combine(RepoRoot(),"build","Build.cs"));[Fact]publicvoidContinuous_Workflow_Should_Request_The_OidcToken(){ReadWorkflow().ShouldContain("id-token: write");}[Fact]publicvoidContinuous_Workflow_Should_Exchange_The_OidcToken_For_A_ShortLived_Key(){varworkflow=ReadWorkflow();// Pinned by digest, never by a moving tag.workflow.ShouldContain("NuGet/login@8d196754b4036150537f80ac539e15c2f1028841");workflow.ShouldContain("secrets.NUGET_USER");workflow.ShouldContain("steps.nuget-login.outputs.NUGET_API_KEY");}[Fact]publicvoidContinuous_Workflow_Should_Gate_The_Login_On_A_Version_Tag(){// Gating on the TRIGGER, not on NUGET_USER: a fork's PR cannot obtain this repo's OIDC// token, and a secret-based guard would turn a missing policy into a green build with an// unpublished version. Do not "simplify" this to `if: env.NUGET_USER != ''`.ReadWorkflow().ShouldContain("startsWith(github.ref, 'refs/tags/v')");}[Fact]publicvoidNo_LongLived_NuGetApiKey_Secret_Should_Be_Referenced_Anywhere(){ReadWorkflow().ShouldNotContain("secrets.NUGET_API_KEY");ReadBuildScript().ShouldNotContain("ImportSecrets");}}
Step 2: Run the suite and verify it fails.
dotnet test FormCraft.UnitTests/FormCraft.UnitTests.csproj -c Release
Expected: No_LongLived_NuGetApiKey_Secret_Should_Be_Referenced_Anywhere FAILS — build/Build.cs:30 still reads ImportSecrets = ["NUGET_API_KEY"],. The other three should already pass; that is intentional, they are the regression net.
Step 3: Fix the Nuke attribute. In build/Build.cs, replace the [GitHubActions] attribute's stale lines so it reads:
// continuous.yml is HAND-MAINTAINED (AutoGenerate = false) because it carries the NuGet Trusted// Publishing steps that Nuke cannot express: the OIDC exchange via NuGet/login before the build.// Do NOT set AutoGenerate = true — regenerating this file would silently delete that block and// the next release would fail. TrustedPublishingWorkflowTests guards the result.[GitHubActions("continuous",GitHubActionsImage.UbuntuLatest,AutoGenerate=false,OnPushBranches=["main","dev"],OnPushTags=["v*"],OnPullRequestBranches=["main","dev"],InvokedTargets=[nameof(Continuous)],EnableGitHubToken=true,FetchDepth=0,CacheKeyFiles=["global.json","**/*.csproj"],WritePermissions=[GitHubActionsPermissions.Contents,GitHubActionsPermissions.Packages,GitHubActionsPermissions.IdToken])]
The ImportSecrets = ["NUGET_API_KEY"], line is deleted: the key now arrives as a step output, not as an imported secret.
Step 4: Run the suite and verify it passes.
dotnet build -c Release && dotnet test FormCraft.UnitTests/FormCraft.UnitTests.csproj -c Release
Expected: build succeeds (TreatWarningsAsErrors=true, so any unused-using warning from the edit fails here) and all four tests PASS.
Step 5: Commit.
git -c user.email=phmatray@gmail.com -c user.name="Philippe Matray" \
add FormCraft.UnitTests/Ci/TrustedPublishingWorkflowTests.cs build/Build.cs
git -c user.email=phmatray@gmail.com -c user.name="Philippe Matray" \
commit -m "ci(release): guard trusted-publishing wiring and drop the stale NUGET_API_KEY import"
Step 6: Open the PR against dev and confirm CI is green before merging:
Task 3: Rehearse the publish path with a prerelease tag
The first execution of a never-run publish path should not also be a user-facing release. IsOnVersionTag() matches ^v\d+\.\d+\.\d+ with no $ anchor, so a prerelease tag exercises the identical path.
Files: none. This task pushes a tag and observes.
Interfaces: Consumes Task 1's verified policy and Task 2's merged guard. Produces the go/no-go for Task 4.
Step 1 (owner-only): From an up-to-date dev, cut and push the rehearsal tag:
Step 2: Watch the run. Both continuous.yml and release.yml fire on this tag; the publish lives in continuous.
gh run list --workflow=continuous.yml --limit 3
gh run watch <run-id>
Step 3: Read the run log and confirm the path actually executed — not merely that the run was green. A skipped publish also produces a green run, which is the exact failure mode this rehearsal exists to catch:
Expected: the NuGet login (OIDC -> short-lived key) step ran (not skipped); PublishIfNeeded conditions: shows IsServerBuild: True and IsOnVersionTag: True; Publish executed and pushed. If PublishIfNeeded was skipped, the run is green and the version is unpublished — treat that as a failure.
Step 4: Verify against the nuget.org index — both ids. Probe the index, never the local ~/.nuget/packages/ cache, which can hold a package that exists nowhere:
Expected: 1 for both ids. Indexing can lag a few minutes; re-probe before concluding failure.
Step 5: Record the outcome as a comment on this issue. If either package is missing, stop — fix the policy and re-tag (--skip-duplicate makes a re-run of the same version harmless). Do not proceed to Task 5 under any circumstances.
Task 4: Ship a real release through the OIDC path
Files: none.
Interfaces: Consumes Task 3's proven path. Produces the published release that gates Task 5.
Step 1 (owner-only): Cut the real tag from dev (MinVer derives the version from it; there is no version file to edit):
Step 4: Confirm release.yml created the GitHub release with the git-cliff changelog, and that it did not collide with Nuke's CreateGitHubRelease (which is deliberately not chained):
Step 3 (owner-only):Revoke the key on nuget.org. Deleting the GitHub secret revokes nothing — the key stays valid until removed at Account settings → API keys. Delete the key that was used for FormCraft publishing.
Step 4: Record the completion in repo-audit/TRUSTED_PUBLISHING.md, whose step 6 tracks exactly this residue across the portfolio, and note it on this issue. Then close.
Task: publish policy for the third package, FormCraft.ForFluentUI
Folded in from the merge of #261 (issue #260) — an instance of this issue's cause, not a separate one.
Pack now produces a third package. Publish globs artifacts/*.nupkg, so it will try to push FormCraft.ForFluentUI on the next release, and a brand-new package id has no Trusted Publishing
policy. dotnet nuget push returns 403, and --skip-duplicate does not cover that.
The failure lands in the worst position this issue already documents: release-please has created the
tag and the GitHub Release before the publish job runs, so the release exists with packages
missing. .github/workflows/release-please.yml now says so in its own comment block.
Create the nuget.org Trusted Publishing policy for FormCraft.ForFluentUI, naming this repository and release-please.yml, before the first release that includes it — and confirm the package id can be claimed by the publishing account on its first push.
Problem / motivation
FormCraft's release path was rewired to publish via NuGet Trusted Publishing (OIDC) in #173
(merged 2026-07-27). The workflow side is done and on
devtoday:.github/workflows/continuous.ymldeclares
id-token: write, runsNuGet/loginpinned by digest (8d19675…, v1.2.0) gated onstartsWith(github.ref, 'refs/tags/v'), and feeds the short-lived key to Nuke asNUGET_API_KEY.The
NUGET_USERsecret is set, and the nuget.org policy was registered the same day.But that path has never once executed, and the long-lived key it was meant to retire is still there.
Measured on 2026-08-11:
v3.1.0, 2026-06-10git log -1 --format=%ci v3.1.0gh pr view 173 --json mergedAtgh run listfiltered onevent=push,headBranchstartingv3.1.0(both packages)api.nuget.org/v3-flatcontainer/…/index.jsonNUGET_API_KEYrepo secretgh secret listNUGET_USERrepo secretgh secret listphmatrayis a user account (API returns 422)gh api …/actions/organization-secretsSo the last tag predates the rewrite by six weeks. Every published version of
FormCraftandFormCraft.ForMudBlazorwent out under the old long-lived key, and the OIDC path is wired butunproven. Two consequences, both bad:
Publishchain are wrong, we find out at the exact moment we want to ship a ★61 flagshippackage — and a half-published release on nuget.org cannot be undone (nuget.org delists, it
never deletes).
secrets.NUGET_API_KEYin the workflow any more, so it is inert, not armed — but an inertlong-lived push credential is still a long-lived push credential, and deleting the GitHub secret
does not revoke it on nuget.org.
There is also a latent trap in
build/Build.cs:20-32: the[GitHubActions]attribute still declaresImportSecrets = ["NUGET_API_KEY"]and omitsIdTokenfromWritePermissions. It is harmless todayonly because
AutoGenerate = false. Anyone who flips that flag regeneratescontinuous.ymlandsilently deletes the entire OIDC block.
Proposed solution
Finish the migration the way
repo-audit/TRUSTED_PUBLISHING.mdprescribes — policy first,key deletion last — and prove each step by observation rather than by reading the YAML:
AutoGeneratetrap inbuild/Build.csand add a regression test that fails if theOIDC wiring is ever removed from
continuous.yml.v3.1.1-rc.1), which exercises the exact publish path, andconfirm the package appears on the nuget.org index.
NUGET_API_KEYrepo secret and revoke the key on nuget.org.Alternatives considered
a real user-facing release. Rejected — see Brainstorm.
release-please.yml;FormCraft uses MinVer + hand-cut tags. Changing the version driver is a much larger, separate
decision and is explicitly out of scope here — the Koine reference this issue borrows is the
OIDC publish pattern, which FormCraft already matches.
workflow_dispatchdry-run that logs in but does not push. Proves only half the path andrisks a false negative. See Brainstorm.
Area
CI / release infrastructure —
.github/workflows/continuous.yml,build/Build.cs(NukePublish/PublishIfNeededtargets), and the nuget.org Trusted Publishing policy. No library code changes.Related: #173
🧠 Brainstorm
Problem / context
The migration to Trusted Publishing is 90% done and 0% proven.
repo-audit/TRUSTED_PUBLISHING.md(the canonical, replayable runbook for this portfolio-wide effort) lays out six steps; FormCraft has
completed steps 1–4 (policy created,
NUGET_USERset, workflow migrated, PR merged green) and hascompleted neither step 5 ("a real release, then verify the publication — a merge proves
nothing") nor step 6 (delete the key).
That runbook is emphatic about the ordering, and for a reason it paid for: "Never before. Deleting
first leaves no way back if the policy is wrong." It also carries the portfolio's most expensive
lesson — an earlier note concluded eleven repos "expose nothing" based on
gh secret list, whichonly returns repo secrets; an org-scoped
NUGET_API_KEYexisted and seven repos had an armedpush. The comment was then copied into workflow files, where it served as proof to everyone. That
does not apply to FormCraft (
phmatrayis a user account — the org-secrets endpoint returns 422, sorepo scope is the whole story here) but the discipline does: verify by API, not by comment.
What makes FormCraft's shape different from Koine's, and worth thinking about rather than
copy-pasting:
./build.cmd Continuous, which runs Test → Pack → Publish. The exchanged key lives ~1 hour, soin principle a slow build could outlive its own credential.
Measured: the five most recent
continuousruns took 60–77 s. That is ~50× headroom. Not aconstraint — but it is now measured rather than assumed, and it is the reason not to restructure
the workflow to move
loginadjacent to the push.continuous.ymlfires onpushtomain/dev, onpull_request, and onv*tags. Therunbook's rule is that a tag-only workflow must fail loudly (no
if: env.NUGET_USER != ''guard), because a guard on a tag produces a green CI with an unpublished version that nobody
notices. FormCraft is the mixed case and gets this right already: the guard is on the trigger
(
startsWith(github.ref, 'refs/tags/v')), not on the secret. A fork's PR cannot obtain thisrepo's OIDC token, so gating on the tag is also what keeps fork PRs from failing. Do not
"improve" this into a
NUGET_USERguard.Approaches
A. Prerelease rehearsal tag, then the real release, then revoke. (recommended)
Tag
v3.1.1-rc.1, letcontinuous.ymlrun the whole path, and probe the nuget.org index for theresult.
IsOnVersionTag()(build/Build.cs:379) matches on^v\d+\.\d+\.\d+with no$anchor, so a prerelease tag does trigger publish — the rehearsal exercises the identical code path,
not a simulation of it.
Cost, stated plainly: it publishes a real prerelease version to nuget.org, permanently. nuget.org
delists but never deletes. The blast radius is small —
dotnet add packagewill not resolve aprerelease without
--prerelease— and--skip-duplicate(already set viaEnableSkipDuplicate()in
build/Build.cs:226) makes a re-run idempotent.B. Skip the rehearsal; let the next real release be the test.
Burns no version number. But it moves an untested, irreversible path onto a user-facing release of a
★61 package, and a partial failure (first package pushed, second not) is discovered only after the
first one is public. The whole point of step 5 in the runbook is that a merge proves nothing;
choosing B means the proof and the risk arrive together.
C. A
workflow_dispatchdry-run that performs the OIDC exchange without pushing.Cheap and publishes nothing. Two problems: it proves the login half but not the push half (which is
where the Nuke
Requires/OnlyWhenStaticchain actually lives, and that chain has never run on atag), and the OIDC token's
refclaim from adevdispatch differs from a tag ref — so a rejectionwould be ambiguous between "policy is wrong" and "this is not how the policy is scoped". A false
negative here costs more than the rc version A burns.
Recommendation
A. The cost is one prerelease version number; the return is that the first real release through
OIDC is not also the first release of OIDC. B defers a known risk onto the worst possible moment,
and C's cheapness comes from not testing the part most likely to break.
Fold in one piece of C's instinct as cheap insurance that costs nothing: a regression test on the
workflow file, so the wiring cannot be silently undone later. The repo already has this reflex —
FormCraft.UnitTests/Documentation/DocumentationSamplesTestsexists precisely so documentation"cannot silently drift away from the real public API again", and
RenderPipelineParityTestskeepsthe two render paths honest. A publish path that is exercised roughly four times a year is more
prone to silent drift than either, not less.
Assumptions
phmatray, repositoryphmatray/FormCraft, workflow filecontinuous.yml, environment empty. Task 1 re-verifies thisin the UI rather than trusting the runbook's record — nuget.org does not validate the workflow
filename, so a typo there is accepted silently and only fails at the first tag.
v3.1.1-rc.1is an acceptable version to burn. If a different rehearsal version is preferred, anyv<major>.<minor>.<patch>-<label>works identically.written as an explicit human gate, not automated.
📋 Spec
Goal
The next FormCraft release publishes both packages to nuget.org through OIDC only, proven by
observing the run and the nuget.org index — after which the long-lived
NUGET_API_KEYis deletedfrom GitHub and revoked on nuget.org.
Scope
AutoGenerateregression trap inbuild/Build.csand guard the OIDC wiring with a test.Non-goals
(
MinVerTagPrefix=v); there is no<Version>element anywhere. Changing the version driver is aseparate decision.
phmatray-vs-atypicalaccount split..github/workflows/release.yml(git-cliff + GitHub Release). It fires on the samev*tag but does not push to nuget.org, and
build/Build.cs:300-316deliberately does not chainCreateGitHubReleaseso the two cannot race for the same release.continuous.ymlto moveloginadjacent to the push — the measured 60–77 s buildmakes that unnecessary.
The path being proven
flowchart TD T["git push origin v3.1.1-rc.1"] --> W["continuous.yml<br/>on: push tags v*"] W --> G{"startsWith(github.ref,<br/>'refs/tags/v')"} G -->|false: push to dev / PR| S["login skipped<br/>NUGET_API_KEY unset"] G -->|true| L["NuGet/login@8d19675 (v1.2.0)<br/>user: secrets.NUGET_USER"] L -->|OIDC token, id-token: write| N["nuget.org<br/>Trusted Publishing policy"] N -->|short-lived key ~1h| E["env NUGET_API_KEY"] E --> B["./build.cmd Continuous"] B --> C["Continuous → Test, Pack<br/>Triggers PublishIfNeeded"] C --> P{"OnlyWhenStatic<br/>IsOnVersionTag() && IsServerBuild"} P -->|false| X["no publish"] P -->|true| Q["Publish (DependsOn)<br/>Requires NuGetApiKey"] Q --> R["DotNetNuGetPush --skip-duplicate<br/>FormCraft + FormCraft.ForMudBlazor"] R --> V["verify: api.nuget.org<br/>flatcontainer index = 200"]Key files
.github/workflows/continuous.ymlbuild/Build.cs:20-32[GitHubActions]attributeImportSecrets, addIdToken, documentAutoGenerate = falsebuild/Build.cs:213-229PublishtargetFormCraft.UnitTests/Ci/TrustedPublishingWorkflowTests.csBehaviour and validation rules
IsOnVersionTag()is^v\d+\.\d+\.\d+with no$anchor — prerelease tags publish. This iswhat makes the rehearsal a true rehearsal; it is also a live trap, since a tag like
v1.0.0-donotshipwould publish. Documented, not changed.Publishcarries.Requires(() => NuGetApiKey). On a non-tag run the key is never minted, andPublishIfNeededis skipped byOnlyWhenStatic— which is whydevpushes and PRs are greentoday. The rehearsal is the first time this evaluates with the requirement satisfied.
EnableSkipDuplicate()makes a re-run of the same version a no-op, so re-running a failedrelease job is safe.
permissions:block replaces the workflow-level one.continuous.ymldeclarespermissions only at workflow level (
contents: write,packages: write,id-token: write).Adding a job-level block without re-declaring all three would break the OIDC exchange or the
artifact upload.
Edge cases
wrong value is accepted at creation and fails only at the first tag. Task 1 checks it character
for character against the file on
dev.there. Both halves are required.
Publishglobs*.nupkg/*.snupkgfromartifacts/. Verificationmust probe both ids — a policy misconfigured for only one id would publish one and fail the
other.
~/.nuget/packages/cache: the cache can contain apackage that exists nowhere (this is exactly how
Phosphorwas mistaken for green on 2026-07-26).Assumptions
phmatray(confirmed via the flatcontainer registrationmetadata in
repo-audit/nuget_publish_audit.json).NUGET_USERholds the nuget.org profile name matching the policy's Package Owner, not therepository owner. It is a secret by convention only, not a credential.
🛠️ Implementation plan
Goal: Prove FormCraft's NuGet Trusted Publishing path end-to-end with a real published package, then delete and revoke the long-lived
NUGET_API_KEY.Architecture: Verify preconditions (read-only) → guard the wiring in code → rehearse with a prerelease tag → ship a real release → retire the key. The order is the runbook's and is not negotiable: the key is deleted last, only after a verified publication.
Tech Stack: GitHub Actions +
NuGet/loginv1.2.0 (OIDC), Nuke 10.1.0 (build/Build.cs), MinVer versioning from git tags, xUnit + Shouldly on Microsoft.Testing.Platform.Global Constraints
dev. PRs targetdev(--base dev);mainis the release branch.ci(release): <subject> (#<issue>)— this issue istype:chorebut the work is CI/release, matching ci: publish to NuGet via Trusted Publishing, not a long-lived key #173'sci:prefix.git -c user.email=phmatray@gmail.com -c user.name="Philippe Matray".dotnet test --filteris inert — these test projects run on Microsoft.Testing.Platform, which printsMTP0001and runs the whole suite anyway. Always run the full suite; never report a filtered result.TreatWarningsAsErrors=trueinDirectory.Build.propsis deliberate. Do not relax it to make a build pass.10.0.302(rollForward: latestFeature); multi-targetnet8.0;net10.0— a build error can be TFM-specific.Task 1: Verify the trusted-publishing preconditions against the live repo
Read-only gate. Everything downstream assumes these four facts; the runbook's own history shows what happens when they are assumed rather than measured.
Files: none modified. Output is a comment on this issue recording the measured state.
Interfaces: Produces the go/no-go for Task 3. If any check fails, fix it before tagging anything.
gh api "repos/phmatray/FormCraft/contents/.github/workflows/continuous.yml?ref=dev" --jq .nameExpected:
continuous.yml. Anything else (404, a different name) means the policy names a file that does not exist and the first tag will fail.Expected: secrets
["NUGET_API_KEY","NUGET_USER"], variables[]. The organization-secrets endpoint returns HTTP 422 here becausephmatrayis a user, not an org — that 422 is the evidence that repo scope is complete. Record it; do not re-derive it fromgh secret listalone.devand that no reference to the long-lived secret survives:Expected: hits for
id-token: write,NuGet/login@8d196754b4036150537f80ac539e15c2f1028841, andsecrets.NUGET_USER; zero hits forsecrets.NUGET_API_KEY. (Decode the base64 from raw JSON —gh api --jq @tsvcorrupts base64 content.)Step 4 (owner-only): On nuget.org → Account settings → Trusted Publishing, confirm the FormCraft policy reads exactly: Package owner
phmatray· Repository ownerphmatray· RepositoryFormCraft· Workflow filecontinuous.yml· Environment empty. nuget.org does not validate the workflow filename, so read it rather than assume it.Step 5: Post the four results as a comment on this issue, then proceed. If any check fails, stop and fix it first — do not tag.
Task 2: Guard the OIDC wiring so it cannot be silently regenerated away
build/Build.cs:20-32still declaresImportSecrets = ["NUGET_API_KEY"]and omitsIdTokenfromWritePermissions. Harmless only becauseAutoGenerate = false; flipping that flag regeneratescontinuous.ymland deletes the OIDC block. This task removes the stale declaration and adds a test that fails if the wiring ever disappears.Files:
FormCraft.UnitTests/Ci/TrustedPublishingWorkflowTests.csbuild/Build.cs:20-32(the[GitHubActions]attribute)Interfaces: Produces no public API.
GitHubActionsPermissions.IdTokenis confirmed present in Nuke.Common 10.1.0.FormCraft.UnitTests/Ci/TrustedPublishingWorkflowTests.cs:dotnet test FormCraft.UnitTests/FormCraft.UnitTests.csproj -c ReleaseExpected:
No_LongLived_NuGetApiKey_Secret_Should_Be_Referenced_AnywhereFAILS —build/Build.cs:30still readsImportSecrets = ["NUGET_API_KEY"],. The other three should already pass; that is intentional, they are the regression net.build/Build.cs, replace the[GitHubActions]attribute's stale lines so it reads:The
ImportSecrets = ["NUGET_API_KEY"],line is deleted: the key now arrives as a step output, not as an imported secret.Expected: build succeeds (
TreatWarningsAsErrors=true, so any unused-using warning from the edit fails here) and all four tests PASS.devand confirm CI is green before merging:gh pr create --base dev --title "ci(release): guard trusted-publishing wiring (#<this-issue>)" --fillTask 3: Rehearse the publish path with a prerelease tag
The first execution of a never-run publish path should not also be a user-facing release.
IsOnVersionTag()matches^v\d+\.\d+\.\d+with no$anchor, so a prerelease tag exercises the identical path.Files: none. This task pushes a tag and observes.
Interfaces: Consumes Task 1's verified policy and Task 2's merged guard. Produces the go/no-go for Task 4.
dev, cut and push the rehearsal tag:continuous.ymlandrelease.ymlfire on this tag; the publish lives incontinuous.Expected: the
NuGet login (OIDC -> short-lived key)step ran (not skipped);PublishIfNeeded conditions:showsIsServerBuild: TrueandIsOnVersionTag: True;Publishexecuted and pushed. IfPublishIfNeededwas skipped, the run is green and the version is unpublished — treat that as a failure.~/.nuget/packages/cache, which can hold a package that exists nowhere:Expected:
1for both ids. Indexing can lag a few minutes; re-probe before concluding failure.--skip-duplicatemakes a re-run of the same version harmless). Do not proceed to Task 5 under any circumstances.Task 4: Ship a real release through the OIDC path
Files: none.
Interfaces: Consumes Task 3's proven path. Produces the published release that gates Task 5.
dev(MinVer derives the version from it; there is no version file to edit):Step 2: Watch
continuous.ymland re-run the Step-3 log grep from Task 3, confirming the login step ran andPublishexecuted.Step 3: Verify both packages on the nuget.org index:
Expected:
1for both.release.ymlcreated the GitHub release with the git-cliff changelog, and that it did not collide with Nuke'sCreateGitHubRelease(which is deliberately not chained):gh release view v3.1.1 --json name,createdAt,body --jq '.name'Task 5: Retire the long-lived key — GitHub secret and nuget.org
⛔ Destructive and owner-only. Only after Task 4 verified a real publication. Deleting first leaves no way back if the policy is wrong.
Files: none.
Interfaces: Consumes Task 4's verified release.
Step 1: Re-confirm the gate before touching anything — Task 4 Step 3 printed
1for both package ids. If you cannot point at that output, go back.Step 2 (owner-only): Delete the GitHub repo secret:
gh secret delete NUGET_API_KEY -R phmatray/FormCraft gh api repos/phmatray/FormCraft/actions/secrets --jq '[.secrets[].name]'Expected:
["NUGET_USER"].Step 3 (owner-only): Revoke the key on nuget.org. Deleting the GitHub secret revokes nothing — the key stays valid until removed at Account settings → API keys. Delete the key that was used for FormCraft publishing.
Step 4: Record the completion in
repo-audit/TRUSTED_PUBLISHING.md, whose step 6 tracks exactly this residue across the portfolio, and note it on this issue. Then close.Task: publish policy for the third package,
FormCraft.ForFluentUIFolded in from the merge of #261 (issue #260) — an instance of this issue's cause, not a separate one.
Packnow produces a third package.Publishglobsartifacts/*.nupkg, so it will try to pushFormCraft.ForFluentUIon the next release, and a brand-new package id has no Trusted Publishingpolicy.
dotnet nuget pushreturns 403, and--skip-duplicatedoes not cover that.The failure lands in the worst position this issue already documents: release-please has created the
tag and the GitHub Release before the publish job runs, so the release exists with packages
missing.
.github/workflows/release-please.ymlnow says so in its own comment block.FormCraft.ForFluentUI, naming this repository andrelease-please.yml, before the first release that includes it — and confirm the package id can be claimed by the publishing account on its first push.