The credential model - a contained build token
The build token that authenticates to your private feed is made structurally incapable of reaching a public source, rather than merely configured not to. This is the load-bearing design decision to prevent as much as possible, accidentally sending the wrong credentials to the wrong feed.
The mechanism
Two templates run in the same job:
-
registerModusOpsFeedssets up a per-run SecretStore vault, stores the build token in it, registers the package sources, and binds each internal feed to the token viaCredentialInfo:$credInfo = [Microsoft.PowerShell.PSResourceGet.UtilClasses.PSCredentialInfo]::new($vaultName, $secretName) Register-PSResourceRepository -Name $feed.name -Uri $repoUrl -Trusted ` -CredentialProvider None -CredentialInfo $credInfo -
installModusOpsModulesinstalls the modules with no-Credentialargument at all. Each feed already knows how to authenticate (its boundCredentialInfo); public sources carry no binding, so the token cannot travel to them.
The two flags that make this work:
-CredentialInfobinds the vault secret to that feed only. The token is reachable when resolving that feed and nowhere else.-CredentialProvider Nonedisables the auto-engaged Azure Artifacts credential provider, so resolution falls through to the per-feedCredentialInfoinstead of a global, ambient credential.
The result: an install that passes no token can still authenticate to internal feeds, while the token has no path to a public feed. Public sources (PSGallery) are typically unregistered entirely once the bootstrap is done; Microsoft Artifact Registry (MAR) is used for trusted Microsoft modules and needs no token.
Why “structurally”, not “by configuration”
A configuration that omits a public feed can be edited to add one back. Here, the token lives in a vault and is bound to specific feeds; there is no ambient credential for a public resolution to pick up. Adding a public feed does not grant it the token. The containment is a property of how the credential is wired, not of a setting someone must remember to keep.
How it ports across platforms
The pattern is identical on both planes; only the inputs differ:
| Azure DevOps | GitHub Actions | |
|---|---|---|
| Token source | $(System.AccessToken) | GITHUB_TOKEN or a read:packages PAT |
| Feed | Azure Artifacts NuGet v3 | GitHub Packages (nuget.pkg.github.com/OWNER) |
| Same-job requirement | steps in one job | a composite action (not a reusable workflow) |
| Trusted Microsoft modules | MAR (no token) | MAR (no token) |
The vault + CredentialInfo + -CredentialProvider None core is unchanged. See Templates for how the two planes package these steps.
Secrets on the runner
Be clear-eyed about this: the vault is a real, file-based store, so while the job runs the build token is written - encrypted - to the runner’s disk for PSResourceGet to read back when resolving a feed. The token is briefly materialised on the machine; it is not a pure in-memory secret. Two things keep that acceptable:
- Teardown. The install template’s
always()step wipes the SecretStore and unregisters the sources, so the material is removed even if an earlier step failed. - Ephemerality. On hosted runners (GitHub-hosted
ubuntu-latest, Microsoft-hosted agents) the whole machine is discarded after the job, so nothing persists regardless of teardown.
Self-hosted / non-ephemeral runners. A self-hosted runner persists between jobs, so the on-disk store is only as safe as the teardown that removes it. Prefer ephemeral self-hosted runners (provisioned per job, destroyed after). If you must run a persistent one, make sure the
always()teardown runs - and treat a runner where it didn’t as a potential credential exposure.
Trusting the modules themselves
- Code signing is declined for now. Installing only from a private feed you control - plus trusted Microsoft modules from MAR, which need no token - constrains provenance to sources you already trust, which does much of what signing would. The residual risk is accepted, not closed with signatures; see the security-prominent stance.