666ghj/MiroFish · error · StarHistoryError

could not atomically replace Star History outputs

Error message

could not atomically replace Star History outputs

What it means

Raised by _write_outputs when any OSError escapes the try block that writes temp files (NamedTemporaryFile + fsync + chmod), validates them, and os.replace()s them onto the three output paths. The original OSError is chained as __cause__. The finally-block unlinks leftover temp files, so a failure leaves the existing outputs untouched — the atomic design means nothing is half-written.

Source

Thrown at scripts/star_history.py:1337

            ) 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)
    for relative in OUTPUT_RELATIVES[1:]:
        target = _safe_target(workspace, relative, create_parent=False)
        actual = _read_limited(target, MAX_STATE_BYTES, str(relative))
        _validate_svg(actual)

View on GitHub (pinned to b5b53acc57)

Solutions

  1. Inspect exc.__cause__ — the underlying OSError carries errno (ENOSPC, EACCES, EROFS, ENOENT) that identifies the real problem.
  2. Ensure the running user can create files in .github/star-history/ and static/image/ (the temp files are written into those same directories).
  3. Free space / raise the disk quota if ENOSPC; check the mount is not read-only in containers.
  4. Re-run the command — generation is deterministic from history.json, so the retry produces identical payloads with no partial state.

Example fix

# before: read-only mount in docker-compose.yml
volumes:
  - ./static:/app/static:ro   # EROFS -> 'could not atomically replace...'

# after: writable mount (or write outside and copy in CI)
volumes:
  - ./static:/app/static
Defensive patterns

Strategy: try-catch

Validate before calling

import os

for parent in {t.parent for t in targets.values()}:
    assert parent.is_dir(), f"missing output dir {parent}"
    assert os.access(parent, os.W_OK), f"no write permission on {parent}"

usage = shutil.disk_usage(targets[0].parent)
assert usage.free > 10 * 1024 * 1024, "insufficient disk space for atomic write"

Try / catch

try:
    changed = _write_outputs(root, state)
except StarHistoryError as exc:
    cause = exc.__cause__
    if isinstance(cause, OSError):
        logging.error("atomic replace failed: errno=%s path=%s", cause.errno, cause.filename)
        # EACCES/EROFS -> fix mount/permissions; ENOSPC -> free space; then re-run
    raise

Prevention

When it happens

Trigger: Read-only workspace or missing write permission on static/image/; disk full or inode exhaustion when creating the temp files; fsync failing on exotic filesystems; os.replace failing because the destination is a directory or on a different mount (shouldn't happen since temps are created in the same dir); a parent directory (e.g. .github/star-history) deleted mid-run.

Common situations: CI running as a user without write access to the checkout; Docker容器 with a read-only bind mount for static/; disk quota exceeded during backfill; a pre-push hook or file watcher holding locks on Windows; SELinux denying writes despite rwx bits.

Related errors


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