|
1
|
+ |
#!/bin/sh
|
|
2
|
+ |
# Build, sign, and publish the OpenAgents CLI for every platform the installer
|
|
3
|
+ |
# knows how to ask for.
|
|
4
|
+ |
#
|
|
5
|
+ |
# The contract this script fills is written down in the installer served at
|
|
6
|
+ |
# https://openagents.com/install.sh. Under the base URL
|
|
7
|
+ |
# https://openagents.com/releases it fetches:
|
|
8
|
+ |
#
|
|
9
|
+ |
# <base>/<channel> a bare version string
|
|
10
|
+ |
# <base>/openagents-<version>-<platform> the executable, no extension
|
|
11
|
+ |
# <base>/SHA256SUMS-<version> "<sha256> <name>" per line
|
|
12
|
+ |
#
|
|
13
|
+ |
# Anything this script emits that disagrees with those three shapes is a broken
|
|
14
|
+ |
# release, so the names are derived here once and never spelled twice.
|
|
15
|
+ |
#
|
|
16
|
+ |
# Usage:
|
|
17
|
+ |
# ops/release-cli.sh --version 0.1.0-rc.1
|
|
18
|
+ |
# ops/release-cli.sh --version 0.1.0-rc.1 --publish
|
|
19
|
+ |
# ops/release-cli.sh --version 0.1.0 --publish --channel stable
|
|
20
|
+ |
#
|
|
21
|
+ |
# Options:
|
|
22
|
+ |
# --version X.Y.Z[-suffix] Required. The version to build and name.
|
|
23
|
+ |
# --targets "a b c" Platforms to attempt. Defaults to all five.
|
|
24
|
+ |
# --publish Upload to the release bucket. Off by default.
|
|
25
|
+ |
# --channel NAME Point a channel at this version after publishing.
|
|
26
|
+ |
# --allow-partial Publish even though some platforms are missing.
|
|
27
|
+ |
# --allow-prerelease-channel Let a prerelease version claim a channel.
|
|
28
|
+ |
# --skip-notarization Build macOS artifacts without Apple notarization.
|
|
29
|
+ |
|
|
30
|
+ |
set -eu
|
|
31
|
+ |
|
|
32
|
+ |
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
33
|
+ |
repo_root=$(CDPATH= cd -- "$script_dir/.." && pwd)
|
|
34
|
+ |
|
|
35
|
+ |
# platform | rust target triple | builder | expected `file` signature
|
|
36
|
+ |
#
|
|
37
|
+ |
# The fourth column is the anti-forgery check. Cross-compilation fails in ways
|
|
38
|
+ |
# that still leave a runnable file sitting at the output path -- a stale
|
|
39
|
+ |
# artifact from a previous target, or a host build that silently ignored the
|
|
40
|
+ |
# requested triple -- and a release that ships a darwin binary under a
|
|
41
|
+ |
# linux-x86_64 name is worse than one that admits it built four of five. Every
|
|
42
|
+ |
# artifact is read back and matched against this signature before it is staged.
|
|
43
|
+ |
platform_table='macos-aarch64|aarch64-apple-darwin|cargo|Mach-O 64-bit executable arm64
|
|
44
|
+ |
macos-x86_64|x86_64-apple-darwin|cargo|Mach-O 64-bit executable x86_64
|
|
45
|
+ |
linux-x86_64|x86_64-unknown-linux-gnu|zigbuild|ELF 64-bit LSB*x86-64
|
|
46
|
+ |
linux-aarch64|aarch64-unknown-linux-gnu|zigbuild|ELF 64-bit LSB*ARM aarch64
|
|
47
|
+ |
windows-x86_64|x86_64-pc-windows-gnu|zigbuild|PE32+ executable*x86-64'
|
|
48
|
+ |
|
|
49
|
+ |
all_platforms=$(printf '%s\n' "$platform_table" | cut -d'|' -f1 | tr '\n' ' ')
|
|
50
|
+ |
|
|
51
|
+ |
version=''
|
|
52
|
+ |
targets=''
|
|
53
|
+ |
publish=0
|
|
54
|
+ |
channel=''
|
|
55
|
+ |
allow_partial=0
|
|
56
|
+ |
allow_prerelease_channel=0
|
|
57
|
+ |
skip_notarization=0
|
|
58
|
+ |
|
|
59
|
+ |
bucket=${OPENAGENTS_RELEASES_BUCKET:-openagentsgemini-cli-releases}
|
|
60
|
+ |
gcloud_config=${CLOUDSDK_CONFIG:-/Users/christopherdavid/work/.secrets/gcloud-sa-config}
|
|
61
|
+ |
notary_env=${OPENAGENTS_NOTARY_ENV:-/Users/christopherdavid/work/.secrets/appstoreconnect.env}
|
|
62
|
+ |
|
|
63
|
+ |
# The code signing identifier is pinned rather than derived from the file name.
|
|
64
|
+ |
# codesign defaults the identifier to the basename, which would give the same
|
|
65
|
+ |
# build a different identity depending on where it was staged.
|
|
66
|
+ |
signing_identifier='com.openagents.cli'
|
|
67
|
+ |
|
|
68
|
+ |
die() {
|
|
69
|
+ |
echo "$@" >&2
|
|
70
|
+ |
exit 1
|
|
71
|
+ |
}
|
|
72
|
+ |
|
|
73
|
+ |
while [ $# -gt 0 ]; do
|
|
74
|
+ |
case "$1" in
|
|
75
|
+ |
--version) version=${2:-}; shift 2 ;;
|
|
76
|
+ |
--targets) targets=${2:-}; shift 2 ;;
|
|
77
|
+ |
--channel) channel=${2:-}; shift 2 ;;
|
|
78
|
+ |
--publish) publish=1; shift ;;
|
|
79
|
+ |
--allow-partial) allow_partial=1; shift ;;
|
|
80
|
+ |
--allow-prerelease-channel) allow_prerelease_channel=1; shift ;;
|
|
81
|
+ |
--skip-notarization) skip_notarization=1; shift ;;
|
|
82
|
+ |
-h | --help) sed -n '2,28p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
|
83
|
+ |
*) die "unknown option: $1" ;;
|
|
84
|
+ |
esac
|
|
85
|
+ |
done
|
|
86
|
+ |
|
|
87
|
+ |
[ -n "$version" ] || die "--version is required"
|
|
88
|
+ |
|
|
89
|
+ |
# The same grammar the installer applies to its argument. A version this script
|
|
90
|
+ |
# accepts but the installer rejects would publish an artifact nobody can ask for.
|
|
91
|
+ |
printf '%s' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9._]+)?$' ||
|
|
92
|
+ |
die "invalid version: $version (expected X.Y.Z or X.Y.Z-suffix)"
|
|
93
|
+ |
|
|
94
|
+ |
case "$version" in
|
|
95
|
+ |
*-*) prerelease=1 ;;
|
|
96
|
+ |
*) prerelease=0 ;;
|
|
97
|
+ |
esac
|
|
98
|
+ |
|
|
99
|
+ |
[ -n "$targets" ] || targets=$all_platforms
|
|
100
|
+ |
|
|
101
|
+ |
for command_name in cargo file shasum; do
|
|
102
|
+ |
command -v "$command_name" >/dev/null 2>&1 ||
|
|
103
|
+ |
die "$command_name is required to build a release"
|
|
104
|
+ |
done
|
|
105
|
+ |
|
|
106
|
+ |
dist="$repo_root/dist/releases/$version"
|
|
107
|
+ |
rm -rf "$dist"
|
|
108
|
+ |
mkdir -p "$dist"
|
|
109
|
+ |
|
|
110
|
+ |
built=''
|
|
111
|
+ |
missing=''
|
|
112
|
+ |
manifest_entries=''
|
|
113
|
+ |
|
|
114
|
+ |
notary_loaded=0
|
|
115
|
+ |
load_notary_env() {
|
|
116
|
+ |
[ "$notary_loaded" = 0 ] || return 0
|
|
117
|
+ |
[ -f "$notary_env" ] || return 1
|
|
118
|
+ |
# The App Store Connect key id, issuer, and private key path are read from the
|
|
119
|
+ |
# operator's secret file at call time and passed to notarytool per invocation.
|
|
120
|
+ |
# `notarytool store-credentials` would copy the same private key into the
|
|
121
|
+ |
# login keychain, leaving a second durable copy of a credential that already
|
|
122
|
+ |
# exists on disk; passing it per call leaves nothing new behind.
|
|
123
|
+ |
set -a
|
|
124
|
+ |
# shellcheck disable=SC1090
|
|
125
|
+ |
. "$notary_env"
|
|
126
|
+ |
set +a
|
|
127
|
+ |
notary_loaded=1
|
|
128
|
+ |
return 0
|
|
129
|
+ |
}
|
|
130
|
+ |
|
|
131
|
+ |
# Sign, notarize, and verify one macOS artifact.
|
|
132
|
+ |
#
|
|
133
|
+ |
# Apple cannot staple a notarization ticket to a bare Mach-O executable -- only
|
|
134
|
+ |
# a container such as a .zip, .dmg, or .pkg carries the stapled ticket -- and
|
|
135
|
+ |
# `xcrun stapler staple` on a bare binary fails with error 73. The installer
|
|
136
|
+ |
# downloads a single executable and marks it executable, so a stapled container
|
|
137
|
+ |
# would mean changing a landed contract to buy something the install path does
|
|
138
|
+ |
# not use: curl sets no com.apple.quarantine attribute, so Gatekeeper is not
|
|
139
|
+ |
# consulted on a piped install at all. The artifact therefore ships bare, and
|
|
140
|
+ |
# `spctl --assess -t install` confirms Apple's notary service recognizes it
|
|
141
|
+ |
# online as "Notarized Developer ID" for the paths where quarantine does apply.
|
|
142
|
+ |
sign_and_notarize() {
|
|
143
|
+ |
artifact=$1
|
|
144
|
+ |
platform=$2
|
|
145
|
+ |
|
|
146
|
+ |
command -v codesign >/dev/null 2>&1 || die "codesign is required for $platform"
|
|
147
|
+ |
|
|
148
|
+ |
load_notary_env ||
|
|
149
|
+ |
die "missing $notary_env; macOS artifacts must be signed. Pass --skip-notarization to build unsigned."
|
|
150
|
+ |
|
|
151
|
+ |
[ -n "${OA_DEVELOPER_ID_APPLICATION:-}" ] ||
|
|
152
|
+ |
die "OA_DEVELOPER_ID_APPLICATION is not set in $notary_env"
|
|
153
|
+ |
|
|
154
|
+ |
echo " signing $platform as $signing_identifier"
|
|
155
|
+ |
codesign --force --timestamp --options runtime \
|
|
156
|
+ |
--identifier "$signing_identifier" \
|
|
157
|
+ |
--sign "$OA_DEVELOPER_ID_APPLICATION" \
|
|
158
|
+ |
"$artifact" >/dev/null 2>&1 ||
|
|
159
|
+ |
die "codesign failed for $platform"
|
|
160
|
+ |
|
|
161
|
+ |
codesign --verify --strict "$artifact" ||
|
|
162
|
+ |
die "signature does not verify for $platform"
|
|
163
|
+ |
|
|
164
|
+ |
if [ "$skip_notarization" = 1 ]; then
|
|
165
|
+ |
echo " skipping notarization for $platform (--skip-notarization)"
|
|
166
|
+ |
notary_status='skipped'
|
|
167
|
+ |
notary_submission=''
|
|
168
|
+ |
return 0
|
|
169
|
+ |
fi
|
|
170
|
+ |
|
|
171
|
+ |
command -v xcrun >/dev/null 2>&1 || die "xcrun is required to notarize $platform"
|
|
172
|
+ |
|
|
173
|
+ |
submission_zip="$artifact.notarize.zip"
|
|
174
|
+ |
rm -f "$submission_zip"
|
|
175
|
+ |
/usr/bin/ditto -c -k --keepParent "$artifact" "$submission_zip"
|
|
176
|
+ |
|
|
177
|
+ |
echo " notarizing $platform"
|
|
178
|
+ |
notary_log="$artifact.notary.log"
|
|
179
|
+ |
xcrun notarytool submit "$submission_zip" \
|
|
180
|
+ |
--key "$ASC_API_PRIVATE_KEY_PATH" \
|
|
181
|
+ |
--key-id "$ASC_API_KEY_ID" \
|
|
182
|
+ |
--issuer "$ASC_API_ISSUER_ID" \
|
|
183
|
+ |
--wait --timeout 30m >"$notary_log" 2>&1 ||
|
|
184
|
+ |
{ cat "$notary_log" >&2; die "notarization failed for $platform"; }
|
|
185
|
+ |
|
|
186
|
+ |
notary_submission=$(awk '/^ id: /{print $2; exit}' "$notary_log")
|
|
187
|
+ |
notary_status=$(awk '/^ status: /{print $2; exit}' "$notary_log")
|
|
188
|
+ |
rm -f "$submission_zip"
|
|
189
|
+ |
|
|
190
|
+ |
[ "$notary_status" = "Accepted" ] ||
|
|
191
|
+ |
{ cat "$notary_log" >&2; die "notarization for $platform came back $notary_status"; }
|
|
192
|
+ |
|
|
193
|
+ |
echo " notarized $platform ($notary_submission, $notary_status)"
|
|
194
|
+ |
|
|
195
|
+ |
# Evidence, not ceremony: this is the assessment an operator would run by hand
|
|
196
|
+ |
# to answer "will Gatekeeper accept this".
|
|
197
|
+ |
spctl --assess -vv -t install "$artifact" 2>&1 | sed 's/^/ /'
|
|
198
|
+ |
}
|
|
199
|
+ |
|
|
200
|
+ |
echo "Building OpenAgents CLI $version"
|
|
201
|
+ |
echo
|
|
202
|
+ |
|
|
203
|
+ |
for platform in $targets; do
|
|
204
|
+ |
row=$(printf '%s\n' "$platform_table" | grep "^$platform|") ||
|
|
205
|
+ |
die "unknown platform: $platform (known: $all_platforms)"
|
|
206
|
+ |
|
|
207
|
+ |
triple=$(printf '%s' "$row" | cut -d'|' -f2)
|
|
208
|
+ |
builder=$(printf '%s' "$row" | cut -d'|' -f3)
|
|
209
|
+ |
expected=$(printf '%s' "$row" | cut -d'|' -f4)
|
|
210
|
+ |
|
|
211
|
+ |
echo "$platform ($triple, $builder)"
|
|
212
|
+ |
|
|
213
|
+ |
if ! rustup target list --installed 2>/dev/null | grep -qx "$triple"; then
|
|
214
|
+ |
echo " SKIP: rust target $triple is not installed (rustup target add $triple)"
|
|
215
|
+ |
missing="$missing $platform"
|
|
216
|
+ |
continue
|
|
217
|
+ |
fi
|
|
218
|
+ |
|
|
219
|
+ |
if [ "$builder" = zigbuild ] && ! command -v cargo-zigbuild >/dev/null 2>&1; then
|
|
220
|
+ |
echo " SKIP: cargo-zigbuild is not installed and $triple cannot be built natively here"
|
|
221
|
+ |
missing="$missing $platform"
|
|
222
|
+ |
continue
|
|
223
|
+ |
fi
|
|
224
|
+ |
|
|
225
|
+ |
# Every artifact is rebuilt from source into its own target directory. Nothing
|
|
226
|
+ |
# is copied forward from a previous run, so a build that fails cannot leave a
|
|
227
|
+ |
# stale binary behind for the verification step to bless.
|
|
228
|
+ |
case "$triple" in
|
|
229
|
+ |
*windows*) output="$repo_root/target/$triple/release/oa.exe" ;;
|
|
230
|
+ |
*) output="$repo_root/target/$triple/release/oa" ;;
|
|
231
|
+ |
esac
|
|
232
|
+ |
rm -f "$output"
|
|
233
|
+ |
|
|
234
|
+ |
build_log="$dist/$platform.build.log"
|
|
235
|
+ |
if [ "$builder" = zigbuild ]; then
|
|
236
|
+ |
build_command='cargo zigbuild'
|
|
237
|
+ |
else
|
|
238
|
+ |
build_command='cargo build'
|
|
239
|
+ |
fi
|
|
240
|
+ |
|
|
241
|
+ |
if ! (cd "$repo_root" && $build_command --release -p openagents-cli --target "$triple") \
|
|
242
|
+ |
>"$build_log" 2>&1; then
|
|
243
|
+ |
echo " SKIP: build failed (see $build_log)"
|
|
244
|
+ |
tail -5 "$build_log" | sed 's/^/ /'
|
|
245
|
+ |
missing="$missing $platform"
|
|
246
|
+ |
continue
|
|
247
|
+ |
fi
|
|
248
|
+ |
|
|
249
|
+ |
[ -f "$output" ] || { echo " SKIP: build reported success but produced no binary"; missing="$missing $platform"; continue; }
|
|
250
|
+ |
|
|
251
|
+ |
# The forgery check. `file` reads the actual Mach-O/ELF/PE header rather than
|
|
252
|
+ |
# trusting the path the compiler was asked to write to.
|
|
253
|
+ |
signature=$(file -b "$output")
|
|
254
|
+ |
# shellcheck disable=SC2254
|
|
255
|
+ |
case "$signature" in
|
|
256
|
+ |
$expected*) ;;
|
|
257
|
+ |
*)
|
|
258
|
+ |
echo " REFUSED: $output is '$signature', expected '$expected'"
|
|
259
|
+ |
echo " Refusing to publish a binary under a platform name it does not match."
|
|
260
|
+ |
missing="$missing $platform"
|
|
261
|
+ |
continue
|
|
262
|
+ |
;;
|
|
263
|
+ |
esac
|
|
264
|
+ |
|
|
265
|
+ |
# The installer never appends an extension to the artifact URL, on any
|
|
266
|
+ |
# platform: it computes artifact_base before it branches on windows and never
|
|
267
|
+ |
# revisits it. The staged file name matches that URL exactly.
|
|
268
|
+ |
artifact="$dist/openagents-$version-$platform"
|
|
269
|
+ |
cp "$output" "$artifact"
|
|
270
|
+ |
chmod +x "$artifact"
|
|
271
|
+ |
|
|
272
|
+ |
notary_status='not-applicable'
|
|
273
|
+ |
notary_submission=''
|
|
274
|
+ |
case "$platform" in
|
|
275
|
+ |
macos-*) sign_and_notarize "$artifact" "$platform" ;;
|
|
276
|
+ |
esac
|
|
277
|
+ |
|
|
278
|
+ |
sha=$(shasum -a 256 "$artifact" | awk '{print $1}')
|
|
279
|
+ |
size=$(wc -c <"$artifact" | tr -d ' ')
|
|
280
|
+ |
echo " ok $signature"
|
|
281
|
+ |
echo " sha256 $sha ($size bytes)"
|
|
282
|
+ |
|
|
283
|
+ |
built="$built $platform"
|
|
284
|
+ |
manifest_entries="$manifest_entries
|
|
285
|
+ |
{\"platform\": \"$platform\", \"target\": \"$triple\", \"builder\": \"$builder\", \"sha256\": \"$sha\", \"bytes\": $size, \"notarization\": \"$notary_status\", \"notarization_submission\": \"$notary_submission\"},"
|
|
286
|
+ |
echo
|
|
287
|
+ |
done
|
|
288
|
+ |
|
|
289
|
+ |
[ -n "$built" ] || die "no platform built; nothing to publish"
|
|
290
|
+ |
|
|
291
|
+ |
# The sums file the installer reads. Its lookup is
|
|
292
|
+ |
# awk '$2 == name || $2 == "*" name'
|
|
293
|
+ |
# over "<sha256> <name>", and for windows it appends ".exe" to the name it
|
|
294
|
+ |
# looks up even though it downloaded a URL without one. That asymmetry lives in
|
|
295
|
+ |
# a landed installer, so the sums file reproduces it exactly: the artifact keeps
|
|
296
|
+ |
# its extensionless name and the sums entry carries the .exe the installer will
|
|
297
|
+ |
# search for. Diverging here would checksum-fail every Windows install.
|
|
298
|
+ |
sums="$dist/SHA256SUMS-$version"
|
|
299
|
+ |
: >"$sums"
|
|
300
|
+ |
for platform in $built; do
|
|
301
|
+ |
artifact="openagents-$version-$platform"
|
|
302
|
+ |
sums_name=$artifact
|
|
303
|
+ |
case "$platform" in
|
|
304
|
+ |
windows-*) sums_name="$artifact.exe" ;;
|
|
305
|
+ |
esac
|
|
306
|
+ |
sha=$(shasum -a 256 "$dist/$artifact" | awk '{print $1}')
|
|
307
|
+ |
printf '%s %s\n' "$sha" "$sums_name" >>"$sums"
|
|
308
|
+ |
done
|
|
309
|
+ |
|
|
310
|
+ |
cat >"$dist/release-manifest.json" <<EOF
|
|
311
|
+ |
{
|
|
312
|
+ |
"schema": "openagents.cli-release.v1",
|
|
313
|
+ |
"version": "$version",
|
|
314
|
+ |
"built_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
|
315
|
+ |
"git_sha": "$(git -C "$repo_root" rev-parse --verify HEAD)",
|
|
316
|
+ |
"host": "$(uname -sm)",
|
|
317
|
+ |
"artifacts": [$(printf '%s' "$manifest_entries" | sed '$ s/,$//')
|
|
318
|
+ |
]
|
|
319
|
+ |
}
|
|
320
|
+ |
EOF
|
|
321
|
+ |
|
|
322
|
+ |
echo "Built: $(printf '%s' "$built" | tr -s ' ')"
|
|
323
|
+ |
if [ -n "$missing" ]; then
|
|
324
|
+ |
echo "Missing:$missing"
|
|
325
|
+ |
fi
|
|
326
|
+ |
echo "Staged in $dist"
|
|
327
|
+ |
echo
|
|
328
|
+ |
|
|
329
|
+ |
if [ -n "$missing" ] && [ "$allow_partial" = 0 ]; then
|
|
330
|
+ |
echo "This release is missing:$missing" >&2
|
|
331
|
+ |
echo "" >&2
|
|
332
|
+ |
echo "A channel that points at a version some platforms cannot install is a" >&2
|
|
333
|
+ |
echo "broken channel, and the installer reports a bare download failure rather" >&2
|
|
334
|
+ |
echo "than 'unsupported platform'. Fix the toolchain, restrict --targets to" >&2
|
|
335
|
+ |
echo "what you meant to ship, or pass --allow-partial to publish anyway." >&2
|
|
336
|
+ |
exit 1
|
|
337
|
+ |
fi
|
|
338
|
+ |
|
|
339
|
+ |
if [ "$publish" = 0 ]; then
|
|
340
|
+ |
echo "Not publishing (--publish was not passed)."
|
|
341
|
+ |
exit 0
|
|
342
|
+ |
fi
|
|
343
|
+ |
|
|
344
|
+ |
command -v gcloud >/dev/null 2>&1 || die "gcloud is required to publish"
|
|
345
|
+ |
CLOUDSDK_CONFIG=$gcloud_config
|
|
346
|
+ |
export CLOUDSDK_CONFIG
|
|
347
|
+ |
|
|
348
|
+ |
echo "Publishing to gs://$bucket"
|
|
349
|
+ |
for platform in $built; do
|
|
350
|
+ |
artifact="openagents-$version-$platform"
|
|
351
|
+ |
gcloud storage cp "$dist/$artifact" "gs://$bucket/$artifact" \
|
|
352
|
+ |
--content-type=application/octet-stream --quiet
|
|
353
|
+ |
done
|
|
354
|
+ |
gcloud storage cp "$sums" "gs://$bucket/SHA256SUMS-$version" \
|
|
355
|
+ |
--content-type=text/plain --quiet
|
|
356
|
+ |
|
|
357
|
+ |
echo "Published $version"
|
|
358
|
+ |
|
|
359
|
+ |
[ -n "$channel" ] || { echo "No channel updated (--channel was not passed)."; exit 0; }
|
|
360
|
+ |
|
|
361
|
+ |
if [ "$prerelease" = 1 ] && [ "$allow_prerelease_channel" = 0 ]; then
|
|
362
|
+ |
die "refusing to point '$channel' at prerelease $version; pass --allow-prerelease-channel if that is the intent"
|
|
363
|
+ |
fi
|
|
364
|
+ |
|
|
365
|
+ |
# The pointer file is the whole body, no trailing newline beyond the one the
|
|
366
|
+ |
# installer strips with tr -d '[:space:]'.
|
|
367
|
+ |
pointer=$(mktemp)
|
|
368
|
+ |
printf '%s\n' "$version" >"$pointer"
|
|
369
|
+ |
gcloud storage cp "$pointer" "gs://$bucket/$channel" \
|
|
370
|
+ |
--content-type=text/plain --cache-control='public, max-age=60' --quiet
|
|
371
|
+ |
rm -f "$pointer"
|
|
372
|
+ |
|
|
373
|
+ |
echo "Channel '$channel' now points at $version"
|