666ghj/MiroFish · error · StarHistoryError

output path changed during update

Error message

output path changed during update

What it means

Raised by _write_outputs during the atomic publish loop: for each output (history.json, star-history-light.svg, star-history-dark.svg) it re-resolves the destination with _safe_target and compares against the path captured before writing temp files. A mismatch means the workspace layout moved underneath the update — typically a symlink swap or directory replacement between the two resolutions — and the update aborts before os.replace to avoid writing through a redirected path.

Source

Thrown at scripts/star_history.py:1333

                dir=target.parent,
                prefix=f".{target.name}.",
                suffix=".tmp",
                delete=False,
            ) as handle:
                handle.write(payloads[relative])
                handle.flush()
                os.fsync(handle.fileno())
                temporary_paths[relative] = Path(handle.name)
            os.chmod(temporary_paths[relative], 0o644)

        _validate_svg(temporary_paths[LIGHT_SVG_RELATIVE].read_bytes())
        _validate_svg(temporary_paths[DARK_SVG_RELATIVE].read_bytes())
        json.loads(temporary_paths[STATE_RELATIVE].read_bytes())

        for relative in OUTPUT_RELATIVES:
            checked_target = _safe_target(workspace, relative, create_parent=False)
            if checked_target != targets[relative]:
                raise StarHistoryError("output path changed during update")
            os.replace(temporary_paths[relative], targets[relative])
            temporary_paths.pop(relative, None)
    except OSError as exc:
        raise StarHistoryError("could not atomically replace Star History outputs") from exc
    finally:
        for temporary in temporary_paths.values():
            try:
                temporary.unlink(missing_ok=True)
            except OSError:
                pass

    check_workspace(workspace)
    return True


def check_workspace(workspace: Path) -> None:
    state = load_state(workspace, require_canonical=True)
    expected = _output_payloads(state)

View on GitHub (pinned to b5b53acc57)

Solutions

  1. Serialize runs: only one star-history update per checkout at a time (CI mutex, lockfile around the execute() entry point).
  2. Remove symlinks from the output paths — keep static/image/ and .github/star-history/ as real directories tracked by git.
  3. Retry the command once after the race clears; the write is idempotent (payloads are deterministic from history.json) so a clean retry is safe.
  4. If a deploy pipeline must swap directories, make it wait on the generator's completion marker rather than racing it.

Example fix

# before: two concurrent jobs race the same workspace
job1: python scripts/star_history.py update   #
job2: python scripts/star_history.py update   # path re-resolves differently -> raises

# after: serialize with a lock around execute()
with filelock.FileLock(".star-history.lock", timeout=600):
    result = execute("update", github=gh, clock=clock, workspace=root)
Defensive patterns

Strategy: retry

Validate before calling

from pathlib import Path

def outputs_are_stable(workspace: Path) -> bool:
    # resolve twice; if paths move between resolutions, another actor is mutating the tree
    first = {r: _safe_target(workspace, r, create_parent=False) for r in OUTPUT_RELATIVES}
    second = {r: _safe_target(workspace, r, create_parent=False) for r in OUTPUT_RELATIVES}
    return first == second and not any(p.is_symlink() for p in first.values())

Try / catch

for attempt in range(2):
    try:
        changed = _write_outputs(root, state)
        break
    except StarHistoryError as exc:
        if "output path changed" not in str(exc) or attempt == 1:
            raise
    # race with another writer; release and retry once

Prevention

When it happens

Trigger: A symlink at static/image/star-history-light.svg (or a parent dir like .github/star-history) is retargeted while the job runs; a concurrent process (another CI job, a deploy hook) rewrites/replaces directories during the seconds between target capture (line 1300) and re-check (line 1331); renaming the workspace root concurrently.

Common situations: Two CI jobs or scheduled runs executing simultaneously in one checkout; build tooling that swaps static/ via symlink for atomic site deploys; developers re-arranging directories while a long backfill runs; workspace on a network mount with path instability.

Related errors


AI-assisted analysis of 666ghj/MiroFish@b5b53acc57 (2026-08-14). Data as JSON: /api/errors/0b2ba2ae8b2f71e2. Report an issue: GitHub.