ci: batch Telegram push notifications #270
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: CI | |
| on: | |
| push: | |
| branches: [main, dev] | |
| pull_request: | |
| branches: [main] | |
| # Cancel superseded runs on the same ref (rapid pushes / PR force-pushes). | |
| concurrency: | |
| group: ci-${{ github.ref }} | |
| cancel-in-progress: true | |
| # Least privilege. The notify job authenticates to Telegram via its own secrets, | |
| # not GITHUB_TOKEN, so read-only repo access is enough everywhere. | |
| permissions: | |
| contents: read | |
| jobs: | |
| # Node-version-independent static analysis — run once, not per matrix entry. | |
| checks: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version-file: .nvmrc | |
| cache: npm | |
| - name: Install dependencies | |
| run: npm ci | |
| - name: Verify runtime contract | |
| run: npm run check:runtime | |
| - name: Build SDK workspace | |
| run: npm run build -w packages/sdk | |
| - name: Type check | |
| run: npm run typecheck | |
| - name: Lint | |
| run: npm run lint | |
| - name: Format check | |
| run: npm run format:check | |
| - name: Dead code check | |
| run: npm run knip | |
| - name: Circular dependency check | |
| run: npm run circular | |
| - name: Duplicate code check | |
| run: npm run dupcheck | |
| - name: Security audit | |
| run: npm run audit:ci | |
| # Build + test on each supported Node version. | |
| test: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 25 | |
| strategy: | |
| matrix: | |
| node-version: ["22.22.2", "24.15.0"] | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Use Node.js ${{ matrix.node-version }} | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: ${{ matrix.node-version }} | |
| cache: npm | |
| cache-dependency-path: | | |
| package-lock.json | |
| web/package-lock.json | |
| - name: Install dependencies | |
| run: npm ci | |
| - name: Install WebUI dependencies | |
| run: cd web && npm ci | |
| - name: Build SDK workspace | |
| run: npm run build -w packages/sdk | |
| - name: Build | |
| run: npm run build | |
| - name: Test with coverage | |
| run: npm run test:coverage | |
| - name: CLI smoke test | |
| run: node dist/cli/index.js --help | |
| # ---- Telegram notification (one summary per push, pushes only) ---- | |
| notify: | |
| needs: [checks, test] | |
| if: always() && github.event_name == 'push' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Notify Telegram | |
| env: | |
| TG_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} | |
| TG_CHAT: ${{ secrets.TELEGRAM_CHAT_ID }} | |
| BRANCH: ${{ github.ref_name }} | |
| BEFORE_SHA: ${{ github.event.before }} | |
| AFTER_SHA: ${{ github.event.after }} | |
| CHECKS_RESULT: ${{ needs.checks.result }} | |
| TEST_RESULT: ${{ needs.test.result }} | |
| run: | | |
| set -uo pipefail | |
| NULL_SHA="0000000000000000000000000000000000000000" | |
| if [ -z "$TG_TOKEN" ] || [ -z "$TG_CHAT" ]; then | |
| echo "Telegram secrets not configured, skipping" | |
| exit 0 | |
| fi | |
| # Send one message, retrying until Telegram accepts it. Do not use | |
| # curl --fail: a 429 body contains the retry_after value we need. | |
| send() { | |
| local text="$1" attempt resp ra | |
| for attempt in 1 2 3 4 5 6; do | |
| resp=$(curl -sS -m 25 -X POST "https://api.telegram.org/bot${TG_TOKEN}/sendMessage" \ | |
| -d chat_id="${TG_CHAT}" -d parse_mode=HTML -d disable_web_page_preview=true \ | |
| --data-urlencode "text=${text}" 2>/dev/null || true) | |
| case "$resp" in | |
| *'"ok":true'*) return 0 ;; | |
| esac | |
| # 429 -> honor retry_after; other transient error -> small backoff. | |
| ra=$(printf '%s' "$resp" | grep -o '"retry_after":[0-9]*' | grep -o '[0-9]*' || true) | |
| sleep "$(( ${ra:-2} + 1 ))" | |
| done | |
| echo "WARN: gave up sending after retries: ${text%%$'\n'*}" | |
| return 1 | |
| } | |
| REPO_URL="${{ github.server_url }}/${{ github.repository }}" | |
| # One summary notification per push (avoid per-commit spam). | |
| if [ -n "$BRANCH" ]; then | |
| if [ "$BEFORE_SHA" = "$NULL_SHA" ] || ! git merge-base --is-ancestor "$BEFORE_SHA" "$AFTER_SHA" 2>/dev/null; then | |
| COUNT=$(git rev-list --count "$AFTER_SHA") | |
| RANGE="$AFTER_SHA" | |
| URL="$REPO_URL/commits/$BRANCH" | |
| else | |
| COUNT=$(git rev-list --count "$BEFORE_SHA..$AFTER_SHA") | |
| RANGE="$BEFORE_SHA..$AFTER_SHA" | |
| URL="$REPO_URL/compare/$BEFORE_SHA...$AFTER_SHA" | |
| fi | |
| NOUN=commits; [ "$COUNT" = "1" ] && NOUN=commit | |
| REPO_NAME=${REPO_URL##*/} | |
| LABEL=$(printf '%s' "$BRANCH" | tr '[:lower:]' '[:upper:]') | |
| TEXT="<b>[$LABEL] $REPO_NAME</b>" | |
| if [ "$CHECKS_RESULT" = "success" ] && [ "$TEST_RESULT" = "success" ]; then | |
| printf -v TEXT '%s\n%s %s pushed to %s' "$TEXT" "$COUNT" "$NOUN" "$BRANCH" | |
| else | |
| printf -v TEXT '%s\nCI failed on %s (%s %s)' "$TEXT" "$BRANCH" "$COUNT" "$NOUN" | |
| fi | |
| # Commit titles (HTML-escaped), oldest first, capped to avoid huge messages. | |
| MAX=50 | |
| TITLES=$(git log --reverse --max-count="$MAX" --pretty=format:'%s' "$RANGE" \ | |
| | sed -e 's/&/\&/g' -e 's/</\</g' -e 's/>/\>/g') | |
| if [ "$COUNT" -gt 1 ]; then | |
| if [ "$COUNT" -gt "$MAX" ]; then | |
| printf -v TITLES '%s\n… and %s more' "$TITLES" "$((COUNT - MAX))" | |
| fi | |
| # Multiple commits: collapse the title list into an expandable quote. | |
| printf -v TEXT '%s\n\n<blockquote expandable>%s</blockquote>' "$TEXT" "$TITLES" | |
| elif [ -n "$TITLES" ]; then | |
| printf -v TEXT '%s\n\n<blockquote>%s</blockquote>' "$TEXT" "$TITLES" | |
| fi | |
| printf -v TEXT '%s\n\n<a href="%s">View commits</a>' "$TEXT" "$URL" | |
| send "$TEXT" | |
| fi |