The Dependabot Secret Store

Every repository has TWO secret stores, and a Dependabot-triggered workflow run can only see the second one. Settings → Secrets and variables has an Actions tab and a Dependabot tab; they share names but nothing else. A run opened by dependabot[bot] resolves secrets.X against the Dependabot store, so a secret that exists — visibly, on the Actions tab, used by every other run of the same workflow — resolves to the empty string.

The tell is one line in the run log, above the first step:

Secret source: Dependabot

This is not a bug and not a misconfiguration. GitHub puts a Dependabot pull request in the same trust class as a fork pull request: the diff was composed from a third-party registry's metadata, so the run gets a read-only GITHUB_TOKEN and no Actions secrets. The Dependabot store exists so that Dependabot's own update jobs can reach private registries; workflow runs it triggers inherit it.

Why this reads as a workflow bug and is not one

A preflight job asserts every externally-provisioned input and fails RED naming what to provision — the shape AGENTS.md requires, because a gate that cannot run must never go grey and read as a pass. On a Dependabot run it does exactly that:

##[error]Required CI inputs are not provisioned, so the heavy gate cannot run.
         A gate that cannot run fails RED — it must never go grey and read as a pass.
##[error]missing: secrets.REGISTRY_PUBLISH_TOKEN — …

Everything about that message is correct except the reader's next move. The maintainer opens the Actions tab, finds REGISTRY_PUBLISH_TOKEN sitting there, and concludes the gate is broken. The error names the right secret and, unless it says so, the wrong store.

So the first fix is textual and costs nothing: every preflight's remediation line must name the store the run actually read. Set them under Settings → Secrets and variables → Actions is actively misleading on a Dependabot run.

The fleet, measured 2026-09-06

gh api repos/Systemorph/<repo>/actions/secrets and .../dependabot/secrets return names only — never values, from any credential. Both stores, every repo that has a .github/dependabot.yml:

repo Actions Dependabot in Actions only (raw store diff)
MeshWeaver 14 0 all 14
MeshWeaver.Plugins 12 4 ACR_PUSH_PASSWORD, ACR_PUSH_USERNAME, AZURE_CLIENT_ID, AZURE_SUBSCRIPTION_ID, AZURE_TENANT_ID, MESHWEAVER_REPO_TOKEN, PLATFORM_WEBHOOK_SECRET, REGISTRY_PUBLISH_TOKEN
MeshWeaver.Reinsurance 10 4 AZURE_CLIENT_ID, AZURE_SUBSCRIPTION_ID, AZURE_TENANT_ID, MESHWEAVER_REPO_TOKEN, MW_REGISTRY_KEY, PLATFORM_WEBHOOK_SECRET
MeshWeaver.SocialMedia 11 4 the six above + REGISTRY_PUBLISH_TOKEN
MeshWeaver.Crm 9 4 AZURE_CLIENT_ID, AZURE_SUBSCRIPTION_ID, AZURE_TENANT_ID, MW_REGISTRY_KEY, PLATFORM_WEBHOOK_SECRET
MeshWeaver.Manufacturing 9 5 ACR_PASSWORD, ACR_USERNAME, MW_REGISTRY_INSTANCE_KEY, PLATFORM_WEBHOOK_SECRET
MeshWeaver.Education 9 2 AZURE_CLIENT_ID, AZURE_SUBSCRIPTION_ID, AZURE_TENANT_ID, MESHWEAVER_APP_ID, MESHWEAVER_APP_PRIVATE_KEY, MW_REGISTRY_KEY, PLATFORM_WEBHOOK_SECRET
Memex 8 0 all 8

No repository has a secret in the Dependabot store that is absent from Actions. The mirror is always a subset — it drifts one way only, which is why nobody notices it drifting.

The raw diff is not the failure set — and neither is the preflight list

Most of those secrets are consumed by push-only lanes (publish-bake, tag-modules) or by steps gated on if: inputs.publish, and are simply unused on a pull request. So the raw diff over-counts.

The first attempt at narrowing it intersected the raw diff with the set each repo's preflight asserts, and that denominator is wrong — it is the very defect this page documents, one level up. A preflight's list is hand-maintained and goes stale; a secret can be consumed by a pull-request job that no preflight ever asked about, and then the empty value surfaces deep in a later lane wearing a message that names no secret at all. Measured on MeshWeaver.Reinsurance#128 (run 33354473532): Required CI inputs passed, and compile-check died one job later with

##[error]compose-sealed-modules.sh: --registry-url needs --registry-key

— an empty secrets.MW_REGISTRY_KEY. Using the preflight list as the denominator would have scored that run as no gap.

The correct denominator is what a pull-request-reachable job actually references: every secrets.NAME in a job whose if: does not exclude a Dependabot pull request, including names the caller passes into a shared workflow_call lane. Measured that way on 2026-09-06 with check-pr-secret-preflight.py --check-stores (see below):

repo consumed on a Dependabot PR and missing from the Dependabot store status
MeshWeaver.Manufacturing ACR_USERNAME, ACR_PASSWORD, MW_REGISTRY_INSTANCE_KEY measured red — run 33357319017, Required CI inputs names all three
MeshWeaver.SocialMedia REGISTRY_PUBLISH_TOKEN, MW_REGISTRY_KEY REGISTRY_PUBLISH_TOKEN measured red (run 33354469022); MW_REGISTRY_KEY reaches compile-check and test-repos and no preflight asked for it
MeshWeaver.Reinsurance MW_REGISTRY_KEY measured red at compile-check (above). Its AZURE_* triple was mirrored on 2026-09-06
MeshWeaver.Crm MW_REGISTRY_KEY latent, not a control — same compile-check / test-repos path as Reinsurance, same absent key. Its preflight does not assert it, which is why it looked clean
MeshWeaver.Education MW_REGISTRY_KEY latent — the e2e-* jobs consume it whenever changes.outputs.mesh is true; its preflight asserts only the registry user/password
MeshWeaver.Plugins REGISTRY_PUBLISH_TOKEN latent — passed into modules-floor/modules-rest, whose publish step is if: inputs.publish (false on a PR), so it is unused today and its emptiness is not yet fatal
MeshWeaver MESHWEAVER_APP_PRIVATE_KEY latentauto-arm and merge-queue-steward both run on a Dependabot PR and both assert the App credential. dotnet-test.yml's two credentialed gates are actor-exempted instead (below)
Memex MESHWEAVER_APP_PRIVATE_KEY latent — its auto-arm calls core's shared lane with the same pair

There are no controls. Under the correct denominator every repository in the fleet has or had a gap; Crm and Education only looked clean because their preflights were the least complete, which is the failure mode inverting the signal.

Two structural facts that shape any fix

There is no Dependabot variables store. GET /repos/{owner}/{repo}/dependabot/variables answers 404; a Dependabot run reads ordinary repository variables. So vars.X is single-store and only secrets.X is doubled — which is why vars.MW_TEST_IMAGE resolves perfectly in the same run whose secrets.ACR_USERNAME is empty, and why a reader comparing the two concludes the workflow is broken. When a name has to be duplicated, check which namespace reads it.

No CI credential can read the Dependabot store. The permissions: block has no secrets or dependabot-secrets key, so GITHUB_TOKEN cannot list either store; and the only GitHub App installed on the org (meshweaver-cloud, app id 4220566) holds contents / metadata / pull_requests only. A workflow that diffs the two stores therefore cannot exist today without a new credential.

That matters less than it sounds, because a name diff is the weaker instrument anyway: no API returns a value, so a secret present with an EMPTY value is indistinguishable from a healthy one by name — and an empty value is exactly the failure mode this fleet has hit. The assertion that does catch it is the in-run one, [ -n "${X:-}" ], which tests emptiness in whichever store this event resolves against. That is why the enforced gate below is static and credential-free, and the store diff is an operator command rather than a job.

Why the answer is provisioning, not an actor exemption

The tempting fix is if: github.actor != 'dependabot[bot]' on the heavy gate. AGENTS.md permits an exemption expressed on the event and forbids one expressed as "the secret is empty", and github.actor is an event property, so the shape looks compliant. It is not, for the satellites, and the reason is worth stating precisely because core does carry that exact if:.

1 · The fork exemption states an impossibility; a Dependabot exemption states a preference. A fork PR cannot be given org secrets — GitHub withholds them and no maintainer action changes that, so skipping is the honest signal and the message ("ask a maintainer") is the only actionable one. A Dependabot run can be given them: one gh secret set --app dependabot per name. Exempting it is therefore a decision not to gate, wearing the costume of a structural limit.

2 · Core's exemption is legitimate for one reason, and the reason is a precondition, not a precedent. dotnet-test.yml says it plainly:

🚨 And this is NOT a skip-trapdoor, for a reason specific to this workflow: it also triggers on merge_group, where the credential IS available. So an exempted PR still has its shared rule blocks checked — on the queue's temporary ref, with the token minted, BEFORE the merge lands. The exemption moves WHERE the gate runs, never WHETHER it runs.

That is the test. An actor exemption is legitimate only where the same gate also runs on merge_group and the merge queue is the only path to the default branch.

Measured 2026-09-06: core meets it and nothing else does. Systemorph/MeshWeaver ruleset 2128472 carries a merge_queue rule. Every other repo in the fleet — Plugins, Reinsurance, SocialMedia, Crm, Manufacturing, Education, Memex — has only deletion, non_fast_forward, copilot_code_review and (some) pull_request / required_status_checks; no merge_queue rule in any ruleset and no merge-queue block in classic branch protection. Several satellites do carry a merge_group: trigger in ci.yml, which is the trap: the trigger exists, the event never fires. Copying core's if: into them would move the gate to an event that has no path to main.

3 · And in a satellite the skipped gate would not even look skipped. The satellites' required contexts are the gate jobs themselves (compile-check / …, test-repos / …), not a single collect-results. A required context that never appears counts as satisfied — see Reading CI Signals. The Dependabot PR would go from "red, honestly" to "green, having proven nothing".

4 · A Dependabot PR is the case where the gate matters most. Manufacturing and Crm watch exactly one ecosystem, github-actions. The diff is the CI's own plumbing — actions/download-artifact 7 → 8 in Manufacturing#40. A gate exempted from the PR that changes the gate's own machinery is the worst possible place to spend the exemption.

Conclusion: provision the missing names into the Dependabot store. For core specifically the exemption stands, because core has the queue — but core's empty Dependabot store is worth remembering as the reason those two gates were exempted rather than fixed.

The rule

A secret that any pull_request-triggered lane requires is provisioned in BOTH stores. Adding a line to a preflight's missing array is a two-store act — and for a credential whose raw value is stored NOWHERE (an mwi_ instance key, an ACR token password), "provisioned in both" means the Dependabot store gets its OWN issued credential, because mirroring one is impossible for everybody. See Three tiers below.

And every secret a pull-request-reachable job consumes is asserted by a preflight — so that a store gap is reported by NAME, in the first cheap job, instead of surfacing twenty minutes later as a script complaining about an argument. This half is enforced (below); the first half cannot be, because no CI credential can see the second store.

That invisibility is why this recurred. #2249 (2026-08) was the same defect, closed after provisioning four names into two satellites. Since then Reinsurance added MW_REGISTRY_KEY and the AZURE_* triple to its preflight, SocialMedia added REGISTRY_PUBLISH_TOKEN, and Manufacturing was created — each into Actions only, each re-opening the hole, none visible to any review or gate. #3399 is the second occurrence.

One smaller thing the audit surfaced, worth converging separately: the same credential is called MW_REGISTRY_INSTANCE_KEY in Manufacturing and MW_REGISTRY_KEY everywhere else.

The gate: every PR-reachable secret is asserted by a preflight

.github/scripts/check-pr-secret-preflight.py is the enforced half of the rule. It is static — it reads workflow files, never a store — so it needs no credential and runs on every pull request in every repository.

For each job that can run on a Dependabot pull_request, it collects every secrets.NAME the job references (including names the caller hands to a shared workflow_call lane) and requires that some pull-request-reachable job in the same repository binds that name to an env var and tests it with [ -n "${NAME:-}" ]. Deciding "can this job run on a Dependabot PR" is a three-valued evaluation of the job's if: against the facts of such a run: a condition that is provably false — github.event_name == 'push', the publish-bake shape, github.actor != 'dependabot[bot]' — takes the job out; anything depending on run-time state (needs.*, inputs.*) is UNKNOWN and the job stays IN. Unknown means reachable, because a missed job is a missed gate.

Where it runs:

An exemption is a line in the caller's .github/pr-secret-preflight-allow.txt:

REGISTRY_PUBLISH_TOKEN  # passed to modules-*, whose publish step is `if: inputs.publish` — false on a PR

A reason is mandatory (at least 12 characters), and an entry whose name is no longer referenced by any pull-request job fails the gate: a stale allow entry is how an exemption outlives the thing it exempted. The other case the allow file covers is a name asserted inside a shared lane the repo only calls — the guard cannot follow a cross-repo uses:, so it says so rather than assuming.

secrets: inherit into a reusable lane is refused outright: the guard cannot see which names the callee consumes, so completeness cannot be proven — and inherit hands the callee every secret the repo owns.

The self-test (--self-test, 21 cases) proves each check fires on its defect and stays silent on its fix, and it runs before the real tree — an unproven gate is no gate. It was falsified both ways on real trees when it was written: breaking core's two MESHWEAVER_APP_ID assertions took core from 0 violations to 1 and exit 0 to exit 1, and restoring them returned it to 0; SocialMedia's real tree reported 4 violations (5 of 9 required names asserted) and its real preflight completion took it to 0 (9 of 9).

Auditing it

Two REST reads per repo. Both return names only — a secret value is not readable through the API from any credential, which is also why mirroring cannot be automated by an agent and needs whoever holds the values.

gh api repos/Systemorph/MeshWeaver.SocialMedia/actions/secrets    --jq '.total_count, (.secrets[].name)'
gh api repos/Systemorph/MeshWeaver.SocialMedia/dependabot/secrets --jq '.total_count, (.secrets[].name)'

Read total_count before reading the list: a truncated listing that is summarised as a count is how a partial mirror reads as a complete one. Then diff against what the repo's preflight asserts:

gh api repos/Systemorph/MeshWeaver.SocialMedia/contents/.github/workflows/ci.yml --jq '.content' \
  | base64 -d | grep -n 'missing+='

And confirm the store a red run actually read, rather than inferring it:

gh api repos/Systemorph/<repo>/actions/jobs/<job-id>/logs | grep -m1 'Secret source:'

The whole audit in one command, from a checkout of the repo, with a credential that is admin on it:

python3 .github/scripts/check-pr-secret-preflight.py --root . \
        --check-stores --repo Systemorph/MeshWeaver.SocialMedia

--check-stores diffs the consumed set against the Dependabot store by name and prints the exact gh secret set line per gap. It never reads a value, and it fails loudly rather than skipping when a store cannot be listed or the listing is truncated — a store that could not be read is a FAILED audit, not a clean one. Remember what it cannot see: a name present with an empty value.

Remediating it

One command per missing name. gh secret set reads the value from stdin when --body is omitted — there is no --body-file flag:

gh secret set <NAME> --app dependabot --repo Systemorph/MeshWeaver.<Repo>

Then re-run the Dependabot PR's workflow. The positive signal is the preflight's own success line, not merely a green wall.

Where the value comes from is the whole difficulty, and it is not the same question for every name — see the three tiers below. Never echo a value into a shell, a log or a file you keep: az keyvault secret show … -o tsv > file then gh secret set … < file, and shred the file.

Three tiers, not two — and the third one nobody can mirror

The list splits three ways, not two, and the third tier is the one that made this recur:

1 · Identifiers. Recoverable from a source an operator is already entitled to read, so mirroring them needs nobody who holds a secret. Fourteen were mirrored this way on 2026-09-06:

name value established from mirrored into
AZURE_CLIENT_ID the github-actions-bake user-assigned identity — the only principal in the tenant with a federated credential for these repos (repo:Systemorph/<repo>:ref:refs/heads/main, in both the classic and the immutable subject format), holding exactly Storage File Data Privileged Contributor on the bake storage account Reinsurance, SocialMedia, Crm, Education
AZURE_TENANT_ID that identity's tenant (az account show) the same four
AZURE_SUBSCRIPTION_ID the subscription of the storage account each repo's own vars.BAKE_PUBLISH_TARGETS names the same four
MESHWEAVER_APP_ID gh api /apps/meshweaver-cloud — a public App id, and the App auto-arm.yml names by slug MeshWeaver, Memex, Education

Giving a Dependabot run the OIDC client id grants it nothing. Federation is keyed on the token's subject, and a Dependabot pull-request run's subject matches no federated credential — so azure/login still refuses. The identifier is what lets the preflight get past "this is not provisioned" to the honest answer.

2 · Secrets that are STORED somewhere an operator can read. A real credential, but Key Vault (or the running pod) holds the same bytes, so mirroring is a copy and can be proved to be one. Two of these, both verified before they were written anywhere:

name authoritative source how the copy was proved correct
MESHWEAVER_APP_PRIVATE_KEY keyvault meshweaverkeyvault/github-app-privatekey minted an RS256 App JWT with it and called GET https://api.github.com/app — the answer was id 4220566 / meshweaver-cloud, the App auto-arm.yml names
REGISTRY_PUBLISH_TOKEN keyvault Systemorph/memexcloud-Plugins-Registry-PublishToken sha256 of the vault value equals sha256 of the RUNNING registry pod's Plugins__Registry__PublishToken (printenv inside a kubectl exec, piped through sha256sum) — a byte-identity proof that prints no value

🚨 Pick the vault object by the DEPLOYMENT, never by the secret's bare name. Both portals keep a publish token and an instance key, and the prefix is the only thing that tells them apart: memex.meshweaver.cloud is the memex-cloud deployment (the memex deployment serves memex.systemorph.com), so the registry every satellite's vars.MW_REGISTRY_URL names is fed by the memexcloud--prefixed objects. The unprefixed Systemorph/Plugins-Registry-PublishToken is a different, equally valid-looking token for the other portal — measured 2026-09-07, its hash does not match the running registry, so copying it would have installed a credential that authenticates against nothing and fails deep in the module lane.

3 · Secrets that exist NOWHERE but inside the Actions store — mirroring them is impossible, for everyone. This is the tier the earlier revision of this page got wrong by saying the values "need someone who holds them". For these, nobody holds them:

For tier 3 the remediation is not a copy. Issue the Dependabot lane its OWN credential.

Tier 3: the Dependabot lane gets its own credential, and that is better

A second credential is not a workaround for an unreadable first one — it is the shape this should have had all along:

🚨 The one thing to get right is that the new credential grants the SAME thing and no more. Register the instance, do not hand-write its grant: registration seeds Admin/_PluginGrant/<id> from PluginCatalog:DefaultGrants (Plugins/*), which is what the gate reads. For ACR, create the new token on the existing per-repo scope map rather than a new one, so the permission set is identical by construction. And verify the credential before storing itGET /api/plugins must answer 200 for an instance key, and GET /v2/<repo>/manifests/<tag> must answer 200 for an ACR token. A credential written into a store unverified is the failure mode this whole page is about, one level down.

The 2026-09-07 provisioning — the fleet at zero

Every repository measured 0 missing afterwards with check-pr-secret-preflight.py --check-stores (before: 12 gaps across 7 repositories):

repo provisioned into the Dependabot store tier source
MeshWeaver MESHWEAVER_APP_PRIVATE_KEY 2 keyvault, JWT-verified
MeshWeaver.Plugins REGISTRY_PUBLISH_TOKEN 2 keyvault, hash-verified vs the running registry
MeshWeaver.SocialMedia REGISTRY_PUBLISH_TOKEN, MW_REGISTRY_KEY 2, 3 keyvault; new instance dependabot-socialmedia
MeshWeaver.Reinsurance MW_REGISTRY_KEY 3 new instance dependabot-reinsurance
MeshWeaver.Crm MW_REGISTRY_KEY 3 new instance dependabot-crm
MeshWeaver.Education MESHWEAVER_APP_ID, MESHWEAVER_APP_PRIVATE_KEY, MW_REGISTRY_KEY 1, 2, 3 public id; keyvault; new instance dependabot-education
MeshWeaver.Manufacturing ACR_USERNAME, ACR_PASSWORD, MW_REGISTRY_INSTANCE_KEY 3 new ACR token manufacturing-dependabot-pull on the existing manufacturing-ci-pull-scope-map; new instance dependabot-manufacturing
Memex MESHWEAVER_APP_PRIVATE_KEY 2 keyvault, JWT-verified

The five dependabot-* registry instances were registered through POST /api/instances/register under one 15-minute mwr_ bootstrap key, which was revoked immediately afterwards (RegistrationKeyService.SetRevoked) rather than left to expire. Each instance key was verified against GET /api/plugins (200, 45 packages) before it was written to a store, and the ACR token against GET /v2/mw-plugin-test/manifests/latest (200, a two-arch manifest list).

What is still open after this: MeshWeaver.Plugins has three static violations — auto-arm.yml consumes MESHWEAVER_APP_ID / MESHWEAVER_APP_PRIVATE_KEY and ci.yml's modules-* jobs consume REGISTRY_PUBLISH_TOKEN, and no preflight in that repo asserts any of them. Its store is complete, so nothing is red today; the gate will say so the moment that repo's platform pin moves past the commit that introduced it. That is the enforced half of the rule doing its job, and the fix is three preflight assertions (or three reasoned allow-file lines) in MeshWeaver.Plugins.

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.