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
8 changes: 7 additions & 1 deletion .github/RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
**deed** is a command line for nostr, built in Zig. It runs on macOS and Linux, on both Intel and ARM, and on Windows. 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.
**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.5.1

**A native build for Windows on ARM.** Releases now carry a zip for aarch64 Windows, run on a Windows ARM machine before the release is published, and the PowerShell installer puts it in place on an ARM64 machine. It used to crash at startup: a stripped build for that target looked for every thread-local variable in the wrong place, because of a linker bug fixed upstream in llvm/llvm-project#200060. That target is now built without stripping, which costs about 250 KB, since its debug information goes to a separate `.pdb` that is not shipped. For an older release without the ARM64 zip, the installer still installs the x86_64 build, which Windows on ARM runs under emulation.

deed is built on nostr 0.16.0. A command that is cancelled while it waits for randomness now reports the cancel, where it used to report a random-number failure.

### What's new in v0.5.0

deed now covers most of what a nostr user does from a shell. Every verb reads records as lines of JSON and writes one result per line, so they pipe into each other.
Expand Down
96 changes: 95 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -170,15 +170,85 @@ jobs:
NOSTR_SECRET_KEY="$sk" "$bin" event -c "windows check" | "$bin" verify
echo "deed runs on Windows"

# aarch64 Windows is built on Linux and run on an arm runner. The Zig 0.16.0
# compiler for aarch64 Windows crashes while building deed on that runner,
# so the runner only runs what was built elsewhere. The binary is built the
# way the release builds it, ReleaseSafe with -Dstrip=true, because that is
# the build that used to crash at startup (see build.zig).
build-windows-arm:
runs-on: ubuntu-latest
timeout-minutes: 20
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 deed and its tests for aarch64 Windows
run: |
set -euo pipefail
zig build -Dtarget=aarch64-windows-gnu -Doptimize=ReleaseSafe -Dstrip=true --prefix out
zig build test-bin -Dtarget=aarch64-windows-gnu --prefix out
ls -l out/bin

- uses: actions/upload-artifact@v4
with:
name: deed-windows-aarch64
path: out/bin
if-no-files-found: error
retention-days: 3

test-windows-arm:
needs: build-windows-arm
runs-on: windows-11-arm
timeout-minutes: 20
defaults:
run:
shell: bash
steps:
# The checkout is the working directory the tests expect, the same one
# `zig build test` runs them from, and it carries the version to compare.
- uses: actions/checkout@v4

- uses: actions/download-artifact@v4
with:
name: deed-windows-aarch64
path: out

- name: Test
run: ./out/deed-test.exe

# The same check as on x86_64 Windows: a key, an event signed with it,
# and the signature checked through a pipe.
- name: The binary runs
run: |
set -euo pipefail
bin=out/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 arm check" | "$bin" verify
echo "deed runs on aarch64 Windows"

# The Windows installer, end to end against the newest published release,
# under both PowerShells: pwsh, and Windows PowerShell 5.1, which is the one
# every Windows machine has and the one whose syntax the script keeps to.
# On the arm runner the installer picks the ARM64 build, or the x86_64 one
# when the release has no ARM64 build.
install-windows:
strategy:
fail-fast: false
matrix:
runner: [windows-latest, windows-11-arm]
shell: [pwsh, powershell]
runs-on: windows-latest
runs-on: ${{ matrix.runner }}
timeout-minutes: 15
defaults:
run:
Expand All @@ -202,6 +272,13 @@ jobs:
exit 1
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
# Whether that release carries an ARM64 build, which decides what the
# installer on the arm runner must have put in place.
if gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' | grep -q -- '-windows-aarch64\.zip$'; then
echo "has_arm64=true" >> "$GITHUB_OUTPUT"
else
echo "has_arm64=false" >> "$GITHUB_OUTPUT"
fi

- name: The installer parses
run: |
Expand Down Expand Up @@ -251,6 +328,23 @@ jobs:
"bin=$(Join-Path $prefix 'deed.exe')" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding ascii
Write-Host "installed $got, on PATH once"

# Which build the installer chose, read from the file and not from what
# runs: the machine field of the PE header, a u16 four bytes past the
# offset stored at 0x3C. The arm runner must get the ARM64 build when the
# release has one, and x86_64 (run under emulation) when it does not.
- name: The installed deed.exe is the build for this machine
env:
RUNNER: ${{ matrix.runner }}
HAS_ARM64: ${{ steps.release.outputs.has_arm64 }}
run: |
$bytes = [IO.File]::ReadAllBytes($env:bin)
$pe = [BitConverter]::ToInt32($bytes, 0x3C)
$machine = [BitConverter]::ToUInt16($bytes, $pe + 4)
$want = 0x8664
if ($env:RUNNER -eq 'windows-11-arm' -and $env:HAS_ARM64 -eq 'true') { $want = 0xAA64 }
if ($machine -ne $want) { Write-Host ("PE machine is 0x{0:X4}, expected 0x{1:X4}" -f $machine, $want); exit 1 }
Write-Host ("PE machine is 0x{0:X4}" -f $machine)

# A real gesture rather than just `version`: libsecp256k1, the bech32
# encoder and the JSON writer and reader in one line, as the check for
# install.sh does. In bash, so the pipe between the two deeds carries
Expand Down
14 changes: 9 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,14 @@ 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 x86_64-windows-gnu; 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 ;;
x86_64-windows-gnu) name=windows-x86_64 ;;
aarch64-windows-gnu) name=windows-aarch64 ;;
esac

rm -rf "out-$target"
Expand Down Expand Up @@ -225,6 +226,8 @@ jobs:
include:
- runner: windows-latest
asset: windows-x86_64
- runner: windows-11-arm
asset: windows-aarch64
runs-on: ${{ matrix.runner }}
defaults:
run:
Expand Down Expand Up @@ -289,10 +292,11 @@ jobs:
"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" \
"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 ten 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 @@ -305,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" = "5" ] || { echo "verified $checked checksums, expected 5" >&2; exit 1; }
[ "$checked" = "6" ] || { echo "verified $checked checksums, expected 6" >&2; exit 1; }

echo "ten 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 @@ -323,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" = "10" ] || { echo "the published release carries $count assets, expected 10" >&2; exit 1; }
[ "$count" = "12" ] || { echo "the published release carries $count assets, expected 12" >&2; exit 1; }

echo "published $GITHUB_REF_NAME"
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ 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
zig build test-bin -Dtarget=aarch64-windows-gnu # build the tests into zig-out/bin/deed-test.exe to run on another machine
```

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.
Expand Down
3 changes: 2 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ macOS, Linux and Windows, on x86_64 and aarch64. Linux release builds are static
- Windows needs an allocator to split the command line into arguments, so `main` uses `Args.Iterator.initAllocator`, which is free elsewhere.
- A Windows console decodes text with its own code page. `console.zig` switches it to UTF-8 for the length of the run, the output page only when stdout is the console and the input page only when stdin is, so the deeds in a pipeline do not switch it back under each other. It puts the pages back before exit and on Ctrl-C. It does nothing on other systems.
- The test relay shrinks a socket's receive window. On Windows std's sockets are AFD handles Winsock does not know, so it sends the option as the same AFD request std uses for its own socket options, and that is best effort.
- On aarch64 Windows `build.zig` ignores `-Dstrip`. With Zig 0.16 a stripped build for that target crashes at its first thread-local access, because the linker misreads the offset in the relocation for the upper bits of a thread-local address (llvm/llvm-project#200060). The debug information sits in a `.pdb` beside the `.exe` there, so the binary only carries the stack trace code it would otherwise drop.

Building only the executable for a target does not analyse code the executable never reaches. `zig build test -Dtarget=x86_64-windows-gnu` builds the test binary too, which is the check that finds a break in the test relay. The host cannot run the result, so the command ends with an error saying so once the compile has succeeded.

Expand All @@ -198,7 +199,7 @@ Two details matter when adding a test that dials. Use `testrelay.DialAllocator`

`bench/` is not a test suite. `bench/run.py` measures size, startup, memory, signing and verifying throughput, store speed, and relays over loopback against a build, using `bench/relay.py` as the relay. [`BENCHMARKS.md`](BENCHMARKS.md) has the numbers and how to reproduce them.

CI (`.github/workflows/ci.yml`) builds, tests and checks formatting on Linux and macOS, builds and tests on Windows, checks that the installer scripts are pure ASCII and parse, and installs the latest release and runs it, with install.sh on Linux and macOS and with install.ps1 on Windows under both pwsh and Windows PowerShell 5.1. The release workflow (`release.yml`) builds every target from the tag, publishes each as a draft with a SHA-256 beside it, downloads the artifacts back and runs them on matching runners, and lifts the draft only after every check passes.
CI (`.github/workflows/ci.yml`) builds, tests and checks formatting on Linux and macOS, builds and tests on Windows, builds the aarch64 Windows binary and test binary on Linux and runs both on a Windows ARM runner (the Zig compiler for that target crashes there), checks that the installer scripts are pure ASCII and parse, and installs the latest release and runs it, with install.sh on Linux and macOS and with install.ps1 on x86_64 and ARM Windows under both pwsh and Windows PowerShell 5.1. The release workflow (`release.yml`) builds every target from the tag, publishes each as a draft with a SHA-256 beside it, downloads the artifacts back and runs them on matching runners, and lifts the draft only after every check passes.

## Where to start reading

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# deed

**The nostr command line.**
**Fastest and smallest nostr command line, built for AI agents and humans.**

A deed is two things at once: a signed instrument, and a thing done. So is a nostr event. `deed` makes them, reads them and proves them.

Expand All @@ -22,7 +22,7 @@ Each checks the download against the SHA-256 published beside it, and if the dig

`--version <tag>` installs a particular release, `--prefix <dir>` puts it somewhere else, and `--archive <file>` installs from an archive you already have, which still wants its `.sha256` beside it. `--help` lists them. On Windows they are `-Version`, `-Prefix`, `-Archive` and `-Help` when you run the script as a file, and `$env:DEED_VERSION`, `$env:DEED_PREFIX` and `$env:DEED_ARCHIVE` for the piped line.

On Windows on ARM the installer puts the x86_64 build in place, which Windows 11 runs under emulation. The Windows binary is not code-signed, so Windows may show a warning the first time it runs. A store made with `--store` takes 1 GiB of disk on Windows from the first run, because LMDB sizes the file to its whole map up front there; on macOS and Linux it grows with what is kept. deed reads a leading `~` in a `--store` path from `HOME`, which Windows does not usually set, so give a full path there.
On Windows on ARM the installer puts the native ARM64 build in place, or the x86_64 build, which Windows 11 runs under emulation, for an older release that has no ARM64 build. The Windows binary is not code-signed, so Windows may show a warning the first time it runs. A store made with `--store` takes 1 GiB of disk on Windows from the first run, because LMDB sizes the file to its whole map up front there; on macOS and Linux it grows with what is kept. deed reads a leading `~` in a `--store` path from `HOME`, which Windows does not usually set, so give a full path there.

## What it does

Expand Down
26 changes: 25 additions & 1 deletion build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,23 @@ pub fn build(b: *std.Build) void {

// Release builds pass -Dstrip. Without it a Linux binary carries about
// nine megabytes of debug sections, three quarters of the file.
const strip = b.option(bool, "strip", "Leave debug information out of the binary");
const strip_wanted = b.option(bool, "strip", "Leave debug information out of the binary");

// Except on aarch64 Windows, where a stripped build crashes at its first
// thread-local access. Stripping drops the local symbols, so the compiler
// addresses each thread-local variable as an offset into the .tls section,
// and puts that offset in the instruction that relocates the upper bits.
// The linker in Zig 0.16 (LLD from LLVM 21) reads that offset as a count of
// 4 KiB pages rather than bytes, so a variable 8 bytes in is looked for
// 32 KiB in. LLD fixed this in llvm/llvm-project#200060, and the exception
// can go once the pinned Zig links with an LLD that has the fix. Little is
// lost meanwhile: a Windows binary keeps its debug information in a .pdb
// beside it, which the release does not ship, so the .exe only grows by
// the stack trace code, about 250 KB.
const strip = if (target.result.os.tag == .windows and target.result.cpu.arch == .aarch64)
false
else
strip_wanted;

const exe_mod = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
Expand All @@ -40,10 +56,18 @@ pub fn build(b: *std.Build) void {
// contain the text.
const test_filter = b.option([]const u8, "test-filter", "Run only the tests whose name contains this text");
const exe_tests = b.addTest(.{
.name = "deed-test",
.root_module = exe_mod,
.filters = if (test_filter) |f| b.dupeStrings(&.{f}) else &.{},
});
const run_exe_tests = b.addRunArtifact(exe_tests);
const test_step = b.step("test", "Run unit tests");
test_step.dependOn(&run_exe_tests.step);

// `zig build test-bin -Dtarget=<target>` builds the tests into
// zig-out/bin/deed-test without running them, for a machine that can run
// that target but not build it. CI uses it for aarch64 Windows.
const install_tests = b.addInstallArtifact(exe_tests, .{});
const test_bin_step = b.step("test-bin", "Build the unit tests without running them");
test_bin_step.dependOn(&install_tests.step);
}
6 changes: 3 additions & 3 deletions build.zig.zon
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
.{
.name = .deed,
.version = "0.5.0",
.version = "0.5.1",
.fingerprint = 0x89498c2094a1a4a3,
.minimum_zig_version = "0.16.0",
.dependencies = .{
Expand All @@ -10,8 +10,8 @@
// themselves later, and "whatever main happened to be that afternoon"
// is not that.
.nostr = .{
.url = "https://github.com/zig-nostr/nostr/archive/refs/tags/v0.15.1.tar.gz",
.hash = "nostr-0.15.1-CMyPzUp8EQA8ebwjsaco8u35hSiprJW0DS74viHXIVvr",
.url = "https://github.com/zig-nostr/nostr/archive/refs/tags/v0.16.0.tar.gz",
.hash = "nostr-0.16.0-CMyPzYmkEQBqRKpV_8jJYu769GBnHVzyZELHxbblGk0V",
},
},
.paths = .{
Expand Down
Loading
Loading