Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .github/RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
**deed** is a command line for nostr, built in Zig. It runs on macOS and Linux, on both Intel and ARM. The Linux binaries are statically linked, so there is no glibc version to satisfy. The macOS binaries are **ad-hoc signed, not notarized**, which means a download that came through a browser needs `xattr -dr com.apple.quarantine` before it will run.
**deed** is a command line for nostr, built in Zig. It runs on macOS, Linux and Windows, on both Intel and ARM. The Linux binaries are statically linked, so there is no glibc version to satisfy. The macOS binaries are **ad-hoc signed, not notarized**, which means a download that came through a browser needs `xattr -dr com.apple.quarantine` before it will run. The Windows builds are zips holding `deed.exe`, which is not code-signed.

Every artifact below is published with a `.sha256` beside it, so the download can be checked against a digest that was written by the same job that built it.

### What's new in v0.4.0

**deed runs on Windows.** Each release now carries a zip for x86_64 and one for ARM, holding `deed.exe`, with a `.sha256` beside each. The nostr library underneath builds and passes its tests on Windows too, including the read deadline that `req` and `fetch --timeout` rely on.

**A new store's directories are made for you, and a store that will not open says why.** `--store ~/.deed/db` used to need `~/.deed` to exist already, and a store that failed to open printed only an error name. deed now creates the missing directories and names the reason: no permission, not a store, or a directory where the file should be. `--local` only reads, so it never creates an empty store at a mistyped path.

**Codes in capitals decode.** An all-uppercase `NPUB1...` or `NEVENT1...` decodes, as BIP-173 allows, and so does a `NOSTR:` scheme in any case, which is what a QR code carries. A code in mixed case is still refused.

### What's new in v0.3.2

**deed works well with AI agents.** A skill in `skills/deed` teaches an agent the commands, working recipes, and the two rules that matter: publishing is public and permanent, so it asks first, and a secret key stays in `NOSTR_SECRET_KEY`. Add it with `npx skills add zig-nostr/deed`, or in Claude Code as a plugin. `AGENTS.md` covers changing deed itself.
Expand Down
44 changes: 44 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,47 @@ jobs:
fi
[ ! -e "$work/out/bin/deed" ] || { echo "it refused and installed anyway" >&2; exit 1; }
echo "a tampered archive is refused and nothing is installed"

# Windows gets its own job because most of the steps above are bash scripts
# for the macOS and Linux installer. What matters here is that deed builds,
# that its tests (which talk to relays over real loopback sockets) pass, and
# that the binary that was built runs. Formatting does not depend on the
# platform and is checked above.
build-test-windows:
runs-on: windows-latest
timeout-minutes: 20
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4

- name: Read Zig version
id: zig-version
run: echo "version=$(cat .zigversion)" >> "$GITHUB_OUTPUT"

- uses: mlugg/setup-zig@v2
with:
version: ${{ steps.zig-version.outputs.version }}

- name: Build
run: zig build

- name: Test
run: zig build test

# A binary that links is not a binary that runs. This makes a key, signs
# an event with it and checks the signature, through a pipe, which covers
# the argument parsing, the environment, and standard input and output
# that differ most on Windows.
- name: The binary runs
run: |
set -euo pipefail
bin=zig-out/bin/deed.exe
[ -f "$bin" ] || { echo "the build produced no deed.exe" >&2; exit 1; }
got="$("$bin" version)"
want="deed $(sed -n 's/^pub const version = "\(.*\)";$/\1/p' src/main.zig)"
[ "$got" = "$want" ] || { echo "the binary says '$got', expected '$want'" >&2; exit 1; }
sk="$("$bin" key generate --hex)"
NOSTR_SECRET_KEY="$sk" "$bin" event -c "windows check" | "$bin" verify
echo "deed runs on Windows"
121 changes: 100 additions & 21 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: Release

# On a version tag, build deed for four targets from that commit, publish each
# On a version tag, build deed for six targets from that commit, publish each
# with a checksum beside it, and make the release visible only once every claim
# about it has been checked by a command that fails the job.
#
Expand Down Expand Up @@ -82,18 +82,26 @@ jobs:
v="${{ steps.version.outputs.version }}"
mkdir -p dist

for target in aarch64-macos-none x86_64-macos-none x86_64-linux-musl aarch64-linux-musl; do
for target in aarch64-macos-none x86_64-macos-none x86_64-linux-musl aarch64-linux-musl x86_64-windows-gnu aarch64-windows-gnu; do
case "$target" in
aarch64-macos-none) name=macos-aarch64 ;;
x86_64-macos-none) name=macos-x86_64 ;;
x86_64-linux-musl) name=linux-x86_64 ;;
aarch64-linux-musl) name=linux-aarch64 ;;
aarch64-macos-none) name=macos-aarch64 ;;
x86_64-macos-none) name=macos-x86_64 ;;
x86_64-linux-musl) name=linux-x86_64 ;;
aarch64-linux-musl) name=linux-aarch64 ;;
x86_64-windows-gnu) name=windows-x86_64 ;;
aarch64-windows-gnu) name=windows-aarch64 ;;
esac

rm -rf "out-$target"
zig build -Dtarget="$target" -Doptimize=ReleaseSafe -Dstrip=true --prefix "out-$target"

bin="out-$target/bin/deed"
# Windows binaries carry an .exe suffix and ship in a zip, which is
# what Expand-Archive and Explorer open. Every other target ships a
# tar.gz holding a binary named deed.
case "$target" in
*-windows-*) bin="out-$target/bin/deed.exe"; ext=zip ;;
*) bin="out-$target/bin/deed"; ext=tar.gz ;;
esac
[ -s "$bin" ] || { echo "$target produced no binary" >&2; exit 1; }

# The linker ad-hoc signs the arm64 Mach-O and leaves the x86_64 one
Expand All @@ -107,14 +115,27 @@ jobs:
;;
esac

asset="deed-$v-$name.tar.gz"
tar -czf "dist/$asset" -C "out-$target/bin" deed -C "$GITHUB_WORKSPACE" LICENSE README.md
[ -s "dist/$asset" ] || { echo "$asset is empty" >&2; exit 1; }

# An archive holding only LICENSE and README unpacks perfectly and
# installs nothing, so the binary is confirmed to be in there.
tar -tzf "dist/$asset" | grep -qx deed \
|| { echo "$asset carries no member named deed" >&2; exit 1; }
asset="deed-$v-$name.$ext"
case "$ext" in
zip)
# -j keeps the files at the top of the archive, and -X leaves
# out the extra attributes that make a zip differ by machine.
zip -q -X -j "dist/$asset" "$bin" LICENSE README.md
[ -s "dist/$asset" ] || { echo "$asset is empty" >&2; exit 1; }
unzip -Z1 "dist/$asset" | grep -qx deed.exe \
|| { echo "$asset carries no member named deed.exe" >&2; exit 1; }
;;
*)
tar -czf "dist/$asset" -C "out-$target/bin" deed -C "$GITHUB_WORKSPACE" LICENSE README.md
[ -s "dist/$asset" ] || { echo "$asset is empty" >&2; exit 1; }

# An archive holding only LICENSE and README unpacks perfectly
# and installs nothing, so the binary is confirmed to be in
# there.
tar -tzf "dist/$asset" | grep -qx deed \
|| { echo "$asset carries no member named deed" >&2; exit 1; }
;;
esac

( cd dist && shasum -a 256 "$asset" > "$asset.sha256" )
[ -s "dist/$asset.sha256" ] || { echo "$asset.sha256 is empty" >&2; exit 1; }
Expand Down Expand Up @@ -192,9 +213,65 @@ jobs:
./deed event -c "release check" --sec "$sk" | ./deed verify
echo "$asset runs on ${{ matrix.runner }}"

# The same check for the Windows zips, on Windows. Extraction is PowerShell's
# own Expand-Archive, which is what the README tells a reader to use, and the
# rest runs in bash so that the pipe between the two deeds carries bytes and
# not the re-encoded text PowerShell would make of it.
verify-windows:
name: run ${{ matrix.asset }} on ${{ matrix.runner }}
needs: build
strategy:
fail-fast: false
matrix:
include:
- runner: windows-latest
asset: windows-x86_64
- runner: windows-11-arm
asset: windows-aarch64
runs-on: ${{ matrix.runner }}
defaults:
run:
shell: bash
steps:
- name: Download it and check it
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
set -euo pipefail
v="${GITHUB_REF_NAME#v}"
asset="deed-$v-${{ matrix.asset }}.zip"

gh release download "$GITHUB_REF_NAME" \
--pattern "$asset" --pattern "$asset.sha256" --dir .

sha256sum -c "$asset.sha256"
echo "asset=$asset" >> "$GITHUB_ENV"

- name: Unpack it
shell: pwsh
run: Expand-Archive -Path $env:asset -DestinationPath unpacked

- name: Run it
run: |
set -euo pipefail
v="${GITHUB_REF_NAME#v}"
bin=./unpacked/deed.exe
[ -f "$bin" ] || { echo "the archive holds no deed.exe" >&2; exit 1; }

got="$("$bin" version)"
[ "$got" = "deed $v" ] || { echo "the binary says '$got', expected 'deed $v'" >&2; exit 1; }

# The same gesture as on the other platforms: libsecp256k1, the
# bech32 encoder and the JSON writer and reader in one line, on the
# artifact as published.
sk="$("$bin" key generate --hex)"
"$bin" event -c "release check" --sec "$sk" | "$bin" verify
echo "${{ matrix.asset }} runs on ${{ matrix.runner }}"

publish:
name: check the page, then lift the draft
needs: verify
needs: [verify, verify-windows]
runs-on: ubuntu-latest
steps:
- name: Everything the page must carry, before anyone can see it
Expand All @@ -214,10 +291,12 @@ jobs:
"deed-$v-linux-aarch64.tar.gz" "deed-$v-linux-aarch64.tar.gz.sha256" \
"deed-$v-linux-x86_64.tar.gz" "deed-$v-linux-x86_64.tar.gz.sha256" \
"deed-$v-macos-aarch64.tar.gz" "deed-$v-macos-aarch64.tar.gz.sha256" \
"deed-$v-macos-x86_64.tar.gz" "deed-$v-macos-x86_64.tar.gz.sha256" | sort)"
"deed-$v-macos-x86_64.tar.gz" "deed-$v-macos-x86_64.tar.gz.sha256" \
"deed-$v-windows-aarch64.zip" "deed-$v-windows-aarch64.zip.sha256" \
"deed-$v-windows-x86_64.zip" "deed-$v-windows-x86_64.zip.sha256" | sort)"
actual="$(ls dist | sort)"
diff <(printf '%s\n' "$expected") <(printf '%s\n' "$actual") \
|| { echo "the release assets are not the eight expected files" >&2; exit 1; }
|| { echo "the release assets are not the twelve expected files" >&2; exit 1; }

for f in dist/*; do
[ -s "$f" ] || { echo "$f is empty" >&2; exit 1; }
Expand All @@ -230,9 +309,9 @@ jobs:
done
# A glob that matched nothing would leave the loop body unrun and the
# step green, having verified precisely nothing.
[ "$checked" = "4" ] || { echo "verified $checked checksums, expected 4" >&2; exit 1; }
[ "$checked" = "6" ] || { echo "verified $checked checksums, expected 6" >&2; exit 1; }

echo "eight assets, all present, none empty, every digest matching"
echo "twelve assets, all present, none empty, every digest matching"

- name: Lift the draft, then read the page back
env:
Expand All @@ -248,6 +327,6 @@ jobs:
# Read the list again from the published release. What was checked in
# the step above was the draft, and this is the page a reader opens.
count="$(gh release view "$GITHUB_REF_NAME" --json assets --jq '.assets | length')"
[ "$count" = "8" ] || { echo "the published release carries $count assets, expected 8" >&2; exit 1; }
[ "$count" = "12" ] || { echo "the published release carries $count assets, expected 12" >&2; exit 1; }

echo "published $GITHUB_REF_NAME"
11 changes: 9 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,27 @@ A guide to this repository for anyone changing it, people and coding agents alik
```sh
zig build # binary at zig-out/bin/deed
zig build test # unit tests, including real relays on loopback
zig fmt --check src build.zig # CI fails on unformatted code
zig fmt --check . # CI fails on unformatted code
zig build -Doptimize=ReleaseSafe -Dstrip=true # the release build
python3 bench/run.py zig-out/bin/deed # benchmarks, see BENCHMARKS.md
zig build test -Dtarget=x86_64-windows-gnu # compile the tests for Windows; the run step then fails, the host cannot run them
```

Use the Zig version in `.zigversion`. The `nostr` dependency is pinned by URL and hash in `build.zig.zon`; move it with `zig fetch --save=nostr <tarball url>` and check the diff is only the url and hash lines.
Use the Zig version in `.zigversion`. Building only the executable for Windows is not enough to catch a break there: code the executable never reaches, the test relay for one, only gets analysed when the test binary is built, which is what the Windows line above does. The `nostr` dependency is pinned by URL and hash in `build.zig.zon`; move it with `zig fetch --save=nostr <tarball url>` and check the diff is only the url and hash lines.

## Layout

[`ARCHITECTURE.md`](ARCHITECTURE.md) explains how the pieces fit: where each command lives, how a run flows, the store, the exit codes and how it is tested. The tree below is the short version.

```
src/
main.zig # dispatch, the stdout/stderr writers, exit codes
cli.zig # exit code constants, the stdin record reader
console.zig # Windows only: UTF-8 console code pages for the length of a run
cmd_*.zig # one file per verb
relayset.zig # the shared relay query behind req and fetch
dial.zig # dialling every relay at once under one deadline
storepath.zig # opening the store a --store path names, and saying why one will not open
testrelay.zig # a websocket relay on loopback, for tests only
bench/ # the benchmark script and its relay
scripts/ # the one-line installer (pure ASCII, CI checks it)
Expand All @@ -37,6 +42,8 @@ skills/deed/ # the skill for agents that operate deed

- Every verb takes its inputs as arguments or, given none, as newline-delimited records on stdin, and writes one result per line on stdout. Diagnostics go to stderr. Keep it that way: it is what makes the verbs compose.
- Exit codes are an interface: 0 success, 1 ran and failed, 2 not understood, 141 the reader went away. Do not add new ones casually.
- Match a NIP-19 prefix with `keyinput.hasPrefix` and strip `nostr:` with `keyinput.stripUri`, never `std.mem.startsWith`: bech32 allows all upper case, and a code in upper case has to reach the decoder.
- Open a `--store` path with `storepath.open`, so a new store's directories are made and a store that will not open says why.
- Anything a relay sends is untrusted: events are verified and matched against the filter before they are printed or stored, and relay text is escaped before it reaches a terminal.
- Tests that dial use `testrelay.DialAllocator`, not `std.testing.allocator`: on macOS a stack-capturing allocator can swallow a cancel and hang the test.
- `zig build test` caches passing runs. To check for flakiness, run the test binary directly (`ls -t .zig-cache/o/*/test | head -1`) several times.
Expand Down
Loading
Loading