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
50 changes: 50 additions & 0 deletions .github/verify-windows-signature.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""Report whether every executable in the Windows release zip is signed.

The signing chain is silent when it breaks, and it broke two different ways in
a row under a green run: v0.69.0's signing request failed outright, and
v0.69.1's completed while the signed file never reached the release. This reads
the shipped zip itself, so nothing but the published bytes can satisfy it.

Reports rather than blocks — the caller runs it with continue-on-error, since
SignPath is on a self-signed test certificate while the project's OSS
application is under review and publishing must not depend on it. For the same
reason this checks that a certificate is present, not that it chains to a
trusted root.

Usage: python .github/verify-windows-signature.py <zip>
"""

import struct
import sys
import zipfile


def certificate_table_size(pe: bytes) -> int:
"""Size of the PE's certificate table — data directory entry 4, zero when unsigned."""
pe_header = struct.unpack_from("<I", pe, 0x3C)[0]
optional_header = pe_header + 24
magic = struct.unpack_from("<H", pe, optional_header)[0]
# Data directories follow the optional header, whose size differs between
# PE32 (magic 0x10b) and PE32+ (0x20b).
data_directories = optional_header + (96 if magic == 0x10B else 112)
_, size = struct.unpack_from("<II", pe, data_directories + 4 * 8)
return size


archive = sys.argv[1]
unsigned = []
with zipfile.ZipFile(archive) as zf:
executables = [name for name in zf.namelist() if name.endswith(".exe")]
if not executables:
print(f"::error::{archive} contains no .exe to verify")
sys.exit(1)
for name in executables:
size = certificate_table_size(zf.read(name))
print(f"{name}: certificate table {size} bytes")
if size == 0:
unsigned.append(name)

if unsigned:
print(f"::error::unsigned in {archive}: {', '.join(unsigned)}")
sys.exit(1)
print(f"{archive}: all {len(executables)} executables signed")
42 changes: 37 additions & 5 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -163,14 +163,15 @@ jobs:
# storage zip unless archive: false — since the file we're
# uploading is already a zip, the default produced a zip-of-a-zip
# that SignPath saw as "the artifact", so the configured
# <pe-file path="wt.exe"> never matched (confirmed via a real
# <pe-file> paths never matched (confirmed via a real
# signing-request failure: "Expected path to match exactly 1
# item, but found 0", processing unsigned-windows-zip.zip).
# archive: false uploads the file as-is; the artifact name then
# becomes the file's own name, so `name:` here is ignored.
archive: false
path: target/distrib/worktrunk-x86_64-pc-windows-msvc.zip
- name: Submit SignPath signing request
id: signpath
if: contains(matrix.targets, 'x86_64-pc-windows-msvc') && steps.upload-unsigned-windows-artifact.outcome == 'success'
continue-on-error: true
uses: signpath/github-action-submit-signing-request@v2
Expand All @@ -181,11 +182,33 @@ jobs:
signing-policy-slug: 'test-signing'
github-artifact-id: '${{ steps.upload-unsigned-windows-artifact.outputs.artifact-id }}'
wait-for-completion: true
output-artifact-directory: 'target/distrib'
# Without this, the action extracts the zip's contents as loose
# files instead of saving back the signed zip under its original
# name, silently breaking the checksum step and the release upload.
# A scratch directory, not target/distrib: SignPath names the
# download after the GitHub artifact, which is already
# worktrunk-x86_64-pc-windows-msvc.zip, so writing it alongside the
# unsigned build would land it at …zip.zip and leave the unsigned
# original in place (how v0.69.1 shipped unsigned). The next step
# takes whatever lands here, so SignPath's naming doesn't matter.
output-artifact-directory: 'target/signpath'
# Without this, the action extracts the returned zip's contents as
# loose files instead of saving back the signed zip.
skip-decompress: true
- name: Replace the Windows artifact with the signed one
if: contains(matrix.targets, 'x86_64-pc-windows-msvc') && steps.signpath.outcome == 'success'
continue-on-error: true
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
signed=(target/signpath/*)
if [ ${#signed[@]} -ne 1 ]; then
echo "::error::expected one signed artifact, found ${#signed[@]}: ${signed[*]-}"
exit 1
fi
# Check the replacement before it overwrites the build: shipping an
# unsigned zip is tolerable while the certificate is a test one,
# shipping a corrupt one never is.
python .github/verify-windows-signature.py "${signed[0]}"
mv "${signed[0]}" target/distrib/worktrunk-x86_64-pc-windows-msvc.zip
- name: Recompute checksum for signed Windows artifact
if: contains(matrix.targets, 'x86_64-pc-windows-msvc')
shell: bash
Expand All @@ -194,6 +217,15 @@ jobs:
# -b matches cargo-dist's own checksum format (`<hash> *<filename>`);
# sha256sum's default text mode omits the asterisk.
sha256sum -b worktrunk-x86_64-pc-windows-msvc.zip > worktrunk-x86_64-pc-windows-msvc.zip.sha256
# Records what the release actually ships. The step above gates the
# overwrite; this one answers the question the logs never could — a
# signing request can complete while the signed file never reaches the
# release, which is how v0.69.1 shipped unsigned.
- name: Report whether the shipped Windows artifact is signed
if: contains(matrix.targets, 'x86_64-pc-windows-msvc')
continue-on-error: true
shell: bash
run: python .github/verify-windows-signature.py target/distrib/worktrunk-x86_64-pc-windows-msvc.zip
- id: cargo-dist
name: Post-build
# We force bash here just because github makes it really hard to get values up
Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Changelog

## 0.69.2

### Improved

- **`wt remove` resolves every fsmonitor daemon in one `lsof` call**: The end-of-command sweep forked one `lsof` per `git fsmonitor--daemon` on the machine — with `core.fsmonitor` enabled globally that is one daemon per repo ever touched, routinely over a hundred — and the spawn cost compounds under load rather than staying fixed per call. All daemons now resolve in a single call: 108 spawns to 1 on a live machine. ([#3581](https://github.com/max-sixty/worktrunk/pull/3581))

### Documentation

- **Troubleshooting no longer suggests disabling `core.fsmonitor` globally**: the guidance for a wedged daemon keeps to the targeted fixes — kill the daemon serving that worktree, or let the next `wt list` respawn the live ones. ([#3581](https://github.com/max-sixty/worktrunk/pull/3581))

### Internal

- **The Windows zip ships the signed binary**: SignPath names its download after the GitHub artifact, so the signed zip landed beside the unsigned build as `worktrunk-x86_64-pc-windows-msvc.zip.zip` while the checksum step and the release upload both kept reading the original — so v0.69.1 shipped an unsigned binary under a green run and a completed signing request. (v0.69.0's unsigned binary was the separate upload bug fixed in [#3566](https://github.com/max-sixty/worktrunk/pull/3566), where the request failed outright.) The signed file now replaces the built one only after it verifies, and a final step reads the shipped zip and reports any executable without a certificate. Signing remains non-blocking on a self-signed test certificate pending SignPath's OSS review, so Windows will not show a trusted publisher yet. ([#3590](https://github.com/max-sixty/worktrunk/pull/3590))

## 0.69.1

### Improved
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ filterset = "test(/readme_sync/)"

[package]
name = "worktrunk"
version = "0.69.1"
version = "0.69.2"
edition = "2024"
rust-version = "1.96"
build = "build.rs"
Expand Down
Loading