Repository navigation
Prove NuGet Trusted Publishing end-to-end and retire the long-lived NUGET_API_KEY #198
Description
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 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.
Measured on 2026-08-11:
| Fact | Value | How it was measured |
|---|---|---|
| Last version tag | v3.1.0, 2026-06-10 |
git log -1 --format=%ci v3.1.0 |
| Trusted Publishing merged | 2026-07-27T18:48Z (#173) | gh pr view 173 --json mergedAt |
| Tag-triggered workflow runs since | none | 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
Publishchain 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_KEYin 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
AutoGeneratetrap inbuild/Build.csand add a regression test that fails if the
OIDC wiring is ever removed fromcontinuous.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_KEYrepo 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_dispatchdry-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.
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_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 recentcontinuousruns 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 moveloginadjacent to the push. continuous.ymlfires onpushtomain/dev, onpull_request, and onv*tags. The
runbook's rule is that a tag-only workflow must fail loudly (noif: 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 aNUGET_USERguard.
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 Owner
phmatray, repository
phmatray/FormCraft, workflow filecontinuous.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.1is 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
AutoGenerateregression trap inbuild/Build.csand 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-atypicalaccount split. - Touching
.github/workflows/release.yml(git-cliff + GitHub Release). It fires on the samev*
tag but does not push to nuget.org, andbuild/Build.cs:300-316deliberately does not chain
CreateGitHubReleaseso the two cannot race for the same release. - Restructuring
continuous.ymlto moveloginadjacent 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"]
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 |
build/Build.cs:213-229 |
Publish target |
none — read to confirm behaviour |
FormCraft.UnitTests/Ci/TrustedPublishingWorkflowTests.cs |
new guard test | create |
Behaviour and validation rules
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-donotshipwould publish. Documented, not changed.Publishcarries.Requires(() => NuGetApiKey). On a non-tag run the key is never minted, and
PublishIfNeededis skipped byOnlyWhenStatic— which is whydevpushes 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-level
permissions:block replaces the workflow-level one.continuous.ymldeclares
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 ondev. - Deleting the GitHub secret revokes nothing. The key remains valid on nuget.org until revoked
there. Both halves are required. - Two packages, one push.
Publishglobs*.nupkg/*.snupkgfromartifacts/. 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 howPhosphorwas mistaken for green on 2026-07-26).
Assumptions
- Package owner on nuget.org for both ids is
phmatray(confirmed via the flatcontainer registration
metadata inrepo-audit/nuget_publish_audit.json). NUGET_USERholds 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.
Tech Stack: GitHub Actions + NuGet/login v1.2.0 (OIDC), Nuke 10.1.0 (build/Build.cs), MinVer versioning from git tags, xUnit + Shouldly on Microsoft.Testing.Platform.
Global Constraints
- Default branch is
dev. PRs targetdev(--base dev);mainis the release branch. - Merge style: squash. PR title
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. - Commit identity:
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.- SDK pinned to
10.0.302(rollForward: latestFeature); multi-targetnet8.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 .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.
- 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
devand that no reference to the long-lived secret survives:
gh api "repos/phmatray/FormCraft/contents/.github/workflows/continuous.yml?ref=dev" --jq .content \
| base64 -d | grep -nE "id-token: write|NuGet/login@|secrets.NUGET_USER|secrets.NUGET_API_KEY"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 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-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.
Files:
- Create:
FormCraft.UnitTests/Ci/TrustedPublishingWorkflowTests.cs - 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:
using System.IO;
namespace FormCraft.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>
public class TrustedPublishingWorkflowTests
{
private static string RepoRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is 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");
return dir!.FullName;
}
private static string ReadWorkflow() =>
File.ReadAllText(Path.Combine(RepoRoot(), ".github", "workflows", "continuous.yml"));
private static string ReadBuildScript() =>
File.ReadAllText(Path.Combine(RepoRoot(), "build", "Build.cs"));
[Fact]
public void Continuous_Workflow_Should_Request_The_OidcToken()
{
ReadWorkflow().ShouldContain("id-token: write");
}
[Fact]
public void Continuous_Workflow_Should_Exchange_The_OidcToken_For_A_ShortLived_Key()
{
var workflow = 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]
public void Continuous_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]
public void No_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 ReleaseExpected: 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 ReleaseExpected: 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
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.
- Step 1 (owner-only): From an up-to-date
dev, cut and push the rehearsal tag:
git -C <repo> checkout dev && git -C <repo> pull --ff-only
git -C <repo> tag v3.1.1-rc.1
git -C <repo> push origin v3.1.1-rc.1- Step 2: Watch the run. Both
continuous.ymlandrelease.ymlfire on this tag; the publish lives incontinuous.
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:
gh run view <run-id> --log | grep -nE "NuGet login|PublishIfNeeded conditions|IsOnVersionTag|Pushing|Publish|error"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:
for id in formcraft formcraft.formudblazor; do
printf '%s ' "$id"
curl -sS "https://api.nuget.org/v3-flatcontainer/$id/index.json" | grep -c '3.1.1-rc.1'
done
# control probe — must print 200:
curl -sS -o /dev/null -w '%{http_code}\n' https://api.nuget.org/v3-flatcontainer/newtonsoft.json/index.jsonExpected: 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-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.
- Step 1 (owner-only): Cut the real tag from
dev(MinVer derives the version from it; there is no version file to edit):
git -C <repo> tag v3.1.1
git -C <repo> push origin v3.1.1-
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:
for id in formcraft formcraft.formudblazor; do
printf '%s ' "$id"
curl -sS "https://api.nuget.org/v3-flatcontainer/$id/index.json" | grep -c '"3.1.1"'
doneExpected: 1 for both.
- Step 4: Confirm
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.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 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.
Activity
phmatray commentedon Aug 11, 2026
Task 1 — preconditions verified against the live repo
Measured 2026-08-11 against
phmatray/FormCraft@dev(3bb3bde). Three of the four checks areautomatable and were run; Step 4 is owner-only and is handed back below.
Step 1 — the workflow file named by the policy exists on
dev✅Exact match for the filename the policy is supposed to name.
Step 2 — secret and variable inventory ✅
Exactly as predicted. The 422 is the evidence, not an error to work around:
phmatrayis a useraccount, so no organization scope exists and repo scope is the whole inventory.
NUGET_API_KEYisstill present — inert, but still a long-lived push credential, which is what Task 5 exists to retire.
Step 3 — OIDC wiring present, no long-lived secret reference ✅
Read via
-H "Accept: application/vnd.github.raw"rather than decoding base64 —gh api --jqisknown to corrupt base64 content.
All three expected hits present; zero references to the long-lived secret. The gate is on the
trigger (
startsWith(github.ref, 'refs/tags/v')), not onNUGET_USER— correct, and now pinnedby a test so it stays that way.
Step 4 — nuget.org policy fields 🔴 owner-only, NOT verified
I cannot read the nuget.org UI. This remains open and is the one thing standing between here and a
safe rehearsal tag. Please confirm at Account settings → Trusted Publishing that the FormCraft
policy reads exactly:
phmatrayphmatrayFormCraftcontinuous.ymlnuget.org does not validate the workflow filename — a typo is accepted silently at creation and
fails only at the first tag, which is precisely the failure mode the rehearsal is meant to catch
early. Read it character for character rather than assuming.
Verdict
3 of 4 green; go/no-go for Task 3 is gated on Step 4. Nothing in the automatable half contradicts
the issue's premise: the wiring is correct and the long-lived key is still live.
phmatray commentedon Aug 11, 2026
Task 1 Step 4 confirmed · Task 2 shipped in #200
Task 1 Step 4 — nuget.org policy ✅ (owner-confirmed)
Philippe confirmed the Trusted Publishing policy is in place on nuget.org. That was the last open
gate on Task 1, so Task 1 is complete and Task 3 is unblocked once #200 merges.
nuget.org stores the workflow filename as free text and never validates it. If the rehearsal tag
fails at the
NuGet login (OIDC -> short-lived key)step, that field — which must read exactlycontinuous.yml— is the first place to look, ahead of the repository/owner fields. Nothing ispublished when that step fails, so the rehearsal stays cheap even in that case.
Task 2 — complete, in #200
build/Build.cslost the staleImportSecrets = ["NUGET_API_KEY"]and gainedGitHubActionsPermissions.IdToken, with a comment recording whyAutoGenerate = falseisload-bearing. The guard test landed as
FormCraft.UnitTests/Ci/TrustedPublishingWorkflowTests.cs.The plan asked for four tests; the PR carries seven, after a code-review pass found the original
four could be defeated three ways and falsely tripped two others. The guard is now verified in both
directions rather than merely observed passing:
Real regressions — each mutation was applied, the suite run, and the named test fired:
AutoGenerate = false→trueBuildScript_Should_Keep_The_Continuous_Workflow_HandMaintained.OnlyWhenStatic(() => IsOnVersionTag())BuildScript_Should_Gate_Publishing_On_A_Version_Tag${{ secrets.NUGET_API_KEY }}torelease.ymlNo_Workflow_Should_Reference_The_LongLived_NuGetApiKey_Secretif:onto a different stepContinuous_Workflow_Should_Gate_The_Login_On_A_Version_TagNuGet/login@<sha>→@v1Continuous_Workflow_Should_Exchange_The_OidcToken_For_A_ShortLived_KeyFalse alarms that must NOT fire — applied, suite stayed green (755 passed):
secrets.NUGET_API_KEYImportSecretsNuGet/loginThree of the review's findings were worth the extra work, and are the reason the count went 4 → 7:
..._Referenced_Anywhereread onlycontinuous.ymlandBuild.cs.release.ymlfires on the samev*tag, so a publish step added there would re-armthe exact credential this issue exists to retire while
continuous.ymlstayed spotless. The scannow covers every workflow file and fails if it finds none (no vacuous pass).
renovate.jsonextendslocal>phmatray/.github:renovate-ci, which pins action digests — so digest bumps are routine here.The assertion is now the shape (
NuGet/login@[0-9a-f]{40}), which still rejects@v1butsurvives a legitimate bump. A guard that cries wolf on routine PRs gets deleted.
ShouldNotContain("secrets.NUGET_API_KEY")would have fired on a documentation-only edit. Whole-linecomments are now stripped before matching.
Also fixed, found by the review and not in the plan:
BUILD_RELEASE_SETUP.md:31still instructedmaintainers to create a
NUGET_API_KEYrepository secret and omitted bothNUGET_USERandid-token: write. That is the one doc #173 missed (build/README.mdwas updated). Left as-is itwould have told someone to re-create the credential Task 5 deletes.
Deliberately not done
Tasks 3, 4 and 5 are owner-only by this plan's Global Constraints. They push tags that publish
permanently to nuget.org and then delete and revoke a live credential — irreversible, outward-facing
acts that are the owner's to make. #200 is the prerequisite; the handoff is in its description.
phmatray commentedon Aug 11, 2026
#202 (issue #197) landed while #200 was open and
moved the entire publish path to release-please. The
goal of Tasks 3–5 is unchanged; the commands are not.
What changed
devsince #202continuous.ymlrelease-please.yml(jobnupkg)git tag v3.1.1-rc.1 && git push origin v3.1.1-rc.1rehearsesrelease.ymlcreates the GitHub Releaserelease.ymlwas deleted; release-please does itstartsWith(github.ref, 'refs/tags/v')release_created == 'true' && env.NUGET_USER != ''id-token: writeat workflow levelnupkgonly (narrower — this was a #200 follow-up, and #202 fixed it)Why hand-tagging cannot work any more, and this is a hard constraint rather than a preference:
release-please creates the tag using
GITHUB_TOKEN, and GitHub deliberately does not fireon: push: tagsfor events created by that token. A tag-triggered publish workflow could thereforenever run again — which is why #202 removed the trigger rather than keeping both paths.
The consequence for this plan is sharp: Task 3 Step 1 as written produces a tag, no workflow run,
no package, and no error. That is the precise failure mode Task 3 exists to catch, arriving from
the rehearsal itself.
Revised Tasks 3 and 4
release-please.ymlacceptsworkflow_dispatch, and itsnupkgjob runson dispatch (
|| github.event_name == 'workflow_dispatch') while the login step stays gated onrelease_created. A dispatch therefore exercises checkout → setup → build →ContinuouswithNUGET_API_KEYempty and no tag, soPublishIfNeeded's static gate skips the push. That provesthe plumbing but not the OIDC exchange or the push — the two things Prove NuGet Trusted Publishing end-to-end and retire the long-lived NUGET_API_KEY #198 most wants proven.
This is the trade-off the original plan avoided by choosing a prerelease tag over approach C;
read that section before choosing, because the reasoning still applies even though the mechanism
it recommended no longer exists.
dev. Thatsingle action produces the version, changelog, tag, GitHub Release and both packages in one run.
Verification is unchanged and is still the important half: probe the nuget.org index for
both ids (
formcraft,formcraft.formudblazor) — never the local~/.nuget/packages/cache,which can hold a package that exists nowhere.
on nuget.org. Deleting the secret revokes nothing.
One thing to revisit, not to assert
#202's login gate is
needs.release-please.outputs.release_created == 'true' && env.NUGET_USER != ''.This issue's own Brainstorm warned against exactly that second clause — "a guard on a secret produces
a green CI with an unpublished version that nobody notices" — because a missing or renamed
NUGET_USERnow yields a green release run that publishes nothing.#202's context is narrower than the case that warning came from (it is
&&-ed withrelease_created,inside a release-only workflow rather than a mixed one), so this is a judgement call for the owner
rather than a defect. It is recorded here because #200's guard test deliberately asserts on
release_createdand not on theNUGET_USERclause — so the tests will not object if you decideto remove it.
ci(release): guard trusted-publishing wiring against silent regenerat…
phmatray commentedon Aug 11, 2026
Reopening: #200 merged and auto-closed this via its
Closes #198, but that only landed Tasks 1–2 (verification + the regression guard).Tasks 3, 4 and 5 are still outstanding — and they are the point of this issue: the OIDC publish path has still never executed, and the long-lived
NUGET_API_KEYis still live on both GitHub and nuget.org.They are owner-only, and their mechanism changed under #202 — hand-pushing a version tag now does nothing silently. See the correction comment above before following the plan's Task 3/4 steps as written.
The
Closeskeyword was my error: it belonged on an issue this PR fully resolved, and this one it does not.phmatray commentedon Aug 11, 2026
Not actionable by an agent as written — and the remaining plan is stale
Tasks 1 and 2 are complete (11/24 steps). Everything left is blocked, for three separate reasons.
Flagging rather than half-executing, because every remaining step is either irreversible or
owner-only.
1. The remaining plan describes a workflow topology that no longer exists
Task 3 Step 1 says to "cut and push the rehearsal tag", and Steps 2/4 say "both
continuous.ymland
release.ymlfire on this tag". Since #197 neither is true:release.ymldoes not exist. Publishing moved intorelease-please.yml'snupkgjob.tags:trigger any more, andcontinuous.ymlinvokesPack, notContinuous, precisely so it can never reachPublishIfNeeded.CLAUDE.mdstates the ruledirectly: "Never tag by hand — no workflow is triggered by
on: push: tagsany more, so ahand-pushed tag silently produces nothing."
So Task 3 as written would produce a tag, no run, and no packages, and its Step 4 nuget.org probe
would report absence — which reads as a failed rehearsal when nothing was ever attempted. The
rehearsal has to be re-specified against release-please before it can be run.
2. Tasks 3 and 4 require a real publish, and this repo's release is deliberately held
Both tasks are "(owner-only)" by their own text, and Task 4 ships a real version to nuget.org.
nuget.org delists but never deletes, so a mistaken id or version is permanent. release-please
currently has release PR #220 (
chore(dev): release 4.0.0) open, and that release is being helddeliberately pending further work — merging it is the only way to exercise this path, and it is
not mine to trigger.
3. Task 5 is destructive and needs explicit sign-off
Deleting the
NUGET_API_KEYrepo secret and revoking the key on nuget.org are irreversible, and aregated on Task 4 having proven the OIDC path end-to-end. Doing them before that proof would leave the
repo unable to publish by either mechanism.
What can be done now, and what I did instead
Nothing in Tasks 3–5 is safely automatable. Two adjacent things were shipped this session and are
worth linking here:
NUGET_USER, so a release thatpublishes nothing can no longer be green. That closes one of the silent-failure modes this issue
was guarding against, without touching the OIDC mechanism.
issue's Task 4, which already owns end-to-end publish verification.
Suggested re-scoping
version", not "push a tag".
~/.nuget/packages/cache, since a cached package can exist where the published one does not.phmatray commentedon Aug 12, 2026
Follow-up from #257: the OIDC regression test this issue plans is currently job-blind
Step 2 of the Proposed solution above commits to "a regression test that fails if the OIDC wiring is
ever removed". That test exists —
FormCraft.UnitTests/Ci/TrustedPublishingWorkflowTests.cs— butwhile job-scoping the sibling guard suite in #255 / #257 it became clear that it cannot currently
catch a misplaced
id-token, which is one of the failure modes this issue exists to rule out.id-token: writeis a job-level permission onnupkg:but it is asserted against the whole file:
So moving that permission to workflow level, or onto the
release-pleasejob, keeps the test greenwhile the OIDC token is unavailable in the job where
NuGet/loginactually runs — the release failsat the one moment this issue is trying to de-risk. The same applies to
steps.login.outputs.NUGET_API_KEY, which only resolves inside the job that owns theloginstep:if
Run: Continuousever moved to a sibling job the expression silently evaluates to empty, Nuke'sPublishgate turns off, and the run is green on a version that never reached nuget.org — theexact shape #226 was filed about, one level up.
Why now: #257 added
WorkflowSource.JobsOf(fileName), so scoping these is a one-line change perassertion — hold them against
JobsOf("release-please.yml")["nupkg"]instead of the whole file.TestReportingTestswas converted this way in #257 and its non-vacuity was verified by moving a stepto a second job and watching the suite turn red.
This was deliberately left out of #257, whose plan is explicit that it changes nothing about what
the publishing suite asserts. Recording it here rather than as its own issue because the guard is
only worth strengthening as part of proving this path end-to-end, which is what this issue owns.
ReleaseWorkflow_Should_Request_The_OidcTokento thenupkgjobsteps.login.outputs.NUGET_API_KEYassertion to the job that owns theloginstepphmatray commentedon Aug 13, 2026
Heads-up from merging #287 (which implemented #267), relevant to this issue's step 2 ("add a regression test that fails if the OIDC wiring is ever removed"): one of the guards you'd be building on is currently inert.
TrustedPublishingWorkflowTests.ReleaseWorkflow_Should_Gate_The_Login_On_A_Created_ReleaseassertsShouldContain("release_created")over the login step's unstripped slice, and that slice (release-please.yml146-169) contains the token twice — once as the wiring on line 148, and once as prose on line 153 (the#221comment block). So deleting the step'sif:outright leaves the test green, which is precisely the #226 failure mode this issue cares about: a green run on a version that never reached nuget.org.Verified by extracting the real slice. Pre-existing — not introduced by #287 — and now filed with a fix plan as #302 (
type:bug,priority:high). Worth landing #302 before relying on that guard as part of the rehearsal here.Not folding it into this issue: #302's fix is a one-line change to how a test helper reads the file, and this issue is
status:blockedon a release rehearsal — no reason for the small fix to wait on that.phmatray commentedon Aug 13, 2026
Correcting my earlier comment here: I said the login gate is inert in a way that risks "a green run on a version that never reached nuget.org". The inert test is real; that consequence is not.
Measured against the real
release-please.yml: with the login step'sif:deleted — and separately, re-pointed at an unrelated condition —ReleaseWorkflow_Should_Gate_The_Login_On_A_Created_Releasepassed both times (it is satisfied by the #221 comment in its slice), but the suite still went red viaReleaseWorkflow_Should_Not_Gate_The_Login_On_The_NuGetUser_Secret, which reads theif:line alone throughStepCondition.So the gate is covered today — you can rely on it for this issue's rehearsal. What #302 actually fixes is that the coverage is incidental: it lives in a test named for the NUGET_USER question, so narrowing that test to match its name would silently drop it. #302 is now
priority:medium, and it is not a blocker for the rehearsal here.Full measurement in #302 (comment).
phmatray commentedon Aug 13, 2026
One more correction from the #302/#311 work, and this one is about this issue's scope rather than its content.
#267 and #302 both carry a Related line saying "#198 owns the sibling job-scoping follow-up", and two remarks in
WorkflowSource.cspoint readers here for the same reason. Checking this issue's body while merging #311: it contains no mention of job-scoping. This issue createdTrustedPublishingWorkflowTests(plan Step 1, ticked) and owns the Trusted Publishing rehearsal — it never took on scoping that suite to jobs.So that work had been deferred three times to an issue that never accepted it. It is now tracked properly as #332, which also repoints the two code cross-references. Nothing is being added to this issue's scope — this comment exists so the next reader does not conclude the job-scoping is covered here.
Unchanged from my earlier correction: the login gate is covered today, so this is not a blocker for the rehearsal.
test(ci): scope trusted-publishing guards to the job (#332) (#364)