Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,17 @@ and could stop an installed copy from updating.
rule: it means the project is mid-migration or carrying a stale file, and
rigging will not pick for you.

- **`rigging` can run a repo's real test command, not just `npm test`.**
`.rigging.json`'s per-stack config gained `testCommand`, a JSON argv array
(`["turbo", "run", "test", "--concurrency=1"]`) that replaces the stack's
default test command — `python -m pytest`, or the node package manager's
`test` script. It is an argv array, not a shell string, so shell
metacharacters are inert and pipes/`&&`/redirects are simply not
expressible; a value carrying a `${{ ... }}` Actions expression or a newline
is refused at load, before it could reach a rendered `run:` line. `init`
does not write it — it is the manual escape hatch for when the default test
command guesses wrong.

### Fixed

- **`rigging:init` no longer refuses every pnpm, yarn, and bun repo.** 0.6.0
Expand Down
537 changes: 537 additions & 0 deletions docs/superpowers/plans/2026-07-22-rigging-custom-test-commands.md

Large diffs are not rendered by default.

68 changes: 66 additions & 2 deletions plugins/rigging/rigging/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,19 @@
#: something they didn't, and the resulting behaviour change surfaces far
#: from its cause.
TOP_LEVEL_KEYS = frozenset({"name", "stacks", "pushBranches"})
STACK_KEYS = frozenset({"versions", "packageManager"})
STACK_KEYS = frozenset({"versions", "packageManager", "testCommand"})


NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$")
VERSION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]*$")

#: The literal that opens a GitHub Actions expression. A testCommand element
#: containing it is rejected at load: GitHub substitutes `${{ ... }}` at the
#: YAML layer, before any shell sees the line, so shlex.quote is no defence --
#: the only safe move is to refuse the value so it never reaches a rendered
#: `run:` block.
EXPRESSION_MARKER = "${{"


class ConfigError(Exception):
"""Raised when .rigging.json exists but cannot be used."""
Expand Down Expand Up @@ -61,6 +68,14 @@ class StackConfig:
#: command the runner does not have.
package_manager: Optional[str] = None

#: A custom test command as an argv tuple, replacing this stack's (or its
#: node package manager's) default test argv. None takes the default. An
#: argv tuple, not a shell string, so shell metacharacters are inert once
#: rendered -- and pipes, redirects, and `&&` are simply not expressible,
#: which is the point: a repo needing a shell pipeline needs a hand-written
#: workflow, not this key.
test_command: Optional[tuple[str, ...]] = None


@dataclass(frozen=True)
class Config:
Expand Down Expand Up @@ -126,6 +141,52 @@ def _valid_package_manager(value, stack_id):
return value


def _valid_test_command(value, stack_id):
"""Validate an optional `testCommand` for one stack into an argv tuple.

Two refusals carry the injection guarantee (the rest is handled by
shlex.quote at render): an element containing `${{` (a GitHub Actions
expression opener, substituted before any shell runs and unquotable) or a
newline (which would break out of the single argv line) is rejected here,
at load, so neither can reach a rendered `run:` block.
"""
if value is None:
return None
if not isinstance(value, list) or not value:
raise ConfigError(
f"{CONFIG_NAME}: 'stacks.{stack_id}.testCommand' must be a "
f"non-empty list of strings (got {value!r})."
)
argv = []
for part in value:
if not isinstance(part, str) or not part:
raise ConfigError(
f"{CONFIG_NAME}: 'stacks.{stack_id}.testCommand' entries must "
f"be non-empty strings (got {part!r})."
)
if EXPRESSION_MARKER in part:
raise ConfigError(
f"{CONFIG_NAME}: 'stacks.{stack_id}.testCommand' entry {part!r} "
f"contains {EXPRESSION_MARKER!r}, a GitHub Actions expression "
f"opener. It is substituted before any shell runs and cannot be "
f"made safe by quoting; remove it."
)
# splitlines() catches every line break -- \n, \r, \r\n, and the
# Unicode separators (U+2028/U+2029/NEL) -- not just \n. A bare \r is a
# line break to a YAML parser, so it would let the rendered run: command
# differ from what was written even though shlex.quote keeps it inert at
# the shell layer. `part.splitlines() != [part]` is true for any element
# carrying a break, including a trailing one (which `len(...) > 1` would
# miss).
if part.splitlines() != [part]:
raise ConfigError(
f"{CONFIG_NAME}: 'stacks.{stack_id}.testCommand' entry {part!r} "
f"contains a line break; each entry is one argv element."
)
argv.append(part)
return tuple(argv)


def _valid_push_branches(value):
if value is None:
return DEFAULT_PUSH_BRANCHES
Expand Down Expand Up @@ -201,7 +262,10 @@ def load_config(root: Path) -> Optional[Config]:
versions = _valid_versions(versions_raw, stack_id)
package_manager = _valid_package_manager(
stack_value.get("packageManager"), stack_id)
test_command = _valid_test_command(
stack_value.get("testCommand"), stack_id)
resolved[stack_id] = StackConfig(versions=versions,
package_manager=package_manager)
package_manager=package_manager,
test_command=test_command)

return Config(name=name, stacks=resolved, push_branches=push_branches)
45 changes: 32 additions & 13 deletions plugins/rigging/rigging/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,23 +51,32 @@ def render_argv(argv: tuple[str, ...]) -> str:


def _manager_steps(stack_id: str, manager_id: str):
"""The setup and run steps contributed by a stack's package manager.

Returns `((), (), ())` for a stack that has no manager concept -- today
every stack but node.
"""The setup, post-setup, and INSTALL steps a stack's package manager
contributes. The test step is resolved separately (see _resolve_test_argv)
so a testCommand can override it. Returns ((), (), ()) for a stack with no
manager concept -- today every stack but node.
"""
if stack_id != "node":
return (), (), ()
manager = stacks.NODE_PACKAGE_MANAGERS[manager_id]
runs = (
stacks.Step(run=render_argv(manager.install)),
stacks.Step(run=render_argv(manager.test)),
)
return manager.setup_steps, manager.post_setup_steps, runs
install_run = (stacks.Step(run=render_argv(manager.install)),)
return manager.setup_steps, manager.post_setup_steps, install_run


def _resolve_test_argv(stack_id: str, manager_id: str,
test_command: tuple[str, ...] | None) -> tuple[str, ...]:
"""The effective test argv for a job: an explicit testCommand wins; else
the node manager's default; else the stack's own default_test."""
if test_command is not None:
return test_command
if stack_id == "node":
return stacks.NODE_PACKAGE_MANAGERS[manager_id].test
return stacks.REGISTRY[stack_id].default_test


def _build_job(stack_id: str, versions: tuple[str, ...],
manager_id: str = stacks.DEFAULT_NODE_PACKAGE_MANAGER) -> Job:
manager_id: str = stacks.DEFAULT_NODE_PACKAGE_MANAGER,
test_command: tuple[str, ...] | None = None) -> Job:
spec = stacks.REGISTRY[stack_id]
setup_step = stacks.Step(
uses=spec.setup_uses,
Expand All @@ -78,23 +87,33 @@ def _build_job(stack_id: str, versions: tuple[str, ...],
# documented order. Nothing here depends on it today (no dependency
# caching is configured), but the documented order is the one that stays
# correct if caching is ever added.
manager_setup, manager_post_setup, manager_runs = _manager_steps(stack_id, manager_id)
manager_setup, manager_post_setup, manager_install = _manager_steps(stack_id, manager_id)
test_argv = _resolve_test_argv(stack_id, manager_id, test_command)
# An empty test argv would render `- run: ""` -- a silent no-op test step,
# a green-but-testless workflow. It is unreachable today (python's
# default_test is non-empty; node resolves via a manager whose test is
# non-empty), so this asserts the invariant rather than handling a live
# case: a future non-node stack registered without a default_test fails
# loudly here instead of shipping CI that tests nothing.
assert test_argv, f"{stack_id}: no test command resolved (empty test argv)"
test_step = stacks.Step(run=render_argv(test_argv))
return Job(
id=spec.id,
runs_on="ubuntu-latest",
matrix_var=spec.matrix_var,
versions=versions,
steps=(
CHECKOUT_STEP, *manager_setup, setup_step,
*manager_post_setup, *spec.steps, *manager_runs,
*manager_post_setup, *spec.steps, *manager_install, test_step,
),
)


def build_plan(cfg: config.Config) -> CiPlan:
jobs = tuple(
_build_job(stack_id, stack_cfg.versions,
stack_cfg.package_manager or stacks.DEFAULT_NODE_PACKAGE_MANAGER)
stack_cfg.package_manager or stacks.DEFAULT_NODE_PACKAGE_MANAGER,
stack_cfg.test_command)
for stack_id, stack_cfg in cfg.stacks.items()
)
return CiPlan(name=cfg.name, jobs=jobs, push_branches=cfg.push_branches)
9 changes: 8 additions & 1 deletion plugins/rigging/rigging/stacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ class StackSpec:
setup_with_key: str
default_versions: tuple[str, ...]
steps: tuple[Step, ...]
#: The stack's OWN default test argv, appended as the final job step when
#: no packageManager supplies one and no testCommand overrides it. Stored
#: as argv (not a Step) so it shares render_argv with a user's testCommand
#: and with the node managers' `test`. Empty for node, whose test comes
#: from its package manager instead.
default_test: tuple[str, ...] = ()


@dataclass(frozen=True)
Expand Down Expand Up @@ -85,8 +91,8 @@ class PackageManager:
"pip install 'pytest>=8,<9'\n"
"if [ -f requirements.txt ]; then pip install -r requirements.txt; fi"
)),
Step(run="python -m pytest"),
),
default_test=("python", "-m", "pytest"),
),
"node": StackSpec(
id="node",
Expand All @@ -97,6 +103,7 @@ class PackageManager:
setup_with_key="node-version",
default_versions=("20",),
steps=(),
default_test=(), # node's default test comes from its package manager
),
}

Expand Down
19 changes: 12 additions & 7 deletions plugins/rigging/skills/init/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,18 @@ Carry the reason forward when there is one: section 3 passes
wraps this same check), so the refusal is enforced by code and not only by
this document.

## Custom test command

- **`testCommand`** (optional, per stack): the test command as a JSON array
of arguments — e.g. `["turbo", "run", "test", "--concurrency=1"]` —
replacing the stack's default (`python -m pytest`, or the node package
manager's `test` script). It is an argv array, not a shell string: each
element is one argument, and shell constructs (pipes, `&&`, redirects,
`$VAR`) are not interpreted. A repo needing a shell pipeline needs a
hand-written workflow. `init` never writes this key — it is a manual
override for when the default guesses wrong (notably `bun run test` for a
repo that wants a different runner).

## 3. Propose the config

*(Fresh-scaffold flow only.)*
Expand Down Expand Up @@ -346,13 +358,6 @@ increments, not gaps in this one:
- stacks beyond `python` and `node`
- **package managers beyond npm, pnpm, yarn, and bun** — those four are
driven; anything else is not detected and not expressible.
- **custom test commands.** There is no way to tell rigging "run `make test`"
or "run `pnpm vitest run`" — a stack's steps come entirely from its
registry entry (or, for node, its selected package manager's registry
entry), and an unknown key under `stacks.<id>` is a hard `ConfigError`, so
there is deliberately no escape hatch to hand-edit the rendered steps. If
the registry's steps are wrong for a repo, that repo needs a hand-written
workflow until a later increment fixes it properly.
- **service containers.** A test suite that needs Postgres, MySQL, Redis, or
anything else alongside the job has nowhere to declare it; the rendered
workflow has no `services:` block at all.
Expand Down
20 changes: 20 additions & 0 deletions plugins/rigging/tests/golden/node-testcommand.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: "ci"
on:
push:
branches: ["main"]
pull_request:
permissions:
contents: read
jobs:
node:
runs-on: "ubuntu-latest"
strategy:
matrix:
node: ["20"]
steps:
- uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1" # v7
- uses: "actions/setup-node@820762786026740c76f36085b0efc47a31fe5020" # v7
with:
node-version: "${{ matrix.node }}"
- run: "npm ci"
- run: "turbo run test --concurrency=1"
23 changes: 23 additions & 0 deletions plugins/rigging/tests/golden/python-testcommand.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: "ci"
on:
push:
branches: ["main"]
pull_request:
permissions:
contents: read
jobs:
python:
runs-on: "ubuntu-latest"
strategy:
matrix:
python: ["3.12"]
steps:
- uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1" # v7
- uses: "actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97" # v7
with:
python-version: "${{ matrix.python }}"
- run: |
python -m pip install --upgrade pip
pip install 'pytest>=8,<9'
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- run: "pytest -q"
Loading