diff --git a/.agents/skills/record-tui-gif/actop-demo.tape b/.agents/skills/record-tui-gif/actop-demo.tape index 1c728b7..8d7760e 100644 --- a/.agents/skills/record-tui-gif/actop-demo.tape +++ b/.agents/skills/record-tui-gif/actop-demo.tape @@ -1,48 +1,67 @@ # actop hero capture — the README/hero GIF. -# One continuous, gapless take in the stack layout that tells the whole story: -# off-camera: start actop -> l (stack layout) -# on-camera: stack + braille dots -> g (block charts) -> p (process table) -# -> t (cycle theme, several times) -> t back to dark -# Layout switch is off-camera (grid would leave a gap at this height); the glyph, -# process, and theme toggles are on-camera and stay full because stack fills the height. -# Render from the repo root: vhs .claude/skills/record-tui-gif/actop-demo.tape +# One continuous, gapless take in the DEFAULT grid layout that tells the whole +# v1.9.x story: +# off-camera: start actop (grid is the default — no layout switch needed) +# on-camera: grid + braille dots -> g (block charts) -> c (per-core panels) +# -> p (process table + watt attribution) -> t (theme cycle) +# +# Why grid rather than stack: since v1.9.0 the dashboard has seven sections, and +# grid is the only layout that shows the mirrored Network / Disk pair side by +# side without a ~57-row column. Stack would need Height ~1240 here, which +# leaves a large gap under Power once the process table is on. +# +# Width is load-bearing: `p` splits the dashboard against the process table, and +# below ~96 cols the grid preset auto-degrades to stack. At 1400px (~165 cols) +# the dashboard drops to ~80 cols and silently degrades mid-take; 1800px +# (~212 cols) leaves it ~122 cols, so grid survives the whole recording. +# +# Render from the repo root: vhs .agents/skills/record-tui-gif/actop-demo.tape # (or via record.sh, which drives a GPU workload so the gauges move). Output images/actop-demo.gif -Require actop +# Record the REPO build, not whatever `actop` happens to be on PATH. Typing a +# bare `actop` picks up the Homebrew/pipx install, which silently recorded a +# stale TUI (a v1.9.x hero was captured off a v1.8.0 binary before this was +# caught — no Network/Disk sections at all). Require the venv entry point. +Require .venv/bin/actop Set Shell "zsh" Set FontSize 16 -# 1400px @ FontSize 16 ≈ 165 cols — well above the ~96-col grid-degrade floor. -Set Width 1400 -# Sized to the stack layout's natural column height so there's no bottom gap. -Set Height 1050 +# 1800px @ FontSize 16 ≈ 212 cols — keeps the dashboard above the 96-col +# grid-degrade floor even after `p` splits it against the process table. +Set Width 1800 +# Sized to the tallest on-camera state (grid + per-core panels + process table +# ≈ 42 dashboard rows + chrome), so nothing clips and no gap opens under Power. +Set Height 930 Set Theme "Dracula" Set Padding 20 Set Framerate 24 -# --- Warm up + switch to stack layout OFF-camera --- +# --- Warm up OFF-camera so the first visible frame already has live gauges --- Hide -Type "actop" +Type ".venv/bin/actop" Enter -Sleep 6s -Type "l" -Sleep 1s +Sleep 7s Show -# --- 1) stack layout, classic braille-dot charts --- -Sleep 5s +# --- 1) default grid layout, classic braille-dot charts --- +# Seven sections: P-CPU | E-CPU, GPU·ANE | Memory, Network | Disk, Power. +Sleep 6s # --- 2) toggle chart glyph dots -> modern solid blocks --- Type "g" Sleep 5s -# --- 3) turn on the process table (needs a poll cycle to populate) --- +# --- 3) reveal the per-core panels inside both cluster boxes --- +Type "c" +Sleep 5s + +# --- 4) turn on the process table (needs a poll cycle to populate) --- Type "p" Sleep 8s -# --- 4) cycle through themes live --- +# --- 5) cycle through themes live --- Type "t" Sleep 2s Type "t" diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 25bbfd6..03a7644 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -1,8 +1,29 @@ name: publish-pypi run-name: "publish-pypi | ${{ github.event_name == 'workflow_dispatch' && inputs.tag_name || github.ref_name }} | ${{ github.sha }}" -# Publishes the actop package to PyPI on a version tag push using PyPI -# Trusted Publishing (OIDC) — no API token is stored in the repo. +# Publishes the actop package to PyPI using PyPI Trusted Publishing (OIDC) — +# no API token is stored in the repo. +# +# MANUAL ONLY — this workflow does NOT run on tag push. Publishing is a +# deliberate step taken AFTER the tag and its CI have landed, because the +# automatic form was self-defeating: a tag push started the publish at the same +# moment the tag appeared, so the run raced the release it was meant to ship and +# never published against a settled tag. Making it a dispatch that takes the tag +# as an input removes the ordering problem entirely — the tag already exists and +# is named explicitly. +# +# Publish with (after `scripts/tag_release.sh` and green CI): +# gh workflow run publish-pypi.yml --ref vX.Y.Z -f tag_name=vX.Y.Z +# +# `--ref` must be the TAG, not a branch: the `pypi` environment's deployment +# policy only admits refs matching `v*`, so a dispatch from main is rejected +# before the job starts. Pointing it at the tag satisfies the policy and keeps +# the "only a tagged commit ever reaches PyPI" guarantee that the old automatic +# trigger provided. +# +# Local fallback, from a clean checkout of the tag (needs a PyPI API token — +# OIDC is only available to the workflow above): +# python -m build && python -m twine upload --skip-existing dist/* # # One-time PyPI-side setup (do this once, signed in as the project owner): # 1. https://pypi.org/manage/account/publishing/ -> "Add a new pending publisher" @@ -15,9 +36,6 @@ run-name: "publish-pypi | ${{ github.event_name == 'workflow_dispatch' && inputs # trusted publisher for the project. on: - push: - tags: - - "v*" workflow_dispatch: inputs: tag_name: diff --git a/CHANGELOG.md b/CHANGELOG.md index dbfdb0d..74f51ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,31 @@ This project follows a Keep a Changelog-style format and uses version tags for r ## [Unreleased] +## [1.9.2] - 2026-08-12 + +### Changed + +- **PyPI publishing left the tag flow — it is now a manual, post-tag step.** + `publish-pypi.yml` no longer triggers on `push: tags`; it is + `workflow_dispatch` only, taking the tag as an input: + `gh workflow run publish-pypi.yml --ref vX.Y.Z -f tag_name=vX.Y.Z`. + The automatic form was self-defeating — the tag push started the publish at + the moment the tag appeared, so the run raced the release it was meant to + ship rather than publishing a settled one. Dispatching against a tag that + already exists removes the ordering problem entirely, and because the `pypi` + environment admits only `v*` refs, the "only a tagged commit reaches PyPI" + guarantee is preserved rather than weakened. OIDC trusted publishing is + unchanged; no token is introduced. +- **Hero GIF re-recorded for the v1.9.x dashboard** (`images/actop-demo.gif`), + and the recording tape now pins the binary it records. The tape typed a bare + `actop`, which resolved to whatever was installed on `PATH` — it had been + capturing a stale Homebrew build with no Network/Disk sections at all. It now + requires `.venv/bin/actop`. The capture moved to the default `grid` layout at + 1800x930 (stack pushed the two new I/O boxes below the fold) and covers the + mirrored Network/Disk pair, per-core panels, both glyph modes, the process + table with watt attribution, and the theme cycle. +- Cover page: net/disk feature card now describes the mirrored charts. + ## [1.9.1] - 2026-08-12 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 6d6e870..58d8ea4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ This file is the single source of truth for repository guidelines, used by Claud ## Release Process -`main` is **PR-only** (branch protection + `.githooks/pre-commit` redaction check and `.githooks/pre-push` guard; run `git config core.hooksPath .githooks` once). The Homebrew formula lives in the separate tap repo `binlecode/homebrew-actop` (not this repo); CI syncs it on tag and publishes to PyPI via OIDC. CI/CD mechanics are owned by the workflow files themselves (`.github/workflows/main-ci.yml`, `release-formula.yml`, `publish-pypi.yml`, `guard-release.yml`) — each carries its one-time setup inline. `guard-release.yml` is the post-push integrity gate: every tag push triggers a scan that verifies every `v*` tag has a corresponding GitHub Release object, catching the "silent link breakage" that caused the v0.8.7–v1.6.6 release-object gap. +`main` is **PR-only** (branch protection + `.githooks/pre-commit` redaction check and `.githooks/pre-push` guard; run `git config core.hooksPath .githooks` once). The Homebrew formula lives in the separate tap repo `binlecode/homebrew-actop` (not this repo); CI syncs it on tag. **PyPI publishing is NOT part of the tag flow** — it is a manual step taken after the tag and its CI have landed (`gh workflow run publish-pypi.yml --ref vX.Y.Z -f tag_name=vX.Y.Z`). The old tag-triggered publish raced the tag that started it and shipped against an unsettled ref; a dispatch naming the tag explicitly has no such ordering problem. CI/CD mechanics are owned by the workflow files themselves (`.github/workflows/main-ci.yml`, `release-formula.yml`, `publish-pypi.yml`, `guard-release.yml`) — each carries its one-time setup inline. `guard-release.yml` is the post-push integrity gate: every tag push triggers a scan that verifies every `v*` tag has a corresponding GitHub Release object, catching the "silent link breakage" that caused the v0.8.7–v1.6.6 release-object gap. **RULE — cutting a release REQUIRES a GitHub Release, not just a tag.** A release is cut with `scripts/tag_release.sh`, which pushes the git tag **and** creates the GitHub Release for it via `gh release create` (release notes pulled from the matching `CHANGELOG.md` section). A bare `git tag` + push is **not** a release: it never surfaces on the `/releases` page or as `Latest`, and leaves the Releases history silently behind the code. Never cut a release by hand-tagging alone — if the `gh release create` step fails, finish it manually (see playbooks below) before calling the release cut done. @@ -74,10 +74,15 @@ git checkout main && git pull --ff-only # 4. Cut the release — tag + GitHub Release (RULE: tag alone is not a release) scripts/tag_release.sh "X.Y.Z" -# 5. Monitor CI — wait for release-formula and publish-pypi to pass +# 5. Monitor CI — wait for release-formula and guard-release to pass gh run list -R binlecode/actop --limit 10 -# 6. Verify +# 6. Publish to PyPI — MANUAL, only after the tag exists and CI is green. +# --ref must be the tag: the `pypi` environment admits only `v*` refs. +gh workflow run publish-pypi.yml --ref "vX.Y.Z" -f tag_name="vX.Y.Z" +gh run watch -R binlecode/actop "$(gh run list -R binlecode/actop --workflow publish-pypi.yml --limit 1 --json databaseId --jq '.[0].databaseId')" + +# 7. Verify pipx install actop && actop --version # PyPI brew update && brew upgrade binlecode/actop/actop # Homebrew (tap repo synced) ``` @@ -96,12 +101,12 @@ brew update && brew upgrade binlecode/actop/actop # Homebrew (tap repo synced Never leave a release cut as a bare tag — verify it appears on `/releases` before moving on. - **`release-formula` fails with tag/version mismatch:** tag `vX.Y.Z` doesn't match `pyproject.toml` in the tag commit. Fix in a new PR, merge, then create a **new** tag (never reuse the old tag). - **`release-formula` fails at tap checkout/push:** missing or under-scoped `HOMEBREW_TAP_TOKEN`. Confirm the secret exists on `binlecode/actop` and the PAT has **Contents: Read/write** on `binlecode/homebrew-actop`. Re-run the failed job. -- **`publish-pypi` fails with OIDC / trusted-publisher error:** verify the trusted publisher on pypi.org matches repo `binlecode/actop`, workflow `publish-pypi.yml`, environment `pypi`; confirm the run is from a `v*` tag (the `pypi` environment's deploy policy is tag-only). `skip-existing` makes re-runs safe. **Break-glass:** publish that version manually with the token-driven Flow B (`twine upload --skip-existing`), then restore OIDC for subsequent releases. +- **`publish-pypi` fails with OIDC / trusted-publisher error:** verify the trusted publisher on pypi.org matches repo `binlecode/actop`, workflow `publish-pypi.yml`, environment `pypi`; confirm the dispatch used `--ref vX.Y.Z` (the `pypi` environment's deploy policy is tag-only, so a dispatch from `main` is rejected before the job starts). `skip-existing` makes re-runs safe. **Break-glass:** publish that version manually with the token-driven Flow B (`twine upload --skip-existing`), then restore OIDC for subsequent releases. - **Emergency rerun without resource refresh:** `workflow_dispatch` on `release-formula` with `refresh_resources=false` as a temporary workaround; follow up with a normal run (`refresh_resources=true`). ### PyPI publishing — two flows -OIDC Trusted Publishing is the default (no stored secret; the `pypi` environment on `binlecode/actop` is tag-only, and the workflow file documents the one-time publisher registration). The token-driven flow is the fallback / bootstrap and the only place a long-lived PyPI credential appears: +OIDC Trusted Publishing is the default (no stored secret; the `pypi` environment on `binlecode/actop` is tag-only, and the workflow file documents the one-time publisher registration). It is **dispatched manually against the tag**, never fired by the tag push. The token-driven flow is the fallback / bootstrap and the only place a long-lived PyPI credential appears: ```bash python -m build # produces dist/*.tar.gz and dist/*.whl @@ -229,7 +234,7 @@ a mount point, not a fake); faking the data or the logic under test is not. ## Commit & Pull Request Guidelines - **Branch from `main`; PR strictly into `main`.** Every branch forks from `main` and targets `main`. **Never fork a feature branch off another feature branch** (no stacked PRs): if you need work that is still on an unmerged branch, wait for it to merge and re-branch from `main`. This holds especially for CI/CD and release changes — they land via a single PR into `main`, never a chained branch. -- **Bump the version in every PR.** Each PR updates `pyproject.toml` version + moves `CHANGELOG.md` `[Unreleased]` into a new dated section, in the same PR: **patch bump by default, minor only for a milestone PR** (major reserved for breaking API/CLI changes). Tagging (`scripts/tag_release.sh`) is a separate step after merge and is what publishes — see the Release Process section above. +- **Bump the version in every PR.** Each PR updates `pyproject.toml` version + moves `CHANGELOG.md` `[Unreleased]` into a new dated section, in the same PR: **patch bump by default, minor only for a milestone PR** (major reserved for breaking API/CLI changes). Tagging (`scripts/tag_release.sh`) is a separate step after merge; publishing to PyPI is a third, manual step after that — see the Release Process section above. - Use concise, imperative commit subjects (as seen in history), e.g. `Add support for M1 Ultra` or `actop/utils.py: add bandwidth of M2`. - Keep commits scoped to one logical change. - Before every commit and before every push, always run: diff --git a/cover/actop-demo.gif b/cover/actop-demo.gif index 9c5bbfa..9cd629c 100644 Binary files a/cover/actop-demo.gif and b/cover/actop-demo.gif differ diff --git a/cover/index.html b/cover/index.html index c756f62..bf29571 100644 --- a/cover/index.html +++ b/cover/index.html @@ -197,7 +197,7 @@
Live network ↓/↑ and disk read/write throughput in bytes/s, sourced from native getifaddrs and IOKit counters — no sudo, no subprocesses.
+Network and disk throughput as mirrored in-over-out charts sharing a zero axis, sourced from native getifaddrs and IOKit counters — no sudo, no subprocesses.