From 606c9cb80c35d60573a554d8d1b2a31463af376a Mon Sep 17 00:00:00 2001 From: David Henning Date: Fri, 31 Jul 2026 00:15:54 +0200 Subject: [PATCH] feat: add an install script for macOS and Linux install.sh downloads the release archive matching the detected OS and architecture, verifies it against the release's checksums.txt, and installs the binary into ~/.local/bin without root privileges or a package manager. When cosign is present the keyless bundle over checksums.txt is verified too, and a failure aborts; nothing is written before verification passes. The Pages workflow copies the root script into the site artifact so https://jwtd.sh/install.sh serves the reviewed file byte-for-byte. Co-Authored-By: Claude Opus 5 --- .github/workflows/pages.yml | 7 ++ .gitignore | 2 + AGENTS.md | 12 +- README.md | 19 +++ RELEASE_NOTES.md | 9 ++ install.sh | 242 ++++++++++++++++++++++++++++++++++++ install_test.go | 236 +++++++++++++++++++++++++++++++++++ site/index.html | 47 ++++++- 8 files changed, 567 insertions(+), 7 deletions(-) create mode 100755 install.sh create mode 100644 install_test.go diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index a61e06d..5c0ca62 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -5,6 +5,7 @@ on: branches: [main] paths: - "site/**" + - "install.sh" - ".github/workflows/pages.yml" workflow_dispatch: @@ -28,6 +29,12 @@ jobs: run: | version="$(gh release view --json tagName --jq .tagName)" sed -i "s/VERSION/${version}/g" site/index.html + # The installer lives at the repository root as the single source of + # truth and is copied into the Pages artifact so that + # https://jwtd.sh/install.sh serves it. It is never edited here, so the + # hosted script is byte-identical to the reviewed one. + - name: Publish the install script at jwtd.sh/install.sh + run: install -m 0755 install.sh site/install.sh - uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0 with: path: site diff --git a/.gitignore b/.gitignore index a58f619..be6a88c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ jwtd .idea/ dist/ completions/ +# Copied from the repository root by the Pages workflow at build time. +site/install.sh .worktrees/ result result-* diff --git a/AGENTS.md b/AGENTS.md index 40e1015..a03f22a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,6 +87,16 @@ Artifacts cross the build/release job boundary as two separate uploads: `jwtd-re Release notes are auto-generated (`--generate-notes`), which lists only merged PR titles. `RELEASE_NOTES.md` holds hand-written prose for the next release: when present and non-empty it is prepended to the generated notes at release creation. Clear it after a release so its contents do not repeat on the following one. +### Install script + +`install.sh` is a package-manager-free installer for macOS and Linux, served at `https://jwtd.sh/install.sh` (`curl -fsSL https://jwtd.sh/install.sh | sh`). It maps `uname -s`/`uname -m` onto the GoReleaser archive names (`jwtd--.tar.gz`, the four darwin/linux × amd64/arm64 targets), downloads that archive plus `checksums.txt` from the latest release — or from `--version ` — extracts the binary, and installs it into `~/.local/bin` (overridable with `--dir`/`JWTD_INSTALL_DIR`). Windows is deliberately unreachable: it is served by WinGet and Scoop. + +**Verification is not optional, and nothing is written before it passes.** The archive is always checked against its `checksums.txt` entry (`sha256sum`, falling back to `shasum -a 256`), and when `cosign` is on `PATH` the keyless bundle over `checksums.txt` is verified against the same certificate identity and issuer the README documents; a cosign failure aborts. cosign itself stays optional because most machines do not have it and the checksum already pins the bytes — but a present cosign is never advisory. The script runs under POSIX `sh` (it is piped into whatever `/bin/sh` the user has, not necessarily bash) and never calls `sudo`, so piping it into a shell is not a privilege decision. + +The binary is copied into the install directory under a temporary name and then `mv`'d into place, so the replacement is a same-filesystem `rename(2)`: an upgrade cannot leave a half-written binary behind, and it does not fail with `ETXTBSY` when the running shell's own `jwtd` is being replaced. A cross-device `mv` straight from the temp directory would do both. + +There is one copy of the script. `.github/workflows/pages.yml` copies the repository root file into the Pages artifact (`install -m 0755 install.sh site/install.sh`, which is git-ignored) so the hosted script is byte-identical to the reviewed one, and the workflow redeploys when `install.sh` changes. `install_test.go` holds down the contract: the archive naming against `.goreleaser.yaml`, verification ordering, the Cosign identity matching the README, `sh`/no-`sudo`, rejection of unsupported platforms (driven by a stubbed `uname`, so the test never touches the network), and the README/site one-liner. + ## Dependencies | Package | Purpose | @@ -141,7 +151,7 @@ JWTD_KEY=key.pem jwtd # same, via environment variable ## Conventions - **Single package.** All code stays in package `main`, split across topical files (`main.go`, `jwe.go`, `keys.go`, `output.go`, `jsonout.go`, `claims.go`). -- **Tests mirror the source files:** `main_test.go`, `jwe_test.go`, `keys_test.go`, `output_test.go`, `jsonout_test.go`, `claims_test.go`, with shared fixtures (key generation, token signing/encryption helpers) in `helpers_test.go` and GoReleaser/release-workflow invariants in `workflow_test.go`. Use table-driven tests where multiple cases share the same structure. +- **Tests mirror the source files:** `main_test.go`, `jwe_test.go`, `keys_test.go`, `output_test.go`, `jsonout_test.go`, `claims_test.go`, with shared fixtures (key generation, token signing/encryption helpers) in `helpers_test.go` GoReleaser/release-workflow invariants in `workflow_test.go`, website/Pages invariants in `site_test.go`, and installer invariants in `install_test.go`. Use table-driven tests where multiple cases share the same structure. - **Color scheme** is configured in `newFormatter()` via `go-prettyjson` and `fatih/color`. Colors auto-disable when stdout is not a TTY. - **Error handling:** Return errors up the call stack with `fmt.Errorf` wrapping (`%w`). The root command suppresses Cobra's automatic error and usage output; `main()` renders non-signature errors and exits nonzero, while invalid signatures print their own details and return `errInvalidSignature`. - **Formatting:** Use `gofmt`/`goimports` standard formatting. No special linter configuration. diff --git a/README.md b/README.md index 42f9335..793a375 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,25 @@ A CLI tool that decodes and pretty-prints JSON Web Tokens (JWTs) and JSON Web En ## Installation +### Install script (macOS and Linux) + +```sh +curl -fsSL https://jwtd.sh/install.sh | sh +``` + +Downloads the release archive for the detected OS and architecture, verifies it against the release's `checksums.txt`, and installs the binary into `~/.local/bin` — no root privileges and no package manager required. When [Cosign](https://docs.sigstore.dev/) is installed, the keyless signature over `checksums.txt` is verified as well; without it the checksum verification still runs and a mismatch aborts the installation. + +Pass options after `--`: + +```sh +curl -fsSL https://jwtd.sh/install.sh | sh -s -- --version v5.3.0 # pin a release +curl -fsSL https://jwtd.sh/install.sh | sh -s -- --dir /usr/local/bin +``` + +`JWTD_VERSION` and `JWTD_INSTALL_DIR` set the same two values. Run the script with `--help` for the full list. The script is [`install.sh`](install.sh) in this repository; review it before piping it into a shell. + +Windows is served by [WinGet](#winget-windows) and [Scoop](#scoop-windows) instead. + ### Homebrew (macOS and Linux) ```sh diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index e69de29..0da441e 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -0,0 +1,9 @@ +## Install script + +jwtd can now be installed on macOS and Linux without a package manager: + +```sh +curl -fsSL https://jwtd.sh/install.sh | sh +``` + +The script picks the release archive matching your OS and architecture, verifies it against the release's `checksums.txt` — and, when `cosign` is installed, verifies the keyless signature over that checksum file — then installs the binary into `~/.local/bin`. No root privileges are involved. Pin a release with `--version v5.3.0` or install elsewhere with `--dir /usr/local/bin`. diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..66c453c --- /dev/null +++ b/install.sh @@ -0,0 +1,242 @@ +#!/bin/sh +# +# jwtd installer for macOS and Linux. +# +# Downloads the release archive matching the detected OS and architecture, +# verifies it against the release's checksums.txt (and, when cosign is +# installed, verifies the keyless signature over that checksum file), then +# installs the binary into ~/.local/bin. No root privileges are required. +# +# curl -fsSL https://jwtd.sh/install.sh | sh +# curl -fsSL https://jwtd.sh/install.sh | sh -s -- --version v5.3.0 +# curl -fsSL https://jwtd.sh/install.sh | sh -s -- --dir /usr/local/bin +# +# Windows is served by Scoop and WinGet instead; see the README. + +set -eu + +REPO="webcodr/jwtd" +CERTIFICATE_IDENTITY_REGEXP="^https://github.com/webcodr/jwtd/\.github/workflows/release\.yml@" +CERTIFICATE_OIDC_ISSUER="https://token.actions.githubusercontent.com" + +info() { + printf '%s\n' "$*" >&2 +} + +warn() { + printf 'warning: %s\n' "$*" >&2 +} + +die() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +have() { + command -v "$1" >/dev/null 2>&1 +} + +usage() { + cat <<'EOF' +Install jwtd, a CLI that decodes and pretty-prints JWT, JWS, and JWE tokens. + +Usage: + install.sh [--version ] [--dir ] + +Options: + -v, --version Release to install (default: the latest release). + Accepts "5.3.0" or "v5.3.0". + -d, --dir Installation directory (default: ~/.local/bin). + -h, --help Show this help. + +Environment: + JWTD_VERSION Same as --version. + JWTD_INSTALL_DIR Same as --dir. + +The archive is always verified against the release's checksums.txt. When +cosign is installed, the keyless Cosign bundle over checksums.txt is verified +as well. +EOF +} + +# detect_os and detect_arch map uname output onto the GOOS/GOARCH pair used in +# the release archive names (jwtd--.tar.gz). +detect_os() { + kernel=$(uname -s) + case "$kernel" in + Linux) printf 'linux\n' ;; + Darwin) printf 'darwin\n' ;; + *) die "unsupported operating system: $kernel (this script installs on Linux and macOS; on Windows use 'winget install WebCodr.jwtd' or Scoop)" ;; + esac +} + +detect_arch() { + machine=$(uname -m) + case "$machine" in + x86_64 | amd64) printf 'amd64\n' ;; + aarch64 | arm64) printf 'arm64\n' ;; + *) die "unsupported architecture: $machine (release binaries are built for amd64 and arm64)" ;; + esac +} + +# Under Rosetta 2 a translated shell reports x86_64, which would install the +# Intel binary on Apple silicon. sysctl.proc_translated is set only in that +# case, so it distinguishes translation from a genuine Intel Mac. +correct_rosetta_arch() { + if [ "$1" = "darwin" ] && [ "$2" = "amd64" ] && have sysctl && + [ "$(sysctl -n sysctl.proc_translated 2>/dev/null || printf '0\n')" = "1" ]; then + printf 'arm64\n' + else + printf '%s\n' "$2" + fi +} + +download() { + url=$1 + destination=$2 + if have curl; then + curl -fsSL --proto '=https' --tlsv1.2 -o "$destination" "$url" || + die "could not download $url (check the release tag and your network connection)" + elif have wget; then + wget -q --https-only -O "$destination" "$url" || + die "could not download $url (check the release tag and your network connection)" + else + die "neither curl nor wget is available; install one of them and re-run" + fi +} + +# verify_checksum matches the archive against its checksums.txt entry. The +# entry is selected by exact file name and written out verbatim so the +# checksum tool sees the original " " formatting. +verify_checksum() { + archive=$1 + if ! awk -v want="$archive" '$2 == want { print $0; found = 1 } END { exit !found }' \ + checksums.txt >"$archive.sha256"; then + die "checksums.txt has no entry for $archive" + fi + + if have sha256sum; then + sha256sum -c "$archive.sha256" >/dev/null || + die "checksum mismatch for $archive; refusing to install" + elif have shasum; then + shasum -a 256 -c "$archive.sha256" >/dev/null || + die "checksum mismatch for $archive; refusing to install" + else + die "neither sha256sum nor shasum is available; cannot verify the download" + fi + info "Checksum verified: $archive" +} + +# verify_signature is best-effort by design: cosign is not a dependency most +# machines have, and the checksum above already pins the archive bytes. When +# cosign is present the bundle is verified and a failure is fatal. +verify_signature() { + base_url=$1 + if ! have cosign; then + info "cosign not found - skipping signature verification (install cosign to verify the release signature)" + return + fi + + download "$base_url/checksums.txt.sigstore.json" checksums.txt.sigstore.json + cosign verify-blob \ + --bundle checksums.txt.sigstore.json \ + --certificate-identity-regexp "$CERTIFICATE_IDENTITY_REGEXP" \ + --certificate-oidc-issuer "$CERTIFICATE_OIDC_ISSUER" \ + checksums.txt >/dev/null 2>&1 || + die "cosign could not verify checksums.txt against the jwtd release workflow; refusing to install" + info "Signature verified: checksums.txt (cosign, keyless)" +} + +# report_path_hint keeps the installer honest about the one thing it cannot do +# for the user: ~/.local/bin is not on every PATH by default. +report_path_hint() { + directory=$1 + case ":$PATH:" in + *":$directory:"*) return ;; + esac + + warn "$directory is not on your PATH. Add it with one of:" + # $PATH stays literal here: the hint is a command for the user to run. + # shellcheck disable=SC2016 + printf ' bash/zsh: echo '\''export PATH="%s:$PATH"'\'' >> ~/.profile\n' "$directory" >&2 + printf ' fish: fish_add_path %s\n' "$directory" >&2 +} + +version=${JWTD_VERSION-} +install_dir=${JWTD_INSTALL_DIR-} + +while [ $# -gt 0 ]; do + case "$1" in + -v | --version) + [ $# -ge 2 ] || die "--version requires a release tag" + version=$2 + shift 2 + ;; + -d | --dir) + [ $# -ge 2 ] || die "--dir requires a path" + install_dir=$2 + shift 2 + ;; + -h | --help) + usage + exit 0 + ;; + *) + die "unknown option: $1 (run with --help for usage)" + ;; + esac +done + +[ -n "$install_dir" ] || install_dir="$HOME/.local/bin" +# The download happens from a temporary working directory, so a relative --dir +# has to be anchored to the caller's directory before that cd. +case "$install_dir" in +/*) ;; +*) install_dir="$PWD/$install_dir" ;; +esac + +os=$(detect_os) +arch=$(detect_arch) +arch=$(correct_rosetta_arch "$os" "$arch") +archive="jwtd-$os-$arch.tar.gz" + +if [ -n "$version" ]; then + case "$version" in + v*) ;; + *) version="v$version" ;; + esac + base_url="https://github.com/$REPO/releases/download/$version" + info "Installing jwtd $version ($os/$arch)" +else + base_url="https://github.com/$REPO/releases/latest/download" + info "Installing the latest jwtd release ($os/$arch)" +fi + +work_dir=$(mktemp -d 2>/dev/null || mktemp -d -t jwtd-install) +staged="" +trap 'rm -rf "$work_dir"; [ -z "$staged" ] || rm -f "$staged"' EXIT INT HUP TERM +cd "$work_dir" + +download "$base_url/$archive" "$archive" +download "$base_url/checksums.txt" checksums.txt +verify_checksum "$archive" +verify_signature "$base_url" + +tar -xzf "$archive" jwtd +[ -f jwtd ] || die "the release archive did not contain a jwtd binary" + +mkdir -p "$install_dir" || die "could not create $install_dir" + +# Copy into the target directory first, then rename within it. A cross-device +# mv would rewrite the destination in place, which fails with ETXTBSY when the +# running shell's jwtd is being upgraded; rename(2) inside one filesystem +# replaces the old binary atomically instead. +installed="$install_dir/jwtd" +staged="$install_dir/.jwtd.install.$$" +cp jwtd "$staged" || die "could not write to $install_dir (choose another directory with --dir)" +chmod 0755 "$staged" +mv -f "$staged" "$installed" || die "could not install into $install_dir (choose another directory with --dir)" +staged="" + +info "Installed $("$installed" --version 2>/dev/null || printf 'jwtd\n') to $installed" +report_path_hint "$install_dir" diff --git a/install_test.go b/install_test.go new file mode 100644 index 0000000..75fde98 --- /dev/null +++ b/install_test.go @@ -0,0 +1,236 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func readInstallScript(t *testing.T) string { + t.Helper() + data, err := os.ReadFile("install.sh") + if err != nil { + t.Fatalf("reading install.sh: %v", err) + } + return string(data) +} + +// runInstallScript executes install.sh with the given arguments and an +// optional directory prepended to PATH, so stubbed uname binaries can drive +// the platform detection without touching the network. +func runInstallScript(t *testing.T, pathPrefix string, args ...string) (string, error) { + t.Helper() + cmd := exec.Command("sh", append([]string{"install.sh"}, args...)...) + cmd.Env = append(os.Environ(), "JWTD_VERSION=", "JWTD_INSTALL_DIR=") + if pathPrefix != "" { + cmd.Env = append(cmd.Env, "PATH="+pathPrefix+string(os.PathListSeparator)+os.Getenv("PATH")) + } + output, err := cmd.CombinedOutput() + return string(output), err +} + +// stubUname writes a uname replacement reporting the given `uname -s` and +// `uname -m` values. +func stubUname(t *testing.T, kernel, machine string) string { + t.Helper() + dir := t.TempDir() + script := "#!/bin/sh\ncase \"$1\" in\n-s) echo " + kernel + " ;;\n-m) echo " + machine + " ;;\nesac\n" + if err := os.WriteFile(filepath.Join(dir, "uname"), []byte(script), 0o755); err != nil { + t.Fatalf("writing uname stub: %v", err) + } + return dir +} + +func TestInstallScriptContract(t *testing.T) { + info, err := os.Stat("install.sh") + if err != nil { + t.Fatalf("install.sh must exist: %v", err) + } + if info.Mode().Perm()&0o111 == 0 { + t.Errorf("install.sh must be executable, got mode %v", info.Mode().Perm()) + } + + script := readInstallScript(t) + if !strings.HasPrefix(script, "#!/bin/sh\n") { + t.Error("install.sh must run under POSIX sh, not bash: it is piped into whatever /bin/sh users have") + } + if !strings.Contains(script, "set -eu") { + t.Error("install.sh must set -eu so a failed step aborts instead of continuing") + } + + for label, required := range map[string]string{ + "default install directory": `install_dir="$HOME/.local/bin"`, + "repository": `REPO="webcodr/jwtd"`, + "latest release URL": "releases/latest/download", + "pinned release URL": "releases/download/$version", + "checksum file": "checksums.txt", + "cosign bundle": "checksums.txt.sigstore.json", + } { + if !strings.Contains(script, required) { + t.Errorf("install.sh is missing %s marker %q", label, required) + } + } + + // The installer writes into a user directory only. A sudo call would make + // piping it into a shell a privilege-escalation decision. + if strings.Contains(script, "sudo") { + t.Error("install.sh must never invoke sudo; it installs into a user-writable directory") + } +} + +// TestInstallScriptTargetsReleaseArchives pins the archive naming to +// .goreleaser.yaml. A rename there would otherwise leave the installer +// requesting assets no release publishes. +func TestInstallScriptTargetsReleaseArchives(t *testing.T) { + config, err := os.ReadFile(".goreleaser.yaml") + if err != nil { + t.Fatalf("reading .goreleaser.yaml: %v", err) + } + if !strings.Contains(string(config), `name_template: "jwtd-{{ .Os }}-{{ .Arch }}"`) { + t.Fatal(".goreleaser.yaml archive name template changed; install.sh builds asset names from it") + } + + script := readInstallScript(t) + if !strings.Contains(script, `archive="jwtd-$os-$arch.tar.gz"`) { + t.Error("install.sh must request jwtd--.tar.gz, matching the GoReleaser archive names") + } + // Only the four macOS/Linux targets are reachable; windows archives exist + // but are served by Scoop and WinGet. + for _, mapping := range []string{"Linux) printf 'linux\\n'", "Darwin) printf 'darwin\\n'"} { + if !strings.Contains(script, mapping) { + t.Errorf("install.sh is missing the OS mapping %q", mapping) + } + } + for _, mapping := range []string{"x86_64 | amd64) printf 'amd64\\n'", "aarch64 | arm64) printf 'arm64\\n'"} { + if !strings.Contains(script, mapping) { + t.Errorf("install.sh is missing the architecture mapping %q", mapping) + } + } +} + +// TestInstallScriptVerifiesBeforeInstalling holds down the property that makes +// a curl-into-shell installer defensible: nothing reaches the install +// directory before the archive has been checked against checksums.txt, and the +// Cosign identity is the one the release workflow signs with. +func TestInstallScriptVerifiesBeforeInstalling(t *testing.T) { + script := readInstallScript(t) + + verify := strings.Index(script, `verify_checksum "$archive"`) + if verify < 0 { + t.Fatal("install.sh must verify the downloaded archive against checksums.txt") + } + extract := strings.Index(script, `tar -xzf "$archive" jwtd`) + if extract < 0 { + t.Fatal("install.sh must extract the binary from the release archive") + } + stage := strings.Index(script, `cp jwtd "$staged"`) + if stage < 0 { + t.Fatal("install.sh must stage the binary inside the install directory before renaming it into place") + } + if verify > extract || verify > stage { + t.Error("install.sh must verify the checksum before extracting or installing the binary") + } + + readme, err := os.ReadFile("README.md") + if err != nil { + t.Fatalf("reading README.md: %v", err) + } + for label, identity := range map[string]string{ + "certificate identity": `^https://github.com/webcodr/jwtd/\.github/workflows/release\.yml@`, + "OIDC issuer": "https://token.actions.githubusercontent.com", + } { + if !strings.Contains(script, identity) { + t.Errorf("install.sh Cosign %s must be %q", label, identity) + } + if !strings.Contains(string(readme), identity) { + t.Errorf("README.md Cosign %s must stay %q so both document the same trust root", label, identity) + } + } + + // cosign is optional, but when present its verdict is fatal. + if !strings.Contains(script, `die "cosign could not verify checksums.txt`) { + t.Error("install.sh must abort when cosign is installed and verification fails") + } +} + +func TestInstallScriptRejectsUnsupportedPlatforms(t *testing.T) { + for name, testCase := range map[string]struct { + kernel string + machine string + want string + }{ + "unsupported kernel": {kernel: "SunOS", machine: "x86_64", want: "unsupported operating system"}, + "windows kernel": {kernel: "MINGW64_NT-10.0", machine: "x86_64", want: "unsupported operating system"}, + "unsupported architecture": {kernel: "Linux", machine: "mips64", want: "unsupported architecture"}, + } { + t.Run(name, func(t *testing.T) { + output, err := runInstallScript(t, stubUname(t, testCase.kernel, testCase.machine)) + if err == nil { + t.Fatalf("install.sh must fail on %s/%s, got output %q", testCase.kernel, testCase.machine, output) + } + if !strings.Contains(output, testCase.want) { + t.Errorf("install.sh must report %q for %s/%s, got %q", testCase.want, testCase.kernel, testCase.machine, output) + } + if strings.Contains(output, "Installing") { + t.Errorf("install.sh must reject the platform before downloading anything, got %q", output) + } + }) + } +} + +func TestInstallScriptUsage(t *testing.T) { + output, err := runInstallScript(t, "", "--help") + if err != nil { + t.Fatalf("install.sh --help must exit zero: %v (%s)", err, output) + } + for _, required := range []string{"--version", "--dir", "JWTD_VERSION", "JWTD_INSTALL_DIR", "~/.local/bin"} { + if !strings.Contains(output, required) { + t.Errorf("install.sh --help must document %q, got %q", required, output) + } + } + + if output, err := runInstallScript(t, "", "--not-a-flag"); err == nil { + t.Errorf("install.sh must reject unknown options, got %q", output) + } + if output, err := runInstallScript(t, "", "--dir"); err == nil { + t.Errorf("install.sh must reject --dir without a value, got %q", output) + } +} + +// TestInstallScriptPublication covers the delivery path: the script is hosted +// at https://jwtd.sh/install.sh by copying the repository copy into the Pages +// artifact, so the documented one-liner cannot drift from the reviewed file. +func TestInstallScriptPublication(t *testing.T) { + pages, err := os.ReadFile(filepath.Join(".github", "workflows", "pages.yml")) + if err != nil { + t.Fatalf("reading Pages workflow: %v", err) + } + if !strings.Contains(string(pages), "install -m 0755 install.sh site/install.sh") { + t.Error("Pages workflow must copy install.sh into the site artifact so jwtd.sh/install.sh serves it") + } + if !strings.Contains(string(pages), `- "install.sh"`) { + t.Error("Pages workflow must redeploy when install.sh changes") + } + + const oneLiner = "curl -fsSL https://jwtd.sh/install.sh | sh" + readme, err := os.ReadFile("README.md") + if err != nil { + t.Fatalf("reading README.md: %v", err) + } + if !strings.Contains(string(readme), oneLiner) { + t.Errorf("README.md must document %q", oneLiner) + } + + index, err := os.ReadFile(filepath.Join("site", "index.html")) + if err != nil { + t.Fatalf("reading site/index.html: %v", err) + } + for _, id := range []string{"macos-script-command", "linux-script-command"} { + block := `` + oneLiner + `` + if !strings.Contains(string(index), block) { + t.Errorf("site/index.html must offer the install script in the %s block: %q", id, block) + } + } +} diff --git a/site/index.html b/site/index.html index 97a9096..974842d 100644 --- a/site/index.html +++ b/site/index.html @@ -292,14 +292,31 @@

Install jwtd.

>

macOS

-

Homebrew formula

+

Install script or Homebrew

- Install the current release from the webcodr tap. Apple silicon - and Intel are both covered. + Install the current release without a package manager, or from + the webcodr tap. Apple silicon and Intel are both covered.

+

+ Install script — verified download into + ~/.local/bin +

+
curl -fsSL https://jwtd.sh/install.sh | sh
+
+ +
+
+
+

Homebrew

brew install webcodr/tap/jwtd
+
+

+ Install script — verified download into + ~/.local/bin +

+
curl -fsSL https://jwtd.sh/install.sh | sh
+
+ +
+

Homebrew — also works on macOS

brew install webcodr/tap/jwtd
@@ -942,7 +975,9 @@

Verifiable releases.

Release archives and Linux packages are listed in checksums.txt, which is signed with a keyless Cosign - bundle. Each archive also includes a Syft SPDX SBOM. + bundle. Each archive also includes a Syft SPDX SBOM. The install + script runs the same checksum check on every download, and the + Cosign check too when cosign is installed.