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
212 changes: 212 additions & 0 deletions .github/workflows/upgrade-smoke.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
---
# Upgrade smoke: end-to-end proof that `basecamp upgrade` self-updates a
# stale binary against the REAL latest release — real download, real Sigstore
# verification, and (on Windows) the real locked-exe rename shuffle — plus
# installer legs pinning the cosign version→flag capability gate against the
# published bundle format.
name: Upgrade Smoke

on:
push:
branches: [main]
paths:
- "internal/commands/upgrade*"
- "internal/commands/doctor.go"
- "internal/version/**"
- "go.mod"
- "go.sum"
- ".goreleaser.yaml"
- "scripts/install.sh"
- "scripts/install.ps1"
- ".github/workflows/upgrade-smoke.yml"
pull_request:
paths:
- "internal/commands/upgrade*"
- "internal/commands/doctor.go"
- "internal/version/**"
- "go.mod"
- "go.sum"
- ".goreleaser.yaml"
- "scripts/install.sh"
- "scripts/install.ps1"
- ".github/workflows/upgrade-smoke.yml"

permissions: {}
Comment thread
jeremy marked this conversation as resolved.

concurrency:
group: upgrade-smoke-${{ github.ref }}
cancel-in-progress: true

jobs:
native-upgrade:
name: native self-update (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
permissions:
contents: read
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod

- name: Build a stale 0.0.1 binary under HOME
run: |
ext=""
[[ "$RUNNER_OS" == "Windows" ]] && ext=".exe"
mkdir -p "$HOME/bin"
go build \
-ldflags "-X github.com/basecamp/basecamp-cli/internal/version.Version=0.0.1" \
-o "$HOME/bin/basecamp$ext" ./cmd/basecamp
"$HOME/bin/basecamp$ext" --version

- name: Run basecamp upgrade against the real latest release
env:
BASECAMP_NO_KEYRING: "1"
# Anonymous api.github.com requests share the runner egress IP's
# rate limit; the CLI attaches this token to its releases/latest
# lookup (attachGitHubAuth) so the check can't 403.
GITHUB_TOKEN: ${{ github.token }}
run: |
ext=""
[[ "$RUNNER_OS" == "Windows" ]] && ext=".exe"
"$HOME/bin/basecamp$ext" upgrade

- name: Assert the installed binary reports the latest release
env:
GH_TOKEN: ${{ github.token }}
run: |
ext=""
[[ "$RUNNER_OS" == "Windows" ]] && ext=".exe"
latest=$(gh api repos/basecamp/basecamp-cli/releases/latest --jq .tag_name)
latest="${latest#v}"
got=$("$HOME/bin/basecamp$ext" --version | awk '{print $NF}')
echo "latest=$latest installed=$got"
test "$got" = "$latest"

installer-sh:
name: install.sh cosign ${{ matrix.cosign }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# sha256 values come from each cosign release's cosign_checksums.txt.
include:
- cosign: v3.0.5 # new bundle format is the default: bare verify-blob
sha256: db15cc99e6e4837daabab023742aaddc3841ce57f193d11b7c3e06c8003642b2
expect: verified
- cosign: v2.6.0 # floor for --new-bundle-format=true
sha256: ea5c65f99425d6cfbb5c4b5de5dac035f14d09131c1a0ea7c7fc32eab39364f9
expect: verified
- cosign: v2.4.0 # unsupported: warn + skip, install still succeeds
sha256: cd7636b3586a3bdac2d9c8f3b421ed119edcb20499107887fd929211110e8418
expect: skipped
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Install cosign ${{ matrix.cosign }} (digest-pinned)
run: |
curl -fsSL -o /tmp/cosign \
"https://github.com/sigstore/cosign/releases/download/${{ matrix.cosign }}/cosign-linux-amd64"
echo "${{ matrix.sha256 }} /tmp/cosign" | sha256sum -c -
chmod +x /tmp/cosign
sudo mv /tmp/cosign /usr/local/bin/cosign
cosign version

- name: Run install.sh against the latest release
env:
BASECAMP_BIN_DIR: /home/runner/.local/bin
BASECAMP_SKIP_SETUP: "1"
BASECAMP_SETUP_AGENT: none
BASECAMP_NO_KEYRING: "1"
run: |
set -o pipefail
bash scripts/install.sh | tee install.log

- name: Assert the signature verification path
run: |
if [[ "${{ matrix.expect }}" == "verified" ]]; then
grep -q "Signature verified" install.log
else
grep -q "Skipping signature verification" install.log
if grep -q "Signature verified" install.log; then
echo "must not claim the signature was verified" >&2
exit 1
fi
fi
grep -q "Checksum verified" install.log
"$HOME/.local/bin/basecamp" --version

installer-ps1:
name: install.ps1 cosign ${{ matrix.cosign }}
runs-on: windows-latest
strategy:
fail-fast: false
matrix:
# sha256 values come from each cosign release's cosign_checksums.txt.
include:
- cosign: v3.0.5
sha256: 44e9e44202b67ddfaaf5ea1234f5a265417960c4ae98c5b57c35bc40ba9dd714
expect: verified
- cosign: v2.6.0
sha256: 7beb4dd1e19a72c328bbf7c0d7342d744edbf5cbb082f227b2b76e04a21c16ef
expect: verified
- cosign: v2.4.0
sha256: 88f1addbae6bdd83ec2c067470c1f56b6d0d3ba35f49ad34603f2502cb2933f3
expect: skipped
permissions:
contents: read
defaults:
run:
shell: pwsh
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Install cosign ${{ matrix.cosign }} (digest-pinned)
run: |
$binDir = Join-Path $env:RUNNER_TEMP 'cosign-bin'
New-Item -ItemType Directory -Force -Path $binDir | Out-Null
$exe = Join-Path $binDir 'cosign.exe'
Invoke-WebRequest -UseBasicParsing `
-Uri "https://github.com/sigstore/cosign/releases/download/${{ matrix.cosign }}/cosign-windows-amd64.exe" `
-OutFile $exe
$actual = (Get-FileHash -Algorithm SHA256 -Path $exe).Hash.ToLowerInvariant()
if ($actual -ne '${{ matrix.sha256 }}') { throw "cosign digest mismatch: $actual" }
Add-Content $env:GITHUB_PATH $binDir

- name: Run install.ps1 against the latest release
env:
BASECAMP_SKIP_SETUP: "1"
BASECAMP_SETUP_AGENT: none
BASECAMP_NO_KEYRING: "1"
run: |
cosign version
& .\scripts\install.ps1 *>&1 | Tee-Object -FilePath install.log

- name: Assert the signature verification path
run: |
$log = Get-Content install.log -Raw
if ('${{ matrix.expect }}' -eq 'verified') {
if ($log -notmatch 'Signature verified') { throw 'expected cosign signature verification' }
} else {
if ($log -notmatch "Skipping signature verification") { throw 'expected the skip warning' }
if ($log -match 'Signature verified') { throw 'must not claim the signature was verified' }
}
if ($log -notmatch 'Checksum verified') { throw 'expected checksum verification' }
& "$env:USERPROFILE\bin\basecamp.exe" --version
4 changes: 4 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ signs:
args:
- sign-blob
- '--bundle=${signature}'
# Pin the published bundle format explicitly (protobuf, v0.3+json) so it
# no longer depends on the CI cosign default. Installers and `basecamp
# upgrade` verify this format; cosign v3 defaults to it anyway.
- '--new-bundle-format=true'
- '${artifact}'
- '--yes'
artifacts: checksum
Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,29 @@ go install github.com/basecamp/basecamp-cli/cmd/basecamp@latest

</details>

## Upgrading

```bash
basecamp upgrade
```

What happens depends on how the CLI was installed:

- **Installer script / tarball** (a binary under your home directory, e.g. `~/bin` or `~/.local/bin`): upgrades in place. The CLI downloads the release for your platform, verifies its Sigstore signature (the keyless `checksums.txt.bundle` published by the release pipeline, identity-pinned to the release workflow and tag) and SHA-256 checksum, swaps the executable transactionally, and confirms the installed binary reports the new version. On failure the previous binary is restored; in the worst case — restoration itself fails mid-swap — the error names the preserved backup file next to the binary so you can put it back by hand.
- **Homebrew / Scoop**: delegates to `brew upgrade --cask` / `scoop update`, then verifies the manager-installed binary actually reports the new version.
- **System packages** (apt/dnf/apk, AUR, Nix) and **`go install` builds**: never touched. `basecamp upgrade` exits nonzero with upgrade guidance for that install method (the exact command where it can be known, e.g. `go install`; otherwise which package manager to use).

`basecamp upgrade` exits 0 only when there is no update, or the update was applied *and confirmed*. Every other outcome is a structured failure (`"ok": false` in JSON) with one of these codes:

| Code | Meaning |
|---|---|
| `upgrade_required` | An update exists but the CLI won't apply it for this install method — the hint carries the right next step |
| `upgrade_incomplete` | The package manager exited 0 but the binary still reports the old version |
| `upgrade_unverified` | The upgrade may have worked, but the installed version could not be confirmed |
| `upgrade_failed` | The update check, download, signature/checksum verification, or executable swap failed — the previous binary remains installed (or the error names the preserved backup if restoration also failed) |

The install scripts verify release signatures when `cosign` is available: cosign v3 verifies the published bundle format as-is, v2.6+ is driven with `--new-bundle-format=true`, and older versions skip signature verification with a warning (SHA-256 checksums are always verified).

## Usage

```bash
Expand Down
10 changes: 10 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,16 @@ basecamp skill install
- No `replace` directives in go.mod
- `make release-check` passes (includes check, replace-check, vuln scan, race-test, surface compat)

## Release size budget

Stripped release binary ≤ 38 MiB; gzip-compressed binary ≤ 13 MiB (a proxy
for release-archive size), per platform. Baseline set when in-process
Sigstore/TUF verification landed for `basecamp upgrade` (v0.8.1 measured
24.4 MiB stripped / 8.0 MiB gzipped; the sigstore-go tree added ~9.7 MiB
stripped / ~3.2 MiB gzipped — accepted cost of verifying releases without a
cosign dependency). An increase beyond either bound needs explicit review of
what grew, not a budget bump. Enforcement is manual for now.

## CI secrets

**Repository secrets** (Settings → Secrets and variables → Actions):
Expand Down
Loading
Loading