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
140 changes: 140 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,56 @@ jobs:
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}

- name: Verify Windows signing secrets
if: github.repository == 'basecamp/basecamp-cli'
run: |
missing=()
for var in SM_API_KEY SM_CLIENT_CERT_FILE_B64 SM_CLIENT_CERT_PASSWORD; do
if [ -z "${!var}" ]; then
missing+=("$var")
fi
done
if [ ${#missing[@]} -gt 0 ]; then
echo "::error::Missing Windows signing secrets: ${missing[*]}"
exit 1
fi
echo "All Windows signing secrets present"
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }}

- name: Set up Java for jsign
if: github.repository == 'basecamp/basecamp-cli'
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with:
distribution: temurin
java-version: '21'

# Guarded like the preflight above: on forks this must not run at all —
# decoding an empty secret would export a nonempty SM_CLIENT_CERT_FILE,
# which the wrapper correctly treats as partial configuration and fails.
# With no SM_* env exported, forks take the wrapper's skip path instead
# and release unsigned.
- name: Prepare Windows signing
if: github.repository == 'basecamp/basecamp-cli'
env:
SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
Comment thread
jeremy marked this conversation as resolved.
run: |
# jsign >= 7.5 is mandatory: 7.1-7.3 are broken against DigiCert
# ONE's current API (bc3-desktop commit 59d6453).
JSIGN_VERSION=7.5
JSIGN_SHA256=602a51c3545a6dc4fb99bd2ea7152b26d1345916d0c93ddfbd5936cb735af91c
umask 077
curl -fsSL --retry 3 -o "$RUNNER_TEMP/jsign-$JSIGN_VERSION.jar" \
"https://github.com/ebourg/jsign/releases/download/$JSIGN_VERSION/jsign-$JSIGN_VERSION.jar"
echo "$JSIGN_SHA256 $RUNNER_TEMP/jsign-$JSIGN_VERSION.jar" | sha256sum -c -
printf '%s' "$SM_CLIENT_CERT_FILE_B64" | base64 -d > "$RUNNER_TEMP/digicert-client-cert.p12"
{
echo "JSIGN_JAR=$RUNNER_TEMP/jsign-$JSIGN_VERSION.jar"
echo "SM_CLIENT_CERT_FILE=$RUNNER_TEMP/digicert-client-cert.p12"
Comment thread
jeremy marked this conversation as resolved.
} >> "$GITHUB_ENV"

- name: Install GoReleaser
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
with:
Expand All @@ -246,6 +296,14 @@ jobs:
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
# Windows Authenticode signing via DigiCert KeyLocker (jsign).
# JSIGN_JAR and SM_CLIENT_CERT_FILE arrive via GITHUB_ENV from the
# "Prepare Windows signing" step above.
SM_API_KEY: ${{ secrets.SM_API_KEY }}
SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }}
# DigiCert ONE certificate ID (not the keypair alias) for the OV cert
# CN=37signals LLC, expires 2027-04-30. Keep in sync with bc3-desktop.
SIGN_ALIAS: 1346fa41-d9f0-4580-b7b9-a95cf5674354
run: |
RELEASE_CHANGELOG=""
if [ -n "$CHANGELOG_FILE" ] && [ -f "$CHANGELOG_FILE" ]; then
Expand All @@ -254,6 +312,10 @@ jobs:
export RELEASE_CHANGELOG
goreleaser release --clean

- name: Clean up Windows signing material
if: always()
run: rm -f "$RUNNER_TEMP/digicert-client-cert.p12" "$RUNNER_TEMP"/jsign-*.jar

- name: Attest build provenance
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
Expand Down Expand Up @@ -338,6 +400,84 @@ jobs:
echo "::warning::Notarization not yet accepted for ${{ matrix.arch }} (ticket propagation may lag)"
fi

windows-verify:
name: Verify Windows signing
needs: [release]
if: startsWith(github.ref, 'refs/tags/v')
runs-on: windows-latest
timeout-minutes: 10
permissions:
contents: read
strategy:
matrix:
arch: [amd64, arm64]
steps:
- name: Download release binary
env:
GH_TOKEN: ${{ github.token }}
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path "$env:RUNNER_TEMP\verify" | Out-Null
gh release download $env:GITHUB_REF_NAME `
--repo $env:GITHUB_REPOSITORY `
--pattern "basecamp_*_windows_${{ matrix.arch }}.zip" `
--dir "$env:RUNNER_TEMP\verify"
if ($LASTEXITCODE -ne 0) { throw 'gh release download failed' }
$zip = Get-ChildItem "$env:RUNNER_TEMP\verify\basecamp_*_windows_${{ matrix.arch }}.zip"
Expand-Archive -Path $zip.FullName -DestinationPath "$env:RUNNER_TEMP\verify\extract" -Force

- name: Verify Authenticode signature
shell: pwsh
run: |
# Signature validation of an arm64 PE needs no execution, so both
# arches verify on the x64 runner.
$sig = Get-AuthenticodeSignature "$env:RUNNER_TEMP\verify\extract\basecamp.exe"
Write-Host "Status (${{ matrix.arch }}): $($sig.Status) - $($sig.StatusMessage)"
Write-Host "Signer: $($sig.SignerCertificate.Subject)"
if ($sig.Status -ne 'Valid') {
Write-Host "::error::Authenticode status (${{ matrix.arch }}): $($sig.Status) - $($sig.StatusMessage)"
exit 1
}
if ($sig.SignerCertificate.Subject -notmatch 'CN=37signals LLC') {
Write-Host "::error::Unexpected signer (${{ matrix.arch }}): $($sig.SignerCertificate.Subject)"
exit 1
}
if (-not $sig.TimeStamperCertificate) {
Write-Host "::error::No timestamp countersignature (${{ matrix.arch }}) - signature would expire with the cert on 2027-04-30"
exit 1
}
Write-Host "Signature valid, signer and timestamp verified (${{ matrix.arch }})"

- name: Download installer asset
if: matrix.arch == 'amd64'
env:
GH_TOKEN: ${{ github.token }}
shell: pwsh
run: |
gh release download $env:GITHUB_REF_NAME `
--repo $env:GITHUB_REPOSITORY `
--pattern 'basecamp_installer.ps1' `
--dir "$env:RUNNER_TEMP\verify"
if ($LASTEXITCODE -ne 0) { throw 'gh release download failed' }

# Both engines: confirms jsign's ps1 content-hash encoding matches what
# PowerShell computes, on Core and on Windows PowerShell 5.1.
- name: Verify installer signature (pwsh)
if: matrix.arch == 'amd64'
shell: pwsh
run: |
$sig = Get-AuthenticodeSignature "$env:RUNNER_TEMP\verify\basecamp_installer.ps1"
Write-Host "Installer status (pwsh): $($sig.Status) - $($sig.StatusMessage)"
if ($sig.Status -ne 'Valid') { exit 1 }

- name: Verify installer signature (Windows PowerShell 5.1)
if: matrix.arch == 'amd64'
shell: powershell
run: |
$sig = Get-AuthenticodeSignature "$env:RUNNER_TEMP\verify\basecamp_installer.ps1"
Write-Host "Installer status (powershell): $($sig.Status) - $($sig.StatusMessage)"
if ($sig.Status -ne 'Valid') { exit 1 }

nix-verify:
name: Verify Nix flake
needs: [release]
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ e2e/recorder/recorder

# GoReleaser output
dist/
.release-extra/

# Test coverage
coverage.out
Expand Down
19 changes: 19 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ before:
hooks:
- sh -c 'mkdir -p completions && go run ./cmd/basecamp completion bash > completions/basecamp.bash && go run ./cmd/basecamp completion zsh > completions/_basecamp && go run ./cmd/basecamp completion fish > completions/basecamp.fish'
- sh -c '{{ if .Prerelease }}echo "Skipping plugin version stamps for prerelease {{ .Version }}"{{ else }}scripts/stamp-plugin-version.sh {{ .Version }} && scripts/stamp-codex-plugin-version.sh {{ .Version }}{{ end }}'
# Authenticode-sign a copy of the PowerShell installer inside the pipeline
# so it lands in checksums.txt (cosign-signed, provenance-attested) and on
# the release as basecamp_installer.ps1. Staged outside dist/ because
# goreleaser requires dist to be empty after before-hooks run. Unsigned
# copy when the SM_* env is absent (forks, make test-release). Raw-main
# install.ps1 stays unsigned.
- sh -c 'mkdir -p .release-extra && cp scripts/install.ps1 .release-extra/basecamp_installer.ps1 && scripts/sign-windows.sh windows .release-extra/basecamp_installer.ps1'

builds:
- id: basecamp
Expand All @@ -30,6 +37,14 @@ builds:
- -X github.com/basecamp/basecamp-cli/internal/version.Version={{.Version}}
- -X github.com/basecamp/basecamp-cli/internal/version.Commit={{.Commit}}
- -X github.com/basecamp/basecamp-cli/internal/version.Date={{.Date}}
hooks:
post:
# Authenticode-sign Windows binaries in place before archiving, so the
# zip, checksums.txt, cosign bundle, and provenance attestation all
# cover the signed binary. No-op for non-windows targets and when the
# SM_* signing env is absent (forks, make test-release).
- cmd: scripts/sign-windows.sh "{{ .Target }}" "{{ .Path }}"
output: true

archives:
- id: default
Expand Down Expand Up @@ -76,6 +91,8 @@ nfpms:
checksum:
name_template: 'checksums.txt'
algorithm: sha256
extra_files:
- glob: .release-extra/basecamp_installer.ps1

# Generate SBOM for supply chain transparency
sboms:
Expand Down Expand Up @@ -122,6 +139,8 @@ release:
prerelease: auto
make_latest: "{{ if .Prerelease }}false{{ else }}auto{{ end }}"
replace_existing_artifacts: true
extra_files:
- glob: .release-extra/basecamp_installer.ps1
name_template: "{{.ProjectName}} v{{.Version}}"
header: |
{{ if .Env.RELEASE_CHANGELOG }}{{ .Env.RELEASE_CHANGELOG }}{{ end }}
Expand Down
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,7 @@ release:
.PHONY: test-release
test-release:
MACOS_SIGN_P12= MACOS_SIGN_PASSWORD= MACOS_NOTARY_KEY= MACOS_NOTARY_KEY_ID= MACOS_NOTARY_ISSUER_ID= \
SM_API_KEY= SM_CLIENT_CERT_FILE= SM_CLIENT_CERT_PASSWORD= \
goreleaser release --snapshot --skip=publish,sign --clean

# Verify the committed CLI surface snapshot (.surface) matches the command tree.
Expand Down
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ curl -fsSL https://basecamp.com/install-cli | bash
irm https://raw.githubusercontent.com/basecamp/basecamp-cli/main/scripts/install.ps1 | iex
```

On Windows 11 with Smart App Control, see [Troubleshooting](#windows-smart-app-control-and-smartscreen) if the install is blocked.

That's it. You now have full access to Basecamp from your terminal.

<details>
Expand Down Expand Up @@ -204,6 +206,36 @@ basecamp doctor --verbose # Verbose output with details
basecamp doctor --json # Structured checks, including Claude and Codex
```

### Windows: Smart App Control and SmartScreen

Releases up to v0.8.0-rc.1 ship an unsigned `basecamp.exe`. To check whether
your installed binary is signed:

```powershell
Get-AuthenticodeSignature (Get-Command basecamp).Source
```

**Smart App Control** (Windows 11) blocks unsigned executables no matter where
they were downloaded from, and it has no per-app exceptions — this applies to
the PowerShell installer, Scoop installs, and manual downloads alike. If it
blocks an unsigned `basecamp.exe`, two options:

1. **Use WSL2 (preferred).** Install the Linux build inside WSL2 — Smart App
Control doesn't apply there and your Windows security setup is untouched:
`wsl --install`, then inside the WSL terminal:
`curl -fsSL https://basecamp.com/install-cli | bash`
2. **Turn Smart App Control off** (Windows Security → App & browser control →
Smart App Control settings) **and leave it off while using the unsigned
build.** Because there are no per-app exceptions, turning it back on
re-blocks `basecamp.exe` on its next run — only re-enable after upgrading
to a signed build. Windows 11 with the March/April 2026 updates can
re-enable Smart App Control from Windows Security without a reset; on older
builds re-enabling requires resetting Windows, so prefer WSL2 there.

**SmartScreen** (without Smart App Control) may warn on first run of an
unrecognized executable — choose "More info" → "Run anyway" if you downloaded
the release from this repository.

## Development

```bash
Expand Down
28 changes: 28 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ make release VERSION=0.2.0 DRY_RUN=1
- Builds binaries for all platforms (darwin, linux, windows, freebsd, openbsd × amd64/arm64)
- Builds `.deb`, `.rpm`, `.apk` Linux packages (amd64 + arm64)
- Signs and notarizes macOS binaries via GoReleaser's built-in notarize (embedded quill)
- Signs Windows binaries with Authenticode via jsign and DigiCert KeyLocker
- Signs a copy of the PowerShell installer and attaches it as `basecamp_installer.ps1`
- Signs checksums with cosign (keyless via Sigstore OIDC)
- Generates SBOM for supply chain transparency
- Updates Homebrew cask (`basecamp-cli`) in `basecamp/homebrew-tap` for stable tags
Expand Down Expand Up @@ -94,6 +96,32 @@ basecamp skill install
| `MACOS_NOTARY_KEY` | Base64-encoded App Store Connect API key (.p8) |
| `MACOS_NOTARY_KEY_ID` | App Store Connect API key ID (10 characters) |
| `MACOS_NOTARY_ISSUER_ID` | App Store Connect issuer UUID |
| `SM_API_KEY` | DigiCert ONE API key for KeyLocker |
| `SM_CLIENT_CERT_FILE_B64` | Base64-encoded DigiCert ONE mTLS client certificate (.p12) |
| `SM_CLIENT_CERT_PASSWORD` | Client certificate unlock password |

## Windows signing

Windows binaries and the installer copy are Authenticode-signed from Linux CI
via [jsign](https://ebourg.github.io/jsign/) against DigiCert KeyLocker (cloud
HSM) — no Windows runner or hardware token involved. bc3-desktop's
`docs/windows-signing.md` is the canonical runbook; this repo is a second
consumer of the same certificate.

- Certificate: OV code signing, `CN=37signals LLC`, expires **2027-04-30**.
The DigiCert ONE certificate ID is pinned once, in
`.github/workflows/release.yml` (`SIGN_ALIAS`) — keep it in sync with
bc3-desktop when the certificate is renewed.
- jsign version and jar sha256 are pinned in the release workflow's
"Prepare Windows signing" step. jsign ≥ 7.5 is required — 7.1–7.3 are
broken against DigiCert ONE's current API.
- Quota: KeyLocker signatures draw from a budget shared with bc3-desktop.
Each tag consumes 3 signatures (2 exes + the installer copy); a release
cycle of rc(s) + stable is ≥ 6, and workflow re-runs after post-signing
failures consume more. Confirm headroom with the bc3-desktop cert owner
before a release burst.
- Signing failures abort the release during the build phase — nothing is
published. Re-run the workflow on the same tag after the outage clears.

## AUR setup (one-time)

Expand Down
Loading
Loading