-
Notifications
You must be signed in to change notification settings - Fork 5
326 lines (300 loc) · 16.1 KB
/
Copy pathrelease.yml
File metadata and controls
326 lines (300 loc) · 16.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
name: Release
# =============================================================================
# WORKFLOW CONFIGURATION
# =============================================================================
permissions:
contents: write
id-token: write # required for cosign keyless (OIDC) signing
on:
workflow_dispatch:
inputs:
version:
description: "Version to Release (x.y.z). Leave empty for auto patch bump."
required: false
type: string
default: ''
# =============================================================================
# JOBS
# =============================================================================
jobs:
version:
name: Resolve Version
uses: ./.github/workflows/version.yml
with:
version: ${{ inputs.version }}
release:
name: "Release"
needs: [version]
# macOS runner: binaries are code-signed in GoReleaser build hooks before
# archiving (checksums must cover the signed bits), and codesign/notarytool
# only exist on macOS. Windows Authenticode uses jsign, which runs anywhere.
runs-on: macos-latest
if: github.event_name == 'workflow_dispatch'
steps:
# The self-updater pins the cosign signing identity to
# release.yml@refs/heads/main (internal/shared/selfupdate/cosign.go). The
# Fulcio certificate carries the DISPATCHING ref, so a release run from any
# other branch would publish assets that every `openframe update` rejects
# ("release signature verification failed"). Fail loudly instead of
# skipping so the dispatcher sees why.
- name: 'Guard: releases only from main'
if: github.ref != 'refs/heads/main'
env:
DISPATCH_REF: ${{ github.ref }}
run: |
echo "==================================================================="
echo "=== GUARD: release dispatched from a non-main ref"
echo "==================================================================="
echo "::error::Releases must be dispatched from main (got $DISPATCH_REF). The cosign identity pinned by the self-updater only accepts release.yml@refs/heads/main, so assets signed from this ref would fail verification for every user."
exit 1
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# Don't leave the checkout token in .git/config for later steps to
# inherit (zizmor: artipacked). The tag push below authenticates with
# an explicit token instead.
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: 'go.mod'
cache: true
# --- Gates: never tag or publish an unverified commit -------------------
- name: 'Gate: lint'
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
with:
version: latest
- name: 'Gate: unit tests'
run: |
echo "==================================================================="
echo "=== GATE: unit tests (root, cmd, internal, tests/testutil)"
echo "==================================================================="
make test-unit
- name: Install GoReleaser
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
with:
install-only: true
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
# Pin the cosign version: the signing flags are coupled to it (v3
# removed --new-bundle-format and makes `--bundle` new-format by
# default, which is what the .goreleaser.yml signs block relies on).
cosign-release: 'v3.1.1'
- name: Install syft (SBOM generator)
uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
# --- Code signing setup (used by scripts/sign-binary.sh via GoReleaser
# build hooks). Mirrors openframe-oss-tenant's sign-macos-package /
# sign-windows-package steps and uses the same org secrets.
- name: Setup macOS code signing
uses: ./.github/steps/setup-macos-signing
with:
apple_certificate_p12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
apple_certificate_password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- name: Install jsign (Azure Trusted Signing client)
env:
JSIGN_VERSION: "7.5"
JSIGN_SHA256: "602a51c3545a6dc4fb99bd2ea7152b26d1345916d0c93ddfbd5936cb735af91c"
run: |
curl -fsSL -o "$RUNNER_TEMP/jsign.jar" \
"https://github.com/ebourg/jsign/releases/download/${JSIGN_VERSION}/jsign-${JSIGN_VERSION}.jar"
echo "${JSIGN_SHA256} $RUNNER_TEMP/jsign.jar" | shasum -a 256 -c -
echo "JSIGN_JAR=$RUNNER_TEMP/jsign.jar" >> "$GITHUB_ENV"
- name: Create and push tag
id: tag
env:
VERSION: ${{ needs.version.outputs.version }}
# Explicit auth for the push, since the checkout no longer persists it.
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git config user.name "github-actions"
git config user.email "[email protected]"
git tag -a "${VERSION}" -m "Release ${VERSION}"
git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "${VERSION}"
- name: Run GoReleaser
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GORELEASER_CURRENT_TAG: ${{ needs.version.outputs.version }}
# Enables scripts/sign-binary.sh in the build hooks. KEYCHAIN_PATH,
# SIGNING_IDENTITY and JSIGN_JAR arrive via GITHUB_ENV from the
# signing setup steps above.
OPENFRAME_SIGN: "1"
APPLE_ID_USERNAME: ${{ secrets.APPLE_ID_USERNAME }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_CODE_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_CODE_SIGNING_ACCOUNT_NAME }}
AZURE_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_CERTIFICATE_PROFILE_NAME }}
run: |
goreleaser release --clean
# --- Smoke: verify what was actually shipped ----------------------------
- name: 'Smoke: released binary reports the injected version'
env:
VERSION: ${{ needs.version.outputs.version }}
run: |
echo "==================================================================="
echo "=== SMOKE: dist binary runs and reports ${VERSION}"
echo "==================================================================="
# Guards the ldflags injection (-X cmd.version): a broken injection
# ships binaries reporting "dev", which disables self-update.
# Runner is macOS/arm64, so exercise the darwin/arm64 binary — this
# also proves the signed, notarized binary still executes.
BIN="$(find dist -type f -name openframe -path '*darwin*arm64*' | head -1)"
if [ -z "$BIN" ]; then echo "::error::no darwin/arm64 binary found in dist/"; find dist -type f | head -20; exit 1; fi
codesign --verify --strict --verbose=2 "$BIN"
got="$("$BIN" --version)"
echo "$got"
if ! echo "$got" | grep -qF "$VERSION"; then
echo "::error::released binary does not report ${VERSION} — ldflags version injection is broken"
exit 1
fi
- name: 'Smoke: published asset URLs resolve (WSL auto-install contract)'
env:
VERSION: ${{ needs.version.outputs.version }}
run: |
echo "==================================================================="
echo "=== SMOKE: bare-tag download URLs resolve for ${VERSION}"
echo "==================================================================="
# wsllauncher and `openframe update` download exactly these bare-tag
# URLs (releases are tagged "x.y.z", no "v" prefix). A 404 here means
# every released Windows binary breaks on first run (audit T0-3).
for asset in "openframe-cli_linux_amd64.tar.gz" "checksums.txt" "checksums.txt.bundle"; do
url="https://github.com/${GITHUB_REPOSITORY}/releases/download/${VERSION}/${asset}"
ok=""
for i in 1 2 3 4 5 6; do
if curl -fsSL -o /dev/null -I "$url"; then ok=1; break; fi
echo "retry $i: $url not yet resolvable"; sleep 5
done
if [ -z "$ok" ]; then echo "::error::released asset URL does not resolve: $url"; exit 1; fi
echo "OK: $url"
done
# --- Cleanup: never leave a half-published release behind ---------------
# A leftover tag wedges the next dispatch: version.yml resolves the next
# version from published releases, so a re-run picks the same version and
# the tag push fails until someone hand-deletes the tag. A half-published
# release failing smoke must not stay visible as `latest` either.
- name: 'Cleanup: delete tag & release on failure'
if: failure() && steps.tag.outcome == 'success'
env:
VERSION: ${{ needs.version.outputs.version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "==================================================================="
echo "=== CLEANUP: rolling back tag/release ${VERSION} after failure"
echo "==================================================================="
gh release delete "${VERSION}" --repo "${GITHUB_REPOSITORY}" --yes && echo "deleted release ${VERSION}" || echo "no release ${VERSION} to delete"
git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" ":refs/tags/${VERSION}" && echo "deleted tag ${VERSION}"
- name: 'Cleanup: delete signing keychain'
if: always()
run: |
if [[ -n "${KEYCHAIN_PATH:-}" && -f "${KEYCHAIN_PATH}" ]]; then
security delete-keychain "$KEYCHAIN_PATH"
fi
# ---------------------------------------------------------------------------
# Post-publish signature verification: download the PUBLISHED assets on real
# Windows/macOS runners and verify what users actually get — the OS trust
# store checks here can't run on the release runner. No secrets needed: the
# pinned identities below are public facts, visible in any signed binary.
# A verification failure triggers cleanup-on-failed-verification, which
# yanks the release and tag (same philosophy as the release job's own
# cleanup: never leave a bad release visible as latest).
verify-windows-signature:
name: "Verify: Windows Authenticode"
needs: [version, release]
runs-on: windows-latest
steps:
- name: Download and verify published Windows binaries
shell: pwsh
env:
VERSION: ${{ needs.version.outputs.version }}
run: |
$ErrorActionPreference = "Stop"
foreach ($arch in @("amd64", "arm64")) {
$url = "https://github.com/$env:GITHUB_REPOSITORY/releases/download/$env:VERSION/openframe-cli_windows_$arch.zip"
Write-Host "Verifying $url"
Invoke-WebRequest -Uri $url -OutFile "openframe_$arch.zip" -MaximumRetryCount 5 -RetryIntervalSec 5
Expand-Archive "openframe_$arch.zip" -DestinationPath "extracted_$arch"
$sig = Get-AuthenticodeSignature -FilePath "extracted_$arch/openframe.exe"
Write-Host " Status: $($sig.Status)"
if ($sig.SignerCertificate) {
Write-Host " Signer: $($sig.SignerCertificate.Subject)"
Write-Host " Issuer: $($sig.SignerCertificate.Issuer)"
}
if ($sig.Status -ne "Valid") {
Write-Error "Signature verification failed for ${arch}: $($sig.Status) — $($sig.StatusMessage)"
exit 1
}
# "Valid" only proves SOME trusted publisher signed it — pin ours.
# Subject of the Azure Trusted Signing cert (the verified legal
# entity; stable across the short-lived certs the service issues):
# CN=Flamingo AI, Inc., O=Flamingo AI, Inc., L=Miami, S=Florida, C=US
if ($sig.SignerCertificate.Subject -notlike "*Flamingo AI, Inc.*") {
Write-Error "Unexpected signer for ${arch}: $($sig.SignerCertificate.Subject)"
exit 1
}
if (-not $sig.TimeStamperCertificate) {
Write-Error "Signature for ${arch} is not timestamped"
exit 1
}
Write-Host " Timestamp Authority: $($sig.TimeStamperCertificate.Subject)"
}
Write-Host "All Windows signatures verified"
verify-macos-signature:
name: "Verify: macOS signature & notarization"
needs: [version, release]
runs-on: macos-latest
steps:
- name: Download and verify published macOS binaries
env:
VERSION: ${{ needs.version.outputs.version }}
run: |
set -euo pipefail
for arch in amd64 arm64; do
url="https://github.com/${GITHUB_REPOSITORY}/releases/download/${VERSION}/openframe-cli_darwin_${arch}.tar.gz"
echo "Verifying $url"
curl -fsSL --retry 3 -o "openframe_${arch}.tar.gz" "$url"
mkdir -p "extracted_${arch}"
tar -xzf "openframe_${arch}.tar.gz" -C "extracted_${arch}"
bin="extracted_${arch}/openframe"
codesign --verify --strict --verbose=2 "$bin"
info="$(codesign -dvv "$bin" 2>&1)"
echo "$info"
if ! echo "$info" | grep -q "Authority=Developer ID Application"; then
echo "::error::${arch} binary is not signed with a Developer ID Application certificate"
exit 1
fi
# The Authority check only proves the certificate class — pin the
# signer to our Apple team (Flamingo AI, Inc.).
if ! echo "$info" | grep -q "TeamIdentifier=F7LDSU8JPJ"; then
echo "::error::${arch} binary signed by an unexpected team: $(echo "$info" | grep TeamIdentifier || echo 'TeamIdentifier missing')"
exit 1
fi
# Notarization check is best-effort: spctl needs quarantine context
# and the ticket can lag right after publication (bare binaries
# can't be stapled), so log the assessment without failing.
spctl --assess --type open --context context:primary-signature --verbose "$bin" || true
done
echo "All macOS signatures verified"
# A bad signature must not stay visible as `latest`. The release job's own
# cleanup only covers its own failure, so failed verification yanks the
# release + tag here. Gated on release success: if the release job failed it
# already rolled itself back (verification is then skipped anyway).
cleanup-on-failed-verification:
name: "Cleanup: yank release on failed verification"
needs: [version, release, verify-windows-signature, verify-macos-signature]
if: failure() && needs.release.result == 'success'
runs-on: ubuntu-latest
steps:
- name: Delete release and tag
env:
VERSION: ${{ needs.version.outputs.version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "==================================================================="
echo "=== CLEANUP: yanking release ${VERSION} after failed verification"
echo "==================================================================="
gh release delete "${VERSION}" --repo "${GITHUB_REPOSITORY}" --yes && echo "deleted release ${VERSION}" || echo "no release ${VERSION} to delete"
gh api -X DELETE "repos/${GITHUB_REPOSITORY}/git/refs/tags/${VERSION}" && echo "deleted tag ${VERSION}" || echo "no tag ${VERSION} to delete"