fix(marketplace): Publish plugin sync atomically

This commit is contained in:
sickn33
2026-03-30 21:43:29 +02:00
parent 95e9852789
commit 8622fe40f5
2 changed files with 115 additions and 42 deletions

View File

@@ -4,11 +4,14 @@ from __future__ import annotations
import argparse
import errno
import json
import os
import re
import shutil
import tempfile
import time
import uuid
from pathlib import Path
from typing import Any
from typing import Any, Callable
from _project_paths import find_repo_root
from plugin_compatibility import build_report as build_plugin_compatibility_report
@@ -587,16 +590,55 @@ def _remove_tree(path: Path, retries: int = 3, delay_seconds: float = 0.1) -> No
def _materialize_plugin_skills(root: Path, destination_root: Path, skill_ids: list[str]) -> None:
if destination_root.is_symlink() or destination_root.is_file():
destination_root.unlink()
elif destination_root.exists():
_remove_tree(destination_root)
destination_root.mkdir(parents=True, exist_ok=True)
for skill_id in skill_ids:
_copy_skill_directory(root, skill_id, destination_root)
def _remove_path(path: Path) -> None:
if path.is_symlink() or path.is_file():
path.unlink()
return
if path.exists():
_remove_tree(path)
def _replace_directory_atomically(
destination_root: Path,
populate: Callable[[Path], None],
) -> None:
parent = destination_root.parent
parent.mkdir(parents=True, exist_ok=True)
staging_root = Path(
tempfile.mkdtemp(
prefix=f".{destination_root.name}.staging-",
dir=parent,
)
)
backup_root = parent / f".{destination_root.name}.backup-{uuid.uuid4().hex}"
replaced_existing = False
try:
populate(staging_root)
if destination_root.exists() or destination_root.is_symlink():
os.replace(destination_root, backup_root)
replaced_existing = True
os.replace(staging_root, destination_root)
except Exception:
if replaced_existing and backup_root.exists() and not destination_root.exists():
os.replace(backup_root, destination_root)
raise
finally:
if staging_root.exists():
_remove_path(staging_root)
if backup_root.exists():
_remove_path(backup_root)
def _supported_skill_ids(
compatibility: dict[str, dict[str, Any]],
target: str,
@@ -619,18 +661,24 @@ def _sync_root_plugins(
codex_root = root / "plugins" / ROOT_CODEX_PLUGIN_NAME
claude_root = root / "plugins" / ROOT_CLAUDE_PLUGIN_DIRNAME
_materialize_plugin_skills(root, codex_root / "skills", codex_skill_ids)
_materialize_plugin_skills(root, claude_root / "skills", claude_skill_ids)
def populate_codex_root(staging_root: Path) -> None:
_materialize_plugin_skills(root, staging_root / "skills", codex_skill_ids)
_write_json(
staging_root / ".codex-plugin" / "plugin.json",
_root_codex_plugin_manifest(metadata, len(codex_skill_ids)),
)
def populate_claude_root(staging_root: Path) -> None:
_materialize_plugin_skills(root, staging_root / "skills", claude_skill_ids)
_write_json(
staging_root / ".claude-plugin" / "plugin.json",
_root_claude_plugin_manifest(metadata, len(claude_skill_ids)),
)
_replace_directory_atomically(codex_root, populate_codex_root)
_replace_directory_atomically(claude_root, populate_claude_root)
_write_json(
codex_root / ".codex-plugin" / "plugin.json",
_root_codex_plugin_manifest(metadata, len(codex_skill_ids)),
)
claude_manifest = _root_claude_plugin_manifest(metadata, len(claude_skill_ids))
_write_json(
claude_root / ".claude-plugin" / "plugin.json",
claude_manifest,
)
_write_json(root / CLAUDE_PLUGIN_PATH, claude_manifest)
@@ -645,25 +693,26 @@ def _sync_bundle_plugin_directory(
plugin_name = _bundle_plugin_name(bundle["id"])
plugin_root = root / "plugins" / plugin_name
if plugin_root.exists():
_remove_tree(plugin_root)
bundle_skills_root = plugin_root / "skills"
bundle_skills_root.mkdir(parents=True, exist_ok=True)
def populate_bundle_plugin(staging_root: Path) -> None:
bundle_skills_root = staging_root / "skills"
bundle_skills_root.mkdir(parents=True, exist_ok=True)
for skill in bundle["skills"]:
_copy_skill_directory(root, skill["id"], bundle_skills_root)
for skill in bundle["skills"]:
_copy_skill_directory(root, skill["id"], bundle_skills_root)
if support["claude"]:
_write_json(
plugin_root / ".claude-plugin" / "plugin.json",
_bundle_claude_plugin_manifest(metadata, bundle),
)
if support["codex"]:
_write_json(
plugin_root / ".codex-plugin" / "plugin.json",
_bundle_codex_plugin_manifest(metadata, bundle),
)
if support["claude"]:
_write_json(
staging_root / ".claude-plugin" / "plugin.json",
_bundle_claude_plugin_manifest(metadata, bundle),
)
if support["codex"]:
_write_json(
staging_root / ".codex-plugin" / "plugin.json",
_bundle_codex_plugin_manifest(metadata, bundle),
)
_replace_directory_atomically(plugin_root, populate_bundle_plugin)
def sync_editorial_bundle_plugins(
@@ -673,13 +722,18 @@ def sync_editorial_bundle_plugins(
bundle_support: dict[str, dict[str, Any]],
) -> None:
plugins_root = root / "plugins"
for candidate in plugins_root.glob("antigravity-bundle-*"):
if candidate.is_dir():
shutil.rmtree(candidate)
expected_plugin_names = {
_bundle_plugin_name(bundle["id"])
for bundle in bundles
if bundle_support[bundle["id"]]["codex"] or bundle_support[bundle["id"]]["claude"]
}
for bundle in bundles:
_sync_bundle_plugin_directory(root, metadata, bundle, bundle_support[bundle["id"]])
for candidate in plugins_root.glob("antigravity-bundle-*"):
if candidate.is_dir() and candidate.name not in expected_plugin_names:
_remove_tree(candidate)
def load_editorial_bundles(root: Path) -> list[dict[str, Any]]:
root = Path(root)
@@ -710,13 +764,6 @@ def sync_editorial_bundles(root: Path) -> None:
root / CODEX_MARKETPLACE_PATH,
_render_codex_marketplace(bundles, bundle_support),
)
_write_json(
root / CODEX_ROOT_PLUGIN_PATH,
_root_codex_plugin_manifest(
metadata,
len(_supported_skill_ids(compatibility, "codex")),
),
)
sync_editorial_bundle_plugins(root, metadata, bundles, bundle_support)

View File

@@ -2,6 +2,7 @@ import importlib.util
import errno
import pathlib
import sys
import tempfile
from unittest import mock
import unittest
@@ -160,6 +161,31 @@ class EditorialBundlesTests(unittest.TestCase):
self.assertEqual(calls["count"], 2)
sleep_mock.assert_called_once()
def test_replace_directory_atomically_swaps_only_after_staging_is_ready(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_root = pathlib.Path(temp_dir)
destination = temp_root / "plugin"
old_file = destination / "skills" / "old.txt"
old_file.parent.mkdir(parents=True, exist_ok=True)
old_file.write_text("old", encoding="utf-8")
observed = {}
def populate(staging_root):
new_file = staging_root / "skills" / "new.txt"
new_file.parent.mkdir(parents=True, exist_ok=True)
new_file.write_text("new", encoding="utf-8")
observed["old_visible_during_populate"] = old_file.is_file()
observed["new_hidden_during_populate"] = not (destination / "skills" / "new.txt").exists()
editorial_bundles._replace_directory_atomically(destination, populate)
self.assertTrue(observed["old_visible_during_populate"])
self.assertTrue(observed["new_hidden_during_populate"])
self.assertFalse(old_file.exists())
self.assertTrue((destination / "skills" / "new.txt").is_file())
if __name__ == "__main__":
unittest.main()