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
16 changes: 10 additions & 6 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ on:
paths:
- "site/**"
- "install.sh"
- "install.ps1"
- ".github/workflows/pages.yml"
workflow_dispatch:

Expand All @@ -29,12 +30,15 @@ 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
# The installers live at the repository root as the single source of
# truth and are copied into the Pages artifact so that
# https://jwtd.sh/install.sh and /install.ps1 serve them. They are never
# edited here, so the hosted scripts are byte-identical to the reviewed
# ones.
- name: Publish the install scripts at jwtd.sh
run: |
install -m 0755 install.sh site/install.sh
install -m 0644 install.ps1 site/install.ps1
- uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0
with:
path: site
Expand Down
89 changes: 89 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,95 @@ jobs:

(cd dist && sha256sum --check checksums.txt)

# install_test.go can only assert the shape of install.ps1; the behaviour it
# guards - archive naming, checksum verification, replacing a running binary,
# and the user PATH edit - needs a real Windows host. This job installs the
# latest published release the way a user would.
windows-installer:
runs-on: windows-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

- name: Check install.ps1 syntax
shell: pwsh
run: |
$errors = $null
[void][System.Management.Automation.Language.Parser]::ParseFile(
(Resolve-Path ./install.ps1), [ref]$null, [ref]$errors)
if ($errors) {
$errors | ForEach-Object { $_.ToString() }
exit 1
}

- name: Install the latest release
shell: pwsh
run: |
$dir = Join-Path $env:RUNNER_TEMP 'jwtd-install'
./install.ps1 -Dir $dir -NoModifyPath
& (Join-Path $dir 'jwtd.exe') --version

# The second run exercises the upgrade path: Windows cannot overwrite an
# existing .exe in place, so the installer renames it aside first and must
# clean up after itself.
- name: Reinstall over the existing binary
shell: pwsh
run: |
$dir = Join-Path $env:RUNNER_TEMP 'jwtd-install'
./install.ps1 -Dir $dir -NoModifyPath
$token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Imp3dGQifQ.JbLj7QQhCqlNvT-EOwJSeB4ArXQPoXlhBGhWSGD7-V4'
& (Join-Path $dir 'jwtd.exe') --json $token
$leftovers = @(Get-ChildItem -Path $dir -Force | Where-Object { $_.Name -ne 'jwtd.exe' })
if ($leftovers.Count -ne 0) {
"the installer left files behind: $($leftovers.Name -join ', ')"
exit 1
}

- name: Write nothing when the release cannot be fetched
shell: pwsh
run: |
$dir = Join-Path $env:RUNNER_TEMP 'jwtd-unavailable'
$failed = $false
try { ./install.ps1 -Dir $dir -NoModifyPath -Version v0.0.0-does-not-exist }
catch { $failed = $true }
if (-not $failed) { 'the installer must fail on an unavailable release'; exit 1 }
if (Test-Path (Join-Path $dir 'jwtd.exe')) {
'the installer must not write a binary when verification cannot pass'
exit 1
}

- name: Add to the user PATH without rewriting existing entries
shell: pwsh
run: |
$key = 'HKCU:\Environment'
$original = (Get-Item $key).GetValue(
'Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
try {
Set-ItemProperty -Path $key -Name 'Path' `
-Value '%USERPROFILE%\seeded;C:\already\there' -Type ExpandString
$dir = Join-Path $env:RUNNER_TEMP 'jwtd-path'
./install.ps1 -Dir $dir

$raw = (Get-Item $key).GetValue(
'Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
$entries = @($raw -split ';' | Where-Object { $_ -ne '' })
# The seeded entry must survive verbatim: an installer that expands
# it would bake this runner's profile path into the user's PATH.
if ($entries[0] -ne '%USERPROFILE%\seeded') {
"unexpanded PATH entry was rewritten: $raw"
exit 1
}
if ($entries -notcontains $dir) {
"install directory missing from PATH: $raw"
exit 1
}
if ((Get-Item $key).GetValueKind('Path') -ne 'ExpandString') {
'PATH must stay a REG_EXPAND_SZ value'
exit 1
}
} finally {
Set-ItemProperty -Path $key -Name 'Path' -Value $original -Type ExpandString
}

nix:
runs-on: ubuntu-latest
steps:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ dist/
completions/
# Copied from the repository root by the Pages workflow at build time.
site/install.sh
site/install.ps1
.worktrees/
result
result-*
15 changes: 13 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,16 +87,27 @@ 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 scripts

`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-<os>-<arch>.tar.gz`, the four darwin/linux × amd64/arm64 targets), downloads that archive plus `checksums.txt` from the latest release — or from `--version <tag>` — 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.
`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-<os>-<arch>.tar.gz`, the four darwin/linux × amd64/arm64 targets), downloads that archive plus `checksums.txt` from the latest release — or from `--version <tag>` — extracts the binary, and installs it into `~/.local/bin` (overridable with `--dir`/`JWTD_INSTALL_DIR`). Windows is deliberately unreachable from this script: it is served by `install.ps1`, 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.

`install.ps1` is the Windows counterpart, served at `https://jwtd.sh/install.ps1` (`irm https://jwtd.sh/install.ps1 | iex`), published by the same Pages step (`install -m 0644 install.ps1 site/install.ps1`) and git-ignored the same way. It keeps install.sh's contract — verify before writing anything, checksum always, a present `cosign` never advisory, the same certificate identity and issuer, no elevation — and differs only where Windows does:

- **It consumes the windows `.zip`, not the `.tar.gz`.** `Expand-Archive` ships with PowerShell 5.1; tar does not. The zips already exist for WinGet and are covered by the signed `checksums.txt`, so this adds no release artifact.
- **Errors `throw`, never `exit`.** The script is normally piped into `Invoke-Expression` in an interactive session, where `exit` would close the user's shell instead of aborting the installation. For the same reason `$ErrorActionPreference`/`$ProgressPreference` are set inside `Install-Jwtd` rather than at script scope: preference variables are dynamically scoped, and setting them at top level would leave them applied to the caller's session afterwards.
- **It edits the user PATH itself.** `install.sh` can only print a hint because it cannot know which shell profile to edit; Windows keeps the user PATH in one `HKCU:\Environment` value. It must be read with `DoNotExpandEnvironmentNames` and written back as `ExpandString` — `[Environment]::SetEnvironmentVariable` expands entries like `%USERPROFILE%` and writes the expanded text back as a plain string, silently rewriting parts of the PATH the installer never touched. `Publish-EnvironmentChange` broadcasts `WM_SETTINGCHANGE` (best-effort) so a newly opened terminal sees the change without a sign-out. `-NoModifyPath`/`JWTD_NO_MODIFY_PATH` opts out.
- **An upgrade renames the old binary aside.** Windows refuses to overwrite a running `.exe` but does allow renaming one, so the installer moves the installed binary to a temporary name, moves the new one into place, and then deletes the old file best-effort (the delete fails while an older `jwtd` is still running). This is the analogue of install.sh's stage-then-`rename(2)` handling of `ETXTBSY`.
- **Options come from environment variables.** `Invoke-Expression` cannot forward arguments, so `JWTD_VERSION`, `JWTD_INSTALL_DIR`, and `JWTD_NO_MODIFY_PATH` are the documented path; the `param()` block serves `& ([scriptblock]::Create((irm …))) -Version …`.
- **Architecture detection corrects for emulation.** An x64 PowerShell under emulation on an ARM64 machine reports X64, which would install the Intel binary; the machine-level `PROCESSOR_ARCHITECTURE` (and `PROCESSOR_ARCHITEW6432`) give the native architecture. This is the Rosetta check's counterpart.

`install_test.go` asserts the shape (archive naming against `.goreleaser.yaml`, verify-before-write ordering, the shared Cosign trust root, no `exit`, no elevation, the registry handling, the publication path); comment lines are stripped before the "must not call" assertions so a comment explaining why the script avoids an API cannot satisfy the check for it. The behaviour needs a real Windows host, so the `windows-installer` job in `.github/workflows/test.yml` installs the latest published release for real, reinstalls over it to exercise the upgrade path, checks that an unavailable release writes nothing, and verifies that the PATH edit adds the directory while leaving a seeded `%USERPROFILE%`-style entry verbatim and the value still `REG_EXPAND_SZ`.

### Open Graph card

`site/og.png` is the 1200×630 social card, rendered from `og/og.html` with headless Chromium at 2x and downsampled (the 2x pass is what keeps the small monospace text crisp). The source deliberately lives **outside** `site/`: the Pages artifact is that directory verbatim, so a generator kept there would be published as a page of its own. `og/og.html` carries the exact regeneration commands in a comment, and its palette is copied from `site/styles.css` — the two must be updated together, since nothing detects a card whose colors have drifted from the site.
Expand Down
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,30 @@ 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.
### Install script (Windows)

```powershell
irm https://jwtd.sh/install.ps1 | iex
```

The same contract as the Unix installer: the release zip for the detected architecture is verified against the release's `checksums.txt` — and against the keyless Cosign signature when `cosign` is on `PATH` — before anything is written. The binary is installed into `%LOCALAPPDATA%\Programs\jwtd`, which is added to your user `PATH`; no administrator privileges are required.

`Invoke-Expression` cannot forward arguments, so the options are environment variables:

```powershell
$env:JWTD_VERSION = 'v5.3.0' # pin a release
$env:JWTD_INSTALL_DIR = 'C:\tools' # install somewhere else
$env:JWTD_NO_MODIFY_PATH = '1' # leave PATH alone
irm https://jwtd.sh/install.ps1 | iex
```

To pass parameters directly instead, create the script block explicitly:

```powershell
& ([scriptblock]::Create((irm https://jwtd.sh/install.ps1))) -Version v5.3.0 -NoModifyPath
```

The script is [`install.ps1`](install.ps1) in this repository; review it before piping it into a shell.

### Homebrew (macOS and Linux)

Expand Down
Loading