Skip to content

Prove NuGet Trusted Publishing end-to-end and retire the long-lived NUGET_API_KEY #198

Open

Description

@phmatray

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:

  1. 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).
  2. 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:

  1. Re-verify the nuget.org policy and secret scope against the live repo (read-only gate).
  2. 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.
  3. 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.
  4. Ship a real release through the same path.
  5. 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.

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 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 Owner phmatray, 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

  1. Re-verify the nuget.org policy, secret scope, and workflow filename against the live repo.
  2. Close the AutoGenerate regression trap in build/Build.cs and guard the OIDC wiring with a test.
  3. Rehearse the publish path with a prerelease tag and verify the result on the nuget.org index.
  4. Ship a real release through the same path.
  5. 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
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-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-level permissions: 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.

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 target dev (--base dev); main is the release branch.
  • Merge style: squash. PR title ci(release): <subject> (#<issue>) — this issue is type:chore but the work is CI/release, matching ci: publish to NuGet via Trusted Publishing, not a long-lived key #173's ci: prefix.
  • Commit identity: git -c user.email=phmatray@gmail.com -c user.name="Philippe Matray".
  • 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:
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 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.

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 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:
gh pr create --base dev --title "ci(release): guard trusted-publishing wiring (#<this-issue>)" --fill

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:
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.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:
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.json

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):
git -C <repo> tag v3.1.1
git -C <repo> push origin v3.1.1
  • Step 2: Watch continuous.yml and re-run the Step-3 log grep from Task 3, confirming the login step ran and Publish executed.

  • 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"'
done

Expected: 1 for both.

  • 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):
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 1 for 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 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.

Activity

  1. phmatray commented on Aug 11, 2026

    @phmatray
    OwnerAuthor

    Task 1 — preconditions verified against the live repo

    Measured 2026-08-11 against phmatray/FormCraft @ dev (3bb3bde). Three of the four checks are
    automatable 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 ✅

    $ gh api "repos/phmatray/FormCraft/contents/.github/workflows/continuous.yml?ref=dev" --jq .name
    continuous.yml

    Exact match for the filename the policy is supposed to name.

    Step 2 — secret and variable inventory ✅

    $ gh api repos/phmatray/FormCraft/actions/secrets   --jq '[.secrets[].name]'
    ["NUGET_API_KEY","NUGET_USER"]
    $ gh api repos/phmatray/FormCraft/actions/variables --jq '[.variables[].name]'
    []
    $ gh api repos/phmatray/FormCraft/actions/organization-secrets
    {"message":"Validation Failed", … ,"status":"422"}
    gh: Validation Failed (HTTP 422)

    Exactly as predicted. The 422 is the evidence, not an error to work around: phmatray is a user
    account, so no organization scope exists and repo scope is the whole inventory. NUGET_API_KEY is
    still 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 --jq is
    known to corrupt base64 content.

    18:  id-token: write # request the GitHub OIDC token for NuGet Trusted Publishing
    54:        if: startsWith(github.ref, 'refs/tags/v')
    55:        uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0
    57:          user: ${{ secrets.NUGET_USER }}
    
    $ grep -c "secrets.NUGET_API_KEY" continuous.yml
    0

    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 on NUGET_USER — correct, and now pinned
    by 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:

    Field Expected
    Package owner phmatray
    Repository owner phmatray
    Repository FormCraft
    Workflow file continuous.yml
    Environment (empty)

    nuget.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.

  2. phmatray commented on Aug 11, 2026

    @phmatray
    OwnerAuthor

    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.

    ⚠️ One thing that confirmation cannot establish, kept here so it is not rediscovered the hard way:
    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 exactly
    continuous.yml — is the first place to look, ahead of the repository/owner fields. Nothing is
    published when that step fails, so the rehearsal stays cheap even in that case.

    Task 2 — complete, in #200

    build/Build.cs lost the stale ImportSecrets = ["NUGET_API_KEY"] and gained
    GitHubActionsPermissions.IdToken, with a comment recording why AutoGenerate = false is
    load-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:

    Mutation Test that fired
    AutoGenerate = false → true BuildScript_Should_Keep_The_Continuous_Workflow_HandMaintained
    drop .OnlyWhenStatic(() => IsOnVersionTag()) BuildScript_Should_Gate_Publishing_On_A_Version_Tag
    add ${{ secrets.NUGET_API_KEY }} to release.yml No_Workflow_Should_Reference_The_LongLived_NuGetApiKey_Secret
    move the version-tag if: onto a different step Continuous_Workflow_Should_Gate_The_Login_On_A_Version_Tag
    unpin NuGet/login@<sha> → @v1 Continuous_Workflow_Should_Exchange_The_OidcToken_For_A_ShortLived_Key

    False alarms that must NOT fire — applied, suite stayed green (755 passed):

    Legitimate edit Result
    a comment mentioning secrets.NUGET_API_KEY green
    a comment mentioning ImportSecrets green
    a Renovate digest bump of NuGet/login green

    Three of the review's findings were worth the extra work, and are the reason the count went 4 → 7:

    • The original test's name lied. ..._Referenced_Anywhere read only continuous.yml and
      Build.cs. release.yml fires on the same v* tag, so a publish step added there would re-arm
      the exact credential this issue exists to retire while continuous.yml stayed spotless. The scan
      now covers every workflow file and fails if it finds none (no vacuous pass).
    • Pinning the literal SHA would have made Renovate's own PRs red. renovate.json extends
      local>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 @v1 but
      survives a legitimate bump. A guard that cries wolf on routine PRs gets deleted.
    • Prose could turn the suite red. Both files carry long comment blocks about this key, so
      ShouldNotContain("secrets.NUGET_API_KEY") would have fired on a documentation-only edit. Whole-line
      comments are now stripped before matching.

    Also fixed, found by the review and not in the plan: BUILD_RELEASE_SETUP.md:31 still instructed
    maintainers to create a NUGET_API_KEY repository secret and omitted both NUGET_USER and
    id-token: write. That is the one doc #173 missed (build/README.md was updated). Left as-is it
    would 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.

  3. phmatray commented on Aug 11, 2026

    @phmatray
    OwnerAuthor

    ⚠️ Tasks 3 and 4 are now wrong in their mechanism — do not follow them as written

    #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

    This plan says Reality on dev since #202
    publish lives in continuous.yml publish lives in release-please.yml (job nupkg)
    git tag v3.1.1-rc.1 && git push origin v3.1.1-rc.1 rehearses a hand-pushed tag now does nothing, silently
    release.yml creates the GitHub Release release.yml was deleted; release-please does it
    gate is startsWith(github.ref, 'refs/tags/v') gate is release_created == 'true' && env.NUGET_USER != ''
    id-token: write at workflow level now job-level on nupkg only (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 fire
    on: push: tags for events created by that token. A tag-triggered publish workflow could therefore
    never 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

    • Task 3 (rehearsal) — release-please.yml accepts workflow_dispatch, and its nupkg job runs
      on dispatch (|| github.event_name == 'workflow_dispatch') while the login step stays gated on
      release_created. A dispatch therefore exercises checkout → setup → build → Continuous with
      NUGET_API_KEY empty and no tag, so PublishIfNeeded's static gate skips the push. That proves
      the 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.
    • Task 4 (real release) — merge the release PR release-please keeps open against dev. That
      single 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.
    • Task 5 — unchanged, and still gated on Task 4: delete the GitHub secret and revoke the key
      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_USER now yields a green release run that publishes nothing.

    #202's context is narrower than the case that warning came from (it is &&-ed with release_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_created and not on the NUGET_USER clause — so the tests will not object if you decide
    to remove it.

  4. phmatray commented on Aug 11, 2026

    @phmatray
    OwnerAuthor

    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_KEY is 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 Closes keyword was my error: it belonged on an issue this PR fully resolved, and this one it does not.

  5. phmatray commented on Aug 11, 2026

    @phmatray
    OwnerAuthor

    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.yml
    and release.yml fire on this tag"
    . Since #197 neither is true:

    • release.yml does not exist. Publishing moved into release-please.yml's nupkg job.
    • No workflow has a tags: trigger any more, and continuous.yml invokes Pack, not
      Continuous, precisely so it can never reach PublishIfNeeded.
    • A hand-pushed tag therefore triggers nothing at all — silently. CLAUDE.md states the rule
      directly: "Never tag by hand — no workflow is triggered by on: push: tags any more, so a
      hand-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 held
    deliberately 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_KEY repo secret and revoking the key on nuget.org are irreversible, and are
    gated 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:

    Suggested re-scoping

    1. Rewrite Tasks 3–5 against release-please: the rehearsal is "merge a release PR on a prerelease
      version"
      , not "push a tag".
    2. Keep Task 4's nuget.org index probe — probing the index, never the local
      ~/.nuget/packages/ cache, since a cached package can exist where the published one does not.
    3. Run Tasks 3–5 as part of whatever release finally ships 4.0.0, rather than as standalone work.
  6. phmatray commented on Aug 12, 2026

    @phmatray
    OwnerAuthor

    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 — but
    while 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: write is a job-level permission on nupkg:

    # .github/workflows/release-please.yml
      nupkg:
        permissions:              # L100
          id-token: write         # L102

    but it is asserted against the whole file:

    WorkflowSource.Stripped("release-please.yml").ShouldContain("id-token: write");

    So moving that permission to workflow level, or onto the release-please job, keeps the test green
    while the OIDC token is unavailable in the job where NuGet/login actually runs — the release fails
    at 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 the login step:
    if Run: Continuous ever moved to a sibling job the expression silently evaluates to empty, Nuke's
    Publish gate turns off, and the run is green on a version that never reached nuget.org — the
    exact shape #226 was filed about, one level up.

    Why now: #257 added WorkflowSource.JobsOf(fileName), so scoping these is a one-line change per
    assertion — hold them against JobsOf("release-please.yml")["nupkg"] instead of the whole file.
    TestReportingTests was converted this way in #257 and its non-vacuity was verified by moving a step
    to 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.

    • Scope ReleaseWorkflow_Should_Request_The_OidcToken to the nupkg job
    • Scope the steps.login.outputs.NUGET_API_KEY assertion to the job that owns the login step
  7. phmatray commented on Aug 13, 2026

    @phmatray
    OwnerAuthor

    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_Release asserts ShouldContain("release_created") over the login step's unstripped slice, and that slice (release-please.yml 146-169) contains the token twice — once as the wiring on line 148, and once as prose on line 153 (the #221 comment block). So deleting the step's if: 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:blocked on a release rehearsal — no reason for the small fix to wait on that.

  8. phmatray commented on Aug 13, 2026

    @phmatray
    OwnerAuthor

    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's if: deleted — and separately, re-pointed at an unrelated condition — ReleaseWorkflow_Should_Gate_The_Login_On_A_Created_Release passed both times (it is satisfied by the #221 comment in its slice), but the suite still went red via ReleaseWorkflow_Should_Not_Gate_The_Login_On_The_NuGetUser_Secret, which reads the if: line alone through StepCondition.

    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).

  9. phmatray commented on Aug 13, 2026

    @phmatray
    OwnerAuthor

    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.cs point readers here for the same reason. Checking this issue's body while merging #311: it contains no mention of job-scoping. This issue created TrustedPublishingWorkflowTests (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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    priority:highShould fix soonstatus:blockedBlocked by external dependencystatus:triagedClassified and ready for analysis/worktype:choreMaintenance, dependencies, tooling

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions