sgc-electron-delta-updater
为 Electron 桌面应用设计、实现和验证增量自动更新,覆盖 macOS Sparkle、签名、appcast、发布产物与失败拦截。用户提到增量更新、Sparkle、检查更新、appcast、DMG 更新或 delta updater 时使用。
Install
npx skills add https://github.com/lovstudio/skills/tree/main/skills/electron-delta-updater
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lovstudio-skills@llmmart
git clone https://github.com/lovstudio/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole lovstudio/skills collection as a plugin from our marketplace. Git is the plain clone.
README
Electron 增量更新 · Electron Delta Updates
为 Electron 应用建立并验证可恢复的增量自动更新流程。
本地安装
将目录链接到本地 Agent Skills 目录,链接名为 sgc-electron-delta-updater。安装后可直接说“给这个 Electron App 加增量自动更新”。
使用
- “为 macOS Electron 应用配置 Sparkle delta-only 更新,并验证 appcast 与公证产物。”
- “检查更新在代理网络下一直转圈,修复并验证后续重试。”
质量门
python3 scripts/validate_skill.py .
同时运行目标项目的更新器单测、打包、签名/公证和公开更新源验证。
依赖
- Python 3.8+
- PyYAML
- 目标 Electron 项目的打包与签名工具
License
MIT
Skill manifest
Electron 增量更新 · Electron Delta Updates
为现有 Electron 应用交付可验证的增量更新链路;以用户实际安装包、公开更新源和安装结果为准,而不是以 CI 成功为准。
Triggers
Activate when
- 用户说“给这个 Electron App 加增量自动更新”或“检查更新一直转圈”。
- 用户提到 macOS Sparkle、appcast、DMG、blockmap、签名、公证或 delta 更新包。
- User asks to “add a delta updater”, “ship Sparkle updates”, or “verify an Electron update release”.
Do not activate when
- 用户只想修改版本号、更新日志或普通下载页;使用版本管理或发布工作流。
- 用户要做网页热更新、PWA 缓存更新或移动端 OTA;使用相应平台的更新机制。
Workflow (MANDATORY)
You MUST follow these steps in order.
Step 0: Establish the update contract
- Identify target platforms, package format, current updater library, update feed, signing owner, release channel, and rollback expectation.
- Treat macOS delta-only behavior as an explicit product promise only when complete archives are intentionally excluded.
- Separate four states: check, download/stage, install handoff, and post-restart verification.
- Read references/electron-macos-delta.md for macOS Sparkle work and references/release-verification.md before modifying a release pipeline.
Step 1: Make checks bounded and recoverable
- Use one in-flight check per update service.
- Pass an
AbortSignalthrough every network layer, including appcast fetches. - On timeout, abort the request, wait for stale updater state and connections to settle, then allow a later retry.
- Configure the updater's own network session; do not assume it inherits a developer shell proxy.
- Keep the UI state retryable after every timeout, download failure, or malformed feed.
Step 2: Enforce the artifact policy
- Generate architecture-specific feed entries and match each delta to its source and target version.
- Verify signature, length, architecture, and URL before staging an update.
- In delta-only mode, stop on any complete-package fallback; surface a manual-install path instead of silently downloading a larger archive.
- If a local feed proxy is required, bind it to loopback, permit only the verified delta URL, preserve HTTP range behavior, and keep the served artifact URL ending in
.delta.
Step 3: Coordinate install and restart
- Complete application cleanup before giving control to the native updater.
- Keep the process alive until asynchronous install handoff is confirmed.
- Use references/release-verification.md to verify the staged update and the installed version after relaunch.
- For a normal user-invoked app restart without an update, use
sgc-electron-app-relaunchinstead.
Step 4: Validate the deliverable
- Add tests for single-flight checks, timeout abort, stale-check recovery, rejected full fallback, feed proxy ranges, and asynchronous install handoff.
- Run the project's format, lint, typecheck, test, package, signing, and notarization checks that apply.
- Verify the public release page plus direct appcast and delta asset responses; compare the final artifact's version, digest, size, signature, and notarization state.
- Report the updater backend, supported platforms, checked URLs, installed-version proof, and any manual-install condition.
Dependencies
- An Electron project with a defined packaging and signing owner.
- Sparkle tooling only for macOS Sparkle paths.
通用反馈闭环
用户在 Skill 驱动任务中提出修改意见时,继续当前产物前必须执行:
- 先判断意见是
task-specific(仅本次)还是reusable(可跨任务复用)。 task-specific只修改当前任务,不改 Skill。reusable先确定作用域:领域规则先更新对应 canonical Skill;适用于所有 Skill 的规则先更新共享规范。- 完成规则更新、版本、lint 与分发核验后,再把修改应用到当前任务。
reusable修改会使此前的“确认”“继续”“发吧”失效;完成当前产物修改和回读后必须停下,等待用户下一步指示,不自动进入发布、提交或其他外部写入。
Files (skills)
-
references
-
electron-macos-delta.md 1.1 KB
# macOS Sparkle 增量更新契约 ## 适用条件 仅在应用以签名的 `.app` / DMG 发布、拥有稳定的 Sparkle 密钥和可公开读取的架构专属 appcast 时采用此路径。不要把开发态、未签名包或 ZIP 下载页当作自动更新测试替代品。 ## 必须保持的边界 - 检查:请求必须有截止时间,并把取消信号传进底层 fetch。 - 下载:增量包的版本、架构、长度和签名必须与 appcast 一致。 - 安装:下载完成不等于已安装;必须等待原生助手确认接手安装。 - 回退:delta-only 产品将完整包回退视为失败,并告诉用户从发布页手动安装。 ## 本地 feed 代理 当原生更新器需要封闭的更新视图时,代理只应: 1. 监听 `127.0.0.1` 的临时端口。 2. 重写 appcast 中已校验的一个 delta enclosure。 3. 把其他 enclosure 改成不可下载的地址。 4. 保留 `.delta` 扩展名、`Range`、`If-Range`、`Content-Range` 和 backpressure。 5. 在下载或安装结束后关闭服务器。 不要把任意上游 URL 变成代理能力,也不要因为代理诊断变慢而阻塞真正的更新请求。 -
release-verification.md 798 B
# 更新发布验证 ## 发布前 - 由最终已签名、已公证的包生成或刷新更新元数据;不要使用公证前的字节。 - 确认稳定版、预览版和各架构 feed 没有互相引用。 - 给更新检查、下载、失败、安装交接和启动后版本写最小自动化覆盖。 ## 发布后 1. 打开公开 release,确认版本、架构和下载包齐全。 2. 直接请求各 appcast 与 delta 资源,记录成功响应与最终 URL。 3. 对下载包执行项目要求的签名、公证、版本、大小和摘要检查。 4. 从旧版本发起一次真实检查、下载、安装交接和重启。 5. 重新读取已安装应用的版本,确认它与 feed 目标版本一致。 绿色 CI、推送的 tag、或下载完成提示都不足以替代第五步。
-
-
scripts
-
validate_skill.py 11.4 KB
#!/usr/bin/env python3 """Validate a portable local LovStudio Skill source directory.""" from __future__ import annotations import argparse import re import sys from pathlib import Path from typing import Any, Iterable try: import yaml except ImportError: print( "ERROR: PyYAML is required. Install it with: python3 -m pip install PyYAML", file=sys.stderr, ) raise SystemExit(2) FRONTMATTER_KEYS = {"name", "description", "license", "allowed-tools", "metadata"} TEXT_SUFFIXES = {".md", ".json", ".yaml", ".yml", ".txt", ".svg", ".py"} JUNK_NAMES = {"__pycache__", ".DS_Store"} JUNK_SUFFIXES = {".pyc", ".pyo"} SKIP_DIRS = {".git", "dist", ".venv", "venv", "node_modules"} SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$") NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") MARKDOWN_LINK_RE = re.compile(r"!?\[[^\]]*]\(([^)]+)\)") SKILL_PATH_RE = re.compile(r"\$(SKILL_DIR|KIT_DIR)/([A-Za-z0-9_./-]+)") class ValidationFailure(Exception): """Raised when source metadata cannot be parsed.""" def read_text(path: Path) -> str: return path.read_text(encoding="utf-8") def compact_text(value: Any) -> str: return re.sub(r"\s+", " ", value).strip() if isinstance(value, str) else "" def split_frontmatter(path: Path) -> tuple[dict[str, Any], str]: text = read_text(path) if not text.startswith("---\n"): raise ValidationFailure(f"{path}: missing YAML frontmatter") marker = text.find("\n---\n", 4) if marker < 0: raise ValidationFailure(f"{path}: frontmatter is not closed") try: data = yaml.safe_load(text[4:marker]) except yaml.YAMLError as exc: raise ValidationFailure( f"{path}: standard YAML parser rejected frontmatter: {exc}" ) from exc if not isinstance(data, dict): raise ValidationFailure(f"{path}: frontmatter must be a mapping") return data, text[marker + 5 :] def iter_files(root: Path) -> Iterable[Path]: for path in root.rglob("*"): if any(part in SKIP_DIRS for part in path.relative_to(root).parts): continue if path.is_file(): yield path def is_relative_to(path: Path, parent: Path) -> bool: try: path.relative_to(parent) return True except ValueError: return False def validate_skill_file(path: Path, errors: list[str]) -> dict[str, Any] | None: try: data, body = split_frontmatter(path) except ValidationFailure as exc: errors.append(str(exc)) return None unexpected = sorted(set(data) - FRONTMATTER_KEYS) if unexpected: errors.append(f"{path}: unsupported frontmatter keys: {', '.join(unexpected)}") name = compact_text(data.get("name")) if not NAME_RE.fullmatch(name) or len(name) > 64: errors.append(f"{path}: name must be kebab-case and at most 64 characters") description = compact_text(data.get("description")) if not 50 <= len(description) <= 200: errors.append( f"{path}: description must contain 50-200 characters " f"(found {len(description)})" ) metadata = data.get("metadata") if not isinstance(metadata, dict): errors.append(f"{path}: metadata must be a mapping") else: if not compact_text(metadata.get("author")): errors.append(f"{path}: metadata.author is required") if not SEMVER_RE.fullmatch(compact_text(metadata.get("version"))): errors.append(f"{path}: metadata.version must use SemVer") tags = metadata.get("tags") if not isinstance(tags, list) or not tags or not all( isinstance(tag, str) and tag.strip() for tag in tags ): errors.append(f"{path}: metadata.tags must be a non-empty list") dependencies = metadata.get("dependencies", []) if not isinstance(dependencies, list): errors.append(f"{path}: metadata.dependencies must be a list") trigger_block = re.search( r"(?ms)^##\s+Triggers\s*$([\s\S]*?)(?=^##\s+|\Z)", body ) if not trigger_block: errors.append(f"{path}: add an explicit '## Triggers' section") else: block = trigger_block.group(1) if len(re.findall(r"(?m)^\s*-\s+\S", block)) < 3: errors.append(f"{path}: add two activation examples and one non-trigger") if not re.search(r"[\u3400-\u9fff]", block): errors.append(f"{path}: add a concrete Chinese trigger phrase") if not re.search(r"(?i)\b(?:the|a|an|create|build|help|publish|review|use)\b", block): errors.append(f"{path}: add a concrete English trigger phrase") if not re.search( r"(?mi)^###\s+(?:Do not activate when|Non-triggers?|不应触发|不要触发)\s*$", body, ): errors.append(f"{path}: add explicit non-trigger conditions") if len(read_text(path).splitlines()) >= 500: errors.append(f"{path}: keep SKILL.md below 500 lines") if not body.strip(): errors.append(f"{path}: body is empty") return data def load_yaml(path: Path, errors: list[str]) -> dict[str, Any] | None: try: data = yaml.safe_load(read_text(path)) except yaml.YAMLError as exc: errors.append(f"{path}: standard YAML parser rejected file: {exc}") return None if not isinstance(data, dict): errors.append(f"{path}: expected a YAML mapping") return None return data def validate_kit(root: Path, skill_names: set[str], errors: list[str]) -> None: manifest = root / "kit.yaml" if not manifest.exists(): return data = load_yaml(manifest, errors) if data is None: return modules = data.get("modules") if not isinstance(modules, list) or not modules: errors.append(f"{manifest}: modules must be a non-empty list") return module_ids: set[str] = set() for index, module in enumerate(modules): label = f"{manifest}: modules[{index}]" if not isinstance(module, dict): errors.append(f"{label}: expected a mapping") continue module_id = compact_text(module.get("id")) skill_name = compact_text(module.get("skill")) relative = compact_text(module.get("path")) if not module_id or module_id in module_ids: errors.append(f"{label}: id is required and must be unique") module_ids.add(module_id) module_path = (root / relative).resolve() if ( not relative or not is_relative_to(module_path, root.resolve()) or not (module_path / "SKILL.md").is_file() ): errors.append(f"{label}: missing module at '{relative}/SKILL.md'") if skill_name not in skill_names: errors.append(f"{label}: unresolved child skill '{skill_name}'") pipelines = data.get("pipelines") if not isinstance(pipelines, dict) or not pipelines: errors.append(f"{manifest}: pipelines must be a non-empty mapping") return for pipeline, sequence in pipelines.items(): if not isinstance(sequence, list) or not sequence: errors.append(f"{manifest}: pipeline '{pipeline}' must be a non-empty list") continue missing = [str(item) for item in sequence if item not in module_ids] if missing: errors.append( f"{manifest}: pipeline '{pipeline}' has unknown modules: " + ", ".join(missing) ) def validate_local_references(root: Path, errors: list[str]) -> None: for path in iter_files(root): if path.suffix.lower() != ".md": continue text = read_text(path) for raw in MARKDOWN_LINK_RE.findall(text): target = raw.strip().split(maxsplit=1)[0].strip("<>").split("#", 1)[0] if ( not target or re.match(r"^[a-z][a-z0-9+.-]*:", target, re.I) or any(token in target for token in ("TODO", "{", "}")) ): continue if not (path.parent / target).resolve().exists(): errors.append(f"{path}: broken local link '{target}'") skill_root = path.parent if path.name == "SKILL.md" else root for variable, target in SKILL_PATH_RE.findall(text): if "TODO" in target: continue base = skill_root if variable == "SKILL_DIR" else root resolved = (base / target.rstrip(".,;:)")).resolve() if not is_relative_to(resolved, root.resolve()) or not resolved.exists(): errors.append(f"{path}: missing required resource '${variable}/{target}'") def validate_hygiene(root: Path, errors: list[str]) -> None: private_path = re.compile(r"(?:/Users/[^/\s]+/|[A-Za-z]:\\\\Users\\\\[^\\\s]+\\\\)") for path in root.rglob("*"): if any(part in SKIP_DIRS for part in path.relative_to(root).parts): continue if path.name in JUNK_NAMES or path.suffix.lower() in JUNK_SUFFIXES: errors.append(f"{path}: generated/cache artifact must not ship") for path in iter_files(root): if path.suffix.lower() not in TEXT_SUFFIXES or path.name == "validate_skill.py": continue text = read_text(path) if private_path.search(text): errors.append(f"{path}: contains a private absolute user path") if path.name != "init_skill.py" and re.search(r"\bTODO\s*[::]", text): errors.append(f"{path}: unresolved TODO placeholder") for relative in ("workbuddy", "scripts/build_workbuddy.py"): if (root / relative).exists(): errors.append( f"{root / relative}: platform distribution artifacts belong to skill-publish" ) def validate_source(root: Path, errors: list[str]) -> None: root_skill = root / "SKILL.md" skill_files = [root_skill, *sorted((root / "skills").glob("*/SKILL.md"))] if not root_skill.is_file(): errors.append(f"{root_skill}: file is required") return parsed: list[tuple[Path, dict[str, Any]]] = [] for path in skill_files: data = validate_skill_file(path, errors) if data: parsed.append((path, data)) names = {compact_text(data.get("name")) for _, data in parsed} if len(names) != len(parsed): errors.append(f"{root}: every embedded Skill must have a unique name") validate_kit(root, names, errors) readme = root / "README.md" if not readme.is_file(): errors.append(f"{readme}: file is required") elif parsed: metadata = parsed[0][1].get("metadata") version = compact_text(metadata.get("version")) if isinstance(metadata, dict) else "" if version and f"version-{version}-" not in read_text(readme): errors.append(f"{readme}: version badge must match {version}") validate_hygiene(root, errors) validate_local_references(root, errors) def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("path", type=Path, help="Local Skill source directory") args = parser.parse_args() root = args.path.expanduser().resolve() if not root.is_dir(): print(f"ERROR: directory does not exist: {root}", file=sys.stderr) return 2 errors: list[str] = [] validate_source(root, errors) if errors: print(f"FAILED: {len(errors)} issue(s)") for error in errors: print(f"- {error}") return 1 print(f"PASSED: source validation ({root})") return 0 if __name__ == "__main__": raise SystemExit(main())
-
-
.gitignore 84 B · in bundle
-
CHANGELOG.md 192 B
# Changelog ## [0.2.0] - 2026-08-24 ### Added - add the shared feedback-classification and approval-invalidation gate used by every LovStudio Skill ## 0.1.0 - Initial local Skill source. -
LICENSE 1 KB · in bundle
-
README.md 833 B
# Electron 增量更新 · Electron Delta Updates  为 Electron 应用建立并验证可恢复的增量自动更新流程。 ## 本地安装 将目录链接到本地 Agent Skills 目录,链接名为 `sgc-electron-delta-updater`。安装后可直接说“给这个 Electron App 加增量自动更新”。 ## 使用 - “为 macOS Electron 应用配置 Sparkle delta-only 更新,并验证 appcast 与公证产物。” - “检查更新在代理网络下一直转圈,修复并验证后续重试。” ## 质量门 ```bash python3 scripts/validate_skill.py . ``` 同时运行目标项目的更新器单测、打包、签名/公证和公开更新源验证。 ## 依赖 - Python 3.8+ - PyYAML - 目标 Electron 项目的打包与签名工具 ## License MIT -
SKILL.md 4.8 KB
--- name: sgc-electron-delta-updater description: > 为 Electron 桌面应用设计、实现和验证增量自动更新,覆盖 macOS Sparkle、签名、appcast、发布产物与失败拦截。用户提到增量更新、Sparkle、检查更新、appcast、DMG 更新或 delta updater 时使用。 license: MIT metadata: author: lovstudio version: "0.2.0" tags: - electron - auto-update - sparkle - delta compatibility: "Electron projects; macOS delta mode requires Sparkle-compatible packaging and signing." dependencies: [] --- # Electron 增量更新 · Electron Delta Updates 为现有 Electron 应用交付可验证的增量更新链路;以用户实际安装包、公开更新源和安装结果为准,而不是以 CI 成功为准。 ## Triggers ### Activate when - 用户说“给这个 Electron App 加增量自动更新”或“检查更新一直转圈”。 - 用户提到 macOS Sparkle、appcast、DMG、blockmap、签名、公证或 delta 更新包。 - User asks to “add a delta updater”, “ship Sparkle updates”, or “verify an Electron update release”. ### Do not activate when - 用户只想修改版本号、更新日志或普通下载页;使用版本管理或发布工作流。 - 用户要做网页热更新、PWA 缓存更新或移动端 OTA;使用相应平台的更新机制。 ## Workflow (MANDATORY) **You MUST follow these steps in order.** ### Step 0: Establish the update contract - Identify target platforms, package format, current updater library, update feed, signing owner, release channel, and rollback expectation. - Treat macOS delta-only behavior as an explicit product promise only when complete archives are intentionally excluded. - Separate four states: check, download/stage, install handoff, and post-restart verification. - Read [references/electron-macos-delta.md](references/electron-macos-delta.md) for macOS Sparkle work and [references/release-verification.md](references/release-verification.md) before modifying a release pipeline. ### Step 1: Make checks bounded and recoverable - Use one in-flight check per update service. - Pass an `AbortSignal` through every network layer, including appcast fetches. - On timeout, abort the request, wait for stale updater state and connections to settle, then allow a later retry. - Configure the updater's own network session; do not assume it inherits a developer shell proxy. - Keep the UI state retryable after every timeout, download failure, or malformed feed. ### Step 2: Enforce the artifact policy - Generate architecture-specific feed entries and match each delta to its source and target version. - Verify signature, length, architecture, and URL before staging an update. - In delta-only mode, stop on any complete-package fallback; surface a manual-install path instead of silently downloading a larger archive. - If a local feed proxy is required, bind it to loopback, permit only the verified delta URL, preserve HTTP range behavior, and keep the served artifact URL ending in `.delta`. ### Step 3: Coordinate install and restart - Complete application cleanup before giving control to the native updater. - Keep the process alive until asynchronous install handoff is confirmed. - Use [references/release-verification.md](references/release-verification.md) to verify the staged update and the installed version after relaunch. - For a normal user-invoked app restart without an update, use `sgc-electron-app-relaunch` instead. ### Step 4: Validate the deliverable - Add tests for single-flight checks, timeout abort, stale-check recovery, rejected full fallback, feed proxy ranges, and asynchronous install handoff. - Run the project's format, lint, typecheck, test, package, signing, and notarization checks that apply. - Verify the public release page plus direct appcast and delta asset responses; compare the final artifact's version, digest, size, signature, and notarization state. - Report the updater backend, supported platforms, checked URLs, installed-version proof, and any manual-install condition. ## Dependencies - An Electron project with a defined packaging and signing owner. - Sparkle tooling only for macOS Sparkle paths. ## 通用反馈闭环 用户在 Skill 驱动任务中提出修改意见时,继续当前产物前必须执行: 1. 先判断意见是 `task-specific`(仅本次)还是 `reusable`(可跨任务复用)。 2. `task-specific` 只修改当前任务,不改 Skill。 3. `reusable` 先确定作用域:领域规则先更新对应 canonical Skill;适用于所有 Skill 的规则先更新共享规范。 4. 完成规则更新、版本、lint 与分发核验后,再把修改应用到当前任务。 5. `reusable` 修改会使此前的“确认”“继续”“发吧”失效;完成当前产物修改和回读后必须停下,等待用户下一步指示,不自动进入发布、提交或其他外部写入。
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.