Update Nodes #221
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: Update Nodes | |
| on: | |
| schedule: | |
| - cron: '0 */6 * * *' | |
| push: | |
| paths: | |
| - 'core/**' | |
| - 'main.py' | |
| - 'config/**' | |
| - 'requirements.txt' | |
| - 'tools/**' | |
| - 'tests/**' | |
| - '.github/workflows/update.yml' | |
| workflow_dispatch: | |
| inputs: | |
| top_n: | |
| description: 'verified.* 输出节点数(默认 300)' | |
| required: false | |
| default: '300' | |
| test_limit: | |
| description: '送入真测的最大节点数(默认 1500)' | |
| required: false | |
| default: '1500' | |
| min_retain_ratio: | |
| description: '缩水守门比例,0=关闭并强制写入本轮真测结果(默认 0)' | |
| required: false | |
| default: '0' | |
| audit: | |
| description: '同时跑订阅源审计' | |
| type: boolean | |
| default: false | |
| permissions: | |
| contents: write | |
| env: | |
| FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" | |
| concurrency: | |
| group: update-nodes | |
| cancel-in-progress: false | |
| jobs: | |
| update: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.11' | |
| cache: pip | |
| - run: pip install -r requirements.txt | |
| - name: Get sing-box version | |
| id: sb-version | |
| run: | | |
| set -euo pipefail | |
| VERSION=$(curl -fsSL -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ | |
| https://api.github.com/repos/SagerNet/sing-box/releases/latest | jq -r '.tag_name') | |
| if [ -z "$VERSION" ] || [ "$VERSION" = "null" ]; then | |
| echo "::error::Failed to fetch sing-box version" | |
| exit 1 | |
| fi | |
| echo "version=$VERSION" >> "$GITHUB_OUTPUT" | |
| echo "Latest sing-box: $VERSION" | |
| - name: Cache sing-box binary | |
| id: cache-sb | |
| uses: actions/cache@v4 | |
| with: | |
| path: ./sing-box | |
| key: sing-box-${{ runner.os }}-${{ steps.sb-version.outputs.version }} | |
| - name: Download sing-box | |
| if: steps.cache-sb.outputs.cache-hit != 'true' | |
| run: | | |
| set -euo pipefail | |
| VERSION="${{ steps.sb-version.outputs.version }}" | |
| VER_NUM="${VERSION#v}" | |
| # Try download, fallback to cache if fails | |
| if curl -fsSL -L -o sb.tar.gz "https://github.com/SagerNet/sing-box/releases/download/${VERSION}/sing-box-${VER_NUM}-linux-amd64.tar.gz"; then | |
| tar -xzf sb.tar.gz | |
| mv "sing-box-${VER_NUM}-linux-amd64/sing-box" ./sing-box | |
| chmod +x ./sing-box | |
| else | |
| echo "::warning::sing-box download failed, trying fallback versions" | |
| for FALLBACK_VER in "v1.11.15" "v1.10.7" "v1.9.7"; do | |
| FV="${FALLBACK_VER#v}" | |
| if curl -fsSL -L -o sb.tar.gz "https://github.com/SagerNet/sing-box/releases/download/${FALLBACK_VER}/sing-box-${FV}-linux-amd64.tar.gz"; then | |
| tar -xzf sb.tar.gz | |
| mv "sing-box-${FV}-linux-amd64/sing-box" ./sing-box | |
| chmod +x ./sing-box | |
| echo "Using fallback sing-box ${FALLBACK_VER}" | |
| break | |
| fi | |
| done | |
| if [ ! -f ./sing-box ]; then | |
| echo "::error::All sing-box download attempts failed" | |
| exit 1 | |
| fi | |
| fi | |
| - name: Audit sources | |
| run: | | |
| python tools/audit_sources.py --sources config/sources.yaml --output output/source_audit.json | |
| - name: Validate code | |
| run: | | |
| python -m compileall -q main.py core tools tests | |
| python tools/validate_config.py --sources config/sources.yaml --filter-rules config/filter_rules.yaml --scoring-rules 'config/scoring*.yaml' | |
| python -m unittest -v tests.test_regressions | |
| - name: Run pipeline | |
| env: | |
| AUTONODES_BRANCH: ${{ github.event.repository.default_branch }} | |
| run: | | |
| set -euo pipefail | |
| TOP_N="${{ github.event.inputs.top_n || '300' }}" | |
| TEST_LIMIT="${{ github.event.inputs.test_limit || '1500' }}" | |
| MIN_RETAIN_RATIO="${{ github.event.inputs.min_retain_ratio || '0' }}" | |
| [[ "$TOP_N" =~ ^[0-9]+$ ]] || { echo "::error::top_n must be a non-negative integer"; exit 1; } | |
| [[ "$TEST_LIMIT" =~ ^[0-9]+$ ]] || { echo "::error::test_limit must be a non-negative integer"; exit 1; } | |
| [[ "$MIN_RETAIN_RATIO" =~ ^[0-9]+([.][0-9]+)?$ ]] || { echo "::error::min_retain_ratio must be a non-negative number"; exit 1; } | |
| [ "$TOP_N" -le 1000 ] || { echo "::error::top_n must be <= 1000"; exit 1; } | |
| [ "$TEST_LIMIT" -le 3000 ] || { echo "::error::test_limit must be <= 3000"; exit 1; } | |
| python main.py \ | |
| --top-n "$TOP_N" \ | |
| --test-limit "$TEST_LIMIT" \ | |
| --min-retain-ratio "$MIN_RETAIN_RATIO" \ | |
| --repo "${{ github.repository }}" \ | |
| --branch "${{ github.event.repository.default_branch }}" | |
| - name: Build health report | |
| run: | | |
| python tools/source_scores_report.py --output-dir output --output output/source_scores.md | |
| python tools/scoring_profiles_report.py --profiles 'config/scoring*.yaml' --output output/scoring_profiles.md | |
| python tools/suggest_source_cleanup.py --output-dir output --sources config/sources.yaml --output output/source_cleanup_suggestions.md --json-output output/source_cleanup_suggestions.json | |
| python tools/health_report.py --output-dir output --verified-prefix verified --output output/health_report.json | |
| python tools/daily_report.py --output-dir output --output output/daily_report.md | |
| - name: Source discovery (scan other projects) | |
| if: always() | |
| continue-on-error: true | |
| run: | | |
| python tools/source_discovery.py --sources config/sources.yaml --output output/source_discovery.json --timeout 15 || true | |
| - name: Write job summary | |
| if: always() | |
| run: | | |
| if [ -f output/stats.json ]; then | |
| python tools/actions_summary.py \ | |
| --stats output/stats.json \ | |
| --repo "${{ github.repository }}" \ | |
| --branch "${{ github.event.repository.default_branch }}" | |
| else | |
| echo "# AutoNodes Run Summary" >> "$GITHUB_STEP_SUMMARY" | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "stats.json was not generated. Check previous steps." >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| - name: Commit & push | |
| run: | | |
| git config user.email "actions@github.com" | |
| git config user.name "GitHub Actions" | |
| BRANCH="${{ github.event.repository.default_branch }}" | |
| # 显式 fetch 一次确保 origin/$BRANCH 是最新的, 避免 stale shallow ref | |
| git fetch --unshallow origin "$BRANCH" 2>/dev/null || git fetch origin "$BRANCH" | |
| git rm -f output/top.* 2>/dev/null || true | |
| git add output/ README.md 2>/dev/null || true | |
| if git diff --staged --quiet; then | |
| echo "no changes" | |
| else | |
| git commit -m "chore: update nodes $(TZ='Asia/Shanghai' date '+%Y-%m-%d %H:%M CST')" | |
| for i in 1 2 3 4 5; do | |
| if git pull --rebase --autostash --strategy-option=theirs origin "$BRANCH"; then | |
| git push origin HEAD:"$BRANCH" && break | |
| else | |
| git rebase --abort 2>/dev/null || true | |
| echo "::error::rebase failed; refusing to force push automatically" | |
| exit 1 | |
| fi | |
| if [ "$i" = "5" ]; then | |
| echo "::error::git push failed after 5 attempts" | |
| exit 1 | |
| fi | |
| sleep 5 | |
| done | |
| fi | |
| - name: Upload debug artifact | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: autonodes-debug | |
| path: | | |
| output/stats.json | |
| output/source_audit.json | |
| output/health_report.json | |
| output/health_report.md | |
| output/daily_report.md | |
| output/source_scores.md | |
| output/scoring_profiles.md | |
| output/source_cleanup_suggestions.md | |
| output/source_cleanup_suggestions.json | |
| output/source_discovery.json | |
| output/node_stability.json | |
| output/trend_history.json | |
| output/*.converter.md | |
| - name: Notify on failure | |
| if: failure() | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| github.rest.issues.create({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| title: `🔴 CI Failed: ${{ github.run_id }}`, | |
| body: `The node update workflow failed.\n\n[View run](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})\n\nTriggered by: ${{ github.event_name }}\nBranch: ${{ github.ref_name }}`, | |
| labels: ['ci-failure'] | |
| }) | |
| .catch(err => core.warning(`Failed to create issue: ${err.message}`)) | |
| - name: Close ci-failure issues on success | |
| if: success() | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const issues = await github.rest.issues.listForRepo({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| state: 'open', | |
| labels: 'ci-failure' | |
| }); | |
| for (const issue of issues.data) { | |
| await github.rest.issues.update({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issue.number, | |
| state: 'closed', | |
| labels: [...issue.labels.map(l => l.name), 'resolved'] | |
| }); | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issue.number, | |
| body: `✅ CI recovered. This issue is now resolved.\n\n[Latest successful run](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})` | |
| }); | |
| } | |
| core.info(`Closed ${issues.data.length} ci-failure issues.`); | |
| - name: Purge jsDelivr cache | |
| run: | | |
| set -euo pipefail | |
| REPO="${{ github.repository }}" | |
| BRANCH="${{ github.event.repository.default_branch }}" | |
| failed=0 | |
| for f in verified.txt verified.yaml verified.json verified.urls verified.converter.md global.txt global.yaml global.json global.urls global.converter.md all.txt all.yaml all.json all.urls all.converter.md stats.json source_audit.json health_report.json health_report.md daily_report.md source_scores.md scoring_profiles.md source_cleanup_suggestions.md source_cleanup_suggestions.json source_discovery.json node_stability.json trend_history.json geo_cache.json; do | |
| code=$(curl -s -o /tmp/jsdelivr-purge.json -w '%{http_code}' "https://purge.jsdelivr.net/gh/${REPO}@${BRANCH}/output/${f}" || true) | |
| if [ "$code" -ge 500 ] || [ "$code" = "000" ]; then | |
| echo "::warning::jsDelivr purge failed for output/${f} HTTP ${code}" | |
| failed=$((failed + 1)) | |
| else | |
| echo "purged output/${f} HTTP ${code}" | |
| fi | |
| done | |
| # Purge chunk files and protocol files | |
| for f in chunks/verified_1.txt chunks/verified_2.txt chunks/verified_3.txt chunks/global_1.txt chunks/global_2.txt chunks/global_3.txt by_protocol/verified_vmess.txt by_protocol/verified_vless.txt by_protocol/verified_trojan.txt by_protocol/verified_shadowsocks.txt; do | |
| code=$(curl -s -o /dev/null -w '%{http_code}' "https://purge.jsdelivr.net/gh/${REPO}@${BRANCH}/output/${f}" || true) && echo "purged output/${f} HTTP ${code}" || true | |
| done | |
| echo "jsDelivr purge warnings: ${failed}" |