Repository navigation
422 lines (390 loc) · 20.7 KB
/
Copy pathrelease.yml
File metadata and controls
422 lines (390 loc) · 20.7 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
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
name: Release
# =============================================================================
# WORKFLOW CONFIGURATION
#
# Releases are MANUAL: dispatch this workflow from main when you decide to
# ship. Versioning stays automatic (semantic-release, .releaserc.yml): the
# version is computed from the Conventional Commits since the last release
# tag — fix → patch, feat → minor, BREAKING CHANGE → major; there is no
# version input. A dispatch with no release-worthy commits is a clean no-op
# (the plan job resolves no version and the release job is skipped).
#
# Deliberately NOT triggered on push: merging to main must never ship a
# release by itself — the dispatch is the human "ship it" decision.
#
# The workflow file name and ref matter beyond aesthetics: the self-updater
# pins the cosign signing identity to release.yml@refs/heads/main
# (internal/shared/selfupdate/cosign.go), so this file must keep its name and
# releases must run from main.
# =============================================================================
on:
workflow_dispatch:
# One release at a time: two dispatches racing would both compute the same
# next version and the loser's tag push would fail mid-release.
concurrency:
group: release
cancel-in-progress: false
permissions:
contents: read
# Pinned semantic-release toolchain, shared by the plan and release jobs.
# npx installs them ephemerally — no package.json in a Go repo. The plan job
# pins Node via the node:24-slim container; the release job runs on a macOS
# runner (containers are Linux-only there), so it uses the runner's
# preinstalled Node — semantic-release v24 needs >=20.8, satisfied by both.
env:
SEMREL_PACKAGES: >-
--package semantic-release@24
--package @semantic-release/exec@7
--package conventional-changelog-conventionalcommits@8
# =============================================================================
# JOBS
# =============================================================================
jobs:
# Cheap dry-run on ubuntu: decides IF a release happens and which version,
# before any expensive gate/signing work. verifyReleaseCmd in .releaserc.yml
# writes the version to the step output; no version → no release needed.
plan:
name: Plan release (semantic-release dry-run)
runs-on: ubuntu-latest
# Pinned Node toolchain instead of whatever the runner ships.
container: node:24-slim
# contents: write, even though the plan never publishes anything:
# semantic-release verifies push access up front (a no-op
# `git push --dry-run` of the current commit) in dry-run mode too, and a
# read-only GITHUB_TOKEN fails that probe with EGITNOPERMISSION/403.
permissions:
contents: write
outputs:
version: ${{ steps.semrel.outputs.version }}
steps:
# Before checkout: the slim image has no git, and without it
# actions/checkout falls back to an API tarball download — no .git dir,
# which semantic-release cannot work with.
- name: Install git (slim image)
run: apt-get update -qq && apt-get install -y -qq git
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# semantic-release reads the full history and tags to find the last
# release and analyze the commits since.
fetch-depth: 0
persist-credentials: false
- name: Resolve next version
id: semrel
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "==================================================================="
echo "=== PLAN: semantic-release dry-run (conventional commits)"
echo "==================================================================="
# The container runs as root while the runner-mounted workspace is
# owned by uid 1001, and actions/checkout writes its safe.directory
# entry into a TEMPORARY global git config that dies with its step —
# so git here refuses the repo ("dubious ownership") and
# semantic-release misreports it as ENOGITREPO "not a git repository".
git config --global --add safe.directory "$GITHUB_WORKSPACE"
npx --yes $SEMREL_PACKAGES semantic-release --dry-run
- name: Report
env:
VERSION: ${{ steps.semrel.outputs.version }}
run: |
if [ -n "$VERSION" ]; then
echo "Release ${VERSION} will be published."
else
echo "No release-worthy commits since the last tag — nothing to do."
fi
release:
name: "Release"
needs: [plan]
if: needs.plan.outputs.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
permissions:
contents: write # tag push + GitHub Release
id-token: write # required for cosign keyless (OIDC) signing
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 TRIGGERING 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 triggered from a non-main ref"
echo "==================================================================="
echo "::error::Releases must run 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:
# Full history + tags for semantic-release (same reason as the plan
# job). Don't leave the checkout token in .git/config for later steps
# to inherit (zizmor: artipacked) — semantic-release authenticates its
# tag push with the explicit token below instead.
fetch-depth: 0
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"
# semantic-release re-analyzes the commits (same result as the plan job),
# creates the bare x.y.z tag and pushes it. It does NOT create the GitHub
# Release — GoReleaser publishes the release with the label-based notes
# (.github/release.yml) against this tag in the next step.
- name: 'semantic-release: create and push tag'
id: semrel
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "==================================================================="
echo "=== semantic-release: tagging the next version"
echo "==================================================================="
npx --yes $SEMREL_PACKAGES semantic-release
- name: 'Guard: planned and tagged versions agree'
env:
PLANNED: ${{ needs.plan.outputs.version }}
TAGGED: ${{ steps.semrel.outputs.version }}
run: |
if [ "$PLANNED" != "$TAGGED" ]; then
echo "::error::plan resolved ${PLANNED} but semantic-release tagged ${TAGGED} — a commit landed on main between the jobs; re-run the workflow"
exit 1
fi
- name: Run GoReleaser
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GORELEASER_CURRENT_TAG: ${{ needs.plan.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.plan.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.plan.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 — pinned by
# tagFormat in .releaserc.yml). 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 run: semantic-release resolves the next
# version from existing tags, so a re-run computes 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.semrel.outcome == 'success'
env:
VERSION: ${{ needs.plan.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: [plan, release]
runs-on: windows-latest
steps:
- name: Download and verify published Windows binaries
shell: pwsh
env:
VERSION: ${{ needs.plan.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: [plan, release]
runs-on: macos-latest
steps:
- name: Download and verify published macOS binaries
env:
VERSION: ${{ needs.plan.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: [plan, release, verify-windows-signature, verify-macos-signature]
if: failure() && needs.release.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Delete release and tag
env:
VERSION: ${{ needs.plan.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"