| name | wads-migrate |
| description | Use when migrating Python projects to modern wads setup (pyproject.toml + uv CI). Triggers on migration tasks, CI modernization, setup.cfg conversion, switching from pip to uv, or moving a repo from inline uv CI to the reusable-workflow stub. |
Wads Project Migration
Overview
Wads manages Python project packaging and CI/CD. Projects exist in four formats:
| Format | pyproject? | .github/workflows/ci.yml | Migration command |
|---|
| Old | no — setup.cfg | old template | setup-to-pyproject then ci-to-uv |
| 2025 | yes | github_ci_publish_2025.yml (inline) | ci-to-uv |
| Modern uv (inline) | yes | github_ci_uv.yml (inline, ~250 lines) | ci-to-stub |
| Modern uv (stub) ★ | yes | 5-line stub → i2mint/wads/.github/workflows/uv-ci.yml | done |
The current default for new projects is the stub. The inline form is kept
as an escape valve for repos that need to customize CI beyond [tool.wads.ci.*].
Why the stub
- One file to fix bugs in across ~180 wads-managed repos.
pyproject.toml is the SSOT for project config; the stub makes CI's vehicle SSOT too.
- Visible customization: a non-stub
ci.yml immediately reads as "this repo customizes CI."
Cons and how to mitigate
| Con | Mitigation |
|---|
| Bad wads merge breaks CI everywhere on next run | Wads's own CI runs the reusable workflow first — canary catches obvious breaks. Crucially: broken CI ≠ broken release. Publish is gated on workflow success, so a bad wads change blocks publication for downstream consumers until wads is fixed, but never ships a broken artifact. This is what makes floating @master safe by default. |
Floating @master means consumers can't pin a known-good wads state | wads-migrate ci-to-stub --pin @0.1.81 writes the stub with a tag pin instead of @master (i2mint tags are bare versions — no v prefix; @v0.1.81 would reference a nonexistent ref). The pinned repo only picks up wads updates when explicitly re-pinned. Use for release-sensitive repos. |
| A secret your tests need isn't reaching CI | Run wads-secrets add VAR_NAME (see "Secrets" below). It declares the var in [tool.wads.ci.env] AND adds the pass-through line to the stub. No workflow edit needed unless the name is outside the wads superset (wads.ci_secrets.DEFAULT_CI_SECRETS), in which case the CLI warns and you either PR wads to widen the superset or use the inline escape valve. |
| Secrets must reach a reusable workflow owned by a different account | The stub passes secrets explicitly (NOT secrets: inherit, which is unreliable cross-owner). The stub's secrets: block lists each name; it's generated from [tool.wads.ci.env] at migrate time and extended by wads-secrets add. |
Detecting Current Format
- Check for
setup.cfg → Old format
- Check
.github/workflows/ci.yml for setup-python without setup-uv → 2025 format
- Check
.github/workflows/ci.yml for astral-sh/setup-uv and i2mint/wads/actions/run-tests-uv → Modern uv (inline)
- Check
.github/workflows/ci.yml for i2mint/wads/.github/workflows/uv-ci.yml → Modern uv (stub) ★
Migration: Old → Modern uv (inline)
wads-migrate setup-to-pyproject setup.cfg -o pyproject.toml
wads-migrate ci-to-uv .github/workflows/ci.yml -o .github/workflows/ci.yml
git rm setup.cfg setup.py
⚠️ Review the generated pyproject before committing — setup-to-pyproject
currently has three rough edges you must fix by hand (verified on a real
migration):
- it emits the deprecated
[project.license] text = ... table — replace
with the SPDX string form: license = "Apache-2.0" under [project];
- it leaves
project_name = "" in [tool.wads.ci] — set it to the
package name (an empty string can break the CI's --cov target);
- it writes
testpaths = ["tests"] unconditionally — if the repo has no
top-level tests/ dir (doctest-only suites are common, as are in-package
pkg/tests/), point it at the package dir instead (e.g. ["titbit"]) so CI
collects the doctests (and any in-package tests) rather than falling back to
rootdir-wide collection;
- it carries an
[options.extras_require] test extra into
[project.optional-dependencies] but does NOT wire it into CI — if the old
testing/tests extra holds test-time deps (e.g. pytest-asyncio), CI won't
install them and the affected tests error on a missing import/marker. Add
[tool.wads.ci.install] with extras = "testing" (the extra's name) so the
reusable workflow installs .[testing].
⚠️ A test-time-dependency gap like the extras one above will NOT be caught by
the local priv test-dependents gate — your local env already has the dep
installed, so it passes locally and fails only in CI's clean env. For any
migration that touches test dependencies, push to a throwaway branch and
watch CI before merging to the default branch (a green local gate is
necessary but not sufficient here).
Then push, watch CI, and once it's green, also run ci-to-stub to land
on the SSOT default.
Migration: 2025 → Modern uv (inline)
wads-migrate ci-to-uv .github/workflows/ci.yml -o .github/workflows/ci.yml
Then push, watch CI, and once green, run ci-to-stub.
Migration: Modern uv (inline) → Modern uv (stub) ★
After the repo is on the inline uv CI and CI is green, convert to the stub:
wads-migrate ci-to-stub
wads-migrate ci-to-stub --pin @0.1.81
ci-to-stub refuses to convert workflows that aren't already on uv-CI — run
ci-to-uv first so the per-repo [tool.wads.ci] audit happens before the
inline workflow disappears.
Secrets are carried over automatically. Both ci-to-uv and ci-to-stub
(and fleet-stub) scan the existing workflow's env: blocks for
${{ secrets.X }} references and inject any not-yet-declared ones into
[tool.wads.ci.env].extra_envvars (recording a secret_aliases entry when the
env-var name differs from the secret name). So a migration is lossless — you
don't lose secrets that were wired only in the old YAML. The command prints what
it carried; review them afterward and promote to required/test if a test
truly depends on one. Use wads-secrets add only for secrets the old workflow
did not already reference (e.g. a brand-new dependency).
New Project
populate my-project --root-url https://github.com/user/my-project
PyPI Auth Requirement
The uv template (and reusable workflow) uses token-only auth. Ensure:
PYPI_PASSWORD GitHub secret is a PyPI API token (starts with pypi-)
PYPI_USERNAME secret is no longer needed
Key Files in Wads
- Reusable workflow (SSOT):
.github/workflows/uv-ci.yml in i2mint/wads
- Stub template:
wads/data/github_ci_uv_stub.yml
- Inline template (escape valve):
wads/data/github_ci_uv.yml
- Config reader:
wads/ci_config.py (CIConfig class)
- Migration:
wads/migration.py (migrate_ci_to_uv, migrate_ci_to_stub)
- Project creation:
wads/populate.py (populate_pkg_dir)
- pyproject template:
wads/data/pyproject_toml_tpl.toml
pyproject.toml CI Config Reference
[tool.wads.ci]
installer = "uv"
[tool.wads.ci.testing]
python_versions = ["3.10", "3.12"]
pytest_args = ["-v", "--tb=short"]
coverage_enabled = true
exclude_paths = ["examples", "scrap"]
test_on_windows = true
[tool.wads.ci.build]
sdist = true
wheel = true
[tool.wads.ci.env]
required_envvars = []
test_envvars = ["OPENAI_API_KEY"]
extra_envvars = []
[tool.wads.ci.env.defaults]
Wiring third-party secrets (OPENAI_API_KEY etc.)
If a package's source modules read env vars at import time (e.g.
config2py.get_config("OPENAI_API_KEY") at module load), the uv CI's wider
pytest collection will fail during import.
For inline-CI repos: add the var name to [tool.wads.ci.env.test_envvars]
in pyproject.toml, then wads-migrate ci-to-uv to re-render the workflow with
a top-level env: block wiring ${{ secrets.X || '' }}. Set the GitHub secret.
For stub repos (the default since wads 0.1.82): the simplest path is
wads-secrets add OPENAI_API_KEY
wads-secrets add HF_TOKEN HF_WRITE_TOKEN
wads-secrets add DB_URL --kind required
This (a) declares the var in [tool.wads.ci.env] so the export-ci-env step
writes it into the job environment, (b) adds the pass-through line to the stub's
secrets: block so the secret is transported to the reusable workflow, and (c)
gh secret sets the value if gh is installed (value from $VAR_NAME or
--value). See the Secrets section below for the full model. If the secret
name is outside the wads superset (wads.ci_secrets.DEFAULT_CI_SECRETS), the CLI
warns; either PR wads to widen the superset (one line, ecosystem-wide) or use the
inline github_ci_uv.yml escape valve.
Do NOT hand-edit job-level env: blocks in ci.yml. Declare via
[tool.wads.ci.env] (or wads-secrets add, which is the safe way to do both).
Secrets (two-layer model)
Secrets reach a stub repo's CI through two coordinated layers:
- Transport — the stub's
secrets: block passes named secrets to the
reusable workflow. Explicit pass-through is used (NOT secrets: inherit,
which is unreliable across GitHub accounts). The universe of passable
names is the superset declared in the wads-side uv-ci.yml
(on.workflow_call.secrets), generated from wads.ci_secrets.DEFAULT_CI_SECRETS.
Each repo's stub passes only PYPI_PASSWORD + the names it actually uses.
- Env-assignment —
[tool.wads.ci.env] (required_envvars,
test_envvars, extra_envvars, defaults, and secret_aliases for
ENV_VAR≠SECRET_NAME) decides which passed secrets become job env vars. The
export-ci-env action writes exactly those to $GITHUB_ENV and fails the
build if a required secret is unset. A passed-but-undeclared secret is
never written to the env (no over-assignment).
wads-secrets add VAR_NAME [SECRET_NAME] does both layers (+ gh secret set)
in one step — the recommended way. wads-secrets list shows what's configured;
wads-secrets superset prints the allowed names. For a name outside the
superset: PR wads.ci_secrets.DEFAULT_CI_SECRETS (benefits all repos) or use the
inline github_ci_uv.yml escape valve.
Publishing runs only on the repo's default branch, when validation passes,
when the commit isn't [skip ci], and when [tool.wads.ci.publish].enabled.
Post-Migration: GitHub Pages + Discussions
A migrated repo's CI publishes docs to the gh-pages branch via epythet, so —
just like on a fresh populate — make sure GitHub Pages is serving from
gh-pages and that Discussions is on. Requires the gh CLI; if gh isn't
installed, skip this and note the user can run epythet configure-pages ORG/REPO
(or set Pages manually) later. ORG/REPO is the repo's owner/name.
GitHub Pages — target is source branch gh-pages, folder / (root). Read
the current config, then follow the decision tree:
gh api repos/ORG/REPO/pages --jq '{branch:.source.branch, path:.source.path, build_type:.build_type}' 2>/dev/null
- Empty / 404 → Pages not enabled (GitHub's default). Enable it
automatically (no need to ask):
gh api repos/ORG/REPO/pages -X POST -f 'source[branch]=gh-pages' -f 'source[path]=/'
If the POST fails because the gh-pages branch doesn't exist yet (CI hasn't
run since the migration), create it from the default branch first, then retry:
DEF=$(gh api repos/ORG/REPO --jq '.default_branch')
gh api repos/ORG/REPO/git/refs -X POST \
-f ref="refs/heads/gh-pages" \
-f sha="$(gh api repos/ORG/REPO/git/ref/heads/$DEF --jq '.object.sha')"
- Already
branch=gh-pages, path=/ → nothing to do.
- Anything else (a different branch, a non-root path, or
build_type=workflow
— the "GitHub Actions" source) → a particular situation; do NOT silently
change it. Check whether gh-pages even exists:
gh api repos/ORG/REPO/branches/gh-pages --jq '.name' 2>/dev/null
Then show the user the current setting (and whether gh-pages exists) and
ask for confirmation before switching. If confirmed, update with PUT
(Pages already exists, so POST would 409):
gh api repos/ORG/REPO/pages -X PUT -f 'source[branch]=gh-pages' -f 'source[path]=/'
GitHub Discussions — enable by default unless the user explicitly opted out:
gh api repos/ORG/REPO --jq '.has_discussions'
gh repo edit ORG/REPO --enable-discussions
Checklist After Migration