Repository navigation
333 lines (310 loc) · 14.2 KB
/
Copy pathrelease.yml
File metadata and controls
333 lines (310 loc) · 14.2 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
name: Release
# A release: universal CloudMachine.app (Apple Silicon + Intel) signed with a
# STABLE certificate, a .dmg in the GitHub Release and an updated cask in
# RenaCode/homebrew-tap (`brew install --cask renacode/tap/cloudmachine`).
#
# Continuous delivery: every merge to main that changes what goes into the
# app (sources, resources, launchd templates, the cask) is released, tagged
# on that commit. Docs-only and test-only merges are not.
#
# Version: mac-app/VERSION is the base. If v<VERSION> is not tagged yet, that
# is the release; otherwise the patch number goes up from the highest tag of
# that major.minor (1.3.0 -> 1.3.1 -> 1.3.2). Bump VERSION by hand only for a
# minor or major release. The computed number is written into the build, so
# the app reports the version it was released as.
#
# Also: "Run workflow" on the Actions tab (same as a merge), and a vX.Y.Z tag
# pushed by hand, which releases exactly that version.
#
# A pull request that changes the build or the cask goes through the same
# pipeline as a dry run: ad-hoc signature, no release and no tap, artifacts
# downloadable from the run.
#
# Secrets (publishing runs only):
# CM_SIGNING_P12_BASE64, CM_SIGNING_P12_PASSWORD - the self-signed
# "CloudMachine Release Signing" certificate (see packaging/README.md).
# Without it the release does NOT build: an ad-hoc signature changes the
# app's identity on every release and macOS revokes Full Disk Access after
# every upgrade.
# TAP_APP_KEY (secret) + TAP_APP_CLIENT_ID (variable) - GitHub App `renacode-tap`
# (Contents: write, installed on RenaCode/homebrew-tap). The job mints a
# 1-hour token scoped to that one repository; nothing expires.
#
# Still no Developer ID and no notarization - Gatekeeper does not know the
# publisher; the cask removes the quarantine attribute in postflight.
on:
push:
tags:
- "v*.*.*"
branches:
- main
paths:
- "mac-app/VERSION"
- "mac-app/Package.swift"
- "mac-app/Package.resolved"
- "mac-app/Sources/**"
- "mac-app/Resources/**"
- "launchd/**"
- "vm/**"
- "config/**"
- "packaging/homebrew/**"
workflow_dispatch:
pull_request:
paths:
- ".github/workflows/release.yml"
- "packaging/**"
- "mac-app/Sources/CloudMachineAgent/BuildAppCommand.swift"
- "mac-app/Sources/CloudMachineAgent/MakeDmgCommand.swift"
- "mac-app/Resources/Info.plist"
env:
CM_SIGNING_CERT_NAME: CloudMachine Release Signing
# One release at a time: a VERSION merge and a hand-pushed tag for the same
# version must not race each other to publish it twice.
concurrency:
group: release-${{ github.event_name == 'pull_request' && github.ref || 'publish' }}
cancel-in-progress: false
jobs:
# Decides on a cheap Linux runner whether this run publishes, so a VERSION
# change that is already released costs no macOS minutes.
decide:
name: Decide
runs-on: ubuntu-latest
outputs:
release: ${{ steps.decide.outputs.release }}
skip: ${{ steps.decide.outputs.skip }}
version: ${{ steps.decide.outputs.version }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- id: decide
run: |
base="$(tr -d '[:space:]' < mac-app/VERSION)"
release=false
skip=false
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
version="$base"
elif [[ "$GITHUB_REF" == refs/tags/* ]]; then
version="${GITHUB_REF_NAME#v}"
release=true
elif git ls-remote --exit-code --tags origin "refs/tags/v${base}" >/dev/null; then
# Base already released: next patch after the highest tag of
# this major.minor. Sorted as versions, not strings (1.3.10 > 1.3.9).
minor="${base%.*}"
last="$(git ls-remote --tags --refs origin "refs/tags/v${minor}.*" \
| sed 's|.*refs/tags/v||' | grep -E "^${minor//./\\.}\.[0-9]+$" | sort -V | tail -1)"
version="${minor}.$(( ${last##*.} + 1 ))"
release=true
else
version="$base"
release=true
fi
if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::'${version}' is not a version (X.Y.Z)."
exit 1
fi
if [ "$release" = "true" ] && [[ "$GITHUB_REF" != refs/tags/* ]] \
&& git ls-remote --exit-code --tags origin "refs/tags/v${version}" >/dev/null; then
echo "::notice::v${version} already exists - nothing to release."
skip=true
fi
echo "Version: ${version} (release=${release})"
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "release=${release}" >> "$GITHUB_OUTPUT"
echo "skip=${skip}" >> "$GITHUB_OUTPUT"
release:
name: Build, release, update tap
needs: decide
if: needs.decide.outputs.skip != 'true'
runs-on: macos-15
permissions:
contents: write
env:
IS_RELEASE: ${{ needs.decide.outputs.release }}
VERSION: ${{ needs.decide.outputs.version }}
# Read by build-app and make-dmg; the computed version must not be
# written into mac-app/VERSION, which would mark every release dirty.
CM_RELEASE_VERSION: ${{ needs.decide.outputs.version }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
fetch-depth: 0
- name: swift test
working-directory: mac-app
run: swift test
- name: Import signing certificate
if: env.IS_RELEASE == 'true'
env:
P12_BASE64: ${{ secrets.CM_SIGNING_P12_BASE64 }}
P12_PASSWORD: ${{ secrets.CM_SIGNING_P12_PASSWORD }}
run: |
if [ -z "$P12_BASE64" ] || [ -z "$P12_PASSWORD" ]; then
echo "::error::CM_SIGNING_P12_* secrets are missing. An ad-hoc signed release would revoke Full Disk Access after every upgrade - aborting. See packaging/README.md."
exit 1
fi
keychain="$RUNNER_TEMP/signing.keychain-db"
keychain_password="$(openssl rand -hex 16)"
printf '%s' "$P12_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12"
/usr/bin/openssl pkcs12 -in "$RUNNER_TEMP/cert.p12" -nokeys \
-passin "pass:$P12_PASSWORD" -out "$RUNNER_TEMP/cert.pem"
security create-keychain -p "$keychain_password" "$keychain"
security set-keychain-settings -lut 21600 "$keychain"
security unlock-keychain -p "$keychain_password" "$keychain"
security import "$RUNNER_TEMP/cert.p12" -k "$keychain" -P "$P12_PASSWORD" \
-T /usr/bin/codesign -T /usr/bin/security
security set-key-partition-list -S apple-tool:,apple: -s -k "$keychain_password" "$keychain"
# Add it to the search list, because `build-app` looks for the
# certificate with `security find-certificate -c` without naming a keychain.
security list-keychains -d user -s "$keychain" $(security list-keychains -d user | tr -d '"')
sudo security add-trusted-cert -d -r trustRoot -p codeSign \
-k /Library/Keychains/System.keychain "$RUNNER_TEMP/cert.pem"
rm -f "$RUNNER_TEMP/cert.p12"
# Shown before the build: v1.3.0 shipped marked DIRTY-TREE, and the log
# did not say which file a runner step had changed.
- name: Working tree state
run: git status --porcelain && git diff --stat
- name: Build CloudMachine.app (universal)
working-directory: mac-app
run: |
# Commit count as the build number, computed here so a failing git
# call inside build-app cannot quietly turn it into a date (v1.3.1).
CM_BUILD_NUMBER="$(git rev-list --count HEAD)"
if [ -z "$CM_BUILD_NUMBER" ]; then
echo "::error::git rev-list --count HEAD returned nothing."; exit 1
fi
export CM_BUILD_NUMBER
swift run cloudmachine-agent build-app --universal
- name: Verify architectures and signature
working-directory: mac-app
run: |
for bin in CloudMachine cloudmachine-agent; do
archs="$(lipo -archs "build/CloudMachine.app/Contents/MacOS/$bin")"
echo "$bin: $archs"
case "$archs" in *arm64*x86_64*|*x86_64*arm64*) ;; *)
echo "::error::$bin is not universal ($archs)"; exit 1 ;;
esac
done
codesign --verify --deep --strict build/CloudMachine.app
signature="$(codesign -dv --verbose=2 build/CloudMachine.app 2>&1)"
echo "$signature"
# `build-app` silently falls back to ad-hoc when it cannot find the
# certificate - here that has to be an error, not a warning.
if [ "$IS_RELEASE" = "true" ] && ! grep -qF "Authority=$CM_SIGNING_CERT_NAME" <<<"$signature"; then
echo "::error::The release is not signed with the '$CM_SIGNING_CERT_NAME' certificate."
exit 1
fi
# `hdiutil create` on GitHub's macOS runners fails now and then with
# "Resource busy" - it did on the first v1.3.0 run, after passing three
# dry runs. Retried with a growing pause; a real failure still fails.
# A release must be exactly a commit. v1.3.0 was not: the macos-14
# runner (Swift 5.10) could not use swift-argument-parser 1.8.2, which
# needs Swift 6, quietly resolved 1.7.2 and rewrote Package.resolved.
# Any tracked file changed by the build stops the release here.
- name: Build did not change tracked files
if: env.IS_RELEASE == 'true'
run: |
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
git status --porcelain --untracked-files=no
git diff
echo "::error::The build changed tracked files; the release would not match its commit."
exit 1
fi
- name: Package .dmg
working-directory: mac-app
run: |
for attempt in 1 2 3 4 5; do
swift run cloudmachine-agent make-dmg && exit 0
echo "::warning::make-dmg attempt ${attempt} failed; retrying"
sleep $((attempt * 10))
done
exit 1
- name: Render cask
id: cask
run: |
version="${{ env.VERSION }}"
dmg="mac-app/build/CloudMachine-${version}.dmg"
sha256="$(shasum -a 256 "$dmg" | cut -d' ' -f1)"
sed -e "s/__VERSION__/${version}/" -e "s/__SHA256__/${sha256}/" \
packaging/homebrew/cloudmachine.rb.in > mac-app/build/cloudmachine.rb
if grep -q '__[A-Z0-9]*__' mac-app/build/cloudmachine.rb; then
echo "::error::An unfilled placeholder is left in the cask."; exit 1
fi
echo "${sha256} CloudMachine-${version}.dmg" > "mac-app/build/CloudMachine-${version}.dmg.sha256"
echo "dmg=${dmg}" >> "$GITHUB_OUTPUT"
# `brew style` applies the cask rules only to files in a tap's Casks/ -
# on a loose file it checks it as plain Ruby and lets cask errors through.
- name: brew style + audit
env:
HOMEBREW_NO_AUTO_UPDATE: "1"
run: |
brew tap-new --no-git renacode/ci-check
tap="$(brew --repository renacode/ci-check)"
mkdir -p "$tap/Casks"
cp mac-app/build/cloudmachine.rb "$tap/Casks/cloudmachine.rb"
brew style --cask renacode/ci-check/cloudmachine
brew audit --cask --strict renacode/ci-check/cloudmachine
- name: Upload artifacts (dry run)
if: env.IS_RELEASE != 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: cloudmachine-${{ env.VERSION }}-dry-run
path: |
mac-app/build/*.dmg
mac-app/build/*.sha256
mac-app/build/cloudmachine.rb
- name: Publish GitHub Release
if: env.IS_RELEASE == 'true'
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
with:
# Creates the tag on this commit when the run did not start from one.
tag_name: v${{ env.VERSION }}
target_commitish: ${{ github.sha }}
files: |
mac-app/build/*.dmg
mac-app/build/*.sha256
generate_release_notes: true
- name: Check tap app
if: env.IS_RELEASE == 'true'
env:
TAP_APP_CLIENT_ID: ${{ vars.TAP_APP_CLIENT_ID }}
TAP_APP_KEY: ${{ secrets.TAP_APP_KEY }}
run: |
if [ -z "$TAP_APP_CLIENT_ID" ] || [ -z "$TAP_APP_KEY" ]; then
echo "::error::Release published, but TAP_APP_CLIENT_ID/TAP_APP_KEY is missing - the cask in the tap was NOT updated. Contents to insert manually:"
cat mac-app/build/cloudmachine.rb
exit 1
fi
# Token and the access probe run on EVERY build, not only on release:
# an app that lost its installation (or a missing key) fails the PR,
# not the release after the DMG is already published.
- name: Tap token
id: tap-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.TAP_APP_CLIENT_ID }}
private-key: ${{ secrets.TAP_APP_KEY }}
owner: RenaCode
repositories: homebrew-tap
- name: Check tap access
env:
TAP_TOKEN: ${{ steps.tap-token.outputs.token }}
run: git ls-remote "https://x-access-token:${TAP_TOKEN}@github.com/RenaCode/homebrew-tap.git" HEAD
- name: Check out tap
if: env.IS_RELEASE == 'true'
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
repository: RenaCode/homebrew-tap
token: ${{ steps.tap-token.outputs.token }}
path: homebrew-tap
- name: Push cask to tap
if: env.IS_RELEASE == 'true'
working-directory: homebrew-tap
run: |
mkdir -p Casks
cp ../mac-app/build/cloudmachine.rb Casks/cloudmachine.rb
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add Casks/cloudmachine.rb
if git diff --cached --quiet; then
echo "Cask unchanged."; exit 0
fi
git commit -m "cloudmachine ${{ env.VERSION }}"
git push