wads.ci_secrets¶
Canonical registry of CI secret names for wads-managed projects.
This module is the single source of truth for the transport layer of wads
CI secrets: what the reusable workflow (uv-ci.yml) declares in
on.workflow_call.secrets and what the caller stub passes.
Transport: named by default, one JSON secret on request¶
A GitHub reusable workflow’s secret interface (on.workflow_call.secrets)
must be static YAML — it is parsed before any job runs and cannot be
parametrized from pyproject.toml. secrets: inherit is documented to
work only when caller and callee share an org/enterprise, so it is unusable
for personal-account repos calling an i2mint-owned workflow.
Named (the default). The workflow declares a superset of optional
secret names (DEFAULT_CI_SECRETS), and each repo’s stub passes, by
name, only PYPI_PASSWORD plus the backing secret of each env var its
[tool.wads.ci.env] declares:
secrets:
PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
The called workflow receives those secrets and nothing else, each masked by
GitHub on its own. The bundled stub template is named; populate and every
re-render (wads-migrate ci-to-stub, ci-on-demand, fleet-stub)
write a named stub, converting a JSON one, unless --transport json is
asked for.
A name outside the superset makes GitHub reject the workflow at parse time
with an opaque startup_failure (issue #63). The superset therefore grows
additively: adding a required: false name never breaks an existing
stub (removing one can). wads-secrets add and wads-migrate warn about
out-of-superset names, and a re-render with no explicit transport falls back
to JSON for such a repo rather than write a stub that cannot start.
JSON (opt-in). wads-migrate ci-to-stub --transport json passes one
statically-declared secret, JSON_TRANSPORT_SECRET
(WADS_CI_SECRETS_JSON), whose value is the caller’s whole secrets
context:
secrets:
WADS_CI_SECRETS_JSON: ${{ toJSON(toJSON(secrets)) }}
Any secret name then reaches the workflow — but so does every secret the repo
can read, org-level ones included, and serialising the whole secrets
context into a workflow in another repository is structurally what a
secret-exfiltration workflow looks like. GitHub’s malicious-workflow scanner
holds such runs (action_required, zero jobs, no log, and the REST approve
endpoint refuses them; issues #74, #88, #101). It was seen first on new
repositories and later on an existing one; switching to named made the next
push run every time. Two details of the JSON form, verified empirically
(issue #63):
Double encoding (
toJSON(toJSON(...))) makes the transported value a single-line JSON string. A multiline secret is masked per line, and the pretty-printed form’s{/}lines would become global masks that mangle every brace in the job log.Re-masking: values extracted from the blob are not automatically masked in the callee, so the
export-ci-envaction emits::add-mask::for each secret-sourced value before exporting it.
So there are two layers:
Transport — named (default) or the JSON secret (opt-in), rendered into static YAML. Plumbing.
Env-assignment — pyproject-driven, exact, per-repo: which transported secrets land in the job env, and which are required (see
wads.ci_configand theexport-ci-envaction). Nothing is exportedunless declared there. The thing users tune.
Keeping the names here (Python) and rendering them into the YAML (with a test pinning the YAML to this module) gives a single SSOT while respecting GitHub’s parse-time-literal constraint.
Module Attributes
Ordered, de-duplicated superset of names a named-transport stub may pass (additive). |
|
the opt-in JSON transport secret + the superset. |
Functions
|
Return |
|
Force |
|
Render the caller stub's JSON-transport |
|
Render a caller stub's named |
|
Render the |
|
The secrets transport of a stub's content: |
|
Rewrite a stub's secrets region to the named transport, passing |
|
Rewrite a stub's secrets region to the given transport. |
|
Warn loudly for names a named-transport stub cannot legally pass. |
Exceptions
Raised when a string cannot be a safe, valid secret/env-var name. |
- wads.ci_secrets.DEFAULT_CI_SECRETS = ('PYPI_PASSWORD', 'TEST_PYPI_PASSWORD', 'NPM_TOKEN', 'SSH_PRIVATE_KEY', 'CODECOV_TOKEN', 'DOCKERHUB_USERNAME', 'DOCKERHUB_TOKEN', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'GEMINI_API_KEY', 'GOOGLE_API_KEY', 'DEEPSEEK_API_KEY', 'OPENROUTER_API_KEY', 'GROQ_API_KEY', 'MISTRAL_API_KEY', 'COHERE_API_KEY', 'PERPLEXITY_API_KEY', 'REPLICATE_API_TOKEN', 'TOGETHER_API_KEY', 'XAI_API_KEY', 'FAL_KEY', 'AZURE_OPENAI_API_KEY', 'AZURE_OPENAI_ENDPOINT', 'TAVILY_API_KEY', 'COMPOSIO_API_KEY', 'SKILLSDIRECTORY_API_KEY', 'NEWSDATA_API_KEY', 'LANGSMITH_API_KEY', 'HF_TOKEN', 'HF_WRITE_TOKEN', 'HUGGINGFACE_TOKEN', 'KAGGLE_USERNAME', 'KAGGLE_KEY', 'WANDB_API_KEY', 'PINECONE_API_KEY', 'ELEVEN_API_KEY', 'ELEVENLABS_API_KEY', 'SUNO_API_KEY', 'SPOTIFY_API_CLIENT_ID', 'SPOTIFY_API_CLIENT_SECRET', 'SPOTIPY_CLIENT_ID', 'SPOTIPY_CLIENT_SECRET', 'ALPACA_API_KEY', 'ALPACA_SECRET_KEY', 'APCA_API_KEY_ID', 'APCA_API_SECRET_KEY', 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN', 'GCP_SA_KEY', 'GOOGLE_APPLICATION_CREDENTIALS_JSON', 'SLACK_BOT_TOKEN', 'SLACK_WEBHOOK_URL', 'DISCORD_WEBHOOK_URL', 'TELEGRAM_BOT_TOKEN', 'TWILIO_ACCOUNT_SID', 'TWILIO_AUTH_TOKEN', 'SENDGRID_API_KEY', 'STRIPE_SECRET_KEY', 'DATABASE_URL', 'MONGODB_URI', 'REDIS_URL')¶
Ordered, de-duplicated superset of names a named-transport stub may pass (additive).
- exception wads.ci_secrets.InvalidSecretName[source]¶
Bases:
ValueErrorRaised when a string cannot be a safe, valid secret/env-var name.
- wads.ci_secrets.WORKFLOW_CALL_SECRETS = ('WADS_CI_SECRETS_JSON', 'PYPI_PASSWORD', 'TEST_PYPI_PASSWORD', 'NPM_TOKEN', 'SSH_PRIVATE_KEY', 'CODECOV_TOKEN', 'DOCKERHUB_USERNAME', 'DOCKERHUB_TOKEN', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'GEMINI_API_KEY', 'GOOGLE_API_KEY', 'DEEPSEEK_API_KEY', 'OPENROUTER_API_KEY', 'GROQ_API_KEY', 'MISTRAL_API_KEY', 'COHERE_API_KEY', 'PERPLEXITY_API_KEY', 'REPLICATE_API_TOKEN', 'TOGETHER_API_KEY', 'XAI_API_KEY', 'FAL_KEY', 'AZURE_OPENAI_API_KEY', 'AZURE_OPENAI_ENDPOINT', 'TAVILY_API_KEY', 'COMPOSIO_API_KEY', 'SKILLSDIRECTORY_API_KEY', 'NEWSDATA_API_KEY', 'LANGSMITH_API_KEY', 'HF_TOKEN', 'HF_WRITE_TOKEN', 'HUGGINGFACE_TOKEN', 'KAGGLE_USERNAME', 'KAGGLE_KEY', 'WANDB_API_KEY', 'PINECONE_API_KEY', 'ELEVEN_API_KEY', 'ELEVENLABS_API_KEY', 'SUNO_API_KEY', 'SPOTIFY_API_CLIENT_ID', 'SPOTIFY_API_CLIENT_SECRET', 'SPOTIPY_CLIENT_ID', 'SPOTIPY_CLIENT_SECRET', 'ALPACA_API_KEY', 'ALPACA_SECRET_KEY', 'APCA_API_KEY_ID', 'APCA_API_SECRET_KEY', 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN', 'GCP_SA_KEY', 'GOOGLE_APPLICATION_CREDENTIALS_JSON', 'SLACK_BOT_TOKEN', 'SLACK_WEBHOOK_URL', 'DISCORD_WEBHOOK_URL', 'TELEGRAM_BOT_TOKEN', 'TWILIO_ACCOUNT_SID', 'TWILIO_AUTH_TOKEN', 'SENDGRID_API_KEY', 'STRIPE_SECRET_KEY', 'DATABASE_URL', 'MONGODB_URI', 'REDIS_URL')¶
the opt-in JSON transport secret + the superset.
- Type:
Every secret
uv-ci.ymldeclares
- wads.ci_secrets.is_valid_secret_name(name)[source]¶
Return
Trueiffnameis already a safe, valid secret name.- Return type:
>>> is_valid_secret_name("OPENAI_API_KEY") True >>> is_valid_secret_name("my-key") False >>> is_valid_secret_name("GITHUB_TOKEN") False
- wads.ci_secrets.normalize_secret_name(name)[source]¶
Force
nameto a validUPPER_SNAKEsecret/env-var name.Hyphens, dots and spaces become underscores; the result is upper-cased. Inputs that look like a value rather than a name are rejected, to guard against accidentally committing a secret value.
- Return type:
>>> normalize_secret_name("my-api.key") 'MY_API_KEY' >>> normalize_secret_name("OPENAI_API_KEY") 'OPENAI_API_KEY' >>> normalize_secret_name(" hf token ") 'HF_TOKEN'
Rejects value-looking and reserved inputs:
>>> normalize_secret_name("sk-proj-aBc/123+xyz=") Traceback (most recent call last): InvalidSecretName: ... >>> normalize_secret_name("GITHUB_TOKEN") Traceback (most recent call last): InvalidSecretName: ...
- wads.ci_secrets.render_stub_json_transport(*, indent=6)[source]¶
Render the caller stub’s JSON-transport
secrets:line (opt-in).- Return type:
>>> print(render_stub_json_transport()) WADS_CI_SECRETS_JSON: ${{ toJSON(toJSON(secrets)) }}
- wads.ci_secrets.render_stub_secrets_passthrough(names=('PYPI_PASSWORD', 'TEST_PYPI_PASSWORD', 'NPM_TOKEN', 'SSH_PRIVATE_KEY', 'CODECOV_TOKEN', 'DOCKERHUB_USERNAME', 'DOCKERHUB_TOKEN', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'GEMINI_API_KEY', 'GOOGLE_API_KEY', 'DEEPSEEK_API_KEY', 'OPENROUTER_API_KEY', 'GROQ_API_KEY', 'MISTRAL_API_KEY', 'COHERE_API_KEY', 'PERPLEXITY_API_KEY', 'REPLICATE_API_TOKEN', 'TOGETHER_API_KEY', 'XAI_API_KEY', 'FAL_KEY', 'AZURE_OPENAI_API_KEY', 'AZURE_OPENAI_ENDPOINT', 'TAVILY_API_KEY', 'COMPOSIO_API_KEY', 'SKILLSDIRECTORY_API_KEY', 'NEWSDATA_API_KEY', 'LANGSMITH_API_KEY', 'HF_TOKEN', 'HF_WRITE_TOKEN', 'HUGGINGFACE_TOKEN', 'KAGGLE_USERNAME', 'KAGGLE_KEY', 'WANDB_API_KEY', 'PINECONE_API_KEY', 'ELEVEN_API_KEY', 'ELEVENLABS_API_KEY', 'SUNO_API_KEY', 'SPOTIFY_API_CLIENT_ID', 'SPOTIFY_API_CLIENT_SECRET', 'SPOTIPY_CLIENT_ID', 'SPOTIPY_CLIENT_SECRET', 'ALPACA_API_KEY', 'ALPACA_SECRET_KEY', 'APCA_API_KEY_ID', 'APCA_API_SECRET_KEY', 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN', 'GCP_SA_KEY', 'GOOGLE_APPLICATION_CREDENTIALS_JSON', 'SLACK_BOT_TOKEN', 'SLACK_WEBHOOK_URL', 'DISCORD_WEBHOOK_URL', 'TELEGRAM_BOT_TOKEN', 'TWILIO_ACCOUNT_SID', 'TWILIO_AUTH_TOKEN', 'SENDGRID_API_KEY', 'STRIPE_SECRET_KEY', 'DATABASE_URL', 'MONGODB_URI', 'REDIS_URL'), *, indent=6)[source]¶
Render a caller stub’s named
secrets:pass-through block (the default).- Return type:
>>> print(render_stub_secrets_passthrough(["PYPI_PASSWORD", "NPM_TOKEN"])) PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- wads.ci_secrets.render_workflow_call_secrets(names=('WADS_CI_SECRETS_JSON', 'PYPI_PASSWORD', 'TEST_PYPI_PASSWORD', 'NPM_TOKEN', 'SSH_PRIVATE_KEY', 'CODECOV_TOKEN', 'DOCKERHUB_USERNAME', 'DOCKERHUB_TOKEN', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'GEMINI_API_KEY', 'GOOGLE_API_KEY', 'DEEPSEEK_API_KEY', 'OPENROUTER_API_KEY', 'GROQ_API_KEY', 'MISTRAL_API_KEY', 'COHERE_API_KEY', 'PERPLEXITY_API_KEY', 'REPLICATE_API_TOKEN', 'TOGETHER_API_KEY', 'XAI_API_KEY', 'FAL_KEY', 'AZURE_OPENAI_API_KEY', 'AZURE_OPENAI_ENDPOINT', 'TAVILY_API_KEY', 'COMPOSIO_API_KEY', 'SKILLSDIRECTORY_API_KEY', 'NEWSDATA_API_KEY', 'LANGSMITH_API_KEY', 'HF_TOKEN', 'HF_WRITE_TOKEN', 'HUGGINGFACE_TOKEN', 'KAGGLE_USERNAME', 'KAGGLE_KEY', 'WANDB_API_KEY', 'PINECONE_API_KEY', 'ELEVEN_API_KEY', 'ELEVENLABS_API_KEY', 'SUNO_API_KEY', 'SPOTIFY_API_CLIENT_ID', 'SPOTIFY_API_CLIENT_SECRET', 'SPOTIPY_CLIENT_ID', 'SPOTIPY_CLIENT_SECRET', 'ALPACA_API_KEY', 'ALPACA_SECRET_KEY', 'APCA_API_KEY_ID', 'APCA_API_SECRET_KEY', 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN', 'GCP_SA_KEY', 'GOOGLE_APPLICATION_CREDENTIALS_JSON', 'SLACK_BOT_TOKEN', 'SLACK_WEBHOOK_URL', 'DISCORD_WEBHOOK_URL', 'TELEGRAM_BOT_TOKEN', 'TWILIO_ACCOUNT_SID', 'TWILIO_AUTH_TOKEN', 'SENDGRID_API_KEY', 'STRIPE_SECRET_KEY', 'DATABASE_URL', 'MONGODB_URI', 'REDIS_URL'), *, indent=4)[source]¶
Render the
on.workflow_call.secrets:body for the reusable workflow.Every secret is declared
required: false— the publish job enforcesPYPI_PASSWORDat run time with a clear message, and the export action enforces any repo-declaredrequired_envvars.- Return type:
>>> print(render_workflow_call_secrets(["PYPI_PASSWORD", "OPENAI_API_KEY"])) PYPI_PASSWORD: required: false OPENAI_API_KEY: required: false
- wads.ci_secrets.stub_transport(stub)[source]¶
The secrets transport of a stub’s content:
"json"or"named".- Return type:
>>> stub_transport(" secrets:\n" + render_stub_json_transport() + "\n") 'json' >>> stub_transport(" secrets:\n PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }}\n") 'named'
- wads.ci_secrets.stub_with_named_transport(stub, names)[source]¶
Rewrite a stub’s secrets region to the named transport, passing
names.- Return type:
>>> stub = "jobs:\n ci:\n secrets:\n" + render_stub_json_transport() + "\n" >>> stub_with_named_transport(stub, ["PYPI_PASSWORD"]).endswith( ... " PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }}\n") True
- wads.ci_secrets.stub_with_transport(stub, *, transport='named', names=('PYPI_PASSWORD',))[source]¶
Rewrite a stub’s secrets region to the given transport.
Works on a stub of either transport, so it converts JSON to named, named to JSON, and re-lists a named stub’s names; the comment lines directly above
secrets:are replaced too, so a stub never describes a transport it does not use.namesis used by the named transport only. A stub whosecijob has nosecrets:mapping block (secrets: inherit, say) raisesValueError.- Return type:
>>> json_stub = ( ... "jobs:\n ci:\n uses: x\n # Transport: whole context.\n" ... " secrets:\n" + render_stub_json_transport() + "\n" ... ) >>> named = stub_with_transport(json_stub, names=["PYPI_PASSWORD", "HF_TOKEN"]) >>> named.endswith(" HF_TOKEN: ${{ secrets.HF_TOKEN }}\n") True >>> "toJSON" in named, stub_transport(named) (False, 'named') >>> back = stub_with_transport(named, transport="json") >>> stub_transport(back), "HF_TOKEN" in back ('json', False) >>> stub_with_transport(back, names=["PYPI_PASSWORD"]) == stub_with_transport( ... json_stub, names=["PYPI_PASSWORD"]) True
- wads.ci_secrets.warn_names_outside_superset(names)[source]¶
Warn loudly for names a named-transport stub cannot legally pass.
A caller may only pass secrets the reusable workflow declares. With
transport="named"that universe is the superset inwads.ci_secrets.DEFAULT_CI_SECRETS; a stub naming anything outside it produces a workflow GitHub rejects at parse time — zero jobs, an opaquestartup_failure(issue #63). Returns the offending names.- Return type: