docs/RELEASE-SIGNING.md
Rama master · réplica de lectura del repositorio original.
# Release signing
> **Status: releases are signed from v3.10.0 onward.** The release workflow signs
> the four published binaries/zips, `SHA256SUMS`, the pushed Docker image
> **digest**, and the source-OTA **attestation** with cosign keyless signing: the signer identity is the workflow's
> GitHub OIDC token, exchanged for a short-lived Fulcio certificate. There is no
> long-lived private key, no signing secret, and no key material in this
> repository. Releases before v3.10.0 are unsigned and have no signature assets.
## What a release publishes
`.github/workflows/release.yml` attaches five signed binary/manifest blobs to
the GitHub Release, plus one Sigstore bundle for each one. It also publishes the
source-OTA attestation described below (with its own bundle):
| Blob | Produced by |
|---|---|
| `windsurfapi.exe` | Windows single-exe (`@yao-pkg/pkg` + esbuild) |
| `windsurfapi-windows.zip` | exe + tray launcher scripts |
| `windsurfapi-macos-arm64` | macOS arm64 binary |
| `windsurfapi-macos.zip` | arm64 binary (x64 if the optional job succeeded) + launch scripts |
| `SHA256SUMS` | `sha256sum` over exactly the four assets above |
For every row, the release also carries one file:
| Artifact | Contents | Use |
|---|---|---|
| `<name>.sigstore.json` | Sigstore bundle: certificate, signature, Rekor inclusion proof | **authoritative** — verifies after the certificate expires |
The bundle is the durable proof. A Fulcio certificate lives for minutes, and
`cosign verify-blob --certificate` checks it against the current time unless a
signed timestamp is supplied. Verify the bundle; there is no detached `.sig` or
`.pem` pair attached to v3.10.0+ releases.
The `docker` job additionally publishes the image's content address:
| Artifact | Contents | Use |
|---|---|---|
| `image-digest.txt` | the `sha256:` manifest digest of the pushed GHCR image | the only digest Docker OTA may deploy |
| `image-digest.txt.sigstore.json` | Sigstore bundle for `image-digest.txt` | proves the digest file came from this workflow |
The `release` job also publishes the source-OTA attestation:
| Artifact | Contents | Use |
|---|---|---|
| `release-attestation.json` | tag, commit, tree and the SHA-256 of every published asset | binds the release tag to the exact commit and asset bytes |
| `release-attestation.json.sigstore.json` | Sigstore bundle for the attestation | proves the binding came from this workflow at this tag |
The manifest uses the **asset names as they appear on the release page** (flat),
so `sha256sum -c SHA256SUMS` works in the directory where the assets were
downloaded.
## Verify a download
Download every asset and signature file into one empty directory, then either
run the helper or the commands it wraps.
### Helper
```bash
node scripts/verify-release-assets.mjs /path/to/download
# or, from this checkout with the default directory:
node scripts/verify-release-assets.mjs --dir /path/to/download
```
The helper requires [cosign](https://docs.sigstore.dev/cosign/system_config/installation/)
on `PATH` (or `--cosign <path>`). It verifies the manifest bundle first, checks
the four assets against the signed manifest, then verifies each asset's own
bundle against the release identity and issuer. Exit codes: `0` verified,
`1` verification failed, `2` usage or environment error.
### Manual commands
```bash
# 1. Verify the manifest's own signature. This is the trust anchor.
cosign verify-blob \
--bundle SHA256SUMS.sigstore.json \
--certificate-identity-regexp '^https://github\.com/dwgx/WindsurfAPI/\.github/workflows/release\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
SHA256SUMS
# 2. Verify each asset's own bundle.
for f in windsurfapi.exe windsurfapi-windows.zip windsurfapi-macos-arm64 windsurfapi-macos.zip; do
cosign verify-blob \
--bundle "$f.sigstore.json" \
--certificate-identity-regexp '^https://github\.com/dwgx/WindsurfAPI/\.github/workflows/release\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
"$f"
done
# 3. Check the bytes against the now-authenticated manifest.
sha256sum -c SHA256SUMS
```
Only the `.sigstore.json` bundle is published from v3.10.0 onward; there is no
detached `.sig`/`.pem` pair to verify. The bundle is the durable proof.
On macOS use `shasum -a 256 -c SHA256SUMS`. On Windows, `Get-FileHash` replaces
step 3:
```powershell
Get-Content SHA256SUMS | ForEach-Object {
$expected, $name = $_ -split '\s+', 2
$actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $name).Hash.ToLower()
if ($actual -ne $expected.ToLower()) { throw "MISMATCH: $name" }
}
```
### Verify the Docker image
The image signature lives in GHCR, attached to the digest, not in the release
download. Read the digest from `image-digest.txt` and verify both the file and
the image:
```bash
# The file that names the digest must itself be signed.
cosign verify-blob \
--bundle image-digest.txt.sigstore.json \
--certificate-identity-regexp '^https://github\.com/dwgx/WindsurfAPI/\.github/workflows/release\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
image-digest.txt
# Then verify the image digest named by that file.
cosign verify ghcr.io/dwgx/windsurf-api@sha256:<digest> \
--certificate-identity-regexp '^https://github\.com/dwgx/WindsurfAPI/\.github/workflows/release\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
```
The tag (`latest`, `3.141.592656`, …) is only a lookup key. Docker OTA resolves
it through the daemon's registry-metadata endpoint and refuses to pull or deploy
unless the result equals the digest in `image-digest.txt`.
The identity is the workflow ref that GitHub's OIDC token carries for a tag
push. To pin one exact release, replace the regexp with:
```text
--certificate-identity https://github.com/dwgx/WindsurfAPI/.github/workflows/release.yml@refs/tags/v3.10.0
```
### Identity and issuer
| Claim | Value |
|---|---|
| Issuer | `https://token.actions.githubusercontent.com` |
| Identity (tag build) | `https://github.com/dwgx/WindsurfAPI/.github/workflows/release.yml@refs/tags/vX.Y.Z` |
`scripts/verify-release-assets.mjs`(`--tag vX.Y.Z` 生成精确锚定的 identity,验收某个版本时用它,不要只依赖默认正则) carries the same values as
`DEFAULT_IDENTITY_REGEXP` / `DEFAULT_OIDC_ISSUER`; `test/release-signing.test.js`
fails if this page and the script drift apart.
## What the signature proves — and what it does not
It proves that the signed bytes were presented to Sigstore by the
`release.yml` workflow running in `dwgx/WindsurfAPI` for a `v*` tag, and that
the signature was recorded in the public Rekor transparency log. Anyone who can
publish or overwrite the GitHub Release but cannot obtain that OIDC identity
cannot produce a matching signature.
It does **not** prove:
- **Human review or intent.** The workflow signs whatever the tag points at.
A compromised maintainer account that can push a tag can also run the
workflow and produce a valid signature. Check the tag and commit out of band
when that matters.
- **A reproducible build.** The signature attests to the produced bytes, not to
a verifiable correspondence with the source tree.
- **That every build action is immutable.** The cosign installer is pinned by
commit SHA and cosign to an exact release, but the rest of the workflow still
uses major action tags (SEC-11 is not fully closed). A compromised upstream
action runs before the signature is produced, so the signature would faithfully
cover a malicious build.
- **The Docker image's build provenance beyond its digest.** The image is signed
by manifest digest, so the signature attests to those bytes. It does not prove
a reproducible build, and a tag that still points at the digest is not itself
evidence. Verify the digest, never the tag. The release-asset helper covers
only release assets; verify the image with `cosign verify` as shown above.
The signature is a provenance claim from a specific repository identity. It is
not a substitute for the hash check, and the hash check is not a substitute for
the signature: the manifest and the binaries used to be served from the same
release with no root of trust outside it.
## How the release job is wired
The `release` job in `.github/workflows/release.yml`:
1. assembles the Windows/macOS assets and generates `SHA256SUMS`;
2. installs `sigstore/cosign-installer` pinned by commit SHA, with
`cosign-release: 'v3.1.4'`;
3. runs `cosign sign-blob` once per signed blob, writing one
`<name>.sigstore.json` bundle per blob;
4. refuses to continue if any signature bundle is empty;
5. uploads the assets and all signature bundles to the GitHub Release.
The job has `permissions: contents: write` and `id-token: write`. The OIDC
permission is what lets cosign obtain a Fulcio certificate; no repository secret
is read anywhere in the signing path. A signing failure fails the job, so a
release cannot be published with the notes claiming signatures but no signature
assets.
The separate `docker` job has `packages: write` and `id-token: write`. After
`docker/build-push-action` returns the pushed digest it:
1. runs `cosign sign --yes ghcr.io/dwgx/windsurf-api@<digest>` (the signature is
stored in GHCR next to that digest);
2. writes `image-digest.txt` with the same digest and exposes it as the job
output `digest`;
3. runs `cosign sign-blob` over `image-digest.txt`;
4. uploads both files as the `image-digest` artifact, which the `release` job
attaches to the GitHub Release.
A digest mismatch between the pushed image and the recorded file cannot happen
inside this job: both come from the same `steps.build.outputs.digest`, and the
step fails if it is not `sha256:<64 hex>`.
Signing depends on Sigstore (Fulcio for the certificate, Rekor for the
transparency entry, TUF for the trust root). An outage blocks the release rather
than silently shipping unsigned assets. That is deliberate: a release that says
it is signed and is not is worse than an obviously unsigned one.
## Install and self-update paths
The git self-update path verifies the release attestation (see below), not the
binary signatures: `release-attestation.json` binds the tag to the commit the
checkout will install. Docker OTA consumes `image-digest.txt` from the matching
release and refuses a tag that does not resolve to that signed digest. Do not
pretend the git path is signed.
### Dashboard `/self-update` (git checkout)
`src/dashboard/api.js` updates by fetching a stable `vX.Y.Z` tag, checking that
the tag object and peeled commit are consistent, that the commit is an ancestor
of the update branch, and that the move is a fast-forward. Before the tag can
become a mutation target, it also verifies `release-attestation.json`:
- the attested `commit` must equal the tag's peeled commit; a mismatch is
refused in every mode;
- when `release-attestation.json.sigstore.json` is present, `cosign verify-blob`
must verify it with the release workflow identity and issuer;
- `WINDSURFAPI_UPDATE_ATTESTATION=warn` (default) logs a warning and proceeds
when the attestation, its bundle or cosign is missing, so an existing install
without cosign keeps updating; `required` refuses instead.
`update.sh` runs the same check (`scripts/verify-release-attestation.mjs`)
before any stash/reset/merge. A pre-staged `WINDSURFAPI_ATTESTATION_DIR` serves
air-gapped hosts and forks.
The signature proves "this release workflow published this tag→commit binding
for these bytes", not human approval or a reproducible build. Anyone who can
push a `v*` tag can mint the same identity; tag protection and a review-gated
release environment are separate controls.
Adding `git verify-tag` is a different root of trust (a signing key that every
deployment must already have) and remains intentionally not wired.
### Docker OTA
The dashboard one-click update (when `/var/run/docker.sock` is mounted) never
treats a tag as an execution target. It resolves the running tag through the
daemon's `GET /distribution/<repo>:<tag>/json` endpoint, then requires the
result to equal the digest in `<DATA_DIR>/image-digest.txt`; a missing file, an
unreadable digest or a mismatch refuses before any pull or container create.
The app image is pulled by digest, the `docker:24-cli` deployer is pinned by
digest in source, and every `docker compose up` uses `--no-build` so Compose can
never build from the on-disk checkout.
Download `image-digest.txt` (and verify its `.sigstore.json` bundle) from the
matching GitHub Release and place it at `<DATA_DIR>/image-digest.txt` before the
first update. A digest-pinned `WINDSURFAPI_IMAGE=repo@sha256:…` is an explicit
operator pin and needs no attestation file.
`deploy/update.sh` still pulls the reference passed by `--tag`/`--image`; pin it
to a digest by hand:
```bash
docker buildx imagetools inspect ghcr.io/dwgx/windsurf-api:<tag>
bash deploy/update.sh --image ghcr.io/dwgx/windsurf-api@sha256:<digest>
```
### Windows / manual install
The Windows distribution is a manual download. Verify before running the exe:
follow [Verify a download](#verify-a-download) with the downloaded files in one
directory. `deploy/windows/README.md` points here. A trojaned exe runs with the
user's privileges and reads the side-by-side `.env` and `accounts.json`, so this
is the path where verification matters most.
### `install-ls.sh`
This installer downloads language-server binaries from a different repository
(`windsurf-ls-release`) and verifies their own manifest/pin hashes. It is not
covered by the release-asset signatures described here.
## Historical releases
- v3.10.0 and later: release assets signed as described above.
- Docker images: signed by digest from the first release that publishes
`image-digest.txt`; earlier images are unsigned.
- v3.9.53–v3.9.60: `SHA256SUMS` only, unsigned. v3.9.53 recorded build-tree
paths (`dist-windows/...`, `dist-macos/...`); later releases use flat names.
- Earlier releases: no manifest and no signatures.
## Changing the identity
If the repository, workflow path, or signing mechanism changes, update the
identity in three places together: this document, `DEFAULT_IDENTITY_REGEXP` in
`scripts/verify-release-assets.mjs`, and the release workflow. The signing test
compares the document with the script so a partial update fails CI. Treat an
identity change as a trust-root change: users who pinned the old identity must
be told explicitly.