授权模型切换为个人副本签发;share 脱敏共建;自更新通道

- 公众版不再共享 1000 次授权池:移除 data/.license.json,
  改置 data/.public_edition.json 标记,首次排盘本机签发个人副本
  (初始 20 次、不限期);推送脱敏档案每盘 +10 次(同盘只计一次)
- 新增 share/ 共建:结构化脱敏导出(share_export.py)+
  双远端推送(share_push.py)+ 隐私协议(share/README.md);
  推送前必须展示脱敏包全文并获用户授权
- 新增自更新:VERSION + manifest.json(SHA256 清单)+
  scripts/self_update.py(24h 缓存检查、先验后换、保留授权与档案);
  排盘成功后 stderr 提示新版本
- SKILL.md 增「档案共建(share/)」「版本与自更新」两节;
  LICENSE.md 增第六条(档案权属与撤回)
- 测试 100 项全绿
This commit is contained in:
2026-10-09 21:24:25 +08:00
parent 5889d7a150
commit 26a83df3c4
20 changed files with 2760 additions and 22 deletions
+91 -1
View File
@@ -1,5 +1,6 @@
"""软授权锁:母版放行、副本扣次、用尽/过期/篡改/总开关。"""
"""软授权锁:母版放行、副本扣次、用尽/过期/篡改/总开关、公众版首启签发、计励幂等。"""
import json
import re
import sys
from pathlib import Path
@@ -71,3 +72,92 @@ class TestMasterSwitch:
gate.consume(root) # 不扣次
st = json.loads((root / "data" / ".license.json").read_text("utf-8"))
assert st["remaining_uses"] == 0
def make_public_marker(root):
(root / "data").mkdir(exist_ok=True)
(root / "data" / ".public_edition.json").write_text(json.dumps(
{"edition": "public", "pack_version": "0.1.0",
"pool_copy_id": "LY-20261009-PUB0"}, ensure_ascii=False), "utf-8")
class TestPublicEdition:
def test_first_check_issues_personal_license(self, root):
make_public_marker(root)
assert gate.check(root) is None
st = json.loads((root / "data" / ".license.json").read_text("utf-8"))
assert re.fullmatch(r"LY-\d{8}-[0-9A-F]{4}", st["copy_id"])
assert st["licensee"] == "public-personal"
assert st["max_uses"] == 20
assert st["remaining_uses"] == 20
assert st["expires_at"] is None
assert st["granted_archives"] == []
def test_second_check_keeps_same_copy_id(self, root):
make_public_marker(root)
gate.check(root)
first = json.loads((root / "data" / ".license.json").read_text("utf-8"))
gate.check(root)
second = json.loads((root / "data" / ".license.json").read_text("utf-8"))
assert first["copy_id"] == second["copy_id"]
assert first["sig"] == second["sig"]
def test_issued_copy_consumes_then_denies(self, root):
make_public_marker(root)
assert gate.check(root) is None
for _ in range(20):
gate.consume(root)
assert "次数已用尽" in gate.check(root)
def test_master_without_marker_stays_unlocked(self, root):
assert gate.check(root) is None # 母版:无状态文件也无标记
assert not (root / "data" / ".license.json").exists()
class TestGrantUses:
def test_grant_adds_uses_and_is_idempotent(self, root):
make_state(root, max_uses=5)
assert gate.grant_uses(root, 10, "hashA") is True
st = json.loads((root / "data" / ".license.json").read_text("utf-8"))
assert st["remaining_uses"] == 15
# 同一盘只计一次:第二次返回 False 且不加次
assert gate.grant_uses(root, 10, "hashA") is False
st = json.loads((root / "data" / ".license.json").read_text("utf-8"))
assert st["remaining_uses"] == 15
assert st["granted_archives"] == ["hashA"]
# 另一盘可再计
assert gate.grant_uses(root, 10, "hashB") is True
st = json.loads((root / "data" / ".license.json").read_text("utf-8"))
assert st["remaining_uses"] == 25
assert st["granted_archives"] == ["hashA", "hashB"]
def test_grant_on_master_returns_true_without_state(self, root):
assert gate.grant_uses(root, 10, "hashA") is True
assert not (root / "data" / ".license.json").exists()
def test_grant_on_tampered_state_refused(self, root):
make_state(root, max_uses=5)
p = root / "data" / ".license.json"
st = json.loads(p.read_text("utf-8"))
st["remaining_uses"] = 999 # 手改次数但不重算签名
p.write_text(json.dumps(st, ensure_ascii=False), "utf-8")
assert gate.grant_uses(root, 10, "hashA") is False
st = json.loads(p.read_text("utf-8"))
assert st["remaining_uses"] == 999 # 未被计励写回
def test_state_written_by_old_schema_still_verifiable(self, root):
# 旧格式状态文件(无 granted_archives 字段)签名校验不受影响
st = {
"copy_id": "LY-20261007-A3F9", "licensee": "张三",
"issued_at": "2026-10-07", "expires_at": None,
"max_uses": 2, "remaining_uses": 2,
}
st["sig"] = gate._sig(st)
(root / "data").mkdir()
(root / "data" / ".license.json").write_text(
json.dumps(st, ensure_ascii=False), "utf-8")
assert gate.check(root) is None
assert gate.grant_uses(root, 10, "hashA") is True
st = json.loads((root / "data" / ".license.json").read_text("utf-8"))
assert st["remaining_uses"] == 12
assert gate.check(root) is None
+247
View File
@@ -0,0 +1,247 @@
"""自更新:版本比较、24h 缓存、manifest 校验拦截、apply 同步与保留区、退出码。
远端用本地临时目录伪造:VERSION / zipball 均以 file:// 地址经
ZIPEC_VERSION_URLS / ZIPEC_ZIPBALL_URLS 注入,不碰真实网络。
"""
import hashlib
import json
import subprocess
import sys
import time
import zipfile
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
import self_update as su
# ---- 造本地副本与伪造远端 ----
def make_local(tmp_path, version="3.5.0"):
root = tmp_path / "local"
(root / "scripts").mkdir(parents=True)
(root / "data").mkdir()
(root / "VERSION").write_text(version + "\n", encoding="utf-8")
(root / "scripts" / "cast_chart.py").write_text("# old\n", encoding="utf-8")
(root / "data" / "cities.json").write_text("{}", encoding="utf-8")
(root / "stale_legacy.py").write_text("# to be removed\n", encoding="utf-8")
(root / "legacy").mkdir()
(root / "legacy" / "gone.py").write_text("# to be removed\n", encoding="utf-8")
# 保留区:更新后必须原样存活
(root / "data" / ".license.json").write_text('{"copy_id": "x"}', encoding="utf-8")
(root / "memory" / "archives").mkdir(parents=True)
(root / "memory" / "archives" / "keep.md").write_text("keep", encoding="utf-8")
(root / "share" / "pending").mkdir(parents=True)
(root / "share" / "pending" / "keep.md").write_text("keep", encoding="utf-8")
return root
def make_remote(tmp_path, version="3.6.0", files=None, tamper=None):
"""造远端 zipball(带一层顶层目录,模拟 GitHub codeload 结构)+ 远端 VERSION。
tamper=<相对路径>:先按原内容算清单哈希,再篡改该文件,用于触发校验拦截。
"""
src = tmp_path / "zipcontent" / "ZiPingEightChar-main"
src.mkdir(parents=True)
all_files = {"VERSION": version + "\n", **(files or {})}
for rel, content in all_files.items():
p = src / rel
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(content, encoding="utf-8")
hashes = {
rel: hashlib.sha256((src / rel).read_bytes()).hexdigest()
for rel in all_files
}
(src / "manifest.json").write_text(
json.dumps({"version": version, "files": hashes}, indent=2) + "\n",
encoding="utf-8",
)
if tamper:
(src / tamper).write_text("# tampered\n", encoding="utf-8")
zip_path = tmp_path / "remote.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
for f in sorted(src.parent.rglob("*")):
zf.write(f, f.relative_to(src.parent))
version_file = tmp_path / "REMOTE_VERSION"
version_file.write_text(version + "\n", encoding="utf-8")
return version_file, zip_path
def point_at_remote(monkeypatch, version_file, zip_path=None):
monkeypatch.setenv("ZIPEC_VERSION_URLS", version_file.as_uri())
if zip_path is not None:
monkeypatch.setenv("ZIPEC_ZIPBALL_URLS", zip_path.as_uri())
REMOTE_FILES = {
"scripts/cast_chart.py": "# new\n",
"scripts/new_feature.py": "# new file\n",
"data/cities.json": '{"new": 1}',
}
class TestVersionCompare:
def test_parse(self):
assert su.parse_version("3.5.0") == (3, 5, 0)
assert su.parse_version(" 3.10.1\n") == (3, 10, 1)
with pytest.raises(ValueError):
su.parse_version("3.x.0")
def test_newer_remote_marks_update(self, tmp_path, monkeypatch):
root = make_local(tmp_path)
vf, _ = make_remote(tmp_path)
point_at_remote(monkeypatch, vf)
assert su.cmd_check(root) == 0
cache = json.loads((root / "data" / ".update_check.json").read_text())
assert cache["remote_version"] == "3.6.0"
assert cache["update_available"] is True
def test_same_version_no_update(self, tmp_path, monkeypatch):
root = make_local(tmp_path)
vf, _ = make_remote(tmp_path, version="3.5.0")
point_at_remote(monkeypatch, vf)
assert su.cmd_check(root) == 0
cache = json.loads((root / "data" / ".update_check.json").read_text())
assert cache["update_available"] is False
class TestCacheFreshness:
def test_fresh_cache_skips_network(self, tmp_path, monkeypatch, capsys):
root = make_local(tmp_path)
calls = []
def fake_fetch(url, timeout=3):
calls.append(url)
return "3.6.0"
monkeypatch.setattr(su, "_fetch_text", fake_fetch)
assert su.cmd_check(root) == 0
assert su.cmd_check(root) == 0
assert len(calls) == 1 # 24h 内第二次 --check 直接读缓存
assert "已检查过" in capsys.readouterr().out
def test_stale_cache_refetches(self, tmp_path, monkeypatch):
root = make_local(tmp_path)
calls = []
monkeypatch.setattr(
su, "_fetch_text",
lambda url, timeout=3: calls.append(url) or "3.6.0",
)
su.cmd_check(root)
p = root / "data" / ".update_check.json"
cache = json.loads(p.read_text())
cache["checked_at"] = time.time() - 25 * 3600 # 人为过期
p.write_text(json.dumps(cache), encoding="utf-8")
assert su.cmd_check(root) == 0
assert len(calls) == 2
def test_notify_reads_cache_only(self, tmp_path, capsys):
root = tmp_path / "local"
root.mkdir()
assert su.notify_message(root) is None # 无缓存 → 静默
assert su.cmd_notify(root) == 0
assert capsys.readouterr().err == ""
def test_notify_prints_hint(self, tmp_path, capsys):
root = make_local(tmp_path)
(root / "data" / ".update_check.json").write_text(json.dumps({
"checked_at": time.time(), "local_version": "3.5.0",
"remote_version": "3.6.0", "update_available": True,
}), encoding="utf-8")
msg = su.notify_message(root)
assert "3.6.0" in msg and "--apply" in msg
assert su.cmd_notify(root) == 0
assert "3.6.0" in capsys.readouterr().err
def test_refresh_cache_failure_silent(self, tmp_path, monkeypatch):
root = make_local(tmp_path)
def boom(url, timeout=3):
raise OSError("network down")
monkeypatch.setattr(su, "_fetch_text", boom)
su.refresh_cache(root) # 不抛
assert not (root / "data" / ".update_check.json").exists()
class TestApply:
def test_success_syncs_and_preserves(self, tmp_path, monkeypatch, capsys):
root = make_local(tmp_path)
vf, zp = make_remote(tmp_path, files=REMOTE_FILES)
point_at_remote(monkeypatch, vf, zp)
assert su.cmd_apply(root) == 0
assert "更新完成" in capsys.readouterr().out
# 清单内文件被覆盖/新增
assert (root / "VERSION").read_text().strip() == "3.6.0"
assert (root / "scripts" / "cast_chart.py").read_text() == "# new\n"
assert (root / "scripts" / "new_feature.py").exists()
assert (root / "manifest.json").exists()
# 清单不含的本地多余文件被清理
assert not (root / "stale_legacy.py").exists()
assert not (root / "legacy").exists() # 清空后的空目录一并移除
# 保留区原样存活
assert (root / "data" / ".license.json").read_text() == '{"copy_id": "x"}'
assert (root / "data" / ".update_check.json").exists()
assert (root / "memory" / "archives" / "keep.md").read_text() == "keep"
assert (root / "share" / "pending" / "keep.md").read_text() == "keep"
def test_tampered_file_aborts_untouched(self, tmp_path, monkeypatch, capsys):
root = make_local(tmp_path)
vf, zp = make_remote(tmp_path, files=REMOTE_FILES,
tamper="scripts/cast_chart.py")
point_at_remote(monkeypatch, vf, zp)
assert su.cmd_apply(root) == 3
assert "不符" in capsys.readouterr().err
# 本地原样不动
assert (root / "VERSION").read_text().strip() == "3.5.0"
assert (root / "scripts" / "cast_chart.py").read_text() == "# old\n"
assert (root / "stale_legacy.py").exists()
assert not (root / "scripts" / "new_feature.py").exists()
def test_already_latest_exit_2(self, tmp_path, monkeypatch, capsys):
root = make_local(tmp_path)
vf, zp = make_remote(tmp_path, version="3.5.0")
point_at_remote(monkeypatch, vf, zp)
assert su.cmd_apply(root) == 2
assert "无需更新" in capsys.readouterr().out
def test_missing_local_version_exit_2(self, tmp_path):
root = tmp_path / "local"
root.mkdir()
assert su.cmd_apply(root) == 2
def test_remote_unreachable_exit_3(self, tmp_path, monkeypatch):
root = make_local(tmp_path)
monkeypatch.setenv("ZIPEC_VERSION_URLS", "file:///nonexistent/VERSION")
assert su.cmd_apply(root) == 3
assert su.cmd_check(root) == 3 # 无缓存且远端不可达
assert (root / "VERSION").read_text().strip() == "3.5.0"
class TestCastChartIntegration:
def test_notice_on_stderr_stdout_clean(self):
"""排盘成功后 stderr 出一行提示;stdout 命盘契约不受影响。"""
root = su.repo_root()
cache_path = root / "data" / ".update_check.json"
old_bytes = cache_path.read_bytes() if cache_path.exists() else None
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(json.dumps({
"checked_at": time.time(), "local_version": "3.5.0",
"remote_version": "9.9.9", "update_available": True,
}), encoding="utf-8")
try:
r = subprocess.run(
[sys.executable, str(root / "scripts" / "cast_chart.py"),
"--name", "测试", "--gender", "男",
"--birth", "1990-05-14 08:30", "--place", "四川省成都市"],
capture_output=True, text=True, timeout=60,
)
assert r.returncode == 0
assert r.stdout.startswith("# 命盘:测试")
assert "技能有新版本 9.9.9" in r.stderr
assert "--apply" in r.stderr
finally:
if old_bytes is None:
cache_path.unlink(missing_ok=True)
else:
cache_path.write_bytes(old_bytes)
+220
View File
@@ -0,0 +1,220 @@
"""share/ 脱敏共建:导出脱敏、完整度门槛、推送计励幂等(本地 bare git 仓库当远端,无网络)。"""
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
SKILL_ROOT = Path(__file__).resolve().parents[1]
SCRIPTS_DIR = SKILL_ROOT / "scripts"
EXPORT = SCRIPTS_DIR / "share_export.py"
PUSH = SCRIPTS_DIR / "share_push.py"
sys.path.insert(0, str(SCRIPTS_DIR))
import _license_gate as gate
import _memory_store as store
PILLARS = ["庚午", "辛巳", "己卯", "戊辰"]
BIRTH = "1990-05-14 08:30"
PLACE = "四川省成都市"
git = pytest.mark.skipif(shutil.which("git") is None, reason="无 git")
def run(script, *args):
env = dict(os.environ)
env["PYTHONPATH"] = str(SCRIPTS_DIR) + os.pathsep + env.get("PYTHONPATH", "")
return subprocess.run([sys.executable, str(script), *args],
capture_output=True, text=True, timeout=120, env=env)
def make_archive(mem_root: Path, feedback: str | None = None) -> Path:
"""造一份手工档案:四柱/大运/关注方向齐全,反馈小节可定制。"""
meta = {"name": "张三", "gender": "男", "birth_clock": BIRTH,
"birthplace": PLACE, "true_solar_time": "1990-05-14 08:23",
"pillars": PILLARS, "day_master": "己",
"created_at": "2026-10-01 10:00", "updated_at": "2026-10-08 15:00"}
body = (
"# 档案:张三(男)\n\n"
"## 盘面\n\n"
"# 命盘:张三\n\n"
"## 大运\n\n"
"- 排法:顺行;起运:7 岁 8 个月 1 天(7.67 岁)\n"
"- 第 1 步:壬午(7.67 岁起,1998 年)\n"
"- 第 2 步:癸未(17.67 岁起,2008 年)\n"
"- 第 3 步:甲申(27.67 岁起,2018 年)\n\n"
"## 小运\n\n(无)\n\n"
"## 主要关注方向\n\n- 婚姻与事业走向\n\n"
"## 归纳\n\n- 性格:未断\n- 身体:未断\n- 事业:未断\n- 感情:未断\n- 婚姻:未断\n\n"
"## 流年大事\n\n(未记录)\n\n"
"## 反馈与澄清\n\n"
+ (feedback if feedback is not None else
"- 【已核实】2018 年升职,跳槽到外企\n- 【未核实】2020 年感情波动\n")
)
arch_dir = mem_root / "archives"
arch_dir.mkdir(parents=True, exist_ok=True)
path = arch_dir / store.archive_name(
PILLARS, "男", store.key_hash(BIRTH, PLACE, "男"))
path.write_text(store.dump_frontmatter(meta) + body, encoding="utf-8")
return path
@pytest.fixture
def share_root(tmp_path):
return tmp_path / "share"
class TestExport:
def test_package_is_sanitized(self, tmp_path, share_root):
arch = make_archive(tmp_path / "memory")
r = run(EXPORT, "--archive", str(arch), "--share-root", str(share_root))
assert r.returncode == 0, r.stderr
out = Path(r.stdout.strip())
assert out.parent == share_root / "pending"
text = out.read_text("utf-8")
pkg = json.loads(text)
# 脱敏:不含姓名、出生分钟/钟表时间、省以下地域
assert "name" not in pkg
assert "张三" not in text
assert "08:30" not in text and "08:23" not in text
assert "成都市" not in text
assert "birth_clock" not in pkg and "birthplace" not in pkg
# 结构字段
assert pkg["schema"] == "share/v1"
assert pkg["gender"] == "男"
assert pkg["pillars"] == PILLARS
assert pkg["birth_date"] == "1990-05-14"
assert pkg["birth_shichen"] == "辰"
assert pkg["region_province"] == "四川省"
assert [l["pillar"] for l in pkg["luck_pillars"]] == ["壬午", "癸未", "甲申"]
assert pkg["luck_pillars"][0]["start_year"] == 1998
assert pkg["focus_categories"] == ["婚姻", "事业"]
assert {f["year"] for f in pkg["feedback"]} == {2018, 2020}
assert {f["status"] for f in pkg["feedback"]} == {"已核实", "未核实"}
assert all(f["category"] for f in pkg["feedback"])
# 盘 hash = sha1(四柱+出生地) 前 8,文件名即 hash
assert out.stem == pkg["chart_hash"]
assert len(pkg["chart_hash"]) == 8
def test_lookup_by_birth_place_gender(self, tmp_path, share_root):
make_archive(tmp_path / "memory")
r = run(EXPORT, "--birth", BIRTH, "--place", PLACE, "--gender", "男",
"--memory-root", str(tmp_path / "memory"),
"--share-root", str(share_root))
assert r.returncode == 0, r.stderr
assert (share_root / "pending").glob("*.json")
def test_completeness_gate_refuses_without_feedback(self, tmp_path, share_root):
arch = make_archive(tmp_path / "memory", feedback="(无)\n")
r = run(EXPORT, "--archive", str(arch), "--share-root", str(share_root))
assert r.returncode == 2
assert "反馈事件" in r.stderr
assert not list((share_root / "pending").glob("*.json"))
def test_archive_not_found(self, tmp_path, share_root):
r = run(EXPORT, "--archive", str(tmp_path / "nope.md"),
"--share-root", str(share_root))
assert r.returncode == 2
@git
class TestPush:
@pytest.fixture
def skill(self, tmp_path):
"""假技能根:share/ 骨架 + 两个 bare 远端 + 授权状态(5 次)。"""
root = tmp_path / "skill"
(root / "share" / "pending").mkdir(parents=True)
(root / "share" / "pushed").mkdir(parents=True)
remotes = {}
for name in ("github", "gitea"):
bare = tmp_path / f"{name}.git"
subprocess.run(["git", "init", "--bare", str(bare)],
capture_output=True, check=True)
remotes[f"{name}_remote"] = str(bare)
(root / "share" / "config.json").write_text(
json.dumps(remotes, ensure_ascii=False), "utf-8")
gate.write_state(root, copy_id="LY-20261009-TEST",
licensee="测试", max_uses=5)
return root
def _export_into(self, skill_root, tmp_path):
arch = make_archive(tmp_path / "memory")
r = run(EXPORT, "--archive", str(arch),
"--share-root", str(skill_root / "share"))
assert r.returncode == 0, r.stderr
return Path(r.stdout.strip())
def test_push_grants_uses_and_moves_package(self, skill, tmp_path):
pkg = self._export_into(skill, tmp_path)
r = run(PUSH, "--root", str(skill))
assert r.returncode == 0, r.stderr
assert not pkg.exists()
assert (skill / "share" / "pushed" / pkg.name).is_file()
st = json.loads((skill / "data" / ".license.json").read_text("utf-8"))
assert st["remaining_uses"] == 15 # 5 + 10
assert st["granted_archives"] == [pkg.stem]
# 远端确实收到提交
log = subprocess.run(
["git", "--git-dir", str(tmp_path / "github.git"), "log", "--oneline"],
capture_output=True, text=True)
assert f"share: {pkg.stem}" in log.stdout
def test_push_same_chart_twice_grants_once(self, skill, tmp_path):
pkg = self._export_into(skill, tmp_path)
assert run(PUSH, "--root", str(skill)).returncode == 0
# 同一盘再次导出推送:仍成功移走,但不重复计励
pkg2 = self._export_into(skill, tmp_path)
assert pkg2.name == pkg.name
r = run(PUSH, "--root", str(skill))
assert r.returncode == 0, r.stderr
assert "不重复" in r.stdout
st = json.loads((skill / "data" / ".license.json").read_text("utf-8"))
assert st["remaining_uses"] == 15
assert st["granted_archives"] == [pkg.stem]
def test_push_failure_keeps_pending(self, skill):
bad = {"github_remote": str(skill / "no-such-repo.git"),
"gitea_remote": ""}
(skill / "share" / "config.json").write_text(
json.dumps(bad, ensure_ascii=False), "utf-8")
pkg = skill / "share" / "pending" / "deadbeef.json"
pkg.write_text(json.dumps({"schema": "share/v1",
"chart_hash": "deadbeef"}), "utf-8")
r = run(PUSH, "--root", str(skill))
assert r.returncode == 3
assert pkg.is_file() # 失败保留 pending
st = json.loads((skill / "data" / ".license.json").read_text("utf-8"))
assert st["remaining_uses"] == 5 # 未计励
def test_no_remote_configured(self, tmp_path):
root = tmp_path / "skill"
(root / "share" / "pending").mkdir(parents=True)
(root / "share" / "config.json").write_text(
json.dumps({"github_remote": "", "gitea_remote": ""}), "utf-8")
(root / "share" / "pending" / "abc12345.json").write_text("{}", "utf-8")
r = run(PUSH, "--root", str(root))
assert r.returncode == 3
assert (root / "share" / "pending" / "abc12345.json").is_file()
def test_env_overrides_config(self, skill, tmp_path):
# config 远端写死为坏地址,环境变量指向好远端
(skill / "share" / "config.json").write_text(json.dumps(
{"github_remote": str(skill / "bad.git"), "gitea_remote": ""}), "utf-8")
good = tmp_path / "env.git"
subprocess.run(["git", "init", "--bare", str(good)],
capture_output=True, check=True)
pkg = skill / "share" / "pending" / "c0ffee11.json"
pkg.write_text(json.dumps({"schema": "share/v1",
"chart_hash": "c0ffee11"}), "utf-8")
env = dict(os.environ)
env["PYTHONPATH"] = str(SCRIPTS_DIR) + os.pathsep + env.get("PYTHONPATH", "")
env["ZIPING_SHARE_GITHUB_REMOTE"] = str(good)
r = subprocess.run([sys.executable, str(PUSH), "--root", str(skill)],
capture_output=True, text=True, timeout=120, env=env)
assert r.returncode == 0, r.stderr
assert (skill / "share" / "pushed" / "c0ffee11.json").is_file()