Skip to content
15 changes: 15 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,21 @@ jobs:
sha256sum numan-* > SHA256SUMS
cat SHA256SUMS

- name: Sign SHA256SUMS for self-update
working-directory: dist
env:
NUMAN_RELEASE_SIGNING_KEY: ${{ secrets.NUMAN_RELEASE_SIGNING_KEY }}
run: |
set -euo pipefail
if [[ -z "${NUMAN_RELEASE_SIGNING_KEY}" ]]; then
echo "::warning::NUMAN_RELEASE_SIGNING_KEY is unset; publishing without SHA256SUMS.sig. \
numan update --self will refuse this release until a signed checksum is present."
exit 0
fi
python3 -m pip install --user pynacl
python3 "${GITHUB_WORKSPACE}/scripts/sign-sha256sums.py" SHA256SUMS SHA256SUMS.sig
cat SHA256SUMS.sig

- name: Prepare release notes from CHANGELOG
env:
RELEASE_TAG: ${{ steps.meta.outputs.tag }}
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ src/
snapshot.rs — `numan snapshot list|inspect|delete|rollback` (Phase 5.3)
deactivate.rs — Plugin + module deactivation: journaled plugin unregister (`execute_with_unregistrar`); module full/partial (Phase 4 / Issue #22 PR2)
plugin_lifecycle.rs — Activate/deactivate-owned lifecycle boundary exposed to opt-in update orchestration (Issue #22 PR3)
update.rs — `numan update [--check] [pkg]`: upgrades; active plugins orchestrate deactivate→upgrade→activate only with exact env opt-in (Phase 5 / Issue #22 PR3)
update.rs — `numan update [--check] [pkg]`: upgrades packages; `numan update --self [--check]`: self-replace standalone binary (or print brew/winget/cargo upgrade); active plugins orchestrate deactivate→upgrade→activate only with exact env opt-in (Phase 5 / Issue #22 PR3)
self_update.rs — GitHub Release self-update for `update --self` (install-method detection, Ed25519-signed SHA256SUMS verify, atomic/Windows-safe binary replace)
remove.rs — `numan remove [--force] <pkg>`: remove from lockfile + delete payload (Phase 5); `--force` bypasses module activation only (active plugins always gated until deactivate, Issue #22)
gc.rs — `numan gc [--dry-run]`: delete orphaned payload directories (Phase 5)
nupm.rs — `numan nupm status|inspect|import|diff`: nupm discovery + import + drift (Phase 6.1–6.3)
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **`numan update --self`**: upgrade the numan CLI itself. Standalone installs download the matching GitHub Release asset, verify the Ed25519 signature over `SHA256SUMS` (`SHA256SUMS.sig` + baked-in release public key), then check the archive digest and replace the binary. Homebrew / winget / cargo installs print the exact upgrade command instead of self-replacing; `--check` still queries GitHub Releases to report whether a newer version exists before printing that hint.

## [0.2.0] - 2026-08-05

### Added
Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,13 +281,17 @@ numan deactivate owner/module-name
#### 5. Maintain installs

```bash
numan update --check # see available upgrades
numan update # apply upgrades
numan update --check # see available package upgrades
numan update # apply package upgrades
numan update --self --check # standalone: report if a newer binary is available
numan update --self # standalone: download, checksum-verify, replace binary
numan remove owner/package-name
numan gc --dry-run # preview orphaned payload dirs
numan gc # delete unreferenced payloads
```

For Homebrew, winget, or `cargo install` installs, `numan update --self` prints the matching upgrade command (`brew upgrade numan`, `winget upgrade tonythethompson.numan`, or `cargo install --locked --force numan-cli`) instead of replacing the binary. With `--check`, those installs still query GitHub Releases to report whether a newer version exists, then print the upgrade command only when an update is available. Standalone apply downloads the archive plus `SHA256SUMS` and `SHA256SUMS.sig`, verifies the Ed25519 signature with a public key baked into the binary, then checks the archive digest.

numan snapshots activation state before `update`, `remove`, `activate`, and `deactivate`, so a bad change can be undone:

```bash
Expand Down Expand Up @@ -348,6 +352,7 @@ Global flag: `--root <path>` — override the numan root directory (all commands
| `numan activate [pkg...]` | Register plugins / write module autoloads (scripts and completion packages are deferred) |
| `numan deactivate [pkg...]` | Remove module autoload entries |
| `numan update [--check] [pkg]` | Upgrade installed packages |
| `numan update --self [--check]` | Upgrade the numan binary (GitHub Release self-replace, or print brew/winget/cargo command) |
| `numan remove [--force] <pkg>` | Remove from lockfile and delete payload |
| `numan gc [--dry-run]` | Delete orphaned package directories |
| `numan snapshot list` | List all committed activation snapshots |
Expand Down Expand Up @@ -377,7 +382,7 @@ Global flag: `--root <path>` — override the numan root directory (all commands
| `install` | `--force` reinstall; `-v` / `--verbose` |
| `activate` | `--verbose`; `--list` status only; `--check` integrity only |
| `deactivate` | `--verbose` |
| `update` | `--check` report only; `-v` / `--verbose` |
| `update` | `--check` report only; `--self` update the numan binary; `-v` / `--verbose` |
| `remove` | `--force` remove despite active activation |
| `gc` | `--dry-run` preview only |
| `registry add` | `--key <base64-public-key>` (required for custom registries; official is auto-configured on `init`) |
Expand Down
24 changes: 22 additions & 2 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,34 @@ Then:
```

6. The [Release workflow](https://github.com/tonythethompson/numan/actions/workflows/release.yml) waits for green CI on the tagged commit, runs preflight checks, then builds archives and publishes.
7. Confirm platform archives and `SHA256SUMS` on GitHub Releases.
8. Confirm the **Publish to crates.io** job succeeds (requires `CRATES_IO_TOKEN` repository secret).
7. Confirm platform archives, `SHA256SUMS`, and `SHA256SUMS.sig` on GitHub Releases.
8. Confirm the **Publish to crates.io** job succeeds (requires Trusted Publishing / OIDC on crates.io).
9. Confirm the [`Publish to WinGet`](../.github/workflows/winget.yml) workflow verifies the `winget-release-ready` artifact and published Windows release asset, then opens the update PR after the `v*.*.*` tag-triggered Release workflow completes (manual recovery: dispatch with required `release_tag`).
10. Confirm the [`Publish to Homebrew tap`](../.github/workflows/homebrew.yml) workflow verifies the `homebrew-release-ready` artifact and pushes `Formula/numan.rb` to [`tonythethompson/homebrew-numan`](https://github.com/tonythethompson/homebrew-numan) (requires `HOMEBREW_TAP_TOKEN`; manual recovery: dispatch with required `release_tag`).
11. After publication, update documentation only if it needs links that depend on newly created release pages or assets; do not use this step to repair README content already shipped in the crate or tag.

**Do not tag until CI is green on `master`.** The release workflow gates on CI check results for tag pushes; pushing a tag on a failing commit blocks publication.

## Self-update signing (`SHA256SUMS.sig`)

`numan update --self` refuses to install unless `SHA256SUMS.sig` verifies with the baked-in `RELEASE_SUMS_PUBLIC_KEY_B64` in `src/cmd/self_update.rs`.

1. Keep the matching 32-byte Ed25519 seed only in the repository secret `NUMAN_RELEASE_SIGNING_KEY` (standard base64). Never commit the seed.
2. The Release workflow runs `scripts/sign-sha256sums.py` when that secret is set and uploads `SHA256SUMS.sig` alongside the archives.
3. To rotate: generate a new seed, update the secret, bump `RELEASE_SUMS_PUBLIC_KEY_B64`, and cut a new release. Older unsigned releases remain installable via brew / winget / cargo / manual download.

Generate a seed and matching public key (local machine only):

```bash
python3 - <<'PY'
import base64
from nacl.signing import SigningKey
sk = SigningKey.generate()
print("NUMAN_RELEASE_SIGNING_KEY=" + base64.b64encode(sk.encode()).decode())
print("RELEASE_SUMS_PUBLIC_KEY_B64=" + base64.b64encode(sk.verify_key.encode()).decode())
PY
```

## CI jobs (reference)

| Job | Purpose |
Expand Down
65 changes: 65 additions & 0 deletions scripts/sign-sha256sums.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Sign SHA256SUMS with the Numan release Ed25519 key.

Reads the 32-byte seed from env NUMAN_RELEASE_SIGNING_KEY (standard base64).
Writes a single-line base64 Ed25519 signature over the exact file bytes to
the output path (default: <input>.sig).

Requires: pip install pynacl
"""

from __future__ import annotations

import base64
import os
import sys


def main() -> int:
if len(sys.argv) < 2 or len(sys.argv) > 3:
print(
f"usage: {sys.argv[0]} SHA256SUMS [SHA256SUMS.sig]",
file=sys.stderr,
)
return 2

sums_path = sys.argv[1]
sig_path = sys.argv[2] if len(sys.argv) == 3 else f"{sums_path}.sig"

seed_b64 = os.environ.get("NUMAN_RELEASE_SIGNING_KEY", "").strip()
if not seed_b64:
print(
"NUMAN_RELEASE_SIGNING_KEY is unset; cannot sign SHA256SUMS",
file=sys.stderr,
)
return 1

try:
from nacl.signing import SigningKey
except ImportError:
print("pynacl is required: pip install pynacl", file=sys.stderr)
return 1

seed = base64.b64decode(seed_b64)
if len(seed) != 32:
print(
f"NUMAN_RELEASE_SIGNING_KEY must decode to 32 bytes, got {len(seed)}",
file=sys.stderr,
)
return 1

with open(sums_path, "rb") as f:
data = f.read()

signing_key = SigningKey(seed)
signature_b64 = base64.b64encode(signing_key.sign(data).signature).decode("ascii")
with open(sig_path, "w", encoding="ascii") as f:
f.write(signature_b64)
f.write("\n")

print(f"Signed {sums_path} -> {sig_path}")
return 0


if __name__ == "__main__":
raise SystemExit(main())
1 change: 1 addition & 0 deletions src/cmd/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ pub mod plugin_lifecycle;
pub mod registry;
pub mod remove;
pub mod search;
pub mod self_update;
pub mod setup;
pub mod snapshot;
pub mod try_cmd;
Expand Down
Loading
Loading