feat: 发布文件到 Gitea 仓库 — 自动隐私检查、创建公开仓库、Git推送
This commit is contained in:
@@ -0,0 +1,62 @@
|
|||||||
|
---
|
||||||
|
name: "publish-to-gitea"
|
||||||
|
description: "将文件/目录发布到 Gitea 仓库 — 自动隐私检查、创建公开仓库、Git 推送"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Publish to Gitea — 发布到 Gitea 仓库
|
||||||
|
|
||||||
|
将文件/目录推送到 Gitea 仓库(git.yoresee.cc),自动隐私检查、创建公开仓库、一键推送。
|
||||||
|
|
||||||
|
## 触发条件
|
||||||
|
|
||||||
|
用户说出类似以下话语时触发:
|
||||||
|
- "把这个推送到 gitea"
|
||||||
|
- "发布到仓库"
|
||||||
|
- "创建 repo 推上去"
|
||||||
|
- "publish to git"
|
||||||
|
- "推到 git.yoresee.cc"
|
||||||
|
|
||||||
|
## 流程
|
||||||
|
|
||||||
|
### 1. 确定要推送的内容
|
||||||
|
|
||||||
|
从上下文中推断要推送的文件/目录。常见场景:
|
||||||
|
- 用户刚创建/修改了一个 skill → 推送整个 skill 目录
|
||||||
|
- 用户说"把这个脚本推上去" → 推送脚本文件
|
||||||
|
- 用户指定了具体路径 → 直接用
|
||||||
|
|
||||||
|
### 2. 确定仓库名和描述
|
||||||
|
|
||||||
|
- **仓库名**:从内容推断,kebab-case 格式
|
||||||
|
- **描述**:一句话说明用途
|
||||||
|
- 用户没明确说则主动确认
|
||||||
|
|
||||||
|
### 3. 执行推送
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/.openclaw/workspace && python3 scripts/gitea_push.py <repo_name> "<description>" <files...>
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本自动完成:
|
||||||
|
1. 🔍 隐私检查(检测 open_id、API key、token、IP 等敏感模式)
|
||||||
|
2. 📦 创建 Gitea **公开**仓库
|
||||||
|
3. 📄 收集文件(排除 .git、node_modules 等)
|
||||||
|
4. 🚀 Git init → commit → push
|
||||||
|
5. 🔗 返回仓库 URL
|
||||||
|
|
||||||
|
### 4. 反馈
|
||||||
|
|
||||||
|
推送成功后告诉用户仓库地址。
|
||||||
|
|
||||||
|
如果脚本检测到疑似隐私数据泄露,**必须停下来询问用户是否继续**——不可自动跳过。
|
||||||
|
|
||||||
|
## 安全约束
|
||||||
|
|
||||||
|
- ⚠️ 推送前必须过隐私检查
|
||||||
|
- 仓库默认**公开**(public),不设 private
|
||||||
|
- 不要推送 .git 目录、node_modules、二进制文件
|
||||||
|
- 如果之前泄露过隐私并已 amend,确认后再推
|
||||||
|
|
||||||
|
## 脚本依赖
|
||||||
|
|
||||||
|
`scripts/gitea_push.py` — Gitea 推送脚本,基于 tea CLI。
|
||||||
+263
@@ -0,0 +1,263 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""推送到 Gitea 仓库脚本
|
||||||
|
用法:
|
||||||
|
python3 gitea_push.py <repo_name> <description> <files_dir...>
|
||||||
|
|
||||||
|
环境变量:
|
||||||
|
GITEA_URL Gitea 实例地址(默认 https://git.yoresee.cc)
|
||||||
|
GITEA_USER Gitea 用户名(默认 NightStar)
|
||||||
|
GITEA_EMAIL Git 提交邮箱(默认 nightstar@yoresee.cc)
|
||||||
|
|
||||||
|
示例:
|
||||||
|
python3 gitea_push.py remind-me "飞书加急提醒" ./skills/remind-me/
|
||||||
|
python3 gitea_push.py my-tool "一个工具" ./src/ scripts/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import subprocess
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
GITEA_URL = os.environ.get("GITEA_URL", "https://git.yoresee.cc")
|
||||||
|
GITEA_USER = os.environ.get("GITEA_USER", "NightStar")
|
||||||
|
GITEA_EMAIL = os.environ.get("GITEA_EMAIL", "nightstar@yoresee.cc")
|
||||||
|
DEFAULT_BRANCH = "master"
|
||||||
|
GIT_HOST = "git.yoresee.cc"
|
||||||
|
|
||||||
|
SANITIZE_PATTERNS = [
|
||||||
|
(r'ou_[a-f0-9]{20,}', 'ou_xxxxxx'), # 飞书 open_id
|
||||||
|
(r'sk-[a-zA-Z0-9]{20,}', 'sk-xxxxxx'), # API key
|
||||||
|
(r't-[a-zA-Z0-9]{20,}', 't-xxxxxx'), # token
|
||||||
|
(r'cli_[a-zA-Z0-9]{10,}', 'cli_xxxxxx'), # 飞书 app_id
|
||||||
|
(r'Bearer [a-zA-Z0-9\-_\.]{20,}', 'Bearer xxxxxx'),
|
||||||
|
(r'Authorization: [a-zA-Z0-9\-_\.]{20,}', 'Authorization: xxxxxx'),
|
||||||
|
(r'[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}', 'x.x.x.x'), # IP
|
||||||
|
]
|
||||||
|
|
||||||
|
ALWAYS_EXCLUDE = {".git", "node_modules", "__pycache__", ".DS_Store", "*.pyc", "*.pyo"}
|
||||||
|
|
||||||
|
|
||||||
|
def run_tea(*args):
|
||||||
|
cmd = ["tea"] + list(args)
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(f"tea 命令失败: {' '.join(cmd)}\n{result.stderr}")
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def load_gitignore_rules(dirs):
|
||||||
|
"""读取所有相关目录下的 .gitignore 并返回 pathspec"""
|
||||||
|
patterns = []
|
||||||
|
seen = set()
|
||||||
|
for d in dirs:
|
||||||
|
p = Path(d).resolve()
|
||||||
|
while True:
|
||||||
|
gitignore = p / ".gitignore"
|
||||||
|
if str(gitignore) not in seen and gitignore.exists():
|
||||||
|
seen.add(str(gitignore))
|
||||||
|
try:
|
||||||
|
with open(gitignore) as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if line and not line.startswith("#"):
|
||||||
|
patterns.append(line)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if p.parent == p:
|
||||||
|
break
|
||||||
|
p = p.parent
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pathspec
|
||||||
|
return pathspec.PathSpec.from_lines("gitwildmatch", patterns)
|
||||||
|
except ImportError:
|
||||||
|
# fallback: 简单 glob 匹配
|
||||||
|
print(" (pathspec 未安装,使用简单 .gitignore 匹配)", file=sys.stderr)
|
||||||
|
patterns_set = set(patterns)
|
||||||
|
return patterns_set, None
|
||||||
|
|
||||||
|
|
||||||
|
def is_ignored(filepath, spec, src_root):
|
||||||
|
"""判断文件是否应被忽略"""
|
||||||
|
name = filepath.name
|
||||||
|
# 内置排除
|
||||||
|
if name in ALWAYS_EXCLUDE:
|
||||||
|
return True
|
||||||
|
if name.endswith(".pyc") or name.endswith(".pyo"):
|
||||||
|
return True
|
||||||
|
if name == ".gitignore":
|
||||||
|
return True
|
||||||
|
|
||||||
|
# .gitignore 规则
|
||||||
|
try:
|
||||||
|
rel = filepath.relative_to(src_root)
|
||||||
|
rel_str = str(rel)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if spec is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
return spec.match_file(rel_str)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_file(path):
|
||||||
|
"""检查文件中的隐私信息"""
|
||||||
|
try:
|
||||||
|
content = Path(path).read_text()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
issues = []
|
||||||
|
for pattern, replacement in SANITIZE_PATTERNS:
|
||||||
|
matches = re.findall(pattern, content)
|
||||||
|
if matches:
|
||||||
|
issues.append(f" {pattern}: 发现 {len(matches)} 处疑似隐私数据")
|
||||||
|
|
||||||
|
if issues:
|
||||||
|
print(f"\n⚠️ 隐私检查 - {path}:", file=sys.stderr)
|
||||||
|
for issue in issues:
|
||||||
|
print(issue, file=sys.stderr)
|
||||||
|
print("", file=sys.stderr)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def collect_files(dirs, work_dir):
|
||||||
|
"""收集需要推送的文件,应用 .gitignore 过滤"""
|
||||||
|
print("[3/5] 准备推送内容(应用 .gitignore 过滤)...", file=sys.stderr)
|
||||||
|
spec = load_gitignore_rules(dirs)
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
for d in dirs:
|
||||||
|
src = Path(d).resolve()
|
||||||
|
if src.is_file():
|
||||||
|
dest = work_dir / src.name
|
||||||
|
dest.write_bytes(src.read_bytes())
|
||||||
|
print(f" + {src.name}", file=sys.stderr)
|
||||||
|
count += 1
|
||||||
|
elif src.is_dir():
|
||||||
|
for f in sorted(src.rglob("*")):
|
||||||
|
if not f.is_file():
|
||||||
|
continue
|
||||||
|
if ".git" in f.parts:
|
||||||
|
continue
|
||||||
|
if is_ignored(f, spec, src):
|
||||||
|
continue
|
||||||
|
rel = f.relative_to(src)
|
||||||
|
dest = work_dir / rel
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
dest.write_bytes(f.read_bytes())
|
||||||
|
print(f" + {rel}", file=sys.stderr)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
print(f" 共收集 {count} 个文件", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print("用法: gitea_push.py <repo_name> <description> <files_dir...>", file=sys.stderr)
|
||||||
|
print("示例: gitea_push.py my-tool '一个工具' ./src/", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
repo_name = sys.argv[1]
|
||||||
|
description = sys.argv[2]
|
||||||
|
dirs = [d for d in sys.argv[3:] if d.strip()]
|
||||||
|
|
||||||
|
if not dirs:
|
||||||
|
print("❌ 请至少指定一个文件或目录", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 1. 隐私检查
|
||||||
|
print("[1/5] 隐私检查...", file=sys.stderr)
|
||||||
|
has_issues = False
|
||||||
|
for d in dirs:
|
||||||
|
p = Path(d)
|
||||||
|
if p.is_file():
|
||||||
|
has_issues |= sanitize_file(p)
|
||||||
|
elif p.is_dir():
|
||||||
|
for f in p.rglob("*"):
|
||||||
|
if f.is_file() and f.suffix in ('.py', '.md', '.json', '.yaml', '.yml', '.sh', '.txt', '.toml', '.js', '.ts'):
|
||||||
|
has_issues |= sanitize_file(f)
|
||||||
|
if has_issues:
|
||||||
|
resp = input("⚠️ 发现疑似隐私数据,继续推送?(yes/no): ").strip().lower()
|
||||||
|
if resp not in ('yes', 'y'):
|
||||||
|
print("已取消推送", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 2. 创建 Gitea 仓库(默认公开,默认分支 master)
|
||||||
|
print(f"[2/5] 创建公开仓库 {repo_name}(分支: {DEFAULT_BRANCH})...", file=sys.stderr)
|
||||||
|
try:
|
||||||
|
output = run_tea("repo", "create", "--name", repo_name,
|
||||||
|
"--description", description,
|
||||||
|
"--branch", DEFAULT_BRANCH,
|
||||||
|
"--init")
|
||||||
|
print(output.strip(), file=sys.stderr)
|
||||||
|
except RuntimeError as e:
|
||||||
|
if "already exists" in str(e).lower() or "409" in str(e):
|
||||||
|
print(f" 仓库 {repo_name} 已存在,使用现有仓库", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
# 3. 收集文件(使用 .gitignore)
|
||||||
|
work_dir = Path(tempfile.mkdtemp(prefix="gitea_"))
|
||||||
|
collect_files(dirs, work_dir)
|
||||||
|
|
||||||
|
# 4. Git 初始化并推送到 master 分支
|
||||||
|
print(f"[4/5] 推送到 Gitea (分支: {DEFAULT_BRANCH})...", file=sys.stderr)
|
||||||
|
subprocess.run(["git", "init", "-b", DEFAULT_BRANCH], cwd=work_dir, capture_output=True)
|
||||||
|
subprocess.run(["git", "config", "user.name", GITEA_USER], cwd=work_dir, capture_output=True)
|
||||||
|
subprocess.run(["git", "config", "user.email", GITEA_EMAIL], cwd=work_dir, capture_output=True)
|
||||||
|
subprocess.run(["git", "add", "-A"], cwd=work_dir, capture_output=True)
|
||||||
|
|
||||||
|
commit_res = subprocess.run(
|
||||||
|
["git", "commit", "-m", f"feat: {description}"],
|
||||||
|
cwd=work_dir, capture_output=True, text=True
|
||||||
|
)
|
||||||
|
if commit_res.returncode != 0:
|
||||||
|
# 可能是空提交(所有文件都被过滤了)
|
||||||
|
print(f" commit 可能为空: {commit_res.stderr.strip()}", file=sys.stderr)
|
||||||
|
|
||||||
|
repo_url = f"git@{GIT_HOST}:{GITEA_USER}/{repo_name}.git"
|
||||||
|
remote_res = subprocess.run(
|
||||||
|
["git", "remote", "add", "origin", repo_url],
|
||||||
|
cwd=work_dir, capture_output=True, text=True
|
||||||
|
)
|
||||||
|
if remote_res.returncode != 0:
|
||||||
|
subprocess.run(["git", "remote", "set-url", "origin", repo_url],
|
||||||
|
cwd=work_dir, capture_output=True)
|
||||||
|
|
||||||
|
push_result = subprocess.run(
|
||||||
|
["git", "push", "-u", "origin", DEFAULT_BRANCH, "--force"],
|
||||||
|
cwd=work_dir,
|
||||||
|
capture_output=True, text=True,
|
||||||
|
env={**os.environ, "GIT_SSH_COMMAND": "ssh -o StrictHostKeyChecking=accept-new"}
|
||||||
|
)
|
||||||
|
if push_result.returncode != 0:
|
||||||
|
print(f"❌ 推送失败:\n{push_result.stderr[:500]}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 5. 完成
|
||||||
|
browse_url = f"{GITEA_URL}/{GITEA_USER}/{repo_name}"
|
||||||
|
clone_url = f"git@{GIT_HOST}:{GITEA_USER}/{repo_name}.git"
|
||||||
|
print(f"\n[5/5] ✅ 推送成功!", file=sys.stderr)
|
||||||
|
print(f" 浏览: {browse_url}", file=sys.stderr)
|
||||||
|
print(f" 克隆: {clone_url}", file=sys.stderr)
|
||||||
|
|
||||||
|
print(json.dumps({
|
||||||
|
"ok": True,
|
||||||
|
"repo": repo_name,
|
||||||
|
"url": browse_url,
|
||||||
|
"clone": clone_url,
|
||||||
|
"branch": DEFAULT_BRANCH
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user