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
- Inspect exc.__cause__ — the underlying OSError carries errno (ENOSPC, EACCES, EROFS, ENOENT) that identifies the real problem.
- Ensure the running user can create files in .github/star-history/ and static/image/ (the temp files are written into those same directories).
- Free space / raise the disk quota if ENOSPC; check the mount is not read-only in containers.
- 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
- Run the generator as a user with write access to .github/star-history/ and static/image/.
- Keep writable mounts writable in container configs (no :ro on output paths).
- Writes are atomic and deterministic — after fixing the underlying OSError, a plain retry is safe.
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
- could not create output directory
- could not read {label}
- output path changed during update
- workspace does not exist
- workspace is not a directory
AI-assisted analysis of 666ghj/MiroFish@b5b53acc57 (2026-08-14).
Data as JSON: /api/errors/4a1d38b79b935326.
Report an issue: GitHub.