Skip to content

Commit a583b8a

Browse files
authored
Merge pull request #124 from ashishkurmi/verify-release-workflow
ci: add Verify Release workflow to gate release artifacts
2 parents d40b3a7 + a0af587 commit a583b8a

1 file changed

Lines changed: 383 additions & 0 deletions

File tree

Lines changed: 383 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,383 @@
1+
name: Verify Release
2+
on:
3+
workflow_dispatch:
4+
inputs:
5+
tag:
6+
description: 'Release tag to verify, e.g. v1.11.6 (draft or published).'
7+
required: true
8+
type: string
9+
repository:
10+
description: >-
11+
Repo to verify, as owner/name. Leave blank to use the repo this
12+
workflow runs in. Set it to step-security/dev-machine-guard to verify
13+
an upstream PUBLISHED release (handy for confirming this workflow
14+
works before opening a PR — draft releases in another repo are not
15+
visible to this repo's token, so only published upstream releases work).
16+
required: false
17+
type: string
18+
default: ''
19+
20+
permissions: {}
21+
22+
env:
23+
# GoReleaser sets tag_name on the draft, so gh resolves drafts by this tag.
24+
TAG: ${{ inputs.tag }}
25+
# Empty input falls back to the current repo. An explicit owner/name (e.g.
26+
# step-security/dev-machine-guard) targets that repo's PUBLISHED releases —
27+
# public release assets are readable cross-repo with this repo's token.
28+
REPO: ${{ inputs.repository || github.repository }}
29+
30+
jobs:
31+
# ---------------------------------------------------------------------------
32+
# Requirement 3: every relevant binary artifact has a valid, correct signed
33+
# checksum. Runs on Linux because ssh-keygen -Y verify (OpenSSH) is the same
34+
# verifier loader.sh uses on customer machines.
35+
# ---------------------------------------------------------------------------
36+
verify-signed-checksums:
37+
name: Verify signed checksums
38+
runs-on: ubuntu-latest
39+
permissions:
40+
# contents: write (not read) is required so the GITHUB_TOKEN can see DRAFT
41+
# releases — GitHub only exposes drafts to tokens with push access. The job
42+
# only ever downloads assets; it never modifies the repo or the release.
43+
contents: write
44+
steps:
45+
- name: Harden the runner (Audit all outbound calls)
46+
# Intentionally left unpinned — pin to a commit SHA after verifying.
47+
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
48+
with:
49+
egress-policy: audit
50+
51+
- name: Download release assets
52+
env:
53+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
54+
run: |
55+
set -euo pipefail
56+
mkdir -p assets
57+
gh release download "$TAG" -R "$REPO" -D assets
58+
echo "Downloaded assets:"
59+
ls -la assets
60+
61+
- name: Verify signed checksums (Ed25519 / ssh-keygen -Y verify)
62+
run: |
63+
set -euo pipefail
64+
VERSION="${TAG#v}"
65+
66+
# --- Verification scheme, kept in sync with loader.sh ---
67+
PUBLIC_KEY_SSH="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILN+WG4lOH/x6MysYOf1oY0PKXLLu9d3ZvQDcvq5Cboi releases@stepsecurity.io"
68+
SIGNATURE_NAMESPACE="stepsecurity-mdm-checksum"
69+
SIGNATURE_IDENTITY="releases@stepsecurity.io"
70+
71+
ALLOWED_SIGNERS="$(mktemp)"
72+
# The namespaces value MUST be double-quoted or ssh-keygen's option
73+
# parser rejects it ("bad options: missing start quote").
74+
printf '%s namespaces="%s" %s\n' \
75+
"$SIGNATURE_IDENTITY" "$SIGNATURE_NAMESPACE" "$PUBLIC_KEY_SSH" \
76+
> "$ALLOWED_SIGNERS"
77+
78+
# Distributable binaries that MUST ship a signed checksum. These are
79+
# the artifacts the loader / installers download and run. The cosign
80+
# .bundle-only artifacts (deb, rpm, darwin_unnotarized) are deliberately
81+
# excluded — they are not served as signed-checksum downloads.
82+
ARTIFACTS=(
83+
"stepsecurity-dev-machine-guard-${VERSION}-darwin"
84+
"stepsecurity-dev-machine-guard-${VERSION}-linux_amd64"
85+
"stepsecurity-dev-machine-guard-${VERSION}-linux_arm64"
86+
"stepsecurity-dev-machine-guard-${VERSION}-windows_amd64.exe"
87+
"stepsecurity-dev-machine-guard-${VERSION}-windows_arm64.exe"
88+
"stepsecurity-dev-machine-guard-task-${VERSION}-windows_amd64.exe"
89+
"stepsecurity-dev-machine-guard-task-${VERSION}-windows_arm64.exe"
90+
"stepsecurity-dev-machine-guard-${VERSION}-x64.msi"
91+
"stepsecurity-dev-machine-guard-${VERSION}-arm64.msi"
92+
)
93+
94+
fail=0
95+
{
96+
echo "## Signed checksum verification"
97+
echo ""
98+
echo "| Artifact | Signed checksum | Result |"
99+
echo "| --- | --- | --- |"
100+
} >> "$GITHUB_STEP_SUMMARY"
101+
102+
for art in "${ARTIFACTS[@]}"; do
103+
bin="assets/${art}"
104+
sig="assets/${art}.sha256.sig"
105+
status="OK"
106+
107+
if [ ! -f "$bin" ]; then
108+
echo "::error::Missing artifact: ${art}"
109+
echo "| \`${art}\` | n/a | ❌ artifact missing |" >> "$GITHUB_STEP_SUMMARY"
110+
fail=1
111+
continue
112+
fi
113+
if [ ! -f "$sig" ]; then
114+
echo "::error::Missing signed checksum: ${art}.sha256.sig"
115+
echo "| \`${art}\` | ❌ missing | ❌ no .sha256.sig |" >> "$GITHUB_STEP_SUMMARY"
116+
fail=1
117+
continue
118+
fi
119+
120+
# Actual SHA-256 of the artifact bytes.
121+
hex="$(sha256sum "$bin" | awk '{print $1}')"
122+
123+
# The .sha256.sig is the base64-wrapped armored SSH signature. Decode
124+
# it back to the multi-line -----BEGIN SSH SIGNATURE----- blob.
125+
decoded="$(mktemp)"
126+
if ! base64 -d < "$sig" > "$decoded" 2>/dev/null; then
127+
echo "::error::Failed to base64-decode ${art}.sha256.sig"
128+
echo "| \`${art}\` | ❌ undecodable | ❌ bad base64 |" >> "$GITHUB_STEP_SUMMARY"
129+
fail=1
130+
continue
131+
fi
132+
133+
# The signed message is the BARE hex SHA-256 with NO trailing newline.
134+
# Verifying against the freshly-computed hex proves both that the
135+
# signature is valid AND that it covers this artifact's real checksum.
136+
if printf '%s' "$hex" | ssh-keygen -Y verify \
137+
-f "$ALLOWED_SIGNERS" \
138+
-I "$SIGNATURE_IDENTITY" \
139+
-n "$SIGNATURE_NAMESPACE" \
140+
-s "$decoded" >/dev/null 2>&1; then
141+
echo "OK: ${art} (sha256=${hex}) — signature verified"
142+
echo "| \`${art}\` | \`${hex:0:16}…\` | ✅ verified |" >> "$GITHUB_STEP_SUMMARY"
143+
else
144+
echo "::error::Signed checksum verification FAILED for ${art}"
145+
echo "| \`${art}\` | \`${hex:0:16}…\` | ❌ signature invalid |" >> "$GITHUB_STEP_SUMMARY"
146+
fail=1
147+
fi
148+
done
149+
150+
if [ "$fail" -ne 0 ]; then
151+
echo "::error::One or more signed checksums are missing or invalid."
152+
exit 1
153+
fi
154+
echo "All relevant artifacts have valid signed checksums."
155+
156+
# NOTE: we deliberately do NOT cross-check the GoReleaser-generated
157+
# checksums.txt. That file records the PRE-signing Windows hashes —
158+
# the release pipeline Authenticode-signs the .exe/.msi files and
159+
# re-uploads them with --clobber afterwards, so the published bytes no
160+
# longer match checksums.txt for any Windows artifact. The signed
161+
# .sha256.sig files verified above are computed on the final served
162+
# bytes, which is the source of truth this gate enforces.
163+
164+
# ---------------------------------------------------------------------------
165+
# Requirement 1: Windows .exe + .msi are Authenticode-signed. Mirrors the
166+
# verification the Release workflow performs after signing.
167+
# ---------------------------------------------------------------------------
168+
verify-windows-signatures:
169+
name: Verify Windows signatures
170+
runs-on: windows-latest
171+
permissions:
172+
contents: write # see drafts; see note in verify-signed-checksums
173+
steps:
174+
- name: Harden the runner (Audit all outbound calls)
175+
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
176+
with:
177+
egress-policy: audit
178+
179+
- name: Download Windows assets
180+
env:
181+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
182+
shell: bash
183+
run: |
184+
set -euo pipefail
185+
mkdir -p assets
186+
gh release download "$TAG" -R "$REPO" -D assets \
187+
-p "*-windows_amd64.exe" \
188+
-p "*-windows_arm64.exe" \
189+
-p "*-x64.msi" \
190+
-p "*-arm64.msi"
191+
ls -la assets
192+
193+
- name: Verify Authenticode signatures
194+
shell: pwsh
195+
run: |
196+
$ErrorActionPreference = 'Stop'
197+
$version = "${{ inputs.tag }}".TrimStart('v')
198+
$files = @(
199+
"stepsecurity-dev-machine-guard-$version-windows_amd64.exe",
200+
"stepsecurity-dev-machine-guard-$version-windows_arm64.exe",
201+
"stepsecurity-dev-machine-guard-task-$version-windows_amd64.exe",
202+
"stepsecurity-dev-machine-guard-task-$version-windows_arm64.exe",
203+
"stepsecurity-dev-machine-guard-$version-x64.msi",
204+
"stepsecurity-dev-machine-guard-$version-arm64.msi"
205+
)
206+
207+
"## Windows Authenticode verification`n" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append
208+
"| Artifact | Status | Signer | Timestamp |" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append
209+
"| --- | --- | --- | --- |" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append
210+
211+
$failed = $false
212+
foreach ($name in $files) {
213+
$f = Join-Path "assets" $name
214+
if (-not (Test-Path $f)) {
215+
Write-Host "::error::Missing Windows artifact: $name"
216+
"| ``$name`` | ❌ missing | - | - |" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append
217+
$failed = $true
218+
continue
219+
}
220+
221+
$sig = Get-AuthenticodeSignature $f
222+
$subject = if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { '<none>' }
223+
$hasTs = [bool]$sig.TimeStamperCertificate
224+
Write-Host "$name : Status=$($sig.Status), Signer=$subject, Timestamped=$hasTs"
225+
226+
$ok = $true
227+
if ($sig.Status -ne 'Valid') {
228+
Write-Host "::error::Signature status is $($sig.Status) (expected Valid) for $name"
229+
$ok = $false
230+
}
231+
if (-not $hasTs) {
232+
Write-Host "::error::No RFC3161 timestamp on $name"
233+
$ok = $false
234+
}
235+
236+
$mark = if ($ok) { '✅ valid' } else { "❌ $($sig.Status)" }
237+
$tsMark = if ($hasTs) { '✅' } else { '❌' }
238+
"| ``$name`` | $mark | $subject | $tsMark |" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append
239+
if (-not $ok) { $failed = $true }
240+
}
241+
242+
if ($failed) {
243+
Write-Host "::error::One or more Windows artifacts are not properly Authenticode-signed."
244+
exit 1
245+
}
246+
Write-Host "All Windows artifacts are Authenticode-signed and timestamped."
247+
248+
# ---------------------------------------------------------------------------
249+
# Requirement 2: the macOS darwin binary is signed (Developer ID + hardened
250+
# runtime) and notarized. Runs on macOS so codesign/spctl are available; spctl
251+
# performs the online Gatekeeper/notarization check.
252+
# ---------------------------------------------------------------------------
253+
verify-macos-notarization:
254+
name: Verify macOS notarization
255+
runs-on: macos-latest
256+
permissions:
257+
contents: write # see drafts; see note in verify-signed-checksums
258+
steps:
259+
- name: Harden the runner (Audit all outbound calls)
260+
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
261+
with:
262+
egress-policy: audit
263+
264+
- name: Download macOS binary
265+
env:
266+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
267+
run: |
268+
set -euo pipefail
269+
VERSION="${TAG#v}"
270+
mkdir -p assets
271+
# Match only the notarized darwin binary, not *-darwin_unnotarized.
272+
gh release download "$TAG" -R "$REPO" -D assets \
273+
-p "stepsecurity-dev-machine-guard-${VERSION}-darwin"
274+
ls -la assets
275+
276+
- name: Verify codesign + notarization
277+
run: |
278+
set -euo pipefail
279+
VERSION="${TAG#v}"
280+
EXPECTED_AUTHORITY="Developer ID Application: Step Security, Inc. (D63S9HLM4L)"
281+
BIN="assets/stepsecurity-dev-machine-guard-${VERSION}-darwin"
282+
283+
if [ ! -f "$BIN" ]; then
284+
echo "::error::macOS darwin binary not found: $(basename "$BIN")"
285+
exit 1
286+
fi
287+
288+
fail=0
289+
290+
# 1. Valid code signature.
291+
echo "== codesign --verify --strict =="
292+
if codesign --verify --strict --verbose=2 "$BIN" 2>&1; then
293+
echo "codesign verification passed."
294+
else
295+
echo "::error::codesign --verify failed for $(basename "$BIN")"
296+
fail=1
297+
fi
298+
299+
# 2. Signed by the Step Security Developer ID, with the hardened runtime.
300+
echo "== codesign -dvvv =="
301+
info="$(codesign -dvvv "$BIN" 2>&1 || true)"
302+
echo "$info" | grep -Ei 'Authority|TeamIdentifier|flags|Timestamp' || true
303+
if echo "$info" | grep -qF "$EXPECTED_AUTHORITY"; then
304+
echo "Developer ID authority present."
305+
else
306+
echo "::error::Expected signing authority not found: ${EXPECTED_AUTHORITY}"
307+
fail=1
308+
fi
309+
if echo "$info" | grep -Eq 'flags=.*runtime'; then
310+
echo "Hardened runtime enabled."
311+
else
312+
echo "::error::Hardened runtime flag (runtime) not set on $(basename "$BIN")"
313+
fail=1
314+
fi
315+
316+
# 3. Notarization — the online Gatekeeper assessment. A bare Mach-O
317+
# binary cannot be stapled, so spctl -t install is the authoritative
318+
# check (this is exactly what the manual notarization step runs).
319+
echo "== spctl -a -vvv -t install =="
320+
assess="$(spctl -a -vvv -t install "$BIN" 2>&1 || true)"
321+
echo "$assess"
322+
if echo "$assess" | grep -q 'accepted' && echo "$assess" | grep -q 'source=Notarized Developer ID'; then
323+
echo "Binary is notarized (Gatekeeper accepted, Notarized Developer ID)."
324+
else
325+
echo "::error::macOS binary is NOT notarized (spctl did not return 'accepted / Notarized Developer ID')."
326+
fail=1
327+
fi
328+
329+
{
330+
echo "## macOS notarization verification"
331+
echo ""
332+
if [ "$fail" -eq 0 ]; then
333+
echo "✅ \`$(basename "$BIN")\` is codesigned (Developer ID + hardened runtime) and notarized."
334+
else
335+
echo "❌ \`$(basename "$BIN")\` failed one or more macOS signing/notarization checks."
336+
fi
337+
} >> "$GITHUB_STEP_SUMMARY"
338+
339+
if [ "$fail" -ne 0 ]; then
340+
exit 1
341+
fi
342+
echo "macOS binary is signed and notarized."
343+
344+
# ---------------------------------------------------------------------------
345+
# Single pass/fail gate. Fails the run if ANY requirement job did not succeed,
346+
# giving one clear status to gate publishing the release on.
347+
# ---------------------------------------------------------------------------
348+
gate:
349+
name: Release verification gate
350+
needs: [verify-signed-checksums, verify-windows-signatures, verify-macos-notarization]
351+
if: always()
352+
runs-on: ubuntu-latest
353+
permissions: {}
354+
steps:
355+
- name: Harden the runner (Audit all outbound calls)
356+
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
357+
with:
358+
egress-policy: audit
359+
360+
- name: Summarize and gate
361+
run: |
362+
checksums="${{ needs.verify-signed-checksums.result }}"
363+
windows="${{ needs.verify-windows-signatures.result }}"
364+
macos="${{ needs.verify-macos-notarization.result }}"
365+
366+
{
367+
echo "## Release verification gate — \`${TAG}\`"
368+
echo ""
369+
echo "| Requirement | Result |"
370+
echo "| --- | --- |"
371+
echo "| Signed checksums (all relevant artifacts) | ${checksums} |"
372+
echo "| Windows Authenticode signatures | ${windows} |"
373+
echo "| macOS notarization | ${macos} |"
374+
} >> "$GITHUB_STEP_SUMMARY"
375+
376+
if [ "$checksums" = "success" ] && [ "$windows" = "success" ] && [ "$macos" = "success" ]; then
377+
echo "✅ Release ${TAG} meets all requirements — safe to publish." >> "$GITHUB_STEP_SUMMARY"
378+
echo "Release ${TAG} meets all requirements."
379+
else
380+
echo "❌ Release ${TAG} is NOT ready — do not publish." >> "$GITHUB_STEP_SUMMARY"
381+
echo "::error::Release ${TAG} failed verification (checksums=${checksums}, windows=${windows}, macos=${macos})."
382+
exit 1
383+
fi

0 commit comments

Comments
 (0)