# 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 | |---|---|---| | `.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 `). 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: \ --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 `.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@` (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/:/json` endpoint, then requires the result to equal the digest in `/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 `/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: bash deploy/update.sh --image ghcr.io/dwgx/windsurf-api@sha256: ``` ### 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.