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-env action 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_config and the export-ci-env action). Nothing is exported

    unless 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

DEFAULT_CI_SECRETS

Ordered, de-duplicated superset of names a named-transport stub may pass (additive).

WORKFLOW_CALL_SECRETS

the opt-in JSON transport secret + the superset.

Functions

is_valid_secret_name(name)

Return True iff name is already a safe, valid secret name.

normalize_secret_name(name)

Force name to a valid UPPER_SNAKE secret/env-var name.

render_stub_json_transport(*[, indent])

Render the caller stub's JSON-transport secrets: line (opt-in).

render_stub_secrets_passthrough([names, indent])

Render a caller stub's named secrets: pass-through block (the default).

render_workflow_call_secrets([names, indent])

Render the on.workflow_call.secrets: body for the reusable workflow.

stub_transport(stub)

The secrets transport of a stub's content: "json" or "named".

stub_with_named_transport(stub, names)

Rewrite a stub's secrets region to the named transport, passing names.

stub_with_transport(stub, *[, transport, names])

Rewrite a stub's secrets region to the given transport.

warn_names_outside_superset(names)

Warn loudly for names a named-transport stub cannot legally pass.

Exceptions

InvalidSecretName

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: ValueError

Raised 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.yml declares

wads.ci_secrets.is_valid_secret_name(name)[source]

Return True iff name is already a safe, valid secret name.

Return type:

bool

>>> 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 name to a valid UPPER_SNAKE secret/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:

str

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

str

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

str

>>> 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 enforces PYPI_PASSWORD at run time with a clear message, and the export action enforces any repo-declared required_envvars.

Return type:

str

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

str

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

str

>>> 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. names is used by the named transport only. A stub whose ci job has no secrets: mapping block (secrets: inherit, say) raises ValueError.

Return type:

str

>>> 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 in wads.ci_secrets.DEFAULT_CI_SECRETS; a stub naming anything outside it produces a workflow GitHub rejects at parse time — zero jobs, an opaque startup_failure (issue #63). Returns the offending names.

Return type:

list