github/spec-kit · error · TemplateResolutionError

Invalid extension registry {registry}: 'extensions' must be

Error message

Invalid extension registry {registry}: 'extensions' must be a mapping

What it means

_StagedWorkflowFile.verify_path() stats the staged path with follow_symlinks=False and fstats the open fd; if either stat call raises OSError, it wraps it as 'Staged workflow file changed before commit'. At this point the staged file was just written, so a failing stat means the path vanished or became inaccessible between write and commit — evidence of tampering or filesystem trouble — and the atomic commit is aborted.

Source

Thrown at scripts/python/common.py:263

    extensions: dict[object, object] = {}
    if os.path.lexists(registry):
        if not registry.is_file():
            raise TemplateResolutionError(
                f"Invalid extension registry {registry}: not a regular file"
            )
        try:
            data = json.loads(registry.read_text(encoding="utf-8"))
        except (OSError, UnicodeError, json.JSONDecodeError) as exc:
            raise TemplateResolutionError(
                f"Failed to parse extension registry {registry}: {exc}"
            ) from exc
        if not isinstance(data, dict):
            raise TemplateResolutionError(
                f"Invalid extension registry {registry}: root must be a mapping"
            )
        raw_extensions = data.get("extensions", {})
        if not isinstance(raw_extensions, dict):
            raise TemplateResolutionError(
                f"Invalid extension registry {registry}: "
                "'extensions' must be a mapping"
            )
        extensions = raw_extensions
        registered_ids = {
            ext_id for ext_id in extensions if isinstance(ext_id, str)
        }

    ranked: list[tuple[int, str]] = []
    for ext_id, metadata in extensions.items():
        if (
            _is_safe_component(ext_id)
            and isinstance(metadata, dict)
            and bool(metadata.get("enabled", True))
        ):
            ranked.append((_normalize_priority(metadata.get("priority")), ext_id))

    try:

View on GitHub (pinned to bf88c9f9a8)

Solutions

  1. Ensure only one specify/workflow process runs against the project at a time (lock or serialize CI steps)
  2. Exclude .specify (and the staging dir) from tmp-reaper/antivirus/cleaner tooling and `git clean -fdx`
  3. Retry the install once after the race source is removed
  4. If it persists, strace/fs_usage the run to find which process unlinks the staged path

Example fix

# before
# CI: two jobs run specify simultaneously in one workspace

# after
# serialize the steps
- run: specify workflow add my.yaml
- run: specify build  # after, not parallel
Defensive patterns

Strategy: retry

Validate before calling

import os, stat

def staged_path_still_valid(staged_path: os.PathLike, fd: int) -> bool:
    try:
        ps = os.stat(staged_path, follow_symlinks=False)
        fs = os.fstat(fd)
    except OSError:
        return False
    return stat.S_ISREG(ps.st_mode) and (ps.st_dev, ps.st_ino) == (fs.st_dev, fs.st_ino)

Try / catch

try:
    staged.verify_path()
except OSError as e:
    if 'changed before commit' in str(e):
        restage_and_retry_once()  # reopen a fresh staged file and rewrite
    else:
        raise

Prevention

When it happens

Trigger: Something removes or re-permissions the staged file (typically under .specify/tmp or the staging dir) between os.write and verify: concurrent cleanup (another specify run, tmp reaper, `git clean`), antivirus/SED quarantining freshly written files, or a flaky mount dropping the inode.

Common situations: Parallel `specify` invocations racing in the same repo; tmp-file reapers or CI janitors deleting new files in .specify/tmp; antivirus on macOS/Windows delaying or removing just-written executables; interrupted runs leaving cleanup handlers that fire late.

Related errors


AI-assisted analysis of github/spec-kit@bf88c9f9a8 (2026-08-14). Data as JSON: /api/errors/dc8e1825495b415c. Report an issue: GitHub.