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
128 changes: 126 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,17 @@ jobs:

# The installer is served over `curl | bash`, and macOS ships bash 3.2,
# where a multibyte character sitting next to a $variable under `set -u`
# aborts the script. An installer that dies on its own punctuation is a
# aborts the script. The Windows one is read by Windows PowerShell 5.1,
# which takes a file without a byte order mark to be in the machine's
# legacy code page. An installer that dies on its own punctuation is a
# bad first impression, so the scripts are pure ASCII and this is what
# keeps them that way. `tr` rather than a grep class, because BSD grep has
# no -P and the character classes differ between the two greps.
- name: The scripts are pure ASCII
run: |
set -euo pipefail
status=0
for f in scripts/*.sh examples/*/*.sh; do
for f in scripts/*.sh scripts/*.ps1 examples/*/*.sh; do
n="$(tr -d '\000-\177' < "$f" | wc -c | tr -d ' ')"
if [ "$n" != "0" ]; then
echo "$f carries $n bytes outside ASCII" >&2
Expand Down Expand Up @@ -167,3 +169,125 @@ jobs:
sk="$("$bin" key generate --hex)"
NOSTR_SECRET_KEY="$sk" "$bin" event -c "windows check" | "$bin" verify
echo "deed runs on 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.
install-windows:
strategy:
fail-fast: false
matrix:
shell: [pwsh, powershell]
runs-on: windows-latest
timeout-minutes: 15
defaults:
run:
shell: ${{ matrix.shell }}
steps:
- uses: actions/checkout@v4

# The newest PUBLISHED release, not the newest tag, as the check for
# install.sh asks: a tag whose release is still a draft has nothing a
# reader could download.
- name: Find the newest published release
id: release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
tag="$(gh release list --repo "$GITHUB_REPOSITORY" --exclude-drafts --exclude-pre-releases --limit 1 --json tagName --jq '.[0].tagName // empty')"
if [ -z "$tag" ]; then
echo "no release tag exists yet, so the installer was NOT exercised" >&2
exit 1
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"

- name: The installer parses
run: |
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile("$PWD\scripts\install.ps1", [ref]$null, [ref]$errors) | Out-Null
if ($errors) { $errors | ForEach-Object { Write-Host $_ }; exit 1 }
Write-Host "install.ps1 parses under PowerShell $($PSVersionTable.PSVersion)"

# The way the README runs it: the script's text handed to
# Invoke-Expression, which is all `irm | iex` does, with the options in
# the environment because a piped run has no command line. The text comes
# from this checkout rather than from main, so it is the change under
# test that runs.
- name: The piped installer installs a release, and it runs
env:
DEED_VERSION: ${{ steps.release.outputs.tag }}
TAG: ${{ steps.release.outputs.tag }}
run: |
$prefix = Join-Path $env:RUNNER_TEMP 'deed-piped'
$env:DEED_PREFIX = $prefix
Get-Content -Raw scripts/install.ps1 | Invoke-Expression
$bin = Join-Path $prefix 'deed.exe'
if (-not (Test-Path -LiteralPath $bin)) { Write-Host "the installer reported success and installed nothing"; exit 1 }
$got = & $bin version
$want = "deed $($env:TAG.Substring(1))"
if ($got -ne $want) { Write-Host "installed binary says '$got', expected '$want'"; exit 1 }
Write-Host "installed $got"

# The same, run as a file with options on its command line. It runs twice,
# because installing over an existing copy is how an upgrade happens, and
# the second run must not add the folder to PATH a second time.
- name: The installer run as a file installs a release, and PATH gets one entry
env:
TAG: ${{ steps.release.outputs.tag }}
run: |
$prefix = Join-Path $env:RUNNER_TEMP 'deed'
foreach ($run in 1, 2) {
& .\scripts\install.ps1 -Version $env:TAG -Prefix $prefix
if ($LASTEXITCODE -ne 0) { Write-Host "run $run of the installer failed"; exit 1 }
}
$got = & (Join-Path $prefix 'deed.exe') version
$want = "deed $($env:TAG.Substring(1))"
if ($got -ne $want) { Write-Host "installed binary says '$got', expected '$want'"; exit 1 }
$user = [Environment]::GetEnvironmentVariable('Path', 'User')
$entries = @($user -split ';' | Where-Object { $_.TrimEnd('\') -eq $prefix.TrimEnd('\') })
if ($entries.Count -ne 1) { Write-Host "the user PATH carries $prefix $($entries.Count) times, expected once"; exit 1 }
"bin=$(Join-Path $prefix 'deed.exe')" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding ascii
Write-Host "installed $got, on PATH once"

# 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
# bytes and not the re-encoded text PowerShell would make of it.
- name: The installed deed signs and verifies
shell: bash
run: |
set -euo pipefail
bin="$(cygpath -u "$bin")"
sk="$("$bin" key generate --hex)"
"$bin" event -c "installer check" --sec "$sk" | "$bin" verify
echo "the installed deed works"

# The guard above has to be able to fail. A tampered archive must be
# refused and must install nothing, because a checksum that is checked and
# then ignored is worse than none: it reads as a promise. The untampered
# archive goes through first, so a refusal means the digest and not the
# -Archive path.
- name: A download that does not verify is refused
env:
TAG: ${{ steps.release.outputs.tag }}
run: |
$work = Join-Path $env:RUNNER_TEMP 'tamper'
New-Item -ItemType Directory -Force -Path $work | Out-Null
$asset = "deed-$($env:TAG.Substring(1))-windows-x86_64.zip"
$base = "https://github.com/$env:GITHUB_REPOSITORY/releases/download/$env:TAG"
$zip = Join-Path $work $asset
Invoke-WebRequest -Uri "$base/$asset" -OutFile $zip -UseBasicParsing
Invoke-WebRequest -Uri "$base/$asset.sha256" -OutFile "$zip.sha256" -UseBasicParsing

& .\scripts\install.ps1 -Archive $zip -Prefix (Join-Path $work 'ok')
if ($LASTEXITCODE -ne 0) { Write-Host "the installer refused the archive as published"; exit 1 }

[IO.File]::AppendAllText($zip, 'x')
$out = Join-Path $work 'out'
& .\scripts\install.ps1 -Archive $zip -Prefix $out
if ($LASTEXITCODE -eq 0) { Write-Host "the installer accepted a tampered archive"; exit 1 }
if (Test-Path -LiteralPath (Join-Path $out 'deed.exe')) { Write-Host "it refused and installed anyway"; exit 1 }
Write-Host "a tampered archive is refused and nothing is installed"
exit 0
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ src/
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)
scripts/ # the one-line installers, install.sh and install.ps1 (pure ASCII, CI checks both)
skills/deed/ # the skill for agents that operate deed
```

Expand Down
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,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 script is pure ASCII and parses, and installs the latest release and runs it. 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, 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.

## Where to start reading

Expand Down
50 changes: 8 additions & 42 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,57 +6,23 @@ A deed is two things at once: a signed instrument, and a thing done. So is a nos

## Install

```sh
curl -fsSL https://raw.githubusercontent.com/zig-nostr/deed/main/scripts/install.sh | bash
```

For macOS and Linux, Intel and ARM; Windows is [below](#windows). It works out which build this machine wants, checks the download against the SHA-256 published beside it, and installs into `~/.local/bin`, so nothing needs root and nothing lands outside your home directory. If the digest does not match, it installs nothing and says so.

`--prefix <dir>` puts it somewhere else, `--version <tag>` installs a particular release, and `--archive <file>` installs from a tarball you already have, which still wants its `.sha256` beside it. Pass `--help` for the list.

### By hand

The script is short and worth reading before you pipe anything into bash. If you would rather do it yourself:
macOS and Linux:

```sh
VERSION=0.4.1
PLATFORM=macos-aarch64 # or macos-x86_64, linux-x86_64, linux-aarch64
BASE=https://github.com/zig-nostr/deed/releases/download/v$VERSION

curl -LO $BASE/deed-$VERSION-$PLATFORM.tar.gz
curl -LO $BASE/deed-$VERSION-$PLATFORM.tar.gz.sha256
shasum -a 256 -c deed-$VERSION-$PLATFORM.tar.gz.sha256 # sha256sum -c on Linux
tar -xzf deed-$VERSION-$PLATFORM.tar.gz
sudo mv deed /usr/local/bin/
curl -fsSL https://raw.githubusercontent.com/zig-nostr/deed/main/scripts/install.sh | bash
```

The Linux builds are statically linked, so there is no glibc version to satisfy.

The macOS builds are ad-hoc signed and not notarized. A copy that arrived through a browser carries a quarantine flag, so clear it once:
Windows, in PowerShell:

```sh
xattr -dr com.apple.quarantine deed
```powershell
irm https://raw.githubusercontent.com/zig-nostr/deed/main/scripts/install.ps1 | iex
```

### Windows
Each checks the download against the SHA-256 published beside it, and if the digest does not match it installs nothing and says so. On macOS and Linux deed goes into `~/.local/bin`. On Windows it goes into `%LOCALAPPDATA%\deed`, which is added to your `PATH`, so `deed version` works in a new terminal. Neither needs root or administrator rights.

Windows takes a zip rather than the script. Download `deed-<version>-windows-x86_64.zip` and the `.sha256` beside it from the [releases page](https://github.com/zig-nostr/deed/releases), then in PowerShell, from the folder they are in:

```powershell
$zip = (Get-Item deed-*-windows-*.zip).Name
$want = (Get-Content "$zip.sha256").Split(' ')[0]
$got = (Get-FileHash $zip -Algorithm SHA256).Hash
if ($got -ne $want) { throw "the SHA-256 does not match, do not run this" }

$dir = "$env:LOCALAPPDATA\deed"
Expand-Archive $zip -DestinationPath $dir -Force
$user = [Environment]::GetEnvironmentVariable('Path', 'User')
if (($user -split ';') -notcontains $dir) {
[Environment]::SetEnvironmentVariable('Path', "$user;$dir".TrimStart(';'), 'User')
}
```
`--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.

That checks the download, unpacks `deed.exe`, with the `LICENSE` and `README.md`, into `%LOCALAPPDATA%\deed`, and puts that folder on your `PATH` unless it is there already, so the same lines install a newer version over an older one. Keep only the zip you mean to install in the folder. Open a new terminal and `deed version` confirms it. PowerShell compares the two digests without regard to case, so the capitals `Get-FileHash` prints are fine. The 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 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.

## What it does

Expand Down
Loading
Loading