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.
.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.
Download every asset and signature file into one empty directory, then either run the helper or the commands it wraps.
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/downloadThe helper requires cosign
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.
# 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 SHA256SUMSOnly 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:
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" }
}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:
# 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.comThe 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:
--certificate-identity https://github.com/dwgx/WindsurfAPI/.github/workflows/release.yml@refs/tags/v3.10.0
| 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.
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 verifyas 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.
The release job in .github/workflows/release.yml:
- assembles the Windows/macOS assets and generates
SHA256SUMS; - installs
sigstore/cosign-installerpinned by commit SHA, withcosign-release: 'v3.1.4'; - runs
cosign sign-blobonce per signed blob, writing one<name>.sigstore.jsonbundle per blob; - refuses to continue if any signature bundle is empty;
- 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:
- runs
cosign sign --yes ghcr.io/dwgx/windsurf-api@<digest>(the signature is stored in GHCR next to that digest); - writes
image-digest.txtwith the same digest and exposes it as the job outputdigest; - runs
cosign sign-bloboverimage-digest.txt; - uploads both files as the
image-digestartifact, which thereleasejob 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.
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.
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
commitmust equal the tag's peeled commit; a mismatch is refused in every mode; - when
release-attestation.json.sigstore.jsonis present,cosign verify-blobmust 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;requiredrefuses 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.
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:
docker buildx imagetools inspect ghcr.io/dwgx/windsurf-api:<tag>
bash deploy/update.sh --image ghcr.io/dwgx/windsurf-api@sha256:<digest>The Windows distribution is a manual download. Verify before running the exe:
follow 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.
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.
- 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:
SHA256SUMSonly, unsigned. v3.9.53 recorded build-tree paths (dist-windows/...,dist-macos/...); later releases use flat names. - Earlier releases: no manifest and no signatures.
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.