wads.scripts.export_ci_env

Export the pyproject-declared CI environment to $GITHUB_ENV.

This is the run-time half of wads’ two-layer secret model (see wads.ci_secrets). The reusable workflow receives the caller’s secrets either as named pass-throughs (the default stub) or as one double-encoded JSON blob under WADS_CI_SECRETS_JSON (opt-in JSON stubs); this script decides which values actually become job environment variables, driven entirely by [tool.wads.ci.env] in the consumer’s pyproject.toml (read via the read-ci-config action):

  • defaults — literal KEY=value pairs, always written.

  • required_envvars — must resolve non-empty, else CI fails with a precise message.

  • test_envvars — exported if set; a warning is emitted if missing.

  • extra_envvars — exported if set; silent if missing.

  • secret_aliases — map ENV_VAR -> SECRET_NAME for the (rare) case where the env var the code reads differs from the GitHub secret name.

Each declared name resolves against secrets first (named ones merged with the transport blob) and the caller’s repository variables second (the vars context resolves to the caller’s repo in a reusable workflow) — so a non-sensitive value like a test verbosity level can live in a repo variable instead of being mis-classified as a secret.

Secret values are never printed. Values extracted from the transport blob are not auto-masked by GitHub (only the blob as a whole is), so every secret-sourced value is re-registered with ::add-mask:: (per line, the documented form for multiline values) before being written to the job env.

Functions

export_ci_env(*[, required, test, extra, ...])

Compute the env assignments to write, plus any missing-required errors.

is_publish_credential(env_var[, secret])

True when env_var (backed by secret) carries a PyPI publish credential.

main()

Read inputs from env, write assignments to $GITHUB_ENV, fail if required missing.

merge_transported_secrets(secrets)

Flatten the secrets mapping, expanding the WADS_CI_SECRETS_JSON blob.

Classes

ExportPlan(assignments, exported, ...)

What export_ci_env() decided, ready for main() to apply.

class wads.scripts.export_ci_env.ExportPlan(assignments, exported, missing_required, missing_test, mask_values)[source]

Bases: NamedTuple

What export_ci_env() decided, ready for main() to apply.

assignments: list

Alias for field number 0

exported: list

Alias for field number 1

mask_values: list

Alias for field number 4

missing_required: list

Alias for field number 2

missing_test: list

Alias for field number 3

wads.scripts.export_ci_env.export_ci_env(*, required=(), test=(), extra=(), defaults=None, aliases=None, secrets=None, vars_=None, withhold_publish=True, warn=<function <lambda>>)[source]

Compute the env assignments to write, plus any missing-required errors.

Pure function (no I/O) so it can be unit-tested without GitHub. Each declared name resolves secrets-first, then falls back to the caller’s repository variables (vars_). Secret-sourced values are collected in mask_values for re-masking; variable- and default-sourced values are not masked (they are not sensitive by definition).

>>> plan = export_ci_env(
...     required=["OPENAI_API_KEY"],
...     test=["TAVILY_API_KEY"],
...     extra=["UNSET_THING", "COSMO_TEST_LEVEL"],
...     defaults={"LOG_LEVEL": "DEBUG"},
...     secrets={"OPENAI_API_KEY": "sk-xxx", "TAVILY_API_KEY": ""},
...     vars_={"COSMO_TEST_LEVEL": "3"},
... )
>>> plan.exported
['LOG_LEVEL', 'OPENAI_API_KEY', 'COSMO_TEST_LEVEL']
>>> plan.missing_required
[]
>>> plan.missing_test
['TAVILY_API_KEY']
>>> plan.mask_values  # only the secret-sourced value
['sk-xxx']

A required var with no backing secret or variable is reported (caller should fail CI):

>>> export_ci_env(required=["OPENAI_API_KEY"], secrets={}).missing_required
[('OPENAI_API_KEY', 'OPENAI_API_KEY')]

Aliases let an env var read a differently-named secret:

>>> plan = export_ci_env(
...     test=["HF_TOKEN"],
...     aliases={"HF_TOKEN": "HF_WRITE_TOKEN"},
...     secrets={"HF_WRITE_TOKEN": "hf_xxx"})
>>> plan.exported
['HF_TOKEN']
>>> plan.assignments
['HF_TOKEN=hf_xxx']

A publish credential (see is_publish_credential()) is never exported, under any name, and never fails the job, unless withhold_publish=False (the publish job). The token must not reach a job that runs tests (issue #110):

>>> plan = export_ci_env(
...     required=["PYPI_PASSWORD"],
...     extra=["UV_PUBLISH_TOKEN"],
...     aliases={"UV_PUBLISH_TOKEN": "PYPI_PASSWORD"},
...     secrets={"PYPI_PASSWORD": "pypi-xxx"},
...     warn=lambda msg: None)
>>> plan.exported, plan.missing_required, plan.mask_values
([], [], [])
wads.scripts.export_ci_env.is_publish_credential(env_var, secret=None)[source]

True when env_var (backed by secret) carries a PyPI publish credential.

Matches the publish env-var names tools read, the wads publish secrets, and any other PyPI-ish credential name, case-insensitively (GitHub secret names are case-insensitive).

Return type:

bool

>>> is_publish_credential("UV_PUBLISH_TOKEN", "PYPI_PASSWORD")
True
>>> is_publish_credential("TWINE_PASSWORD", "WHATEVER")
True
>>> is_publish_credential("MY_TOKEN", "pypi_api_token")
True
>>> is_publish_credential("PYPI_USERNAME")
False
>>> is_publish_credential("OPENAI_API_KEY")
False
wads.scripts.export_ci_env.main()[source]

Read inputs from env, write assignments to $GITHUB_ENV, fail if required missing.

Return type:

int

wads.scripts.export_ci_env.merge_transported_secrets(secrets)[source]

Flatten the secrets mapping, expanding the WADS_CI_SECRETS_JSON blob.

The blob is the caller’s whole secrets context, serialized by the stub with toJSON(toJSON(secrets)) (double-encoded => single-line). A single-encoded blob is also accepted. Named entries win over blob entries (they are the same caller values anyway), and non-exportable keys (the blob itself, github_token) are dropped.

>>> merged = merge_transported_secrets({
...     "WADS_CI_SECRETS_JSON": '"{\\n  \\"MY_KEY\\": \\"v1\\",\\n  \\"github_token\\": \\"t\\"\\n}"',
...     "OPENAI_API_KEY": "sk-named",
... })
>>> sorted(merged)
['MY_KEY', 'OPENAI_API_KEY']
>>> merged["MY_KEY"]
'v1'

Malformed blobs are ignored rather than fatal (the named layer still works, and required-validation reports any name that failed to resolve):

>>> merge_transported_secrets({"WADS_CI_SECRETS_JSON": "not json", "A": "x"})
{'A': 'x'}