Something went wrong. Try again.
npx ccusage
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574name: CI
on: push: branches: [main] pull_request:
permissions: read-all
jobs: changes: name: Detect code changes runs-on: ubuntu-slim outputs: code-changed: ${{ steps.detect.outputs.code-changed }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false - id: detect uses: ./.github/actions/detect-code-changes
# Security gate: scan the repository with tirith (https://tirith.sh/docs/ci/) # before any of the real CI work runs. This is wired on `pull_request` (not # `pull_request_target`), so fork PRs only ever get a read-only token and # cannot leak secrets while we scan their code. The downstream jobs `needs` # this job, so they are skipped unless tirith passes. tirith is installed from # the flake's pinned nixpkgs so CI matches the dev shell. tirith-scan: name: tirith security scan runs-on: blacksmith-32vcpu-ubuntu-2404 timeout-minutes: 15 # Read-only: this job executes PR-controlled code (the local action, the PR # flake, the installed tirith), so it must hold no write scopes. The SARIF # is published from the separate `tirith-sarif-upload` job, which never runs # PR code, keeping the elevated `security-events: write` away from the scan. permissions: contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false - uses: ./.github/actions/setup-nix-cache - name: Install tirith run: nix profile install --inputs-from . nixpkgs#tirith # One pass produces the SARIF report and sets the gate exit code. tirith's # `--ci` exit code is: 0 = no findings, 1 = findings at/above --fail-on, # 2 = findings present but all below it. We capture it (rather than letting # it abort the step) so the SARIF still uploads before the gate decides. # # `*.snap` are excluded: they are terminal snapshot test fixtures that # intentionally contain ANSI escape sequences, so the `ansi_escapes` rule # (High) fires on them as a false positive. The rule stays active for real # source files; we only skip the test data. - name: Scan repository with tirith id: scan run: | set +e tirith scan --ci --fail-on high --exclude '*.snap' --format sarif ./ > results.sarif code=$? set -e echo "exit_code=$code" >> "$GITHUB_OUTPUT" echo "tirith exited ${code}" - name: Summarize findings if: always() run: | count="$(jq '[.runs[].results[]?] | length' results.sarif 2>/dev/null || echo 0)" echo "tirith reported ${count} finding(s)" | tee -a "$GITHUB_STEP_SUMMARY" jq -r '.runs[].results[]? | "- [\(.level // "warning")] \(.ruleId): \(.locations[0].physicalLocation.artifactLocation.uri)"' \ results.sarif 2>/dev/null | tee -a "$GITHUB_STEP_SUMMARY" || true # Hand the SARIF to the privileged upload job as an artifact. Uploaded # before the gate step so findings reach the Security tab even when the # gate then blocks the PR. - name: Upload SARIF artifact if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: tirith-sarif path: results.sarif if-no-files-found: error - name: Gate CI on tirith findings if: always() env: SCAN_EXIT: ${{ steps.scan.outputs.exit_code }} run: | case "$SCAN_EXIT" in 0 | 2) echo "tirith gate passed (exit ${SCAN_EXIT}: no findings, or all below 'high')" ;; 1) echo "::error::tirith found findings at or above the 'high' threshold; blocking downstream CI" exit 1 ;; *) echo "::error::tirith scan errored (exit ${SCAN_EXIT}); blocking downstream CI" exit 1 ;; esac
# Publish the tirith SARIF to the Security tab. Split from the scan so the # `security-events: write` scope lives in a job that never executes # PR-controlled code: it only downloads the artifact and runs trusted, pinned # actions. `always()` so findings are published even when the gate blocked the # PR; skipped for fork PRs, whose token is read-only and cannot upload. tirith-sarif-upload: name: tirith SARIF upload needs: tirith-scan if: ${{ always() && !github.event.pull_request.head.repo.fork }} runs-on: ubuntu-slim timeout-minutes: 10 permissions: contents: read security-events: write steps: # Checkout only places files for path mapping; no PR code is executed in # this privileged job. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: tirith-sarif - uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 with: sarif_file: results.sarif category: tirith
# Fast, unconditional fail-fast gate for the cheap-but-high-signal checks: # secret detection (gitleaks) and workflow/format lint (the treefmt check, # which runs actionlint + zizmor with the repo's exact ignore config). Runs in # parallel with tirith-scan and is NOT gated on `changes`, so secrets and # workflow regressions are caught even on docs-only PRs and on fork PRs (where # local git hooks never run). Both are existing flake checks, so this reuses # their exact behavior instead of re-specifying flags here. The heavy test job # re-runs them inside `nix flake check`; that is intentional duplication so the # gate can fail fast before any build/test work starts. preflight: name: security & lint preflight runs-on: blacksmith-32vcpu-ubuntu-2404 timeout-minutes: 15 permissions: contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false - uses: ./.github/actions/setup-nix-cache - name: Secret scan and workflow lint run: nix build --print-build-logs .#checks.x86_64-linux.gitleaks .#checks.x86_64-linux.treefmt
test: needs: [changes, tirith-scan, preflight] if: needs.changes.outputs.code-changed == 'true' runs-on: blacksmith-32vcpu-ubuntu-2404 timeout-minutes: 30 permissions: contents: read pull-requests: read
steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false - uses: ./.github/actions/setup-nix-cache - parallel: - name: Run nix flake check run: nix flake check --print-build-logs - name: Run Rust tests run: nix build .#ccusage-tests --print-build-logs - name: JS test run: | nix profile install --inputs-from . nixpkgs#pnpm nixpkgs#nodejs nixpkgs#just pnpm install --frozen-lockfile mkdir -p "$HOME/.claude/projects" mkdir -p "$HOME/.config/claude/projects" just test-node - name: Babashka performance harness test run: apps/ccusage/scripts/compare-pr-performance_test.clj
build-native-packages: needs: [changes, tirith-scan, preflight] if: needs.changes.outputs.code-changed == 'true' name: ${{ matrix.name }} permissions: actions: write contents: read runs-on: ${{ matrix.os }} timeout-minutes: 30 strategy: fail-fast: false matrix: include: - name: build-linux-arm64 os: blacksmith-32vcpu-ubuntu-2404-arm platform: linux arch: arm64 binary: result/bin/ccusage - name: build-linux-x64 os: blacksmith-32vcpu-ubuntu-2404 platform: linux arch: x64 binary: result/bin/ccusage - name: build-mac-arm64 os: blacksmith-12vcpu-macos-26 platform: darwin arch: arm64 binary: result/bin/ccusage - name: build-mac-x64 os: blacksmith-12vcpu-macos-26 platform: darwin arch: x64 binary: result/bin/ccusage - name: build-windows-x64 os: blacksmith-32vcpu-windows-2025 platform: win32 arch: x64 binary: rust/target/release/ccusage.exe
steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: ./.github/actions/build-linux-native-package if: matrix.platform == 'linux' with: arch: ${{ matrix.arch }} binary: ${{ matrix.binary }} nix-github-token: ${{ github.event_name != 'pull_request' }} cache-scope: ${{ matrix.name }} - uses: ./.github/actions/build-macos-nix-native-package if: matrix.platform == 'darwin' with: arch: ${{ matrix.arch }} binary: ${{ matrix.binary }} cache-scope: ${{ matrix.name }} - uses: ./.github/actions/build-windows-native-package if: matrix.platform == 'win32' with: arch: ${{ matrix.arch }} binary: ${{ matrix.binary }}
npm-publish-dry-run-and-upload-pkg-pr-now: needs: - changes - build-native-packages if: needs.changes.outputs.code-changed == 'true' permissions: actions: read contents: read runs-on: blacksmith-32vcpu-ubuntu-2404 outputs: urls: ${{ steps.publish.outputs.urls }} preview-url: ${{ steps.preview-url.outputs.url }}
steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: ./.github/actions/setup-nix-cache # Packing only repackages the native binaries restored from the build # matrix below (no Rust build happens here), so the full `nix develop` dev # shell is unnecessary. The package checks bring Nu through their Nix # shebangs; `publint` is the flake's own pinned build, invoked by every # package's prepack script. - run: nix profile install --inputs-from . nixpkgs#pnpm nixpkgs#nodejs .#publint - run: pnpm install --frozen-lockfile - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: ccusage-native-* path: ${{ runner.temp }}/native-packages merge-multiple: true - name: Restore native package binaries run: | for archive in "$RUNNER_TEMP"/native-packages/*.tar; do tar -xf "$archive" done - id: publish # Fetched on demand rather than carried as a root devDependency: this # job is the only thing that runs it, and it never needs to resolve # against the workspace. Renovate does not update `dlx` specifiers, so # bump the pin here by hand. run: pnpm dlx pkg-pr-new@0.0.75 publish --pnpm --bin './apps/ccusage' './packages/ccusage-linux-arm64' './packages/ccusage-linux-x64' './packages/ccusage-darwin-arm64' './packages/ccusage-darwin-x64' './packages/ccusage-win32-x64' # Resolve the ccusage CLI preview URL and build the E2E fixture here (this # job has the checkout); the E2E jobs then run without a checkout. - id: preview-url env: URLS: ${{ steps.publish.outputs.urls }} run: | node <<'JS' const urls = (process.env.URLS ?? '').split(/\s+/).filter(Boolean); const url = urls.find((value) => /(?:^|\/)ccusage@/.test(value)); if (url == null) { throw new Error(`ccusage preview URL not found in: ${urls.join(', ')}`); } require('node:fs').appendFileSync(process.env.GITHUB_OUTPUT, `url=${url}\n`); JS - name: Generate E2E fixture run: .github/scripts/generate-e2e-fixture.nu "$RUNNER_TEMP/e2e-fixture" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ccusage-e2e-fixture path: ${{ runner.temp }}/e2e-fixture if-no-files-found: error
ccusage-preview-e2e: # Run on PRs and on pushes to the default branch. `push` only fires on main # (see on.push.branches), so this validates the published preview package # post-merge too, not just on PRs. if: needs.changes.outputs.code-changed == 'true' name: e2e ${{ matrix.runner }} (${{ matrix.os }}) needs: - changes - npm-publish-dry-run-and-upload-pkg-pr-now runs-on: ${{ matrix.os }} timeout-minutes: 20 # No checkout on purpose: the working directory stays empty so each package # runner behaves like a real end user instead of inheriting the repo's # pnpm-workspace.yaml / package.json (which is why pnpm previously needed # --config overrides). The preview URL and the fixture come from the publish # job via a job output and an artifact. env: CCUSAGE_PREVIEW_URL: ${{ needs.npm-publish-dry-run-and-upload-pkg-pr-now.outputs.preview-url }} LOG_LEVEL: '0' strategy: fail-fast: false matrix: os: - blacksmith-32vcpu-ubuntu-2404 - blacksmith-32vcpu-ubuntu-2404-arm - blacksmith-12vcpu-macos-26 # Intel macOS to exercise the cross-built x86_64 darwin binary on real # Intel hardware (the build runner is arm64). - macos-15-intel - blacksmith-32vcpu-windows-2025 # One job per package runner, each installed via its official action. # Excluded: # yarn - no official install action (it expects corepack, which we avoid) # deno - the npm: specifier only resolves a registry name@version, not # the tarball URL that pkg.pr.new serves (denoland/deno#28686) runner: - npm - pnpm
steps: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: lts/* - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 if: matrix.runner == 'pnpm' with: version: latest - name: Download E2E fixture shell: pwsh env: GH_TOKEN: ${{ github.token }} run: | # actions/download-artifact talks to the artifact backend through the # Blacksmith-proxied results-receiver endpoint, whose local agent is # not yet listening in the first seconds after a macOS VM boots: the # download runs ~2s in and intermittently hits ECONNREFUSED on # ListArtifacts (re-running the job, once the agent is warm, succeeds). # gh run download uses the api.github.com REST API directly, sidestepping # that proxy, and we retry with backoff to ride out any transient hiccup. # These jobs skip actions/checkout, so create a minimal git context # for gh instead of relying on --repo (which some gh versions still # follow with a local git probe). $gitDir = "$env:RUNNER_TEMP/gh-context" New-Item -ItemType Directory -Force -Path $gitDir | Out-Null Push-Location $gitDir git init -q git remote add origin "https://github.com/$env:GITHUB_REPOSITORY.git" Pop-Location $dir = "$env:RUNNER_TEMP/e2e-fixture" for ($i = 1; $i -le 5; $i++) { Push-Location $gitDir gh run download $env:GITHUB_RUN_ID --name ccusage-e2e-fixture --dir $dir Pop-Location if ($LASTEXITCODE -eq 0) { break } if ($i -eq 5) { throw "failed to download the E2E fixture after 5 attempts" } Start-Sleep -Seconds ($i * 5) } "CLAUDE_CONFIG_DIR=$dir" >> $env:GITHUB_ENV - name: Run preview E2E shell: pwsh env: RUNNER_KIND: ${{ matrix.runner }} # pnpm 11 defaults minimumReleaseAge to 1440 (24h), which would reject # a pkg.pr.new preview published moments ago. Opt out via the config # env var (only pnpm reads PNPM_CONFIG_*, so npm ignores it). # https://pnpm.io/settings#minimumreleaseage PNPM_CONFIG_MINIMUM_RELEASE_AGE: '0' # pnpm 11 also defaults blockExoticSubdeps to true, which rejects the # URL-resolved native optional deps (e.g. @ccusage/ccusage-linux-x64) # that pkg.pr.new injects into the published ccusage manifest. Allow # them so `pnpm dlx <url>` can install the matching native binary. # https://pnpm.io/settings#blockexoticsubdeps PNPM_CONFIG_BLOCK_EXOTIC_SUBDEPS: 'false' run: | # pnpm dlx on Windows doesn't install optional native binaries into its # temporary store, so the CLI launcher fails with spawn ENOENT; skip # that one combination. if ($env:RUNNER_KIND -eq 'pnpm' -and $IsWindows) { Write-Host 'skip: pnpm dlx on Windows cannot install the native binary' exit 0 } $url = $env:CCUSAGE_PREVIEW_URL function Invoke-Ccusage([string[]]$ccArgs) { switch ($env:RUNNER_KIND) { 'npm' { return (npx --yes $url @ccArgs) } 'pnpm' { return (pnpm dlx $url @ccArgs) } } } Invoke-Ccusage @('--version') | Out-Host if ($LASTEXITCODE -ne 0) { throw '--version failed' } # Exercise every report command, both offline and online (online fetches # live pricing), validating the JSON output each time. foreach ($cmd in 'daily', 'monthly', 'session') { foreach ($mode in @(@('--offline'), @())) { $label = "$cmd $($mode -join ' ')".Trim() Invoke-Ccusage (@($cmd) + $mode) | Out-Host if ($LASTEXITCODE -ne 0) { throw "ccusage $label failed ($LASTEXITCODE)" } $json = Invoke-Ccusage (@($cmd) + $mode + @('--json')) if ($LASTEXITCODE -ne 0) { throw "ccusage $label --json failed ($LASTEXITCODE)" } $json | node -e "JSON.parse(require('fs').readFileSync(0, 'utf8'))" if ($LASTEXITCODE -ne 0) { throw "ccusage $label --json produced invalid JSON" } Write-Host "ok: $label" } }
ccusage-perf-comment: name: ccusage ${{ matrix.label }} if: github.event_name == 'pull_request' && needs.changes.outputs.code-changed == 'true' needs: - changes - npm-publish-dry-run-and-upload-pkg-pr-now permissions: contents: read pull-requests: write issues: write runs-on: blacksmith-32vcpu-ubuntu-2404 timeout-minutes: 30 strategy: fail-fast: false matrix: include: # Same benchmark from two entry points, posted as two PR comments: # the JS launcher (default runtime) and the bundled Rust native binary. - runtime: js label: perf comment slug: perf-comment head-runtime-arg: '' - runtime: rust label: rust perf comment slug: rust-perf-comment head-runtime-arg: --head-runtime rust
steps: - name: Checkout PR uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false
- name: Setup Nix uses: ./.github/actions/setup-nix-cache with: # js and rust legs share this job id, so scope the cache per runtime. cache-scope: ${{ matrix.runtime }} # This job only benchmarks the prebuilt ccusage binary bundled in the # installed pkg.pr.new package (no Rust build happens here), so the full # `nix develop` dev shell is unnecessary. Install just the tools the perf # scripts invoke, pinned to the flake's nixpkgs via `--inputs-from .`. - name: Install perf tooling run: nix profile install --inputs-from . nixpkgs#just
- name: Resolve PR preview package URL env: PKG_PR_NEW_URLS: ${{ needs.npm-publish-dry-run-and-upload-pkg-pr-now.outputs.urls }} run: | preview_url="$( printf '%s\n' "$PKG_PR_NEW_URLS" \ | tr '[:space:]' '\n' \ | grep -E '(^|/)ccusage@' \ | head -n 1 )" if [ -z "$preview_url" ]; then echo "ccusage preview URL not found in: $PKG_PR_NEW_URLS" >&2 exit 1 fi echo "CCUSAGE_PREVIEW_URL=$preview_url" >> "$GITHUB_ENV"
- name: Generate large ccusage fixture run: | just generate-large-fixture \ "$RUNNER_TEMP/ccusage-large-fixture" \ "$RUNNER_TEMP/ccusage-large-codex-fixture" \ 1024
- name: Compare performance run: | apps/ccusage/scripts/compare-pr-performance.bb \ --base-package-url "https://pkg.pr.new/${{ github.repository }}/ccusage@${{ github.event.pull_request.base.sha }}" \ --base-sha "${{ github.event.pull_request.base.sha }}" \ --head-dir . \ --head-package-url "$CCUSAGE_PREVIEW_URL" \ ${{ matrix.head-runtime-arg }} \ --fixture-dir apps/ccusage/test/fixtures/claude \ --codex-fixture-dir apps/ccusage/test/fixtures/codex \ --warmup 2 \ --runs 7 \ --large-fixture-dir "$RUNNER_TEMP/ccusage-large-fixture" \ --large-codex-fixture-dir "$RUNNER_TEMP/ccusage-large-codex-fixture" \ --large-warmup 0 \ --large-runs 1 \ --package-runner-timeout-ms 300000 \ --output "$RUNNER_TEMP/ccusage-${{ matrix.slug }}.md"
- name: Write job summary run: cat "$RUNNER_TEMP/ccusage-${{ matrix.slug }}.md" >> "$GITHUB_STEP_SUMMARY"
- name: Upsert PR comment if: ${{ !github.event.pull_request.head.repo.fork }} env: COMMENT_FILE: ${{ runner.temp }}/ccusage-${{ matrix.slug }}.md COMMENT_MARKER: <!-- ccusage-${{ matrix.slug }}:${{ github.event.pull_request.head.sha }} --> GITHUB_TOKEN: ${{ github.token }} GITHUB_REPOSITORY: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }} run: .github/scripts/upsert-pr-comment.nu
action-timeline: needs: - changes - tirith-scan - tirith-sarif-upload - preflight - test - build-native-packages - npm-publish-dry-run-and-upload-pkg-pr-now - ccusage-preview-e2e - ccusage-perf-comment if: ${{ always() }} runs-on: ubuntu-slim steps: - uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
# Single check for main's branch ruleset to require. Most jobs above are gated # on `changes` and are skipped on docs-only pull requests, so requiring them # individually would leave those pull requests blocked on checks that never # report. A skipped job is fine here; only real failures fail the gate. ci-gate: name: ci gate needs: - changes - tirith-scan - tirith-sarif-upload - preflight - test - build-native-packages - npm-publish-dry-run-and-upload-pkg-pr-now - ccusage-preview-e2e - ccusage-perf-comment if: ${{ always() }} runs-on: ubuntu-slim steps: - name: Report job results env: RESULTS: ${{ join(needs.*.result, ' ') }} run: echo "$RESULTS" - name: Fail when a job failed or was cancelled if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }} run: exit 1