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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,19 @@ and could stop an installed copy from updating.

## [Unreleased]

### Added

- **`rigging` jobs can run a database alongside their tests.** `.rigging.json`'s
per-stack config gained `services` — `postgres`, `mysql`, or `redis` — as
`{"postgres": {"version": "16", "urlEnv": "TEST_DATABASE_URL"}}`. rigging owns
the image tag, port, credentials, and the **health check** (so the job waits
for readiness instead of racing the container and flaking), and composes the
connection URL from its own credentials into the job-level env var the repo
names. Images are pinned by major tag, not digest — an ephemeral test fixture
on a private network has a different threat model from an Action that runs
with the workflow token. This is the third and final increment of #26: a repo
needing a live Postgres can now use rigging end to end.

### Changed

- Bumping the suite version no longer breaks the test suite. The per-plugin
Expand Down
749 changes: 749 additions & 0 deletions docs/superpowers/plans/2026-07-22-rigging-service-containers.md

Large diffs are not rendered by default.

90 changes: 88 additions & 2 deletions plugins/rigging/rigging/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from pathlib import Path
from typing import Optional

from rigging import services as services_registry
from rigging import stacks

CONFIG_NAME = ".rigging.json"
Expand All @@ -16,12 +17,22 @@
#: 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", "testCommand"})
STACK_KEYS = frozenset({"versions", "packageManager", "testCommand", "services"})


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

#: A GitHub Actions env var name. Same strictness as hull's licenseSecret and
#: for the same reason: it is rendered into YAML adjacent to values that matter,
#: and no legitimate env var name is excluded by this pattern.
URL_ENV_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")

#: A Docker image tag the repo may pin a service to. Reuses VERSION_RE's shape
#: (charset-valid tags only) so a value needing YAML quoting or carrying an
#: Actions expression is refused before render.
SERVICE_VERSION_RE = VERSION_RE

#: 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 --
Expand Down Expand Up @@ -49,6 +60,16 @@ class ConfigError(Exception):
BRANCH_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*$")


@dataclass(frozen=True)
class ResolvedService:
"""One service a stack's job runs: which registry service, at what image
version, and the env var its connection URL is exposed in."""

service_id: str
version: str
url_env: str


@dataclass(frozen=True)
class StackConfig:
"""One stack's settings.
Expand Down Expand Up @@ -76,6 +97,10 @@ class StackConfig:
#: workflow, not this key.
test_command: Optional[tuple[str, ...]] = None

#: Service containers this stack's job runs alongside its tests, in config
#: order. Empty when none are declared.
services: tuple[ResolvedService, ...] = ()


@dataclass(frozen=True)
class Config:
Expand Down Expand Up @@ -187,6 +212,65 @@ def _valid_test_command(value, stack_id):
return tuple(argv)


_SERVICE_KEYS = frozenset({"version", "urlEnv"})


def _valid_services(value, stack_id):
"""Validate the optional `services` mapping into a tuple of ResolvedService.

A service id must be one the registry knows (a workflow rigging cannot
health-check is worse than none); `version` and `urlEnv` are both required
(a service with no urlEnv exposes nothing, and there is no version to
default to); and `urlEnv` must be a plain env var identifier, since it is
rendered into YAML. The image is pinned by tag, and the connection URL is
composed by rigging from registry constants, so neither is user input here.
"""
if value is None:
return ()
if not isinstance(value, dict):
raise ConfigError(
f"{CONFIG_NAME}: 'stacks.{stack_id}.services' must be a JSON object "
f"of service id -> settings (got {value!r})."
)
resolved = []
for service_id, entry in value.items():
if service_id not in services_registry.SERVICE_REGISTRY:
raise ConfigError(
f"{CONFIG_NAME}: 'stacks.{stack_id}.services' names unknown "
f"service {service_id!r}. Allowed: "
f"{', '.join(services_registry.SERVICE_IDS)}."
)
if not isinstance(entry, dict):
raise ConfigError(
f"{CONFIG_NAME}: 'stacks.{stack_id}.services.{service_id}' must "
f"be a JSON object (got {entry!r})."
)
unknown = set(entry) - _SERVICE_KEYS
if unknown:
raise ConfigError(
f"{CONFIG_NAME}: unknown key(s) {', '.join(sorted(unknown))} in "
f"'stacks.{stack_id}.services.{service_id}'. Allowed keys: "
f"{', '.join(sorted(_SERVICE_KEYS))}."
)
version = entry.get("version")
if not isinstance(version, str) or not SERVICE_VERSION_RE.fullmatch(version):
raise ConfigError(
f"{CONFIG_NAME}: 'stacks.{stack_id}.services.{service_id}.version' "
f"is required and must be a string matching "
f"{SERVICE_VERSION_RE.pattern} (got {version!r})."
)
url_env = entry.get("urlEnv")
if not isinstance(url_env, str) or not URL_ENV_RE.fullmatch(url_env):
raise ConfigError(
f"{CONFIG_NAME}: 'stacks.{stack_id}.services.{service_id}.urlEnv' "
f"is required and must be an env var name matching "
f"{URL_ENV_RE.pattern} (got {url_env!r})."
)
resolved.append(ResolvedService(service_id=service_id, version=version,
url_env=url_env))
return tuple(resolved)


def _valid_push_branches(value):
if value is None:
return DEFAULT_PUSH_BRANCHES
Expand Down Expand Up @@ -264,8 +348,10 @@ def load_config(root: Path) -> Optional[Config]:
stack_value.get("packageManager"), stack_id)
test_command = _valid_test_command(
stack_value.get("testCommand"), stack_id)
service_list = _valid_services(stack_value.get("services"), stack_id)
resolved[stack_id] = StackConfig(versions=versions,
package_manager=package_manager,
test_command=test_command)
test_command=test_command,
services=service_list)

return Config(name=name, stacks=resolved, push_branches=push_branches)
42 changes: 40 additions & 2 deletions plugins/rigging/rigging/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,25 @@
from dataclasses import dataclass

from rigging import config, stacks
from rigging import services as services_registry

CHECKOUT_STEP = stacks.Step(
uses="actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1",
uses_version="v7",
)


@dataclass(frozen=True)
class RenderedService:
"""A service container resolved to exactly what render emits."""

name: str
image: str
env: tuple[tuple[str, str], ...]
port: int
options: str


@dataclass(frozen=True)
class Job:
"""One CI job for a configured stack."""
Expand All @@ -26,6 +38,11 @@ class Job:
matrix_var: str
versions: tuple[str, ...]
steps: tuple[stacks.Step, ...]
#: Service containers for this job, in config order. Empty when none.
services: tuple[RenderedService, ...] = ()
#: Job-level environment (today: each service's connection URL under its
#: urlEnv), in config order. Empty when none.
env: tuple[tuple[str, str], ...] = ()


@dataclass(frozen=True)
Expand Down Expand Up @@ -74,9 +91,27 @@ def _resolve_test_argv(stack_id: str, manager_id: str,
return stacks.REGISTRY[stack_id].default_test


def _resolve_services(service_list):
"""Turn config.ResolvedService entries into (rendered services, job env)."""
rendered = []
env = []
for rs in service_list:
spec = services_registry.SERVICE_REGISTRY[rs.service_id]
rendered.append(RenderedService(
name=spec.id,
image=spec.image_ref(rs.version),
env=spec.env,
port=spec.port,
options=spec.health_options,
))
env.append((rs.url_env, spec.url))
return tuple(rendered), tuple(env)


def _build_job(stack_id: str, versions: tuple[str, ...],
manager_id: str = stacks.DEFAULT_NODE_PACKAGE_MANAGER,
test_command: tuple[str, ...] | None = None) -> Job:
test_command: tuple[str, ...] | None = None,
services: tuple[config.ResolvedService, ...] = ()) -> Job:
spec = stacks.REGISTRY[stack_id]
setup_step = stacks.Step(
uses=spec.setup_uses,
Expand All @@ -97,6 +132,7 @@ def _build_job(stack_id: str, versions: tuple[str, ...],
# 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))
rendered_services, job_env = _resolve_services(services)
return Job(
id=spec.id,
runs_on="ubuntu-latest",
Expand All @@ -106,14 +142,16 @@ def _build_job(stack_id: str, versions: tuple[str, ...],
CHECKOUT_STEP, *manager_setup, setup_step,
*manager_post_setup, *spec.steps, *manager_install, test_step,
),
services=rendered_services,
env=job_env,
)


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.test_command)
stack_cfg.test_command, stack_cfg.services)
for stack_id, stack_cfg in cfg.stacks.items()
)
return CiPlan(name=cfg.name, jobs=jobs, push_branches=cfg.push_branches)
18 changes: 17 additions & 1 deletion plugins/rigging/rigging/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,24 @@ def _job_lines(job) -> list[str]:
" strategy:",
" matrix:",
f" {job.matrix_var}: [{versions}]",
" steps:",
]
if job.services:
lines.append(" services:")
for service in job.services:
lines.append(f" {service.name}:")
lines.append(f" image: {_quote(service.image)}")
if service.env:
lines.append(" env:")
for key, value in service.env:
lines.append(f" {key}: {_quote(value)}")
lines.append(" ports:")
lines.append(f" - {_quote(f'{service.port}:{service.port}')}")
lines.append(f" options: {_quote(service.options)}")
if job.env:
lines.append(" env:")
for key, value in job.env:
lines.append(f" {key}: {_quote(value)}")
lines.append(" steps:")
for step in job.steps:
lines.extend(_step_lines(step))
return lines
Expand Down
81 changes: 81 additions & 0 deletions plugins/rigging/rigging/services.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""The service-container registry: databases and caches a CI job can run
alongside its tests, and how to drive each in GitHub Actions.

Pure data module. Stdlib only; no subprocess, no os, no networking -- the same
AST purity test that covers the rest of the engine covers this file.

Service images are pinned by major TAG (postgres:16), not by digest, a
deliberate inconsistency with the Action SHA-pinning rule: a service container
is an ephemeral fixture on a private network that never sees the workflow token
or the checked-out repo, and a tag keeps getting security patches without a
human. The health check is a registry constant, never user input, so no
user-supplied string ever lands in a Docker `options:` line.
"""
from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True)
class ServiceSpec:
"""One service container and how to drive it in a GitHub Actions job."""

id: str
#: Docker image WITHOUT a tag; image_ref appends the repo-chosen version.
image: str
#: The container's port, mapped host:container 1:1 and used in the URL.
port: int
#: Container environment, rendered under the service's `env:`. Ordered so
#: the rendered YAML is deterministic.
env: tuple[tuple[str, str], ...]
#: The full Docker `--health-*` options string. A registry constant so a
#: user string never reaches `options:`; every service MUST carry one, or
#: the job races container startup and flakes.
health_options: str
#: The connection-URL template, composed with .format(port=...). Holds the
#: registry's own credentials and default database name -- never user input.
url_template: str

def image_ref(self, version: str) -> str:
return f"{self.image}:{version}"

@property
def url(self) -> str:
return self.url_template.format(port=self.port)


_HEALTH = "--health-interval 10s --health-timeout 5s --health-retries 5"

SERVICE_REGISTRY: dict[str, ServiceSpec] = {
"postgres": ServiceSpec(
id="postgres",
image="postgres",
port=5432,
env=(("POSTGRES_PASSWORD", "postgres"), ("POSTGRES_DB", "postgres")),
health_options=f"--health-cmd pg_isready {_HEALTH}",
url_template="postgresql://postgres:postgres@localhost:{port}/postgres",
),
"mysql": ServiceSpec(
id="mysql",
image="mysql",
port=3306,
env=(("MYSQL_ROOT_PASSWORD", "mysql"), ("MYSQL_DATABASE", "mysql")),
# `mysqladmin ping` is a LIVENESS probe, not a readiness one: it returns
# exit 0 as soon as the server answers the socket (even on "Access
# denied"), which can be a beat before MySQL will serve queries. That is
# the standard GitHub Actions idiom and --health-retries covers the gap;
# it is not a query-level check. Do not mistake it for one.
health_options=f'--health-cmd "mysqladmin ping" {_HEALTH}',
url_template="mysql://root:mysql@localhost:{port}/mysql",
),
"redis": ServiceSpec(
id="redis",
image="redis",
port=6379,
env=(),
health_options=f'--health-cmd "redis-cli ping" {_HEALTH}',
url_template="redis://localhost:{port}",
),
}

SERVICE_IDS: tuple[str, ...] = tuple(SERVICE_REGISTRY)
Loading