前不久我发了一篇文章《如果有一个本地优先的 Workflow 工具,你们团队会愿意用吗?》,和大家聊了聊我对本地优先工作流工具的一些构想。
这段时间我一直在埋头推进这个项目。今天想跟大家分享一个对我来说蛮重要的阶段性进展:Workrun 的核心功能建设已经初步成型,并且开始具备解决真实、具体问题的能力了。
都说"吃自己的狗粮(Eat your own dog food)"是检验工具好不好用最好的方式。就在今天,我直接使用 Workrun 的 Workflow 编排功能,成功完成了我 "另一个项目"的发布版本(Release Version)流程。
"另一个项目" :github.com/1111mp/sync...
一次真实的"狗粮"实战
以往在发布一个新版本时,往往需要经历不少琐碎的步骤(例如确认改动、跑检查和构建、整理发布内容、核对产物、发布前再确认一次等)。虽然单独看每一步都不难,但手动串联起来既繁琐又容易出错。
今天我没有使用传统的脚本或手动流程,而是直接打开 Workrun,把这一整条发布链路用流程编排的方式搭了出来:

- 需要执行确定性操作的部分,仍然交给本地代码和项目脚本;
- 需要理解上下文的部分,交给 Agent,例如整理改动、生成发布说明;
- 到关键步骤时暂停,由人确认后再继续;
- 整个执行过程都能看到节点状态、日志和输出。
演示视频

这条流程目前主要由 6 个节点组成:
-
开始节点
运行时输入本次要发布的版本号,例如
0.1.3-rc.1。 -
发布前准备(App)
这是一个本地 Python App,负责读取 Synclan 本地仓库的发布上下文:拉取远程分支和标签、找到上一个正式版本、收集这期间的提交和文件变更,同时检查工作区是否干净、版本文件是否一致。
它只读取仓库信息,不修改代码、不创建提交,也不打 tag。最后会把这些信息写入
release_context,供后续节点使用。
查看「发布前准备」App 的 main.py(完整代码)pythonfrom __future__ import annotations import json import re import subprocess import sys from pathlib import Path from typing import Any, TypeAlias from workrun_sdk import process STABLE_TAG_PATTERN = re.compile(r"^v\d+\.\d+\.\d+$") JsonValue: TypeAlias = ( None | bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"] ) JsonObject: TypeAlias = dict[str, JsonValue] def log(message: str) -> None: """向工作流日志输出执行进度,避免影响节点的结构化输出。""" print(f"[release-context] {message}", file=sys.stderr, flush=True) def run_git(repo: Path, *args: str) -> str: result = subprocess.run( ["git", "-C", str(repo), *args], capture_output=True, text=True, check=False, ) if result.returncode != 0: message = result.stderr.strip() or result.stdout.strip() raise RuntimeError(f"Git 命令执行失败:git {' '.join(args)}\n{message}") return result.stdout def resolve_commit(repo: Path, ref: str) -> str: return run_git(repo, "rev-parse", "--verify", f"{ref}^{{commit}}").strip() def find_latest_stable_tag(repo: Path, source_ref: str) -> str: tags = run_git( repo, "tag", "--merged", source_ref, "--sort=-version:refname", ).splitlines() for tag in tags: if STABLE_TAG_PATTERN.fullmatch(tag): return tag raise RuntimeError( f"在 {source_ref} 的历史中未找到正式版本标签," "期望格式为 v主版本.次版本.修订版本,例如 v0.1.2。" ) def collect_commits( repo: Path, revision_range: str, max_commits: int, ) -> tuple[list[dict[str, str]], bool]: output = run_git( repo, "log", "--format=%H%x1f%h%x1f%s%x1f%an%x1f%aI", f"--max-count={max_commits + 1}", revision_range, ) commits: list[dict[str, str]] = [] for line in output.splitlines(): sha, short_sha, subject, author, authored_at = line.split("\x1f", maxsplit=4) commits.append( { "sha": sha, "short_sha": short_sha, "subject": subject, "author": author, "authored_at": authored_at, } ) return commits[:max_commits], len(commits) > max_commits def collect_changed_files(repo: Path, revision_range: str) -> list[dict[str, str]]: files: list[dict[str, str]] = [] for line in run_git(repo, "diff", "--name-status", revision_range).splitlines(): parts = line.split("\t") status = parts[0] if status.startswith(("R", "C")) and len(parts) == 3: files.append( { "status": status, "previous_path": parts[1], "path": parts[2], } ) elif len(parts) == 2: files.append( { "status": status, "path": parts[1], } ) return files def read_json_version(path: Path) -> str | None: if not path.is_file(): return None try: data = json.loads(path.read_text(encoding="utf-8")) except json.JSONDecodeError: return None version = data.get("version") return version if isinstance(version, str) else None def read_toml_version(path: Path) -> str | None: if not path.is_file(): return None content = path.read_text(encoding="utf-8") match = re.search( r'(?ms)^\[package\]\s*$.*?^version\s*=\s*"([^"]+)"', content, ) if match: return match.group(1) match = re.search( r'(?ms)^\[project\]\s*$.*?^version\s*=\s*"([^"]+)"', content, ) return match.group(1) if match else None def collect_versions(repo: Path) -> list[dict[str, str | None]]: readers = [ ("package.json", read_json_version), ("pyproject.toml", read_toml_version), ("Cargo.toml", read_toml_version), ("src-tauri/Cargo.toml", read_toml_version), ("src-tauri/tauri.conf.json", read_json_version), ] versions: list[dict[str, str | None]] = [] for relative_path, reader in readers: version = reader(repo / relative_path) if version is not None: versions.append({"path": relative_path, "version": version}) return versions def find_changelog(repo: Path) -> dict[str, str | None]: candidates = [ "CHANGELOG.md", "changelog.md", "UPDATELOG.md", "CHANGELOG", ] for relative_path in candidates: path = repo / relative_path if not path.is_file(): continue content = path.read_text(encoding="utf-8") heading = re.search(r"^##\s+(v?\S+)", content, re.MULTILINE) return { "path": relative_path, "latest_heading": heading.group(1) if heading else None, } return {"path": None, "latest_heading": None} def main() -> None: raw_input = sys.stdin.read() log("开始收集发布上下文。") try: state: dict[str, Any] = json.loads(raw_input) if raw_input else {} except json.JSONDecodeError as error: raise SystemExit(f"工作流输入不是有效 JSON:{error}") from error repo_path = state.get("repo_path") if not isinstance(repo_path, str) or not repo_path.strip(): raise SystemExit("缺少必填字段 repo_path。") repo = Path(repo_path).expanduser().resolve() if not repo.is_dir(): raise SystemExit(f"仓库路径不存在或不是目录:{repo}") try: log(f"验证 Git 仓库:{repo}") if run_git(repo, "rev-parse", "--is-inside-work-tree").strip() != "true": raise RuntimeError("指定路径不是 Git 工作区。") target_version = state.get("target_version") remote = state.get("remote", "origin") source_ref = state.get("source_ref", f"{remote}/dev") target_ref = state.get("target_ref", f"{remote}/main") max_commits = state.get("max_commits", 250) if not isinstance(target_version, str) or not re.fullmatch( r"\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?", target_version, ): raise SystemExit( "target_version 必须是有效版本号,例如 0.1.3 或 0.1.3-rc.1。" ) if not isinstance(remote, str) or not remote: raise RuntimeError("remote 必须是有效的远程仓库名称。") if not isinstance(source_ref, str) or not source_ref: raise RuntimeError("source_ref 必须是有效的 Git 引用。") if not isinstance(target_ref, str) or not target_ref: raise RuntimeError("target_ref 必须是有效的 Git 引用。") if not isinstance(max_commits, int) or not 1 <= max_commits <= 1000: raise RuntimeError("max_commits 必须是 1 到 1000 之间的整数。") # 只更新远程跟踪分支与 tags,不会修改工作区文件。 log(f"同步远程引用和标签:{remote}") run_git(repo, "fetch", "--tags", "--prune", remote) log(f"解析来源和目标引用:{source_ref}、{target_ref}") source_sha = resolve_commit(repo, source_ref) target_sha = resolve_commit(repo, target_ref) previous_tag = find_latest_stable_tag(repo, source_ref) previous_tag_sha = resolve_commit(repo, previous_tag) revision_range = f"{previous_tag}..{source_ref}" log(f"上一个正式版本:{previous_tag};比较范围:{revision_range}") blocking_reasons = [] log(f"收集提交记录,最多保留 {max_commits} 条。") commits, commits_truncated = collect_commits( repo, revision_range, max_commits, ) changed_files = collect_changed_files(repo, revision_range) worktree_changes = [ line for line in run_git(repo, "status", "--porcelain=v1").splitlines() if line ] version_files = collect_versions(repo) version_values = {item["version"] for item in version_files} log( f"已发现 {len(commits)} 条提交、{len(changed_files)} 个变更文件、" f"{len(version_files)} 个版本文件。" ) if worktree_changes: blocking_reasons.append("工作区存在未提交修改。") if not version_files: blocking_reasons.append("未找到可识别的版本文件。") elif len(version_values) != 1 or None in version_values: blocking_reasons.append("版本文件中的版本号不一致。") if not commits: blocking_reasons.append("上一个正式版本后没有新的提交。") if blocking_reasons: log( f"发布上下文存在 {len(blocking_reasons)} 个阻塞项:{';'.join(blocking_reasons)}" ) else: log("发布上下文检查通过,可用于生成发布说明。") release_context = { "target_version": target_version, "repository": { "path": str(repo), "remote": remote, "source_ref": source_ref, "source_sha": source_sha, "target_ref": target_ref, "target_sha": target_sha, "worktree_clean": not worktree_changes, "worktree_changes": worktree_changes, }, "previous_release": { "tag": previous_tag, "commit_sha": previous_tag_sha, "revision_range": revision_range, }, "commits": commits, "commits_truncated": commits_truncated, "changed_files": changed_files, "diff_stat": run_git(repo, "diff", "--stat", revision_range).rstrip(), "version_files": version_files, "version_files_consistent": ( len(version_values) == 1 and bool(version_files) ), "changelog": find_changelog(repo), "release_notes_ready": not blocking_reasons, "blocking_reasons": blocking_reasons, } log(f"收集完成:{revision_range},发布来源提交:{source_sha[:12]}。") process.result({"release_context": release_context}) except RuntimeError as error: log(f"收集失败:{error}") raise SystemExit(f"收集发布上下文失败:{error}") from error if __name__ == "__main__": main() -
是否准备好发布(If / Else)
根据
release_context.release_notes_ready条件来判断。如果工作区有未提交修改、版本号不一致,或者上个正式版本后没有新增提交,流程会直接结束,不进入后面的发布步骤。

-
生成发布说明(Agent)
Agent 读取前一步收集到的提交记录和文件变更,生成 Markdown 格式的发布说明。这里限制得比较严格:只允许写明确的新功能和问题修复;每一条都要附上对应的提交短 SHA,不能凭空补充内容。
Agent 生成的 Release notes 会输出到 Output key
release_notes,提供下个节点读取。

-
确认发布说明(Human Review)
发布说明生成后,流程会暂停。我可以在这里查看发布上下文、修改说明,并决定批准还是拒绝。
发布这种会影响远程仓库的操作,最后还是应该由人来确认。

-
执行发布(App)
审核通过后,另一个本地 Python App 会执行真正的发布动作:更新版本文件和更新日志、运行必要的检查、创建 release commit、推送分支、fast-forward 合并到目标分支,最后创建并推送版本 tag。
在执行前它还会再次校验:审批后源分支或目标分支有没有变化、目标 tag 是否已存在、工作区是否干净。任何一项不满足都会停止,而不是继续发布。
查看「执行发布」App 的 main.py(完整代码)pythonfrom __future__ import annotations import json import re import subprocess import sys from datetime import datetime from pathlib import Path from typing import Any from workrun_sdk import confirm, process def log(message: str) -> None: timestamp = datetime.now().astimezone().strftime("%H:%M:%S") print(f"[{timestamp}] [release] {message}", flush=True) def confirm_sensitive_operation(operation: str, detail: str) -> None: """Require an explicit approval before a release operation changes state.""" message = f"即将{operation}:\n\n{detail}\n\n取消将立即中止本次发布。" if not confirm( message, title="确认敏感发布操作", confirm_label="继续执行", ): raise SystemExit(f"用户取消了操作:{operation}。发布已中止。") def git(repo: Path, *args: str) -> str: result = subprocess.run( ["git", "-C", str(repo), *args], capture_output=True, text=True, check=False, ) if result.returncode != 0: message = result.stderr.strip() or result.stdout.strip() raise RuntimeError(f"git {' '.join(args)} failed:\n{message}") return result.stdout def cargo(repo: Path, *args: str) -> str: result = subprocess.run( ["cargo", *args], cwd=repo, capture_output=True, text=True, check=False, ) if result.returncode != 0: message = result.stderr.strip() or result.stdout.strip() raise RuntimeError(f"cargo {' '.join(args)} failed:\n{message}") return result.stdout def sha(repo: Path, ref: str) -> str: return git(repo, "rev-parse", "--verify", f"{ref}^{{commit}}").strip() def branch_name(remote_ref: str, remote: str) -> str: prefix = f"{remote}/" if not remote_ref.startswith(prefix): raise RuntimeError( f"发布分支必须是 {prefix} 开头的远程引用,当前为:{remote_ref}" ) branch = remote_ref.removeprefix(prefix) if not branch: raise RuntimeError("发布分支不能为空。") return branch def required_string(value: object, name: str) -> str: if not isinstance(value, str) or not value: raise SystemExit(f"发布上下文缺少必要信息:{name}") return value def switch_branch(repo: Path, remote: str, branch: str) -> None: local_ref = f"refs/heads/{branch}" exists = ( subprocess.run( ["git", "-C", str(repo), "show-ref", "--verify", "--quiet", local_ref], check=False, ).returncode == 0 ) if exists: git(repo, "switch", branch) git(repo, "merge", "--ff-only", f"{remote}/{branch}") else: git(repo, "switch", "--track", "-c", branch, f"{remote}/{branch}") def release_notes_from_state(state: dict[str, Any]) -> str: notes = state.get("release_notes") if not isinstance(notes, str) or not notes.strip(): raise RuntimeError("未找到已审批的发布说明。") return notes.strip() def replace_version(path: Path, version: str) -> None: content = path.read_text(encoding="utf-8") if path.suffix == ".json": updated, count = re.subn( r'("version"\s*:\s*)"[^"]+"', rf'\g<1>"{version}"', content, count=1, ) elif path.suffix == ".toml": updated, count = re.subn( r'(?ms)(^\[package\]\s*$.*?^version\s*=\s*)"[^"]+"', rf'\g<1>"{version}"', content, count=1, ) else: raise RuntimeError(f"不支持更新版本文件:{path}") if count != 1: raise RuntimeError(f"无法在文件中定位版本号:{path}") path.write_text(updated, encoding="utf-8") def main() -> None: log("开始读取发布上下文。") state = json.loads(sys.stdin.read() or "{}") context = state.get("release_context") if not isinstance(context, dict): raise SystemExit("未找到 release_context。") repository = context.get("repository") if not isinstance(repository, dict): raise SystemExit("release_context.repository 无效。") repo = Path(str(repository.get("path", ""))).expanduser().resolve() remote = required_string(repository.get("remote"), "remote") source_ref = required_string(repository.get("source_ref"), "source_ref") target_ref = required_string(repository.get("target_ref"), "target_ref") approved_source_sha = required_string(repository.get("source_sha"), "source_sha") approved_target_sha = required_string(repository.get("target_sha"), "target_sha") version = required_string(context.get("target_version"), "target_version") dry_run = context.get("dry_run") is True if not repo.is_dir(): raise SystemExit(f"仓库路径不存在:{repo}") log( f"发布准备就绪:{source_ref} -> {target_ref},版本 {version}" f"(dry_run={dry_run})。" ) if not re.fullmatch(r"\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?", version): raise SystemExit(f"发布版本号无效:{version}") tag = f"v{version}" source_branch = branch_name(source_ref, remote) target_branch = branch_name(target_ref, remote) release_notes = release_notes_from_state(state) log(f"已获取发布说明({len(release_notes)} 个字符)。") if not release_notes.startswith(f"## {tag}"): raise SystemExit( f"发布说明标题必须以 `## {tag}` 开始,以确保说明与目标版本一致。" ) try: # 检查阶段:不允许脏工作区,也不允许审批后分支发生变化。 log("检查工作区状态。") if git(repo, "status", "--porcelain=v1").strip(): raise RuntimeError("工作区存在未提交修改,已停止发布。") log(f"从远程 {remote} 获取分支和标签。") git(repo, "fetch", "--tags", "--prune", remote) if sha(repo, source_ref) != approved_source_sha: raise RuntimeError( "发布来源分支在审批后已更新,请重新收集上下文并重新审批。" ) if sha(repo, target_ref) != approved_target_sha: raise RuntimeError( "发布目标分支在审批后已更新,请重新收集上下文并重新审批。" ) tag_exists = ( subprocess.run( [ "git", "-C", str(repo), "rev-parse", "-q", "--verify", f"refs/tags/{tag}", ], capture_output=True, text=True, check=False, ).returncode == 0 ) if tag_exists: raise RuntimeError(f"标签 {tag} 已存在,已停止发布。") # 在来源分支写入 release commit。 log(f"切换到来源分支 {source_branch}。") switch_branch(repo, remote, source_branch) version_files = context.get("version_files") if not isinstance(version_files, list) or not version_files: raise RuntimeError("未找到待更新的版本文件。") paths_to_commit: list[str] = [] cargo_manifests: list[str] = [] for item in version_files: if not isinstance(item, dict) or not isinstance(item.get("path"), str): continue relative_path = item["path"] version_path = repo / relative_path if not version_path.is_file(): raise RuntimeError(f"版本文件不存在:{relative_path}") replace_version(version_path, version) paths_to_commit.append(relative_path) log(f"已更新版本文件:{relative_path}") if version_path.name == "Cargo.toml": cargo_manifests.append(relative_path) # Cargo.toml 的 package version 也记录在 Cargo.lock 中。运行 cargo check # 以刷新锁文件,并将所在 workspace 的锁文件一起纳入 release commit。 for manifest_path in cargo_manifests: log(f"读取 Cargo workspace:{manifest_path}") metadata = json.loads( cargo( repo, "metadata", "--no-deps", "--format-version=1", "--manifest-path", manifest_path, ) ) workspace_root = metadata.get("workspace_root") if not isinstance(workspace_root, str): raise RuntimeError(f"无法确定 Cargo workspace:{manifest_path}") log(f"运行 cargo check:{manifest_path}") cargo(repo, "check", "--manifest-path", manifest_path) lock_path = Path(workspace_root) / "Cargo.lock" if lock_path.is_file(): try: relative_lock_path = str(lock_path.relative_to(repo)) except ValueError as error: raise RuntimeError(f"Cargo.lock 不在仓库内:{lock_path}") from error if relative_lock_path not in paths_to_commit: paths_to_commit.append(relative_lock_path) log(f"已将锁文件纳入提交:{relative_lock_path}") changelog = context.get("changelog") changelog_path = changelog.get("path") if isinstance(changelog, dict) else None if not isinstance(changelog_path, str) or not changelog_path: raise RuntimeError("未找到更新日志文件。") update_log = repo / changelog_path new_content = f"{release_notes.rstrip()}\n" log(f"写入发布说明到:{update_log}({len(new_content)} 个字符)。") written = update_log.write_text( new_content, encoding="utf-8", ) if update_log.read_text(encoding="utf-8") != new_content: raise RuntimeError(f"写入发布说明后校验失败:{update_log}") log(f"发布说明写入并校验成功({written} 个字符)。") paths_to_commit.append(changelog_path) if dry_run: log("Dry run 完成:本地文件已更新,未暂存、提交、推送或创建标签。") return log(f"暂存发布文件:{', '.join(paths_to_commit)}") git(repo, "add", "--", *paths_to_commit) if not git(repo, "diff", "--cached", "--name-only").strip(): raise RuntimeError("没有检测到可创建的发布修改。") commit_message = f"chore(release): {tag}" log(f"创建发布提交:{commit_message}") git(repo, "commit", "-m", commit_message) release_commit_sha = sha(repo, "HEAD") log(f"推送来源分支 {source_branch}。") git(repo, "push", remote, f"HEAD:refs/heads/{source_branch}") # 合并到目标分支。仅允许 fast-forward,绝不生成意外 merge commit 或强推。 log(f"切换到目标分支 {target_branch} 并 fast-forward 合并。") switch_branch(repo, remote, target_branch) git(repo, "merge", "--ff-only", source_branch) main_release_sha = sha(repo, "HEAD") log(f"推送目标分支 {target_branch}。") git(repo, "push", remote, f"HEAD:refs/heads/{target_branch}") # Tag 必须创建在已推送至目标分支的 release commit 上。 confirm_sensitive_operation( "创建本地发布标签", f"{tag} -> {main_release_sha}", ) log(f"创建并推送标签 {tag}。") git(repo, "tag", "-a", tag, "-m", f"Release {tag}", main_release_sha) git(repo, "push", remote, f"refs/tags/{tag}") print(f"发布完成:{tag} -> {main_release_sha}") process.result( { "release_result": { "version": version, "tag": tag, "source_branch": source_branch, "target_branch": target_branch, "release_commit_sha": release_commit_sha, "tag_commit_sha": main_release_sha, } } ) except RuntimeError as error: raise SystemExit(f"执行发布失败:{error}") from error if __name__ == "__main__": main()
最终,这条流程顺利跑完,并完成了 Synclan 的一次版本发布。
这件事对我来说还挺重要的。不是因为它说明"以后发布可以一键全自动",而是因为 Workrun 终于开始帮我处理自己的日常工作了。
Workrun 现在能做什么
借这次发布流程,也简单汇报一下目前已经做出来的能力:
- 在画布上编排 Agent、本地 Python App、远程 Agent 和流程控制节点;
- 让 Agent 调用本地 Tool App 或 MCP Server 中的工具;
- 使用 If/Else、Switch 根据运行结果走不同分支;
- 在流程中向用户提问,或在关键步骤进入人工审核;
- 以任务或对话的方式运行工作流,并实时查看模型输出、工具调用和脚本日志;
- 把本地 Python 项目作为可复用的 App 接进工作流,而不是把所有逻辑塞进提示词里。
Workrun 目前仍然是一个本地优先的桌面工具:工作流、配置和本地代码都主要留在自己的设备上。涉及模型服务、远程 Agent 或 MCP 服务时,数据会按实际连接发送出去,这部分也需要由使用者自己判断和配置。
发现短板:真实的痛点才是改进的方向
当然,吃狗粮的过程不仅是为了验证功能,更是为了找茬。
通过这次实际 Workflow 的编排与真实解决问题的过程,我也直观地感受到了目前 Workrun 在一些方面的短板------最明显的是节点之间的数据传递和衔接。
现在虽然可以在工作流状态中传递数据(Workflow State),但在实际编排时,哪些数据由哪个节点产出、下一个节点会读取什么、数据格式是否符合预期,还不够直观。有些时候需要自己记住字段名和上下文,流程一复杂就容易增加理解和调试成本。
另外,节点配置、运行时调试和失败后的处理,也还有不少可以继续打磨的地方。真正拿它做事之后才会发现,画布能连起来只是第一步;让数据流动清楚、让问题容易定位、让流程可以放心反复运行,才是更重要的部分。
不过,能明确看到这些短板反而是一件好事,这为我接下来的迭代指明了非常清晰的方向。
接下来
从上一篇文章里讨论的理念与设想,到今天能真正用它跑通另一个项目的发布流程,Workrun 总算跨过了"只能演示"的阶段。
后面还会继续打磨工作流编辑和运行体验,补更多节点和工具集成,也把调试、错误处理、复用与分享这些事情做得更完整一些。
项目还在早期,欢迎试用、提建议或者一起参与:
👉 GitHub 仓库: github.com/1111mp/work...
感谢大家的关注与支持!