feat: 更新 f8x 使用指南,添加 CLI 参考和工具分类信息 #148
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Build and Deploy | |
| on: | |
| push: | |
| branches: [ "master" ] | |
| paths-ignore: | |
| - 'public/**' | |
| workflow_dispatch: | |
| inputs: | |
| force_rebuild: | |
| description: 'Force rebuild current version' | |
| required: false | |
| default: false | |
| type: boolean | |
| permissions: | |
| contents: write | |
| pages: write | |
| id-token: write | |
| checks: read | |
| actions: read | |
| jobs: | |
| wait-for-validation: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Brief sleep | |
| run: sleep 15 | |
| - name: Wait for validation to complete | |
| uses: lewagon/wait-on-check-action@v1.3.4 | |
| with: | |
| ref: ${{ github.ref }} | |
| check-name: 'Validate Standards' | |
| repo-token: ${{ secrets.GITHUB_TOKEN }} | |
| wait-interval: 10 | |
| - name: Wait for Secret Scan | |
| uses: lewagon/wait-on-check-action@v1.3.4 | |
| with: | |
| ref: ${{ github.ref }} | |
| check-name: 'Scan for Secrets (AK/SK/Passwords)' | |
| repo-token: ${{ secrets.GITHUB_TOKEN }} | |
| wait-interval: 10 | |
| build-and-deploy: | |
| needs: [wait-for-validation] | |
| runs-on: ubuntu-latest | |
| steps: | |
| # 1. 拉取源码 (最新版) | |
| - name: Checkout Source | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 1 | |
| # 2. 下载旧的 index.json (用于合并数据,无需下载 ZIP) | |
| - name: Fetch Current Index | |
| env: | |
| REPO_OWNER: ${{ github.repository_owner }} | |
| REPO_NAME: ${{ github.event.repository.name }} | |
| run: | | |
| # 构造 URL | |
| INDEX_URL="https://redc.${REPO_OWNER}.org/index.json" | |
| echo "⬇️ Fetching index from: $INDEX_URL" | |
| # 尝试下载,如果失败(比如第一次运行)则创建一个空的 JSON | |
| curl -sL "$INDEX_URL" -o prev_index.json || echo '{"templates": {}}' > prev_index.json | |
| # 检查文件内容是否合法,不合法则重置 | |
| if ! jq . prev_index.json >/dev/null 2>&1; then | |
| echo '{"templates": {}}' > prev_index.json | |
| fi | |
| # 3. 构建脚本 (只打包变动的/新的) | |
| - name: Build with Python | |
| env: | |
| REPO_OWNER: ${{ github.repository_owner }} | |
| REPO_NAME: ${{ github.event.repository.name }} | |
| FORCE_REBUILD: ${{ inputs.force_rebuild }} | |
| run: | | |
| mkdir -p public/templates | |
| [ -f index.html ] && cp index.html public/index.html | |
| [ -d assets ] && cp -r assets public/assets | |
| # Inject latest redc version into index.html | |
| REDC_VERSION=$(curl -sf https://api.github.com/repos/wgpsec/redc/releases/latest | python3 -c "import sys,json; print(json.load(sys.stdin).get('tag_name','v0.0.0'))" 2>/dev/null || echo "v0.0.0") | |
| sed -i "s/__REDC_VERSION__/${REDC_VERSION}/g" public/index.html | |
| python3 -c " | |
| import os | |
| import json | |
| import shutil | |
| import hashlib | |
| import datetime | |
| # --- 配置 --- | |
| repo_owner = os.environ.get('REPO_OWNER') | |
| repo_name = os.environ.get('REPO_NAME') | |
| force_rebuild = os.environ.get('FORCE_REBUILD') == 'true' | |
| base_url = f'https://redc.${REPO_OWNER}.org' | |
| output_dir = 'public' | |
| templates_dir = os.path.join(output_dir, 'templates') | |
| # 旧索引路径 | |
| prev_index_path = 'prev_index.json' | |
| if not os.path.exists(templates_dir): os.makedirs(templates_dir) | |
| # --- A. 加载旧索引 --- | |
| manifest_data = {} | |
| try: | |
| with open(prev_index_path, 'r', encoding='utf-8') as f: | |
| old_json = json.load(f) | |
| if isinstance(old_json.get('templates'), dict): | |
| manifest_data = old_json['templates'] | |
| except: | |
| print('⚠️ Failed to load prev_index.json, starting fresh.') | |
| # --- B. 遍历处理 --- | |
| repo_root = '.' | |
| exclude = {'.git', '.github', 'public', 'previous_build', '.gitignore', 'index.html', 'plugins', 'skills', 'assets'} | |
| # 标记本次构建是否有更新 (如果没有更新,甚至可以不提交 index.json,但为了更新时间戳还是提交吧) | |
| has_changes = False | |
| # 跟踪当前扫描到的所有模板 ID | |
| current_template_ids = set() | |
| for provider in sorted(os.listdir(repo_root)): | |
| provider_path = os.path.join(repo_root, provider) | |
| if not os.path.isdir(provider_path) or provider in exclude: continue | |
| for template in sorted(os.listdir(provider_path)): | |
| template_path = os.path.join(provider_path, template) | |
| if not os.path.isdir(template_path): continue | |
| template_id = f'{provider}/{template}' | |
| current_template_ids.add(template_id) | |
| # 1. 读取本地最新信息 | |
| meta_name = template; meta_user = 'Unknown'; meta_desc = 'No description.'; meta_version = '0.0.0' | |
| meta_desc_en = ''; meta_tags = [] | |
| case_path = os.path.join(template_path, 'case.json') | |
| try: | |
| if os.path.exists(case_path): | |
| with open(case_path, 'r', encoding='utf-8') as cf: | |
| c = json.load(cf) | |
| meta_name = c.get('name', template) | |
| meta_user = c.get('user', 'Unknown') | |
| meta_desc = c.get('description', 'No description.') | |
| meta_version = c.get('version', '0.0.0') | |
| meta_desc_en = c.get('description_en', '') | |
| meta_tags = c.get('tags', []) | |
| except: pass | |
| # 读取 README | |
| readme_content = '' | |
| readme_path = os.path.join(template_path, 'README.md') | |
| try: | |
| if os.path.exists(readme_path): | |
| with open(readme_path, 'r', encoding='utf-8') as rf: readme_content = rf.read() | |
| except: pass | |
| # 读取 README_EN | |
| readme_en_content = '' | |
| readme_en_path = os.path.join(template_path, 'README_EN.md') | |
| try: | |
| if os.path.exists(readme_en_path): | |
| with open(readme_en_path, 'r', encoding='utf-8') as rf: readme_en_content = rf.read() | |
| except: pass | |
| # 2. 初始化元数据结构 | |
| if template_id not in manifest_data: | |
| manifest_data[template_id] = { | |
| 'id': template_id, 'provider': provider, 'slug': template, 'versions': {} | |
| } | |
| # 总是更新通用元数据 (Title/Desc/Readme) | |
| manifest_data[template_id]['latest'] = meta_version | |
| manifest_data[template_id]['metadata'] = { | |
| 'name': meta_name, 'author': meta_user, 'description': meta_desc, | |
| 'description_en': meta_desc_en, 'tags': meta_tags, 'readme': readme_content, | |
| 'readme_en': readme_en_content | |
| } | |
| # 3. 检查版本是否存在 | |
| zip_filename = f'{provider}_{template}_v{meta_version}.zip' | |
| zip_output_path = os.path.join(templates_dir, zip_filename) | |
| version_exists = meta_version in manifest_data[template_id]['versions'] | |
| # 只有当:强制重建 OR 版本不存在 时,才打包 | |
| if force_rebuild or not version_exists: | |
| print(f'🔨 Packaging: {zip_filename}') | |
| shutil.make_archive(zip_output_path.replace('.zip', ''), 'zip', root_dir=template_path) | |
| h = hashlib.sha256() | |
| with open(zip_output_path, 'rb') as f: | |
| for chunk in iter(lambda: f.read(4096), b''): h.update(chunk) | |
| manifest_data[template_id]['versions'][meta_version] = { | |
| 'url': f'{base_url}/templates/{zip_filename}', | |
| 'sha256': h.hexdigest(), | |
| 'updated_at': datetime.datetime.utcnow().isoformat() + 'Z' | |
| } | |
| has_changes = True | |
| else: | |
| print(f'⏩ Skipping: {zip_filename} (Version exists in index)') | |
| # --- 删除不再存在的模板 --- | |
| deleted_templates = [tid for tid in manifest_data.keys() if tid not in current_template_ids] | |
| for template_id in deleted_templates: | |
| print(f'🗑️ Removing deleted template: {template_id}') | |
| del manifest_data[template_id] | |
| has_changes = True | |
| # --- C. 保存 Index --- | |
| # 即使没有新 ZIP,更新一下 updated_at 也是好的 | |
| final_manifest = { | |
| 'updated_at': datetime.datetime.utcnow().isoformat() + 'Z', | |
| 'repo_name': repo_name, | |
| 'templates': manifest_data | |
| } | |
| with open(os.path.join(output_dir, 'index.json'), 'w', encoding='utf-8') as f: | |
| json.dump(final_manifest, f, indent=2, ensure_ascii=False) | |
| print(f'✨ Index updated.') | |
| " | |
| # 3.5 构建插件索引和 ZIP 包 | |
| - name: Build Plugin Registry | |
| env: | |
| REPO_OWNER: ${{ github.repository_owner }} | |
| run: | | |
| mkdir -p public/plugins | |
| python3 -c " | |
| import os | |
| import json | |
| import shutil | |
| import hashlib | |
| import datetime | |
| repo_owner = os.environ.get('REPO_OWNER') | |
| base_url = f'https://redc.{repo_owner}.org' | |
| plugins_src = 'plugins' | |
| plugins_out = os.path.join('public', 'plugins') | |
| if not os.path.isdir(plugins_src): | |
| print('⏩ No plugins/ directory, skipping') | |
| exit(0) | |
| os.makedirs(plugins_out, exist_ok=True) | |
| # 加载已有的 registry (如果有) | |
| prev_registry_url = f'{base_url}/plugins/plugin-registry.json' | |
| prev_registry = {'version': 1, 'plugins': []} | |
| prev_path = 'prev_plugin_registry.json' | |
| os.system(f'curl -sL \"{prev_registry_url}\" -o {prev_path} 2>/dev/null || true') | |
| try: | |
| with open(prev_path, 'r') as f: | |
| prev_registry = json.load(f) | |
| except: | |
| pass | |
| # 已有插件的 hash 缓存 | |
| prev_hashes = {} | |
| for p in prev_registry.get('plugins', []): | |
| if 'sha256' in p: | |
| prev_hashes[p['name']] = p['sha256'] | |
| plugins = [] | |
| for entry in sorted(os.listdir(plugins_src)): | |
| plugin_dir = os.path.join(plugins_src, entry) | |
| manifest_path = os.path.join(plugin_dir, 'plugin.json') | |
| if not os.path.isdir(plugin_dir) or not os.path.exists(manifest_path): | |
| continue | |
| try: | |
| with open(manifest_path, 'r', encoding='utf-8') as f: | |
| manifest = json.load(f) | |
| except: | |
| print(f'⚠️ Invalid plugin.json in {entry}, skipping') | |
| continue | |
| name = manifest.get('name', entry) | |
| version = manifest.get('version', '0.0.0') | |
| zip_filename = f'{name}_v{version}.zip' | |
| zip_path = os.path.join(plugins_out, zip_filename) | |
| # 计算源码 hash | |
| h = hashlib.sha256() | |
| for root, dirs, files in sorted(os.walk(plugin_dir)): | |
| dirs.sort() | |
| for fn in sorted(files): | |
| fp = os.path.join(root, fn) | |
| rel = os.path.relpath(fp, plugin_dir) | |
| h.update(rel.encode()) | |
| with open(fp, 'rb') as ff: | |
| for chunk in iter(lambda: ff.read(4096), b''): | |
| h.update(chunk) | |
| src_hash = h.hexdigest() | |
| # 只有内容变化才重新打包 | |
| if src_hash != prev_hashes.get(name, ''): | |
| print(f'🔨 Packaging plugin: {zip_filename}') | |
| shutil.make_archive(zip_path.replace('.zip', ''), 'zip', root_dir=plugin_dir) | |
| else: | |
| print(f'⏩ Skipping plugin: {zip_filename} (unchanged)') | |
| plugins.append({ | |
| 'name': name, | |
| 'version': version, | |
| 'description': manifest.get('description', ''), | |
| 'description_en': manifest.get('description_en', ''), | |
| 'author': manifest.get('author', ''), | |
| 'category': manifest.get('category', ''), | |
| 'tags': manifest.get('tags', []), | |
| 'min_redc_version': manifest.get('min_redc_version', ''), | |
| 'url': f'{base_url}/plugins/{zip_filename}', | |
| 'sha256': src_hash | |
| }) | |
| # 写入 plugin-registry.json | |
| registry = { | |
| 'version': 1, | |
| 'updated': datetime.datetime.utcnow().strftime('%Y-%m-%d'), | |
| 'plugins': plugins | |
| } | |
| registry_path = os.path.join(plugins_out, 'plugin-registry.json') | |
| with open(registry_path, 'w', encoding='utf-8') as f: | |
| json.dump(registry, f, indent=2, ensure_ascii=False) | |
| print(f'✨ Plugin registry updated: {len(plugins)} plugins') | |
| " | |
| # 3.6 构建 Skills 索引和 ZIP 包 | |
| - name: Build Skills Registry | |
| env: | |
| REPO_OWNER: ${{ github.repository_owner }} | |
| run: | | |
| mkdir -p public/skills | |
| python3 -c " | |
| import os | |
| import json | |
| import re | |
| import shutil | |
| import hashlib | |
| import datetime | |
| repo_owner = os.environ.get('REPO_OWNER') | |
| base_url = f'https://redc.{repo_owner}.org' | |
| skills_src = 'skills' | |
| skills_out = os.path.join('public', 'skills') | |
| if not os.path.isdir(skills_src): | |
| print('⏩ No skills/ directory, skipping') | |
| exit(0) | |
| os.makedirs(skills_out, exist_ok=True) | |
| # 加载已有的 registry | |
| prev_registry_url = f'{base_url}/skills/skill-registry.json' | |
| prev_hashes = {} | |
| prev_path = 'prev_skill_registry.json' | |
| os.system(f'curl -sL \"{prev_registry_url}\" -o {prev_path} 2>/dev/null || true') | |
| try: | |
| with open(prev_path, 'r') as f: | |
| prev_data = json.load(f) | |
| for s in prev_data.get('skills', []): | |
| if 'sha256' in s: | |
| prev_hashes[s['id']] = s['sha256'] | |
| except: | |
| pass | |
| skills = [] | |
| for entry in sorted(os.listdir(skills_src)): | |
| skill_dir = os.path.join(skills_src, entry) | |
| skill_md = os.path.join(skill_dir, 'SKILL.md') | |
| if not os.path.isdir(skill_dir) or not os.path.exists(skill_md): | |
| continue | |
| # Parse YAML frontmatter | |
| with open(skill_md, 'r', encoding='utf-8') as f: | |
| content = f.read() | |
| name = entry | |
| description = '' | |
| tags = [] | |
| if content.startswith('---'): | |
| end = content.find('---', 3) | |
| if end > 0: | |
| front = content[3:end] | |
| for line in front.strip().split('\n'): | |
| line = line.strip() | |
| if line.startswith('name:'): | |
| name = line[5:].strip().strip('\"\\\"') | |
| elif line.startswith('description:'): | |
| description = line[12:].strip().strip('\"\\\"') | |
| elif line.startswith('tags:'): | |
| tag_str = line[5:].strip() | |
| tags = [t.strip() for t in re.split(r'[,,]', tag_str) if t.strip()] | |
| # Compute hash | |
| h = hashlib.sha256() | |
| for root, dirs, files in sorted(os.walk(skill_dir)): | |
| dirs.sort() | |
| for fn in sorted(files): | |
| fp = os.path.join(root, fn) | |
| rel = os.path.relpath(fp, skill_dir) | |
| h.update(rel.encode()) | |
| with open(fp, 'rb') as ff: | |
| for chunk in iter(lambda: ff.read(4096), b''): | |
| h.update(chunk) | |
| src_hash = h.hexdigest() | |
| zip_filename = f'skill-{entry}.zip' | |
| zip_path = os.path.join(skills_out, zip_filename) | |
| if src_hash != prev_hashes.get(entry, ''): | |
| print(f'🔨 Packaging skill: {zip_filename}') | |
| shutil.make_archive(zip_path.replace('.zip', ''), 'zip', root_dir=skill_dir) | |
| else: | |
| print(f'⏩ Skipping skill: {zip_filename} (unchanged)') | |
| skills.append({ | |
| 'id': entry, | |
| 'name': name, | |
| 'description': description, | |
| 'tags': tags, | |
| 'url': f'{base_url}/skills/{zip_filename}', | |
| 'sha256': src_hash | |
| }) | |
| registry = { | |
| 'version': 1, | |
| 'updated': datetime.datetime.utcnow().strftime('%Y-%m-%d'), | |
| 'skills': skills | |
| } | |
| registry_path = os.path.join(skills_out, 'skill-registry.json') | |
| with open(registry_path, 'w', encoding='utf-8') as f: | |
| json.dump(registry, f, indent=2, ensure_ascii=False) | |
| print(f'✨ Skill registry updated: {len(skills)} skills') | |
| " | |
| # 4. 部署 (Keep Files 模式) | |
| - name: Deploy to gh-pages | |
| uses: peaceiris/actions-gh-pages@v3 | |
| with: | |
| github_token: ${{ secrets.GITHUB_TOKEN }} | |
| publish_dir: ./public | |
| publish_branch: gh-pages | |
| keep_files: true | |
| commit_message: "Deploy: ${{ github.event.head_commit.message }}" |