diff --git a/.github/actions/octo-notify/action.yml b/.github/actions/octo-notify/action.yml deleted file mode 100644 index 7fb0553..0000000 --- a/.github/actions/octo-notify/action.yml +++ /dev/null @@ -1,180 +0,0 @@ -# Octo Notify — the organization's single outbound transport. -# -# One composite action for all outbound notification, used by every Plane-6 -# notification workflow. It does TRANSPORT ONLY — it knows nothing about issues -# vs PRs vs CI. The event→message mapping (formatting the IM content string, -# building the webhook JSON payload, sanitizing field values) stays in each -# calling workflow. This is what keeps a single shared action from degrading -# into a junk-drawer. -# -# Two modes, enabled independently by which inputs are supplied (use one or both): -# -# IM mode — set `im-message` (+ `im-token`, `im-group-id`): wraps the -# message in the Octo IM bot envelope and POSTs it to the -# allowlisted IM API. -# Webhook mode — set `webhook-url` (+ `webhook-payload`): POSTs the caller-built -# JSON payload as-is to an external endpoint (e.g. the triage -# agent). The URL is a capability secret — callers MUST pass it -# from a secret, never hardcode it (repos are public). -# -# Shared core (written once here): HTTP POST with retry + exponential backoff on -# 429/5xx, honoring Retry-After on 429. -# -# SECURITY: all inputs are consumed via `env:` and never interpolated into the -# shell or into a JSON string with `${{ }}`. Callers likewise must pass values -# through `with:` (which lands in `env:` here), never build payloads by string -# interpolation in a `run:` block. -name: Octo Notify -description: >- - Organization outbound transport: Octo IM message and/or external webhook POST, - with retry and exponential backoff. Transport only — message/payload mapping - stays in the calling workflow. - -inputs: - # ── IM mode ────────────────────────────────────────────────────────── - im-message: - description: >- - Pre-formatted IM message content. When non-empty, IM mode is enabled. - Build and sanitize this string in the calling workflow. - required: false - default: '' - im-token: - description: 'Octo IM bot token (pass from secrets.OCTO_BOT_TOKEN). Required when im-message is set.' - required: false - default: '' - im-group-id: - description: 'Octo IM target group id. Required when im-message is set.' - required: false - default: '' - im-api-base: - description: 'Octo IM API base URL. Only the production endpoint is allowlisted.' - required: false - default: 'https://im.deepminer.com.cn/api' - # ── Webhook mode ───────────────────────────────────────────────────── - webhook-url: - description: >- - External endpoint URL. When non-empty, webhook mode is enabled. - Pass from a secret — the URL path may itself be the auth capability. - required: false - default: '' - webhook-payload: - description: >- - JSON string to POST as the webhook body. Build it safely in the calling - workflow (e.g. via json.dumps over env vars). Required when webhook-url is set. - required: false - default: '' - -runs: - using: composite - steps: - - name: Send notifications - shell: bash - env: - IM_MESSAGE: ${{ inputs.im-message }} - IM_TOKEN: ${{ inputs.im-token }} - IM_GROUP_ID: ${{ inputs.im-group-id }} - IM_API_BASE: ${{ inputs.im-api-base }} - WEBHOOK_URL: ${{ inputs.webhook-url }} - WEBHOOK_PAYLOAD: ${{ inputs.webhook-payload }} - run: | - python3 - <<'PYEOF' - import json, os, sys, time, urllib.request, urllib.error - - - def post_with_retry(url, body_bytes, headers, label, retries=3): - """POST with exponential backoff on 429/5xx; honor Retry-After on 429.""" - last_err = None - for attempt in range(1, retries + 1): - try: - req = urllib.request.Request(url, data=body_bytes, headers=headers, method='POST') - with urllib.request.urlopen(req, timeout=15) as r: - print(f' -> {label}: HTTP {r.status}') - return True - except urllib.error.HTTPError as e: - last_err = e - if e.code in (429, 500, 502, 503, 504) and attempt < retries: - wait = 2 ** attempt - if e.code == 429: - try: - retry_after = int(e.headers.get('Retry-After', 0)) - if retry_after > 0: - wait = max(retry_after, wait) - except (ValueError, TypeError): - pass - print(f' WARN: {label}: HTTP {e.code} on attempt {attempt}, retrying in {wait}s...') - time.sleep(wait) - else: - break - except (urllib.error.URLError, TimeoutError) as e: - last_err = e - if attempt < retries: - wait = 2 ** attempt - print(f' WARN: {label}: {e} on attempt {attempt}, retrying in {wait}s...') - time.sleep(wait) - else: - break - print(f'::error::{label}: failed after {retries} attempts: {last_err}') - return False - - - im_message = os.environ.get('IM_MESSAGE', '') - im_token = os.environ.get('IM_TOKEN', '').strip() - im_group = os.environ.get('IM_GROUP_ID', '').strip() - im_api_base = os.environ.get('IM_API_BASE', 'https://im.deepminer.com.cn/api').strip().rstrip('/') - webhook_url = os.environ.get('WEBHOOK_URL', '').strip() - webhook_payload = os.environ.get('WEBHOOK_PAYLOAD', '') - - attempted = False - failures = [] - - # ── IM mode ────────────────────────────────────────────────────── - if im_message: - attempted = True - if not im_token or not im_group: - print('::error::IM mode (im-message set) requires im-token and im-group-id') - failures.append('im:config') - else: - # Allowlist: only the production IM endpoint may be targeted. - ALLOWED_API_BASES = {'https://im.deepminer.com.cn/api'} - if im_api_base.lower() not in {b.lower() for b in ALLOWED_API_BASES}: - print(f'::error::im-api-base is not allowlisted: {im_api_base}') - failures.append('im:allowlist') - else: - body = json.dumps({ - 'channel_id': im_group, - 'channel_type': 2, - 'payload': {'type': 1, 'content': im_message}, - }).encode() - headers = {'Authorization': f'Bearer {im_token}', 'Content-Type': 'application/json'} - if not post_with_retry(im_api_base + '/v1/bot/sendMessage', body, headers, - f'IM {im_group[:8]}...'): - failures.append('im:send') - - # ── Webhook mode ───────────────────────────────────────────────── - if webhook_url: - attempted = True - if not webhook_payload: - print('::error::webhook mode (webhook-url set) requires webhook-payload') - failures.append('webhook:config') - else: - # Validate the caller built valid JSON before POSTing. - try: - json.loads(webhook_payload) - except json.JSONDecodeError as e: - print(f'::error::webhook-payload is not valid JSON: {e}') - failures.append('webhook:json') - else: - headers = {'Content-Type': 'application/json'} - if not post_with_retry(webhook_url, webhook_payload.encode(), headers, 'webhook'): - failures.append('webhook:send') - - if not attempted: - print('::error::octo-notify invoked with neither im-message nor webhook-url') - sys.exit(2) - - if failures: - print(f'::error::octo-notify completed with failures: {failures}') - sys.exit(1) - - print('octo-notify: all sends succeeded') - PYEOF diff --git a/.github/workflows/octo-ci-status.yml b/.github/workflows/octo-ci-status.yml deleted file mode 100644 index 518a112..0000000 --- a/.github/workflows/octo-ci-status.yml +++ /dev/null @@ -1,246 +0,0 @@ -name: Octo CI Status (reusable) -# Reusable workflow: detects CI state changes on main branch and notifies Octo IM. -# Only fires when state transitions: success→failure (alert) or failure→success (recovery). -# -# Refactored (Wave 2): the state-machine MAPPING (fetch run + history, compute -# transition, format message) stays here; TRANSPORT delegated to the shared -# octo-notify action. Two IM targets (ci-status feed + repo project group) → two -# octo-notify steps. See docs/workflow-architecture.md Plane 6. - -on: - workflow_call: - inputs: - repo_name: - type: string - required: true - workflow_name: - type: string - required: true - conclusion: - type: string - required: true - run_id: - type: number - required: false - default: 0 - workflow_id: - type: number - required: true - description: 'Numeric workflow ID. Pass github.event.workflow_run.workflow_id from the calling workflow (workflow_run event context).' - run_url: - type: string - required: true - project_group_id: - type: string - required: true - api_base_url: - type: string - required: false - default: 'https://im.deepminer.com.cn/api' - description: 'Octo IM API base URL. Only the production endpoint is allowlisted; any other value will cause the workflow to fail.' - ci_status_group_id: - type: string - required: false - default: '4ade985d984e432eb7fbdd0ad4f8118a' - description: 'Octo IM group ID for the central CI status feed.' - secrets: - OCTO_BOT_TOKEN: - required: true - -permissions: {} - -jobs: - notify: - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - actions: read - steps: - - name: Compute CI state transition and build message - id: build - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO_NAME: ${{ inputs.repo_name }} - WORKFLOW_NAME: ${{ inputs.workflow_name }} - CONCLUSION: ${{ inputs.conclusion }} - RUN_ID: ${{ inputs.run_id }} - WORKFLOW_ID: ${{ inputs.workflow_id }} - RUN_URL: ${{ inputs.run_url }} - PROJECT_GROUP_ID: ${{ inputs.project_group_id }} - CI_STATUS_GROUP_ID: ${{ inputs.ci_status_group_id }} - run: | - python3 - <<'PYEOF' - import os, json, re, urllib.request, urllib.error, sys - - OUT = os.environ['GITHUB_OUTPUT'] - DELIM = '__OCTO_NOTIFY_EOF_9f3a__' - - def emit(key, value): - with open(OUT, 'a') as f: - f.write(f'{key}<<{DELIM}\n{value}\n{DELIM}\n') - - def skip(reason): - print(reason) - emit('skip', 'true') - sys.exit(0) - - def require_env(name): - val = os.environ.get(name, '').strip() - if not val: - print(f'ERROR: required environment variable {name} is missing or empty') - sys.exit(2) - return val - - def require_group_id(name): - """Validate format only (32-char hex); this is not an authorization check.""" - val = require_env(name) - if not re.fullmatch(r'[0-9a-f]{32}', val): - print(f'ERROR: {name} must be a 32-char lowercase hex group id') - sys.exit(2) - return val - - def require_repo_name(name): - """Validate repo name to prevent path traversal in GitHub API URLs.""" - val = require_env(name) - if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]{0,99}', val) or val in {'.', '..'}: - print(f'ERROR: {name} contains invalid characters: {val!r}') - sys.exit(2) - return val - - conclusion = require_env('CONCLUSION') - # Cancelled runs are not real failures; skip silently - if conclusion == 'cancelled': - skip('Cancelled run, skipping.') - - repo = require_repo_name('REPO_NAME') - wf_name = require_env('WORKFLOW_NAME') - run_url = require_env('RUN_URL') - proj_gid = require_group_id('PROJECT_GROUP_ID') - ci_gid = require_group_id('CI_STATUS_GROUP_ID') - gh_token = require_env('GITHUB_TOKEN') - - workflow_id = int(os.environ.get('WORKFLOW_ID', 0) or 0) - - # 1. Fetch the current run precisely by run_id - run_id = int(os.environ.get('RUN_ID', 0) or 0) - current = None - if run_id: - req_current = urllib.request.Request( - f'https://api.github.com/repos/Mininglamp-OSS/{repo}/actions/runs/{run_id}', - headers={ - 'Authorization': f'Bearer {gh_token}', - 'Accept': 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - } - ) - try: - with urllib.request.urlopen(req_current, timeout=15) as r: - current = json.load(r) - except (urllib.error.HTTPError, urllib.error.URLError) as e: - print(f'ERROR: failed to fetch run {run_id}: {e}') - sys.exit(1) - - # 2. Fetch previous completed runs (scoped to this workflow if workflow_id is known) - if workflow_id: - history_url = ( - f'https://api.github.com/repos/Mininglamp-OSS/{repo}/actions/workflows/{workflow_id}/runs' - f'?branch=main&status=completed&per_page=30' - ) - else: - history_url = ( - f'https://api.github.com/repos/Mininglamp-OSS/{repo}/actions/runs' - f'?branch=main&status=completed&per_page=30' - ) - req_hist = urllib.request.Request(history_url, headers={ - 'Authorization': f'Bearer {gh_token}', - 'Accept': 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - }) - try: - with urllib.request.urlopen(req_hist, timeout=15) as r: - all_runs = json.load(r)['workflow_runs'] - except (urllib.error.HTTPError, urllib.error.URLError) as e: - print(f'ERROR: failed to fetch workflow run history: {e}') - sys.exit(1) - - # If run_id wasn't passed or lookup failed, fall back to first matching in history - if not current: - same_wf = [r for r in all_runs if r['name'] == wf_name] - if not same_wf: - skip('No matching runs found, skipping.') - current = same_wf[0] - - curr_created = current['created_at'] - - # Previous: any run older than current (by created_at), same workflow name - older = sorted( - [r for r in all_runs - if r['id'] != current['id'] - and r['name'] == wf_name # defensive; redundant when workflow_id is set (already scoped) - and r['created_at'] < curr_created], - key=lambda r: r['created_at'], - reverse=True, - ) - previous = older[0] if older else None - - curr_conclusion = current['conclusion'] - prev_conclusion = previous['conclusion'] if previous else None - - print(f'State: {prev_conclusion} → {curr_conclusion}') - - # Only act on state changes - if curr_conclusion == prev_conclusion: - skip('No state change, silent.') - - # Guard: first-ever run has no previous history — skip silently - if prev_conclusion is None: - skip('First run detected (no previous history), skipping notification.') - - # Failure-like conclusions: treat all of these as "CI broken" - FAILURE_LIKE = {'failure', 'timed_out', 'action_required', 'startup_failure'} - - curr_failed = curr_conclusion in FAILURE_LIKE - prev_failed = prev_conclusion in FAILURE_LIKE - - if curr_failed and not prev_failed: - status_label = {'timed_out': '⏱️ timed out', 'action_required': '⚠️ action required', - 'startup_failure': '💥 startup failed'}.get(curr_conclusion, '❌ failed') - msg = ( - f'🚨 [{repo}] main CI {status_label}\n\n' - f'工作流:{wf_name}\n' - f'🔗 {run_url}' - ) - elif curr_conclusion == 'success' and prev_failed: - msg = ( - f'✅ [{repo}] main CI 已恢复\n\n' - f'工作流:{wf_name}\n' - f'🔗 {run_url}' - ) - else: - skip(f'Unhandled transition {prev_conclusion!r} → {curr_conclusion!r}, skipping.') - - emit('skip', 'false') - emit('im_message', msg) - emit('ci_group_id', ci_gid) - emit('project_group_id', proj_gid) - PYEOF - - - name: Send to CI status feed - if: steps.build.outputs.skip != 'true' - uses: Mininglamp-OSS/.github/.github/actions/octo-notify@v1 - with: - im-message: ${{ steps.build.outputs.im_message }} - im-token: ${{ secrets.OCTO_BOT_TOKEN }} - im-group-id: ${{ steps.build.outputs.ci_group_id }} - im-api-base: ${{ inputs.api_base_url }} - - - name: Send to repo project group - # Run even if the ci-status send failed, so one IM group outage does not - # suppress the other (matches the original "attempt both groups" behavior). - if: ${{ always() && steps.build.outputs.skip != 'true' }} - uses: Mininglamp-OSS/.github/.github/actions/octo-notify@v1 - with: - im-message: ${{ steps.build.outputs.im_message }} - im-token: ${{ secrets.OCTO_BOT_TOKEN }} - im-group-id: ${{ steps.build.outputs.project_group_id }} - im-api-base: ${{ inputs.api_base_url }}