Skip to content

Commit 07b431c

Browse files
committed
ci: sign releases and isolate Docker PR builds
1 parent a4f0e17 commit 07b431c

3 files changed

Lines changed: 197 additions & 27 deletions

File tree

.github/workflows/docker-pr.yml

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
name: Docker PR Build
2+
3+
on:
4+
pull_request:
5+
branches: [main]
6+
7+
permissions:
8+
contents: read
9+
10+
jobs:
11+
build:
12+
name: Build (${{ matrix.image.short }}/${{ matrix.platform.arch }})
13+
runs-on: ${{ matrix.platform.runner }}
14+
strategy:
15+
fail-fast: false
16+
matrix:
17+
image:
18+
- dockerfile: Dockerfile
19+
short: windshift
20+
- dockerfile: deploy/coding-agent/Dockerfile
21+
short: ws-carrier
22+
- dockerfile: deploy/windshift-runner/Dockerfile
23+
short: windshift-runner
24+
platform:
25+
- os: linux/amd64
26+
runner: ubuntu-latest
27+
arch: amd64
28+
- os: linux/arm64
29+
runner: ubuntu-24.04-arm
30+
arch: arm64
31+
steps:
32+
- name: Checkout code
33+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
34+
35+
- name: Set up Docker Buildx
36+
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
37+
38+
- name: Compute build metadata
39+
id: build_meta
40+
shell: bash
41+
run: |
42+
echo "version=dev" >> "$GITHUB_OUTPUT"
43+
echo "commit=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
44+
echo "date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_OUTPUT"
45+
46+
- name: Build image
47+
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
48+
with:
49+
context: .
50+
file: ${{ matrix.image.dockerfile }}
51+
platforms: ${{ matrix.platform.os }}
52+
cache-from: type=gha,scope=${{ matrix.image.short }}-${{ matrix.platform.arch }}
53+
cache-to: type=gha,mode=max,scope=${{ matrix.image.short }}-${{ matrix.platform.arch }}
54+
build-args: |
55+
VERSION=${{ steps.build_meta.outputs.version }}
56+
RELEASE_NAME=
57+
COMMIT=${{ steps.build_meta.outputs.commit }}
58+
BUILD_DATE=${{ steps.build_meta.outputs.date }}
59+
push: false

.github/workflows/docker.yml

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,12 @@
1-
name: Docker Build
1+
name: Docker Publish
22

33
on:
44
push:
55
branches: [main]
66
tags: ['v*']
7-
pull_request:
8-
branches: [main]
97

108
permissions:
119
contents: read
12-
packages: write
13-
attestations: write
14-
id-token: write
1510

1611
env:
1712
REGISTRY: ghcr.io
@@ -20,6 +15,9 @@ jobs:
2015
build:
2116
name: Build (${{ matrix.image.short }}/${{ matrix.platform.arch }})
2217
runs-on: ${{ matrix.platform.runner }}
18+
permissions:
19+
contents: read
20+
packages: write
2321
strategy:
2422
fail-fast: false
2523
matrix:
@@ -48,7 +46,6 @@ jobs:
4846
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
4947

5048
- name: Log in to Container Registry
51-
if: github.event_name != 'pull_request'
5249
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
5350
with:
5451
registry: ${{ env.REGISTRY }}
@@ -103,13 +100,15 @@ jobs:
103100
COMMIT=${{ steps.build_meta.outputs.commit }}
104101
BUILD_DATE=${{ steps.build_meta.outputs.date }}
105102
tags: ${{ env.REGISTRY }}/${{ matrix.image.name }}:stage-${{ github.sha }}-${{ matrix.platform.arch }}
106-
push: ${{ github.event_name != 'pull_request' }}
103+
push: true
107104

108105
merge:
109106
name: Merge Manifests (${{ matrix.image.short }})
110107
runs-on: ubuntu-latest
111-
if: github.event_name != 'pull_request'
112108
needs: build
109+
permissions:
110+
contents: read
111+
packages: write
113112
strategy:
114113
# One image's merge hiccup must not cancel the others' publishes.
115114
fail-fast: false

release.sh

Lines changed: 130 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -174,13 +174,22 @@ create_git_tag() {
174174
fi
175175

176176
if [ "$DRY_RUN" = true ]; then
177-
log_info "[DRY-RUN] Would create git tag: $tag"
177+
log_info "[DRY-RUN] Would create signed git tag: $tag"
178178
log_info "[DRY-RUN] Would push tag to remote"
179179
return 0
180180
fi
181181

182-
git tag -a "$tag" -m "Release $tag"
183-
log_success "Created git tag: $tag"
182+
if [ -n "${RELEASE_GPG_KEY:-}" ]; then
183+
git -c gpg.format=openpgp -c user.signingkey="$RELEASE_GPG_KEY" \
184+
tag -s "$tag" -m "Release $tag"
185+
else
186+
local ssh_key
187+
ssh_key=$(release_ssh_signing_key) ||
188+
die "Release signing requires RELEASE_GPG_KEY or RELEASE_SSH_KEY (or an SSH user.signingkey in Git config)"
189+
git -c gpg.format=ssh -c user.signingkey="$ssh_key" \
190+
tag -s "$tag" -m "Release $tag"
191+
fi
192+
log_success "Created signed git tag: $tag"
184193
git push origin "$tag"
185194
log_success "Pushed tag to remote"
186195
TAG_CREATED=true
@@ -884,27 +893,107 @@ write_release_provenance() {
884893
log_success "Wrote PROVENANCE.txt"
885894
}
886895

887-
sign_checksums_if_possible() {
896+
release_ssh_signing_key() {
897+
if [ -n "${RELEASE_SSH_KEY:-}" ]; then
898+
printf '%s\n' "$RELEASE_SSH_KEY"
899+
return 0
900+
fi
901+
902+
[ "$(git config --get gpg.format 2>/dev/null || true)" = "ssh" ] || return 1
903+
git config --get user.signingkey 2>/dev/null
904+
}
905+
906+
release_signing_identity() {
907+
if [ -n "${RELEASE_SIGNING_IDENTITY:-}" ]; then
908+
printf '%s\n' "$RELEASE_SIGNING_IDENTITY"
909+
return 0
910+
fi
911+
912+
git config --get user.email 2>/dev/null
913+
}
914+
915+
preflight_release_signing() {
888916
if [ "$DRY_RUN" = true ]; then
889-
log_info "[DRY-RUN] Would sign SHA256SUMS.txt if gpg is available"
917+
log_info "[DRY-RUN] Would verify the release signing key"
918+
return 0
919+
fi
920+
921+
if [ -n "${RELEASE_GPG_KEY:-}" ]; then
922+
command -v gpg >/dev/null 2>&1 ||
923+
die "gpg is required when RELEASE_GPG_KEY is set"
924+
gpg --batch --list-secret-keys "$RELEASE_GPG_KEY" >/dev/null 2>&1 ||
925+
die "No GPG secret key found for RELEASE_GPG_KEY=$RELEASE_GPG_KEY"
926+
log_success "GPG release signing key is available"
890927
return 0
891928
fi
892929

893-
[ -f dist/releases/SHA256SUMS.txt ] || return 0
930+
local ssh_key identity
931+
ssh_key=$(release_ssh_signing_key) ||
932+
die "Release signing requires RELEASE_GPG_KEY or RELEASE_SSH_KEY (or an SSH user.signingkey in Git config)"
933+
identity=$(release_signing_identity) ||
934+
die "SSH release signing requires RELEASE_SIGNING_IDENTITY or user.email in Git config"
935+
[ -n "$identity" ] ||
936+
die "SSH release signing requires RELEASE_SIGNING_IDENTITY or user.email in Git config"
937+
[ -f "$ssh_key" ] ||
938+
die "SSH release signing key not found: $ssh_key"
939+
command -v ssh-keygen >/dev/null 2>&1 ||
940+
die "ssh-keygen is required for SSH release signing"
941+
log_success "SSH release signing key is available"
942+
}
894943

895-
if [ -z "${RELEASE_GPG_KEY:-}" ]; then
896-
log_warn "RELEASE_GPG_KEY not set; SHA256SUMS.txt will not be signed"
944+
sign_release_checksums() {
945+
if [ "$DRY_RUN" = true ]; then
946+
log_info "[DRY-RUN] Would sign and verify SHA256SUMS.txt"
897947
return 0
898948
fi
899949

900-
if ! command -v gpg >/dev/null 2>&1; then
901-
log_warn "gpg not found; SHA256SUMS.txt will not be signed"
950+
local checksums="dist/releases/SHA256SUMS.txt"
951+
[ -s "$checksums" ] || die "Missing or empty $checksums"
952+
953+
if [ -n "${RELEASE_GPG_KEY:-}" ]; then
954+
local gpg_signature="${checksums}.asc"
955+
gpg --batch --yes --armor --detach-sign --local-user "$RELEASE_GPG_KEY" \
956+
-o "$gpg_signature" "$checksums" ||
957+
die "Failed to sign SHA256SUMS.txt with GPG"
958+
[ -s "$gpg_signature" ] || die "GPG did not create $gpg_signature"
959+
gpg --batch --verify "$gpg_signature" "$checksums" >/dev/null 2>&1 ||
960+
die "Generated GPG release signature could not be verified"
961+
log_success "Signed and verified SHA256SUMS.txt -> SHA256SUMS.txt.asc"
902962
return 0
903963
fi
904964

905-
gpg --batch --yes --armor --detach-sign --local-user "$RELEASE_GPG_KEY" \
906-
-o dist/releases/SHA256SUMS.txt.asc dist/releases/SHA256SUMS.txt
907-
log_success "Signed SHA256SUMS.txt -> SHA256SUMS.txt.asc"
965+
local ssh_key identity ssh_signature public_key allowed_signers
966+
ssh_key=$(release_ssh_signing_key) ||
967+
die "Release signing requires RELEASE_GPG_KEY or RELEASE_SSH_KEY (or an SSH user.signingkey in Git config)"
968+
identity=$(release_signing_identity) ||
969+
die "SSH release signing requires RELEASE_SIGNING_IDENTITY or user.email in Git config"
970+
ssh_signature="${checksums}.sig"
971+
972+
ssh-keygen -Y sign -f "$ssh_key" -n windshift-release "$checksums" >/dev/null ||
973+
die "Failed to sign SHA256SUMS.txt with SSH"
974+
[ -s "$ssh_signature" ] || die "SSH signing did not create $ssh_signature"
975+
976+
if [[ "$ssh_key" == *.pub ]]; then
977+
public_key=$(awk 'NR == 1 { print $1 " " $2 }' "$ssh_key")
978+
else
979+
public_key=$(ssh-keygen -y -f "$ssh_key") ||
980+
die "Could not derive the public key for release signature verification"
981+
fi
982+
[ -n "$public_key" ] || die "Could not derive the public release signing key"
983+
984+
allowed_signers="dist/releases/.allowed_signers.tmp"
985+
printf '%s %s\n' "$identity" "$public_key" > "$allowed_signers"
986+
if ! ssh-keygen -Y verify \
987+
-f "$allowed_signers" \
988+
-I "$identity" \
989+
-n windshift-release \
990+
-s "$ssh_signature" \
991+
< "$checksums" >/dev/null 2>&1; then
992+
rm -f "$allowed_signers"
993+
die "Generated SSH release signature could not be verified"
994+
fi
995+
rm -f "$allowed_signers"
996+
log_success "Signed and verified SHA256SUMS.txt -> SHA256SUMS.txt.sig"
908997
}
909998

910999
ensure_buildx() {
@@ -1019,6 +1108,18 @@ create_github_release() {
10191108

10201109
check_gh_cli
10211110

1111+
if [ "$DRY_RUN" = false ]; then
1112+
local signature_found=false
1113+
for signature in dist/releases/SHA256SUMS.txt.asc dist/releases/SHA256SUMS.txt.sig; do
1114+
if [ -s "$signature" ]; then
1115+
signature_found=true
1116+
break
1117+
fi
1118+
done
1119+
[ "$signature_found" = true ] ||
1120+
die "Refusing to publish a release without a SHA256SUMS.txt signature"
1121+
fi
1122+
10221123
# Create git tag if needed
10231124
local current_tag=$(get_git_tag)
10241125
if [ -z "$current_tag" ]; then
@@ -1033,7 +1134,7 @@ create_github_release() {
10331134

10341135
# Collect assets
10351136
local assets=()
1036-
for file in dist/releases/*.tar.gz dist/releases/*.zip dist/releases/*.dmg dist/releases/SHA256SUMS.txt dist/releases/SHA256SUMS.txt.asc dist/releases/PROVENANCE.txt; do
1137+
for file in dist/releases/*.tar.gz dist/releases/*.zip dist/releases/*.dmg dist/releases/SHA256SUMS.txt dist/releases/SHA256SUMS.txt.asc dist/releases/SHA256SUMS.txt.sig dist/releases/PROVENANCE.txt; do
10371138
[ -f "$file" ] && assets+=("$file")
10381139
done
10391140

@@ -1133,6 +1234,7 @@ cmd_release() {
11331234
determine_version
11341235

11351236
REQUIRE_SIGNED_DMG=true
1237+
preflight_release_signing
11361238

11371239
if [ "$CONFIRM" = true ] && [ "$DRY_RUN" = false ]; then
11381240
echo ""
@@ -1170,7 +1272,7 @@ cmd_release() {
11701272
build_desktop_mac
11711273
write_release_provenance
11721274
generate_checksums
1173-
sign_checksums_if_possible
1275+
sign_release_checksums
11741276
build_docker
11751277
create_github_release
11761278

@@ -1217,13 +1319,21 @@ GHCR push access is probed for 'push' and 'release', and an expired 1Password
12171319
session is refreshed via 'op signin' whenever the DMG will be built. Bad
12181320
credentials therefore fail in seconds rather than after a full build.
12191321
1220-
Desktop signing (optional, only consulted when running on macOS):
1322+
Desktop signing (required for an official macOS release):
12211323
APPLE_SIGNING_IDENTITY Developer ID Application cert name in your keychain
12221324
APPLE_ID Apple ID email (for notarization)
12231325
APPLE_PASSWORD App-specific password (for notarization)
12241326
APPLE_PASSWORD_OP_REF 1Password item ID — alternative to APPLE_PASSWORD
12251327
APPLE_TEAM_ID Apple Developer team ID (for notarization)
1226-
RELEASE_GPG_KEY Optional GPG key id/email used to sign SHA256SUMS.txt
1328+
1329+
Release signing (required for 'release'):
1330+
RELEASE_GPG_KEY GPG key id/email used to create SHA256SUMS.txt.asc
1331+
RELEASE_SSH_KEY SSH private/public key path used to create SHA256SUMS.txt.sig
1332+
Defaults to Git's SSH user.signingkey when configured.
1333+
RELEASE_SIGNING_IDENTITY
1334+
SSH signer identity; defaults to Git's user.email.
1335+
1336+
Desktop behavior:
12271337
For 'build' and 'push': when unset, the DMG is produced unsigned and
12281338
unnotarized — Gatekeeper will block double-click on download, users must
12291339
right-click → Open.
@@ -1335,4 +1445,6 @@ main() {
13351445
esac
13361446
}
13371447

1338-
main "$@"
1448+
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
1449+
main "$@"
1450+
fi

0 commit comments

Comments
 (0)