Merge pull request #847 from agorevski/algore/cicd-descript-release-matrix

perf(ci): reduce GitHub Actions costs ~60-65% across all workflows
This commit is contained in:
Alex Gorevski 2026-02-19 06:54:40 -08:00 committed by GitHub
commit fedfd6ae01
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 271 additions and 64 deletions

View file

@ -4,13 +4,13 @@ updates:
- package-ecosystem: cargo - package-ecosystem: cargo
directory: "/" directory: "/"
schedule: schedule:
interval: weekly interval: monthly
target-branch: main target-branch: main
open-pull-requests-limit: 5 open-pull-requests-limit: 3
labels: labels:
- "dependencies" - "dependencies"
groups: groups:
rust-minor-patch: rust-all:
patterns: patterns:
- "*" - "*"
update-types: update-types:
@ -20,14 +20,31 @@ updates:
- package-ecosystem: github-actions - package-ecosystem: github-actions
directory: "/" directory: "/"
schedule: schedule:
interval: weekly interval: monthly
target-branch: main target-branch: main
open-pull-requests-limit: 3 open-pull-requests-limit: 1
labels: labels:
- "ci" - "ci"
- "dependencies" - "dependencies"
groups: groups:
actions-minor-patch: actions-all:
patterns:
- "*"
update-types:
- minor
- patch
- package-ecosystem: docker
directory: "/"
schedule:
interval: monthly
target-branch: main
open-pull-requests-limit: 1
labels:
- "ci"
- "dependencies"
groups:
docker-all:
patterns: patterns:
- "*" - "*"
update-types: update-types:

View file

@ -41,25 +41,7 @@ jobs:
run: ./scripts/ci/detect_change_scope.sh run: ./scripts/ci/detect_change_scope.sh
lint: lint:
name: Lint Gate (Format + Clippy) name: Lint Gate (Format + Clippy + Strict Delta)
needs: [changes]
if: needs.changes.outputs.rust_changed == 'true' && (github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'ci:full'))
runs-on: blacksmith-2vcpu-ubuntu-2404
timeout-minutes: 20
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
with:
toolchain: 1.92.0
components: rustfmt, clippy
- uses: useblacksmith/rust-cache@f53e7f127245d2a269b3d90879ccf259876842d5 # v3
- name: Run rust quality gate
run: ./scripts/ci/rust_quality_gate.sh
lint-strict-delta:
name: Lint Gate (Strict Delta)
needs: [changes] needs: [changes]
if: needs.changes.outputs.rust_changed == 'true' && (github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'ci:full')) if: needs.changes.outputs.rust_changed == 'true' && (github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'ci:full'))
runs-on: blacksmith-2vcpu-ubuntu-2404 runs-on: blacksmith-2vcpu-ubuntu-2404
@ -71,8 +53,10 @@ jobs:
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
with: with:
toolchain: 1.92.0 toolchain: 1.92.0
components: clippy components: rustfmt, clippy
- uses: useblacksmith/rust-cache@f53e7f127245d2a269b3d90879ccf259876842d5 # v3 - uses: useblacksmith/rust-cache@f53e7f127245d2a269b3d90879ccf259876842d5 # v3
- name: Run rust quality gate
run: ./scripts/ci/rust_quality_gate.sh
- name: Run strict lint delta gate - name: Run strict lint delta gate
env: env:
BASE_SHA: ${{ needs.changes.outputs.base_sha }} BASE_SHA: ${{ needs.changes.outputs.base_sha }}
@ -80,8 +64,8 @@ jobs:
test: test:
name: Test name: Test
needs: [changes, lint, lint-strict-delta] needs: [changes, lint]
if: needs.changes.outputs.rust_changed == 'true' && (github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'ci:full')) && needs.lint.result == 'success' && needs.lint-strict-delta.result == 'success' if: needs.changes.outputs.rust_changed == 'true' && (github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'ci:full')) && needs.lint.result == 'success'
runs-on: blacksmith-2vcpu-ubuntu-2404 runs-on: blacksmith-2vcpu-ubuntu-2404
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
@ -106,8 +90,8 @@ jobs:
with: with:
toolchain: 1.92.0 toolchain: 1.92.0
- uses: useblacksmith/rust-cache@f53e7f127245d2a269b3d90879ccf259876842d5 # v3 - uses: useblacksmith/rust-cache@f53e7f127245d2a269b3d90879ccf259876842d5 # v3
- name: Build release binary - name: Build binary (smoke check)
run: cargo build --release --locked --verbose run: cargo build --locked --verbose
docs-only: docs-only:
name: Docs-Only Fast Path name: Docs-Only Fast Path
@ -185,7 +169,7 @@ jobs:
lint-feedback: lint-feedback:
name: Lint Feedback name: Lint Feedback
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
needs: [changes, lint, lint-strict-delta, docs-quality] needs: [changes, lint, docs-quality]
runs-on: blacksmith-2vcpu-ubuntu-2404 runs-on: blacksmith-2vcpu-ubuntu-2404
permissions: permissions:
contents: read contents: read
@ -201,7 +185,7 @@ jobs:
RUST_CHANGED: ${{ needs.changes.outputs.rust_changed }} RUST_CHANGED: ${{ needs.changes.outputs.rust_changed }}
DOCS_CHANGED: ${{ needs.changes.outputs.docs_changed }} DOCS_CHANGED: ${{ needs.changes.outputs.docs_changed }}
LINT_RESULT: ${{ needs.lint.result }} LINT_RESULT: ${{ needs.lint.result }}
LINT_DELTA_RESULT: ${{ needs.lint-strict-delta.result }} LINT_DELTA_RESULT: ${{ needs.lint.result }}
DOCS_RESULT: ${{ needs.docs-quality.result }} DOCS_RESULT: ${{ needs.docs-quality.result }}
with: with:
script: | script: |
@ -231,7 +215,7 @@ jobs:
ci-required: ci-required:
name: CI Required Gate name: CI Required Gate
if: always() if: always()
needs: [changes, lint, lint-strict-delta, test, build, docs-only, non-rust, docs-quality, lint-feedback, workflow-owner-approval] needs: [changes, lint, test, build, docs-only, non-rust, docs-quality, lint-feedback, workflow-owner-approval]
runs-on: blacksmith-2vcpu-ubuntu-2404 runs-on: blacksmith-2vcpu-ubuntu-2404
steps: steps:
- name: Enforce required status - name: Enforce required status
@ -276,7 +260,7 @@ jobs:
fi fi
lint_result="${{ needs.lint.result }}" lint_result="${{ needs.lint.result }}"
lint_strict_delta_result="${{ needs.lint-strict-delta.result }}" lint_strict_delta_result="${{ needs.lint.result }}"
test_result="${{ needs.test.result }}" test_result="${{ needs.test.result }}"
build_result="${{ needs.build.result }}" build_result="${{ needs.build.result }}"

View file

@ -1,12 +1,6 @@
name: Feature Matrix name: Feature Matrix
on: on:
push:
branches: [main]
paths:
- "Cargo.toml"
- "Cargo.lock"
- "src/**"
schedule: schedule:
- cron: "30 4 * * 1" # Weekly Monday 4:30am UTC - cron: "30 4 * * 1" # Weekly Monday 4:30am UTC
workflow_dispatch: workflow_dispatch:
@ -61,6 +55,3 @@ jobs:
- name: Check feature combination - name: Check feature combination
run: cargo check --locked ${{ matrix.args }} run: cargo check --locked ${{ matrix.args }}
- name: Test feature combination
run: cargo test --locked ${{ matrix.args }}

View file

@ -15,7 +15,7 @@ jobs:
(github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'labeled' || github.event.action == 'unlabeled')) || (github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'labeled' || github.event.action == 'unlabeled')) ||
(github.event_name == 'pull_request_target' && (github.event_name == 'pull_request_target' &&
(github.event.action == 'labeled' || github.event.action == 'unlabeled')) (github.event.action == 'labeled' || github.event.action == 'unlabeled'))
runs-on: blacksmith-2vcpu-ubuntu-2404 runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
issues: write issues: write
@ -34,7 +34,7 @@ jobs:
await script({ github, context, core }); await script({ github, context, core });
first-interaction: first-interaction:
if: github.event.action == 'opened' if: github.event.action == 'opened'
runs-on: blacksmith-2vcpu-ubuntu-2404 runs-on: ubuntu-latest
permissions: permissions:
issues: write issues: write
pull-requests: write pull-requests: write
@ -65,7 +65,7 @@ jobs:
labeled-routes: labeled-routes:
if: github.event.action == 'labeled' if: github.event.action == 'labeled'
runs-on: blacksmith-2vcpu-ubuntu-2404 runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
issues: write issues: write

View file

@ -12,7 +12,7 @@ jobs:
permissions: permissions:
issues: write issues: write
pull-requests: write pull-requests: write
runs-on: blacksmith-2vcpu-ubuntu-2404 runs-on: ubuntu-latest
steps: steps:
- name: Mark stale issues and pull requests - name: Mark stale issues and pull requests
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0

View file

@ -2,7 +2,7 @@ name: PR Check Status
on: on:
schedule: schedule:
- cron: "15 */12 * * *" - cron: "15 8 * * *" # Once daily at 8:15am UTC
workflow_dispatch: workflow_dispatch:
permissions: {} permissions: {}
@ -13,13 +13,13 @@ concurrency:
jobs: jobs:
nudge-stale-prs: nudge-stale-prs:
runs-on: blacksmith-2vcpu-ubuntu-2404 runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
pull-requests: write pull-requests: write
issues: write issues: write
env: env:
STALE_HOURS: "4" STALE_HOURS: "48"
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

View file

@ -16,7 +16,7 @@ permissions:
jobs: jobs:
intake: intake:
name: Intake Checks name: Intake Checks
runs-on: blacksmith-2vcpu-ubuntu-2404 runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- name: Checkout repository - name: Checkout repository

View file

@ -25,8 +25,7 @@ permissions:
jobs: jobs:
label: label:
runs-on: blacksmith-2vcpu-ubuntu-2404 runs-on: ubuntu-latest
timeout-minutes: 10
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

View file

@ -21,13 +21,8 @@ on:
paths: paths:
- "Dockerfile" - "Dockerfile"
- ".dockerignore" - ".dockerignore"
- "Cargo.toml" - "docker-compose.yml"
- "Cargo.lock"
- "rust-toolchain.toml" - "rust-toolchain.toml"
- "src/**"
- "crates/**"
- "benches/**"
- "firmware/**"
- "dev/config.template.toml" - "dev/config.template.toml"
- ".github/workflows/pub-docker-img.yml" - ".github/workflows/pub-docker-img.yml"
workflow_dispatch: workflow_dispatch:

View file

@ -3,8 +3,20 @@ name: Sec Audit
on: on:
push: push:
branches: [main] branches: [main]
paths:
- "Cargo.toml"
- "Cargo.lock"
- "src/**"
- "crates/**"
- "deny.toml"
pull_request: pull_request:
branches: [main] branches: [main]
paths:
- "Cargo.toml"
- "Cargo.lock"
- "src/**"
- "crates/**"
- "deny.toml"
schedule: schedule:
- cron: "0 6 * * 1" # Weekly on Monday 6am UTC - cron: "0 6 * * 1" # Weekly on Monday 6am UTC

View file

@ -2,7 +2,7 @@ name: Sec CodeQL
on: on:
schedule: schedule:
- cron: "0 6,18 * * *" # Twice daily at 6am and 6pm UTC - cron: "0 6 * * 1" # Weekly Monday 6am UTC
workflow_dispatch: workflow_dispatch:
concurrency: concurrency:

View file

@ -17,7 +17,7 @@ permissions:
jobs: jobs:
update-notice: update-notice:
name: Update NOTICE with new contributors name: Update NOTICE with new contributors
runs-on: blacksmith-2vcpu-ubuntu-2404 runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

View file

@ -1,8 +1,8 @@
name: Test Benchmarks name: Test Benchmarks
on: on:
push: schedule:
branches: [main] - cron: "0 3 * * 1" # Weekly Monday 3am UTC
workflow_dispatch: workflow_dispatch:
concurrency: concurrency:
@ -39,7 +39,7 @@ jobs:
path: | path: |
target/criterion/ target/criterion/
benchmark_output.txt benchmark_output.txt
retention-days: 30 retention-days: 7
- name: Post benchmark summary on PR - name: Post benchmark summary on PR
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'

View file

@ -0,0 +1,209 @@
#!/usr/bin/env python3
"""Fetch GitHub Actions workflow runs for a given date and summarize costs.
Usage:
python fetch_actions_data.py [OPTIONS]
Options:
--date YYYY-MM-DD Date to query (default: yesterday)
--mode brief|full Output mode (default: full)
brief: billable minutes/hours table only
full: detailed breakdown with per-run list
--repo OWNER/NAME Repository (default: zeroclaw-labs/zeroclaw)
-h, --help Show this help message
"""
import argparse
import json
import subprocess
from datetime import datetime, timedelta, timezone
def parse_args():
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(
description="Fetch GitHub Actions workflow runs and summarize costs.",
)
yesterday = (datetime.now(timezone.utc) - timedelta(days=1)).strftime("%Y-%m-%d")
parser.add_argument(
"--date",
default=yesterday,
help="Date to query in YYYY-MM-DD format (default: yesterday)",
)
parser.add_argument(
"--mode",
choices=["brief", "full"],
default="full",
help="Output mode: 'brief' for billable hours only, 'full' for detailed breakdown (default: full)",
)
parser.add_argument(
"--repo",
default="zeroclaw-labs/zeroclaw",
help="Repository in OWNER/NAME format (default: zeroclaw-labs/zeroclaw)",
)
return parser.parse_args()
def fetch_runs(repo, date_str, page=1, per_page=100):
"""Fetch completed workflow runs for a given date."""
url = (
f"https://api.github.com/repos/{repo}/actions/runs"
f"?created={date_str}&per_page={per_page}&page={page}"
)
result = subprocess.run(
["curl", "-sS", "-H", "Accept: application/vnd.github+json", url],
capture_output=True, text=True
)
return json.loads(result.stdout)
def fetch_jobs(repo, run_id):
"""Fetch jobs for a specific run."""
url = f"https://api.github.com/repos/{repo}/actions/runs/{run_id}/jobs?per_page=100"
result = subprocess.run(
["curl", "-sS", "-H", "Accept: application/vnd.github+json", url],
capture_output=True, text=True
)
return json.loads(result.stdout)
def parse_duration(started, completed):
"""Return duration in seconds between two ISO timestamps."""
if not started or not completed:
return 0
try:
s = datetime.fromisoformat(started.replace("Z", "+00:00"))
c = datetime.fromisoformat(completed.replace("Z", "+00:00"))
return max(0, (c - s).total_seconds())
except Exception:
return 0
def main():
args = parse_args()
repo = args.repo
date_str = args.date
brief = args.mode == "brief"
print(f"Fetching workflow runs for {repo} on {date_str}...")
print("=" * 100)
all_runs = []
for page in range(1, 5): # up to 400 runs
data = fetch_runs(repo, date_str, page=page)
runs = data.get("workflow_runs", [])
if not runs:
break
all_runs.extend(runs)
if len(runs) < 100:
break
print(f"Total workflow runs found: {len(all_runs)}")
print()
# Group by workflow name
workflow_stats = {}
for run in all_runs:
name = run.get("name", "Unknown")
event = run.get("event", "unknown")
conclusion = run.get("conclusion", "unknown")
run_id = run.get("id")
if name not in workflow_stats:
workflow_stats[name] = {
"count": 0,
"events": {},
"conclusions": {},
"total_job_seconds": 0,
"total_jobs": 0,
"run_ids": [],
}
workflow_stats[name]["count"] += 1
workflow_stats[name]["events"][event] = workflow_stats[name]["events"].get(event, 0) + 1
workflow_stats[name]["conclusions"][conclusion] = workflow_stats[name]["conclusions"].get(conclusion, 0) + 1
workflow_stats[name]["run_ids"].append(run_id)
# For each workflow, sample up to 3 runs to get job-level timing
print("Sampling job-level timing (up to 3 runs per workflow)...")
print()
for name, stats in workflow_stats.items():
sample_ids = stats["run_ids"][:3]
for run_id in sample_ids:
jobs_data = fetch_jobs(repo, run_id)
jobs = jobs_data.get("jobs", [])
for job in jobs:
started = job.get("started_at")
completed = job.get("completed_at")
duration = parse_duration(started, completed)
stats["total_job_seconds"] += duration
stats["total_jobs"] += 1
# Extrapolate: if we sampled N runs but there are M total, scale up
sampled = len(sample_ids)
total = stats["count"]
if sampled > 0 and sampled < total:
scale = total / sampled
stats["estimated_total_seconds"] = stats["total_job_seconds"] * scale
else:
stats["estimated_total_seconds"] = stats["total_job_seconds"]
# Print summary sorted by estimated cost (descending)
sorted_workflows = sorted(
workflow_stats.items(),
key=lambda x: x[1]["estimated_total_seconds"],
reverse=True
)
if brief:
# Brief mode: compact billable hours table
print(f"{'Workflow':<40} {'Runs':>5} {'Est.Mins':>9} {'Est.Hours':>10}")
print("-" * 68)
grand_total_minutes = 0
for name, stats in sorted_workflows:
est_mins = stats["estimated_total_seconds"] / 60
grand_total_minutes += est_mins
print(f"{name:<40} {stats['count']:>5} {est_mins:>9.1f} {est_mins/60:>10.2f}")
print("-" * 68)
print(f"{'TOTAL':<40} {len(all_runs):>5} {grand_total_minutes:>9.0f} {grand_total_minutes/60:>10.1f}")
print(f"\nProjected monthly: ~{grand_total_minutes/60*30:.0f} hours")
else:
# Full mode: detailed breakdown with per-run list
print("=" * 100)
print(f"{'Workflow':<40} {'Runs':>5} {'SampledJobs':>12} {'SampledMins':>12} {'Est.TotalMins':>14} {'Events'}")
print("-" * 100)
grand_total_minutes = 0
for name, stats in sorted_workflows:
sampled_mins = stats["total_job_seconds"] / 60
est_total_mins = stats["estimated_total_seconds"] / 60
grand_total_minutes += est_total_mins
events_str = ", ".join(f"{k}={v}" for k, v in stats["events"].items())
conclusions_str = ", ".join(f"{k}={v}" for k, v in stats["conclusions"].items())
print(
f"{name:<40} {stats['count']:>5} {stats['total_jobs']:>12} "
f"{sampled_mins:>12.1f} {est_total_mins:>14.1f} {events_str}"
)
print(f"{'':>40} {'':>5} {'':>12} {'':>12} {'':>14} outcomes: {conclusions_str}")
print("-" * 100)
print(f"{'GRAND TOTAL':>40} {len(all_runs):>5} {'':>12} {'':>12} {grand_total_minutes:>14.1f}")
print(f"\nEstimated total billable minutes on {date_str}: {grand_total_minutes:.0f} min ({grand_total_minutes/60:.1f} hours)")
print()
# Also show raw run list
print("\n" + "=" * 100)
print("DETAILED RUN LIST")
print("=" * 100)
for run in all_runs:
name = run.get("name", "Unknown")
event = run.get("event", "unknown")
conclusion = run.get("conclusion", "unknown")
run_id = run.get("id")
started = run.get("run_started_at", "?")
print(f" [{run_id}] {name:<40} conclusion={conclusion:<12} event={event:<20} started={started}")
if __name__ == "__main__":
main()