diff --git a/.agents/skills/release-openspec/SKILL.md b/.agents/skills/release-openspec/SKILL.md index 19c03a6c03..c9f414daa6 100644 --- a/.agents/skills/release-openspec/SKILL.md +++ b/.agents/skills/release-openspec/SKILL.md @@ -146,7 +146,7 @@ After the Version Packages PR merges: - `npm view @fission-ai/openspec@ version` - remote tag `v` points at the expected commit; - `gh release view v` exists and is not a prerelease. - - The release's assets include the 8 `openspec-*` standalone binaries and `SHA256SUMS`, attached by the Binaries workflow (`binaries.yml`) about 10 minutes after the release is published. Until `SHA256SUMS` is there, the install scripts keep installing the previous release, so users are never left without one. + - The release's assets include the 8 `openspec-*` standalone archives (`.tar.gz`, `.zip` on Windows) and `SHA256SUMS`, attached by the Binaries workflow (`binaries.yml`) about 10 minutes after the release is published. Until `SHA256SUMS` is there, the install scripts keep installing the previous release, so users are never left without one. 3. If only some artifacts exist, report partial state and resume verification before retrying any publish action. Never republish a version already on npm. Missing binaries never need an npm action: re-run `binaries.yml` with `workflow_dispatch` and the release tag to attach them. 4. Once all artifacts exist, read [references/release-notes.md](references/release-notes.md), polish the GitHub Release, and verify the saved title/body. diff --git a/.changeset/install-archives.md b/.changeset/install-archives.md new file mode 100644 index 0000000000..6225edb521 --- /dev/null +++ b/.changeset/install-archives.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +Install from openspec.dev: `curl -fsSL https://openspec.dev/cli/install.sh | sh` (Windows: `irm https://openspec.dev/cli/install.ps1 | iex`). Release files are now `.tar.gz` archives (`.zip` on Windows). diff --git a/.github/workflows/binaries.yml b/.github/workflows/binaries.yml index 22cfec30fc..00ac817de9 100644 --- a/.github/workflows/binaries.yml +++ b/.github/workflows/binaries.yml @@ -65,10 +65,24 @@ jobs: echo "::error::package.json version $version does not match tag $REF" exit 1 } - - name: Checksums and installer + # Users download archives: .tar.gz, or .zip on Windows. The bare + # binaries stay in dist-bin for the tests below. + - name: Archives, checksums and installer run: | cd dist-bin - sha256sum openspec-* > SHA256SUMS + for bin in openspec-*; do + name=${bin%.exe} + rm -rf pack && mkdir pack && cp ../LICENSE pack/ + if [ "$bin" != "$name" ]; then + cp "$bin" pack/openspec.exe + (cd pack && zip -q "../$name.zip" openspec.exe LICENSE) + else + cp "$bin" pack/openspec && chmod 755 pack/openspec + tar -czf "$name.tar.gz" -C pack openspec LICENSE + fi + done + rm -rf pack + sha256sum openspec-*.tar.gz openspec-*.zip > SHA256SUMS cp ../scripts/binary/install.sh ../scripts/binary/install.ps1 . cat SHA256SUMS - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -196,7 +210,7 @@ jobs: foreach ($tag in 'v1.1.0', 'v1.2.0') { $d = Join-Path $site "releases/download/$tag" New-Item -ItemType Directory -Force -Path $d | Out-Null - Copy-Item "dist-bin/openspec-$env:ASSET.exe" $d + Copy-Item "dist-bin/openspec-$env:ASSET.zip" $d if ($tag -eq 'v1.1.0') { Copy-Item dist-bin/SHA256SUMS $d } } Set-Content -Path (Join-Path $site 'releases.atom') -Value ('' + (('v1.2.0', 'v1.1.0' | ForEach-Object { "" }) -join '') + '') @@ -283,13 +297,15 @@ jobs: # SHA256SUMS goes last: installers treat a release that has it as # complete and keep installing the previous one until then. The # installers themselves are served from main, not from releases. - gh release upload "$tag" openspec-* --clobber --repo "$GITHUB_REPOSITORY" + gh release upload "$tag" openspec-*.tar.gz openspec-*.zip --clobber --repo "$GITHUB_REPOSITORY" gh release upload "$tag" SHA256SUMS --clobber --repo "$GITHUB_REPOSITORY" - # Signed proof each binary was built by this workflow from this tag, which + # Signed proof each archive was built by this workflow from this tag, which # anyone can check with `gh attestation verify`. The binaries are not # notarized or code-signed yet, so this is the verifiable trail meanwhile. # After the upload, so a first-time problem here never holds binaries back. - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 continue-on-error: true with: - subject-path: 'dist-bin/openspec-*' + subject-path: | + dist-bin/openspec-*.tar.gz + dist-bin/openspec-*.zip diff --git a/README.md b/README.md index ce794e0949..22279aca46 100644 --- a/README.md +++ b/README.md @@ -117,18 +117,16 @@ Solo, OpenSpec keeps you and your AI honest on a single repo. On a team, the har ## Quick Start -Install OpenSpec. It is a single file and needs no Node.js (on Alpine Linux, first run `apk add libstdc++ libgcc`). - -macOS and Linux: +Install OpenSpec. On macOS or Linux, in your terminal: ```bash -curl -fsSL https://raw.githubusercontent.com/Fission-AI/OpenSpec/main/scripts/binary/install.sh | sh +curl -fsSL https://openspec.dev/cli/install.sh | sh ``` -Windows (PowerShell): +On Windows, in PowerShell: ```powershell -irm https://raw.githubusercontent.com/Fission-AI/OpenSpec/main/scripts/binary/install.ps1 | iex +irm https://openspec.dev/cli/install.ps1 | iex ``` Prefer a package manager? `npm install -g @fission-ai/openspec@latest` (needs Node.js 20.19.0 or higher) or the official [Homebrew formula](https://formulae.brew.sh/formula/openspec), `brew install openspec`. diff --git a/docs-lab/reference/cli.md b/docs-lab/reference/cli.md index 982acf456d..ef7e7a700d 100644 --- a/docs-lab/reference/cli.md +++ b/docs-lab/reference/cli.md @@ -2329,7 +2329,7 @@ For the standalone install, the update command is `openspec install`. ## openspec install -Installs the standalone `openspec`, a single file that runs without Node.js. Run it from an npm or Homebrew install to switch, or from the standalone install to upgrade it in place. +Installs the standalone `openspec`. Run it from an npm or Homebrew install to switch, or from the standalone install to upgrade it in place. ```bash openspec install # newest release diff --git a/docs-lab/start/installation.md b/docs-lab/start/installation.md index 1e228cf821..b367cdf169 100644 --- a/docs-lab/start/installation.md +++ b/docs-lab/start/installation.md @@ -5,55 +5,19 @@ ## Install -OpenSpec installs as a single `openspec` file. It needs no Node.js. - On macOS or Linux, in your terminal: ```bash -curl -fsSL https://raw.githubusercontent.com/Fission-AI/OpenSpec/main/scripts/binary/install.sh | sh +curl -fsSL https://openspec.dev/cli/install.sh | sh ``` On Windows, in PowerShell: ```powershell -irm https://raw.githubusercontent.com/Fission-AI/OpenSpec/main/scripts/binary/install.ps1 | iex -``` - -- **Where it goes**: `~/.local/bin`; on Windows, `%LOCALAPPDATA%\Programs\openspec`, which it adds to your user PATH. -- **What it checks**: it picks the file for your machine and verifies it against the release's `SHA256SUMS` before installing. -- **Which release**: the newest one whose files are all attached. For the few minutes after a release is published while its files upload, it installs the previous one and says so. -- **Alpine Linux**: run `apk add libstdc++ libgcc` first. -- **Options**: set `OPENSPEC_INSTALL_DIR` to install elsewhere, or `OPENSPEC_VERSION=v1.15.0` to pin a release. - -If the installer says the folder isn't on your PATH, add it as it shows. Then [check it worked](#check-it-worked). - -The workflow itself runs inside an AI coding tool: Claude Code, Cursor, or any other tool on the [supported list](../reference/supported-tools.md). - -### Download it yourself - -Each [release](https://github.com/Fission-AI/OpenSpec/releases/latest) has one file per platform: - -| Platform | File | -|---|---| -| macOS 13+ (Apple silicon / Intel) | `openspec-darwin-arm64` / `openspec-darwin-x64` | -| Linux, glibc 2.17+ (arm64 / x64) | `openspec-linux-arm64` / `openspec-linux-x64` | -| Alpine 3.17+ (arm64 / x64) | `openspec-linux-arm64-musl` / `openspec-linux-x64-musl` | -| Windows 10+ (arm64 / x64) | `openspec-windows-arm64.exe` / `openspec-windows-x64.exe` | - -Rename it to `openspec` (`openspec.exe` on Windows), run `chmod +x openspec` on macOS or Linux, and put it in a folder on your PATH. - -> [!NOTE] -> macOS blocks a file downloaded in a browser until you run `xattr -d com.apple.quarantine openspec`. The install command above doesn't need this. - -### Verify a download - -Each release binary comes with a signed record of the workflow run that built it. With the [GitHub CLI](https://cli.github.com), in your terminal: - -```bash -gh attestation verify openspec-darwin-arm64 --repo Fission-AI/OpenSpec +irm https://openspec.dev/cli/install.ps1 | iex ``` -It confirms the file is the one that release's workflow built, or fails. This is optional; the installer already checks every download against `SHA256SUMS`. +If the installer prints an `export PATH=...` line, run it. Then [check it worked](#check-it-worked). ## Install with your AI assistant @@ -165,6 +129,56 @@ If that prints a version number, the CLI is on your PATH. It installs once per m Next, [set up your project](setup.md). If your assistant already ran init, that page shows what it wrote and how to adjust it. +## Install options + +To pick a version or a folder, set a variable before `sh`: + +```bash +curl -fsSL https://openspec.dev/cli/install.sh | OPENSPEC_VERSION=v1.15.0 sh +``` + +- **`OPENSPEC_VERSION`**: the release to install, such as `v1.15.0`. The default is the newest release. +- **`OPENSPEC_INSTALL_DIR`**: the folder to install to. The default is `~/.local/bin` (Windows: `%LOCALAPPDATA%\Programs\openspec`). + +On Windows, set the variable first: + +```powershell +$env:OPENSPEC_VERSION = 'v1.15.0' +irm https://openspec.dev/cli/install.ps1 | iex +``` + +On Alpine Linux, run `apk add libstdc++ libgcc` before installing. + +### Download by hand + +Each release has one file per computer: + +| Computer | File | +|---|---| +| Mac, Apple silicon | `openspec-darwin-arm64.tar.gz` | +| Mac, Intel | `openspec-darwin-x64.tar.gz` | +| Linux, arm64 | `openspec-linux-arm64.tar.gz` | +| Linux, x64 | `openspec-linux-x64.tar.gz` | +| Alpine, arm64 | `openspec-linux-arm64-musl.tar.gz` | +| Alpine, x64 | `openspec-linux-x64-musl.tar.gz` | +| Windows, arm64 | `openspec-windows-arm64.zip` | +| Windows, x64 | `openspec-windows-x64.zip` | + +1. **Download** your file from the [latest release](https://github.com/Fission-AI/OpenSpec/releases/latest). Files are attached a few minutes after a release is published. If yours isn't listed yet, wait and reload, or take it from the previous release. +2. **Unpack** it. In your terminal: `tar -xzf openspec-darwin-arm64.tar.gz`. On Windows, right-click the `.zip` and choose Extract All. +3. **On macOS**, run `xattr -d com.apple.quarantine openspec` in the folder you unpacked to. macOS blocks files downloaded in a browser until you do. +4. **Move** `openspec` into a folder on your PATH, such as `~/.local/bin`. On Windows, move `openspec.exe` into a folder on your user PATH, such as `%LOCALAPPDATA%\Programs\openspec`. + +### Verify a download + +To check that a file was built by the OpenSpec release workflow, run this with the [GitHub CLI](https://cli.github.com): + +```bash +gh attestation verify openspec-darwin-arm64.tar.gz --repo Fission-AI/OpenSpec +``` + +It fails if the workflow didn't build the file. The install command already checks each download against the release's `SHA256SUMS`, so this step is optional. + ## Switch from npm to the standalone install Switching is optional; npm keeps working. Your settings and your projects carry over; only the program changes. diff --git a/install.md b/install.md index 51e73bacc4..936368cfaf 100644 --- a/install.md +++ b/install.md @@ -21,18 +21,18 @@ TODO: ## 1. Install -Run `openspec --version` first. If it already prints a version, the CLI is installed: say so, run `openspec version --check` (it prints the update command for how OpenSpec was installed when it can tell; otherwise update it the way it was installed, e.g. `brew upgrade openspec`), offer to run that command, and skip ahead to step 3. If it was installed with npm or another package manager, mention once that `openspec install` switches to the standalone install, which runs without Node.js. Only offer it: the user may have chosen npm on purpose, so don't repeat it or switch unless they ask. Never switch an install to a different method on your own; that leaves two copies competing on PATH. +Run `openspec --version` first. If it already prints a version, the CLI is installed: say so, run `openspec version --check` (it prints the update command for how OpenSpec was installed when it can tell; otherwise update it the way it was installed, e.g. `brew upgrade openspec`), offer to run that command, and skip ahead to step 3. If it was installed with npm or another package manager, mention once that `openspec install` switches to the standalone install. Only offer it: the user may have chosen npm on purpose, so don't repeat it or switch unless they ask. Never switch an install to a different method on your own; that leaves two copies competing on PATH. -Install the standalone OpenSpec binary. It is a single file and needs no Node.js (on Alpine Linux, `apk add libstdc++ libgcc` first). Show the command for the user's OS and run it only once they confirm, then skip to step 3: +Install the standalone OpenSpec binary (on Alpine Linux, `apk add libstdc++ libgcc` first). Show the command for the user's OS and run it only once they confirm, then skip to step 3: ```bash # macOS / Linux -curl -fsSL https://raw.githubusercontent.com/Fission-AI/OpenSpec/main/scripts/binary/install.sh | sh +curl -fsSL https://openspec.dev/cli/install.sh | sh ``` ```powershell # Windows (PowerShell) -irm https://raw.githubusercontent.com/Fission-AI/OpenSpec/main/scripts/binary/install.ps1 | iex +irm https://openspec.dev/cli/install.ps1 | iex ``` If the user would rather use a package manager, or the installer fails, continue with step 2. diff --git a/scripts/README.md b/scripts/README.md index 2d1d82842f..92e16b188c 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -75,8 +75,9 @@ Validates package version consistency before publishing. Builds the standalone `openspec` executables (no Node.js needed), the default way users install OpenSpec. `.github/workflows/binaries.yml` cross-compiles -every asset on Linux, tests each on its own OS and CPU, and attaches them, -`SHA256SUMS`, `install.sh` and `install.ps1` to each published release. +every asset on Linux, tests each on its own OS and CPU, and attaches them to +each published release as `.tar.gz` archives (`.zip` on Windows) with +`SHA256SUMS`. The installers are served from main. | File | What it is | |---|---| diff --git a/scripts/binary/install.ps1 b/scripts/binary/install.ps1 index 685fb7e9b5..eb058c9dcf 100644 --- a/scripts/binary/install.ps1 +++ b/scripts/binary/install.ps1 @@ -1,6 +1,6 @@ # Installs the standalone `openspec.exe` (no Node.js needed) on Windows. # -# irm https://raw.githubusercontent.com/Fission-AI/OpenSpec/main/scripts/binary/install.ps1 | iex +# irm https://openspec.dev/cli/install.ps1 | iex # # $env:OPENSPEC_VERSION release to install (default: the newest complete one), e.g. v1.15.0 # $env:OPENSPEC_INSTALL_DIR where to put it (default: %LOCALAPPDATA%\Programs\openspec) @@ -84,19 +84,36 @@ $base = "$releases/download/$version" } - Write-Host "Downloading $asset ($version)..." try { - Invoke-WebRequest -UseBasicParsing -Uri "$base/$asset" -OutFile $exe if (-not (Test-Path $sums)) { Invoke-WebRequest -UseBasicParsing -Uri "$base/SHA256SUMS" -OutFile $sums } } catch { throw "openspec install: could not download from ${base}: $($_.Exception.Message). $npmHint" } - $line = Get-Content $sums | Where-Object { ($_ -split '\s+')[1] -eq $asset } | Select-Object -First 1 - if (-not $line) { throw "openspec install: $asset is not listed in SHA256SUMS" } - $expected = ($line -split '\s+')[0] - $actual = (Get-FileHash -Algorithm SHA256 -Path $exe).Hash - if ($actual -ne $expected) { throw "openspec install: checksum mismatch for $asset; refusing to install" } + # Releases ship a .zip; v1.15.0 and earlier, the bare .exe. + $lines = Get-Content $sums + $file = $null + $expected = $null + foreach ($candidate in "$($asset -replace '\.exe$', '.zip')", $asset) { + $line = $lines | Where-Object { ($_ -split '\s+')[1] -eq $candidate } | Select-Object -First 1 + if ($line) { $file = $candidate; $expected = ($line -split '\s+')[0]; break } + } + if (-not $file) { throw "openspec install: $asset is not listed in SHA256SUMS" } + + Write-Host "Downloading $file ($version)..." + $download = Join-Path $tmp $file + try { + Invoke-WebRequest -UseBasicParsing -Uri "$base/$file" -OutFile $download + } catch { + throw "openspec install: could not download from ${base}: $($_.Exception.Message). $npmHint" + } + $actual = (Get-FileHash -Algorithm SHA256 -Path $download).Hash + if ($actual -ne $expected) { throw "openspec install: checksum mismatch for $file; refusing to install" } + if ($file -ne $asset) { + $unpacked = Join-Path $tmp 'unpacked' + Expand-Archive -Path $download -DestinationPath $unpacked + Move-Item -Path (Join-Path $unpacked 'openspec.exe') -Destination $exe + } $installed = $null # A binary that cannot start at all throws instead of setting an exit code. diff --git a/scripts/binary/install.sh b/scripts/binary/install.sh index 956b5088cf..4d9b248f38 100644 --- a/scripts/binary/install.sh +++ b/scripts/binary/install.sh @@ -1,7 +1,7 @@ #!/bin/sh # Installs the standalone `openspec` binary (no Node.js needed) on Linux or macOS. # -# curl -fsSL https://raw.githubusercontent.com/Fission-AI/OpenSpec/main/scripts/binary/install.sh | sh +# curl -fsSL https://openspec.dev/cli/install.sh | sh # # OPENSPEC_VERSION release to install (default: the newest complete one), e.g. v1.15.0 # OPENSPEC_INSTALL_DIR where to put it (default: ~/.local/bin) @@ -46,7 +46,7 @@ main() { case "$(uname -s)" in Linux) os=linux ;; Darwin) os=darwin ;; - *) fail "unsupported OS $(uname -s). On Windows, download openspec-windows-x64.exe from the release page." ;; + *) fail "unsupported OS $(uname -s). On Windows, use install.ps1." ;; esac case "$(uname -m)" in x86_64 | amd64) arch=x64 ;; @@ -138,21 +138,33 @@ main() { base="$releases/download/$version" fi - echo "Downloading $asset ($version)..." - download "$base/$asset" "$tmp/$asset" || fail "could not download $base/$asset; $npm_hint" if [ ! -s "$tmp/SHA256SUMS" ]; then download "$base/SHA256SUMS" "$tmp/SHA256SUMS" || fail "could not download $base/SHA256SUMS; $npm_hint" fi - expected=$(awk -v f="$asset" '$2 == f { print $1 }' "$tmp/SHA256SUMS") + # Releases ship a .tar.gz; v1.15.0 and earlier, the bare binary. + file="$asset.tar.gz" + expected=$(awk -v f="$file" '$2 == f { print $1 }' "$tmp/SHA256SUMS") + if [ -z "$expected" ]; then + file=$asset + expected=$(awk -v f="$file" '$2 == f { print $1 }' "$tmp/SHA256SUMS") + fi [ -n "$expected" ] || fail "$asset is not listed in SHA256SUMS" + + echo "Downloading $file ($version)..." + download "$base/$file" "$tmp/$file" || fail "could not download $base/$file; $npm_hint" if command -v sha256sum >/dev/null 2>&1; then - actual=$(sha256sum "$tmp/$asset" | awk '{ print $1 }') + actual=$(sha256sum "$tmp/$file" | awk '{ print $1 }') else - actual=$(shasum -a 256 "$tmp/$asset" | awk '{ print $1 }') + actual=$(shasum -a 256 "$tmp/$file" | awk '{ print $1 }') fi - [ "$actual" = "$expected" ] || fail "checksum mismatch for $asset; refusing to install" + [ "$actual" = "$expected" ] || fail "checksum mismatch for $file; refusing to install" + if [ "$file" != "$asset" ]; then + mkdir "$tmp/unpacked" + tar -xzf "$tmp/$file" -C "$tmp/unpacked" openspec || fail "could not unpack $file" + mv "$tmp/unpacked/openspec" "$tmp/$asset" + fi chmod 755 "$tmp/$asset" if ! installed=$("$tmp/$asset" --version); then case "$asset" in diff --git a/test/binary-installer.test.ts b/test/binary-installer.test.ts index fc26f6f6e4..ac532dc491 100644 --- a/test/binary-installer.test.ts +++ b/test/binary-installer.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { execFile } from 'node:child_process'; +import { execFile, execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import fs from 'node:fs'; import http from 'node:http'; @@ -25,15 +25,28 @@ describe.skipIf(process.platform === 'win32')('standalone installer (install.sh) let server: http.Server; let base: string; - /** A release whose files are attached; `complete: false` leaves out SHA256SUMS, as mid-upload. */ - function release(site: string, tag: string, options: { complete?: boolean; tamper?: boolean } = {}) { + /** + * A release whose files are attached, as .tar.gz archives (`bare: true`: bare + * binaries, as v1.15.0 and earlier). `complete: false` leaves out SHA256SUMS, + * as mid-upload. + */ + function release(site: string, tag: string, options: { complete?: boolean; tamper?: boolean; bare?: boolean } = {}) { const dir = path.join(root, site, 'releases', 'download', tag); fs.mkdirSync(dir, { recursive: true }); const sums: string[] = []; for (const asset of ASSETS) { const body = `#!/bin/sh\necho ${tag.replace(/^v/, '')}\n`; - fs.writeFileSync(path.join(dir, asset), options.tamper ? `${body}# changed\n` : body); - sums.push(`${createHash('sha256').update(body).digest('hex')} ${asset}`); + let file = asset; + if (options.bare) { + fs.writeFileSync(path.join(dir, file), body); + } else { + file = `${asset}.tar.gz`; + const pack = fs.mkdtempSync(path.join(root, 'pack-')); + fs.writeFileSync(path.join(pack, 'openspec'), body); + execFileSync('tar', ['-czf', path.join(dir, file), '-C', pack, 'openspec']); + } + sums.push(`${createHash('sha256').update(fs.readFileSync(path.join(dir, file))).digest('hex')} ${file}`); + if (options.tamper) fs.appendFileSync(path.join(dir, file), '# changed\n'); } if (options.complete !== false) fs.writeFileSync(path.join(dir, 'SHA256SUMS'), sums.join('\n') + '\n'); } @@ -109,6 +122,9 @@ describe.skipIf(process.platform === 'win32')('standalone installer (install.sh) release('noise', 'v1.3.0-beta.1'); release('noise', 'v1.2.0'); + feed('bare', ['v1.15.0']); + release('bare', 'v1.15.0', { bare: true }); + feed('tampered', ['v1.2.0']); release('tampered', 'v1.2.0', { tamper: true }); }); @@ -152,6 +168,12 @@ describe.skipIf(process.platform === 'win32')('standalone installer (install.sh) expect(result.installed).toContain('echo 1.2.0'); }); + it('installs a release that attached bare binaries instead of archives', async () => { + const result = await install('bare'); + expect(result.code, result.out).toBe(0); + expect(result.installed).toContain('echo 1.15.0'); + }); + it('refuses a file that does not match SHA256SUMS', async () => { const result = await install('tampered'); expect(result.code).not.toBe(0);