@marcel91200 / caddy Mirror · read-only
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 11, 01:00 UTC

master
yml 552 lines 21.9 KB
Raw
1 name: Release
2
3 on:
4 push:
5 tags:
6 - 'v*.*.*'
7
8 env:
9 # https://github.com/actions/setup-go/issues/491
10 GOTOOLCHAIN: local
11
12 permissions:
13 contents: read
14
15 jobs:
16 verify-tag:
17 name: Verify Tag Signature and Approvals
18 runs-on: ubuntu-latest
19 permissions:
20 contents: write
21 pull-requests: write
22 issues: write
23
24 outputs:
25 verification_passed: ${{ steps.verify.outputs.passed }}
26 tag_version: ${{ steps.info.outputs.version }}
27 proposal_issue_number: ${{ steps.find_proposal.outputs.result && fromJson(steps.find_proposal.outputs.result).number || '' }}
28
29 # Values from the event, step outputs and API results are only ever
30 # passed through env:, never interpolated as expressions into run: or
31 # script: bodies.
32 steps:
33 # Validate the tag name before anything else uses it. Git allows
34 # characters like $, ( and ; in tag names that match the v*.*.* trigger,
35 # and the tag name ends up in shell commands (including GoReleaser hooks).
36 - name: Validate tag name
37 run: |
38 if [[ ! "$GITHUB_REF" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then
39 echo "❌ Refusing to release $GITHUB_REF: not a vMAJOR.MINOR.PATCH[-PRERELEASE] tag"
40 exit 1
41 fi
42
43 - name: Checkout code
44 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
45 with:
46 fetch-depth: 0
47 # Force fetch upstream tags -- because 65 minutes
48 # tl;dr: actions/checkout@v3 runs this line:
49 # git -c protocol.version=2 fetch --no-tags --prune --progress --no-recurse-submodules --depth=1 origin +ebc278ec98bb24f2852b61fde2a9bf2e3d83818b:refs/tags/
50 # which makes its own local lightweight tag, losing all the annotations in the process. Our earlier script ran:
51 # git fetch --prune --unshallow
52 # which doesn't overwrite that tag because that would be destructive.
53 # Credit to @francislavoie for the investigation.
54 # https://github.com/actions/checkout/issues/290#issuecomment-680260080
55 - name: Force fetch upstream tags
56 run: git fetch --tags --force
57
58 - name: Get tag info
59 id: info
60 run: |
61 echo "version=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
62 echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
63
64 # https://github.community/t5/GitHub-Actions/How-to-get-just-the-tag-name/m-p/32167/highlight/true#M1027
65 - name: Print Go version and environment
66 id: vars
67 run: |
68 printf "Using go at: $(which go)\n"
69 printf "Go version: $(go version)\n"
70 printf "\n\nGo environment:\n\n"
71 go env
72 printf "\n\nSystem environment:\n\n"
73 env
74 echo "version_tag=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_OUTPUT
75 echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
76
77 # Add "pip install" CLI tools to PATH
78 echo ~/.local/bin >> $GITHUB_PATH
79
80 # Parse semver
81 TAG=${GITHUB_REF/refs\/tags\//}
82 SEMVER_RE='[^0-9]*\([0-9]*\)[.]\([0-9]*\)[.]\([0-9]*\)\([0-9A-Za-z\.-]*\)'
83 TAG_MAJOR=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\1#"`
84 TAG_MINOR=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\2#"`
85 TAG_PATCH=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\3#"`
86 TAG_SPECIAL=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\4#"`
87 echo "tag_major=${TAG_MAJOR}" >> $GITHUB_OUTPUT
88 echo "tag_minor=${TAG_MINOR}" >> $GITHUB_OUTPUT
89 echo "tag_patch=${TAG_PATCH}" >> $GITHUB_OUTPUT
90 echo "tag_special=${TAG_SPECIAL}" >> $GITHUB_OUTPUT
91
92 - name: Validate commits and tag signatures
93 id: verify
94 env:
95 signing_keys: ${{ secrets.SIGNING_KEYS }}
96 TAG: ${{ steps.vars.outputs.version_tag }}
97 run: |
98 # Read the string into an array, splitting by IFS
99 IFS=";" read -ra keys_collection <<< "$signing_keys"
100
101 # ref: https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#example-usage-of-the-runner-context
102 touch "$RUNNER_TEMP/allowed_signers"
103
104 # Iterate and print the split elements
105 for item in "${keys_collection[@]}"; do
106
107 # trim leading whitespaces
108 item="${item##*( )}"
109
110 # trim trailing whitespaces
111 item="${item%%*( )}"
112
113 IFS=" " read -ra key_components <<< "$item"
114 # git wants it in format: email address, type, public key
115 # ssh has it in format: type, public key, email address
116 echo "${key_components[2]} namespaces=\"git\" ${key_components[0]} ${key_components[1]}" >> "$RUNNER_TEMP/allowed_signers"
117 done
118
119 git config set --global gpg.ssh.allowedSignersFile "$RUNNER_TEMP/allowed_signers"
120
121 echo "Verifying the tag: $TAG"
122
123 # Verify the tag is signed
124 if ! git verify-tag -v "$TAG" 2>&1; then
125 echo "❌ Tag verification failed!"
126 echo "passed=false" >> $GITHUB_OUTPUT
127 git push --delete origin "$TAG"
128 exit 1
129 fi
130 # Run it again to capture the output
131 git verify-tag -v "$TAG" 2>&1 | tee /tmp/verify-output.txt;
132
133 # SSH verification output typically includes the key fingerprint
134 # Use GNU grep with Perl regex for cleaner extraction (Linux environment)
135 KEY_SHA256=$(grep -oP "SHA256:[\"']?\K[A-Za-z0-9+/=]+(?=[\"']?)" /tmp/verify-output.txt | head -1 || echo "")
136
137 if [ -z "$KEY_SHA256" ]; then
138 # Try alternative pattern with "key" prefix
139 KEY_SHA256=$(grep -oP "key SHA256:[\"']?\K[A-Za-z0-9+/=]+(?=[\"']?)" /tmp/verify-output.txt | head -1 || echo "")
140 fi
141
142 if [ -z "$KEY_SHA256" ]; then
143 # Fallback: extract any base64-like string (40+ chars)
144 KEY_SHA256=$(grep -oP '[A-Za-z0-9+/]{40,}=?' /tmp/verify-output.txt | head -1 || echo "")
145 fi
146
147 if [ -z "$KEY_SHA256" ]; then
148 echo "Somehow could not extract SSH key fingerprint from git verify-tag output"
149 echo "Cancelling flow and deleting tag"
150 echo "passed=false" >> $GITHUB_OUTPUT
151 git push --delete origin "$TAG"
152 exit 1
153 fi
154
155 echo "✅ Tag verification succeeded!"
156 echo "passed=true" >> $GITHUB_OUTPUT
157 echo "key_id=$KEY_SHA256" >> $GITHUB_OUTPUT
158
159 # Fails closed: there must be exactly one open proposal for this exact
160 # version, with enough maintainer approvals of its current head commit.
161 # The tag is left in place on failure, so the job can be re-run once
162 # the proposal is approved.
163 - name: Find related release proposal
164 id: find_proposal
165 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
166 env:
167 TAG: ${{ steps.vars.outputs.version_tag }}
168 MAINTAINER_LOGINS: ${{ secrets.MAINTAINER_LOGINS }}
169 with:
170 script: |
171 const { checkProposal, REQUIRED_APPROVALS } = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/release-proposal.js`);
172
173 const { pr, targetCommit, approvers, approved } = await checkProposal({
174 github, context, version: process.env.TAG, maintainerLogins: process.env.MAINTAINER_LOGINS,
175 });
176 console.log(`Found proposal PR #${pr.number}, for commit ${targetCommit}`);
177 console.log(`Maintainer approvals of ${pr.head.sha}: ${approvers.join(', ') || 'none'}`);
178
179 if (!approved) {
180 core.setFailed(`Proposal #${pr.number} has ${approvers.length} maintainer approvals of its current head commit; ${REQUIRED_APPROVALS} are required`);
181 return;
182 }
183
184 return {
185 number: pr.number,
186 approvals: approvers.length,
187 approvers: approvers.map(login => `@${login}`).join(', '),
188 proposedCommit: targetCommit
189 };
190 result-encoding: json
191
192 - name: Verify proposal commit
193 env:
194 PROPOSED_COMMIT: ${{ fromJson(steps.find_proposal.outputs.result).proposedCommit }}
195 CURRENT_COMMIT: ${{ steps.info.outputs.sha }}
196 TAG: ${{ steps.vars.outputs.version_tag }}
197 run: |
198 echo "Proposed commit: $PROPOSED_COMMIT"
199 echo "Current commit: $CURRENT_COMMIT"
200
201 # Both are full SHAs, so compare them exactly
202 if [[ ! "$PROPOSED_COMMIT" =~ ^[0-9a-f]{40}$ ]]; then
203 echo "❌ Proposal has no valid target commit: $PROPOSED_COMMIT"
204 exit 1
205 fi
206 if [ "$PROPOSED_COMMIT" != "$CURRENT_COMMIT" ]; then
207 echo "❌ Commit mismatch!"
208 echo "The tag points to commit $CURRENT_COMMIT but the proposal was for $PROPOSED_COMMIT"
209 echo "This indicates an error in tag creation."
210 # Delete the tag remotely
211 git push --delete origin "$TAG"
212 echo "Tag $TAG has been deleted"
213 exit 1
214 fi
215
216 echo "✅ Commit hash matches proposal"
217
218 - name: Update release proposal PR
219 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
220 env:
221 RESULT: ${{ steps.find_proposal.outputs.result }}
222 TAG: ${{ steps.info.outputs.version }}
223 KEY_ID: ${{ steps.verify.outputs.key_id }}
224 SHA: ${{ steps.info.outputs.sha }}
225 with:
226 script: |
227 const result = JSON.parse(process.env.RESULT);
228
229 // Add in-progress label
230 await github.rest.issues.addLabels({
231 owner: context.repo.owner,
232 repo: context.repo.repo,
233 issue_number: result.number,
234 labels: ['release-in-progress']
235 });
236
237 // Remove approved label if present
238 try {
239 await github.rest.issues.removeLabel({
240 owner: context.repo.owner,
241 repo: context.repo.repo,
242 issue_number: result.number,
243 name: 'approved'
244 });
245 } catch (e) {
246 console.log('Approved label not found:', e.message);
247 }
248
249 const commentBody = [
250 '## 🚀 Release Workflow Started',
251 '',
252 `- **Tag:** ${process.env.TAG}`,
253 `- **Signed by key:** ${process.env.KEY_ID}`,
254 `- **Commit:** ${process.env.SHA}`,
255 `- **Approved by:** ${result.approvers}`,
256 '',
257 'Release workflow is now running. This PR will be updated when the release is published.'
258 ].join('\n');
259
260 await github.rest.issues.createComment({
261 owner: context.repo.owner,
262 repo: context.repo.repo,
263 issue_number: result.number,
264 body: commentBody
265 });
266
267 - name: Summary
268 env:
269 PROPOSED_COMMIT: ${{ fromJson(steps.find_proposal.outputs.result).proposedCommit }}
270 APPROVERS: ${{ fromJson(steps.find_proposal.outputs.result).approvers }}
271 TAG: ${{ steps.info.outputs.version }}
272 SHA: ${{ steps.info.outputs.sha }}
273 KEY_ID: ${{ steps.verify.outputs.key_id }}
274 run: |
275 {
276 echo "## Tag Verification Summary 🔐"
277 echo ""
278 echo "- **Tag:** $TAG"
279 echo "- **Commit:** $SHA"
280 echo "- **Proposed Commit:** $PROPOSED_COMMIT"
281 echo "- **Signature:** ✅ Verified"
282 echo "- **Signed by:** $KEY_ID"
283 echo "- **Approvals:** ✅ Sufficient"
284 echo "- **Approved by:** $APPROVERS"
285 echo ""
286 echo "Proceeding with release build..."
287 } >> "$GITHUB_STEP_SUMMARY"
288
289 release:
290 name: Release
291 needs: verify-tag
292 if: ${{ needs.verify-tag.outputs.verification_passed == 'true' }}
293 strategy:
294 matrix:
295 os:
296 - ubuntu-latest
297 go:
298 - '1.27'
299
300 include:
301 # Set the minimum Go patch version for the given Go minor
302 # Usable via ${{ matrix.GO_SEMVER }}
303 - go: '1.27'
304 GO_SEMVER: '~1.27.0'
305
306 runs-on: ${{ matrix.os }}
307 # https://github.com/sigstore/cosign/issues/1258#issuecomment-1002251233
308 # https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#adding-permissions-settings
309 permissions:
310 id-token: write
311 # https://docs.github.com/en/rest/overview/permissions-required-for-github-apps#permission-on-contents
312 # "Releases" is part of `contents`, so it needs the `write`
313 contents: write
314 issues: write
315 pull-requests: write
316
317 steps:
318 # Same check as in verify-tag: the tag name ends up in GoReleaser's
319 # shell hooks via $TAG.
320 - name: Validate tag name
321 run: |
322 if [[ ! "$GITHUB_REF" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then
323 echo "❌ Refusing to release $GITHUB_REF: not a vMAJOR.MINOR.PATCH[-PRERELEASE] tag"
324 exit 1
325 fi
326
327 - name: Harden the runner (Audit all outbound calls)
328 uses: step-security/harden-runner@46b06cea07f9e64621fdf066ba77ac6915163d43 # v2.22.3
329 with:
330 egress-policy: audit
331
332 - name: Checkout code
333 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
334 with:
335 fetch-depth: 0
336
337 - name: Install Go
338 uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
339 with:
340 go-version: ${{ matrix.GO_SEMVER }}
341 check-latest: true
342
343 # Force fetch upstream tags -- because 65 minutes
344 # tl;dr: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4.2.2 runs this line:
345 # git -c protocol.version=2 fetch --no-tags --prune --progress --no-recurse-submodules --depth=1 origin +ebc278ec98bb24f2852b61fde2a9bf2e3d83818b:refs/tags/
346 # which makes its own local lightweight tag, losing all the annotations in the process. Our earlier script ran:
347 # git fetch --prune --unshallow
348 # which doesn't overwrite that tag because that would be destructive.
349 # Credit to @francislavoie for the investigation.
350 # https://github.com/actions/checkout/issues/290#issuecomment-680260080
351 - name: Force fetch upstream tags
352 run: git fetch --tags --force
353
354 # https://github.community/t5/GitHub-Actions/How-to-get-just-the-tag-name/m-p/32167/highlight/true#M1027
355 - name: Print Go version and environment
356 id: vars
357 run: |
358 printf "Using go at: $(which go)\n"
359 printf "Go version: $(go version)\n"
360 printf "\n\nGo environment:\n\n"
361 go env
362 printf "\n\nSystem environment:\n\n"
363 env
364 echo "version_tag=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_OUTPUT
365 echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
366
367 # Add "pip install" CLI tools to PATH
368 echo ~/.local/bin >> $GITHUB_PATH
369
370 # Parse semver
371 TAG=${GITHUB_REF/refs\/tags\//}
372 SEMVER_RE='[^0-9]*\([0-9]*\)[.]\([0-9]*\)[.]\([0-9]*\)\([0-9A-Za-z\.-]*\)'
373 TAG_MAJOR=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\1#"`
374 TAG_MINOR=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\2#"`
375 TAG_PATCH=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\3#"`
376 TAG_SPECIAL=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\4#"`
377 echo "tag_major=${TAG_MAJOR}" >> $GITHUB_OUTPUT
378 echo "tag_minor=${TAG_MINOR}" >> $GITHUB_OUTPUT
379 echo "tag_patch=${TAG_PATCH}" >> $GITHUB_OUTPUT
380 echo "tag_special=${TAG_SPECIAL}" >> $GITHUB_OUTPUT
381
382 # Cloudsmith CLI tooling for pushing releases
383 # See https://help.cloudsmith.io/docs/cli
384 - name: Install Cloudsmith CLI
385 run: pip install --upgrade cloudsmith-cli
386
387 - name: Install Cosign
388 uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
389 - name: Cosign version
390 run: cosign version
391 - name: Install Syft
392 uses: anchore/sbom-action/download-syft@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # main
393 - name: Syft version
394 run: syft version
395 - name: Install xcaddy
396 run: |
397 go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
398 xcaddy version
399 # Generate the draft's notes in the same format as GitHub's "Generate
400 # release notes" button, compared against the previous *published*
401 # release by version (not the previous tag, which may be an abandoned
402 # release attempt), and without PRs that already shipped in it as
403 # backports. GoReleaser's own changelog can do neither.
404 - name: Generate release notes
405 env:
406 GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
407 TAG: ${{ steps.vars.outputs.version_tag }}
408 NOTES_FILE: ${{ runner.temp }}/release-notes.md
409 run: |
410 PREVIOUS_RELEASE=$(.github/scripts/previous-release.sh "$TAG")
411 echo "Previous published release: ${PREVIOUS_RELEASE:-none}"
412 .github/scripts/release-notes.sh "$TAG" "$TAG" "$PREVIOUS_RELEASE" > "$NOTES_FILE"
413 cat "$NOTES_FILE"
414
415 # GoReleaser will take care of publishing those artifacts into the release
416 - name: Run GoReleaser
417 uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7.3.0
418 with:
419 version: latest
420 args: release --clean --timeout 60m --release-notes ${{ runner.temp }}/release-notes.md
421 env:
422 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
423 TAG: ${{ steps.vars.outputs.version_tag }}
424
425 # Only publish on non-special tags (e.g. non-beta)
426 # We will continue to push to Gemfury for the foreseeable future, although
427 # Cloudsmith is probably better, to not break things for existing users of Gemfury.
428 # See https://gemfury.com/caddy/deb:caddy
429 - name: Publish .deb to Gemfury
430 if: ${{ steps.vars.outputs.tag_special == '' }}
431 env:
432 GEMFURY_PUSH_TOKEN: ${{ secrets.GEMFURY_PUSH_TOKEN }}
433 run: |
434 for filename in dist/*.deb; do
435 # armv6 and armv7 are both "armhf" so we can skip the duplicate
436 if [[ "$filename" == *"armv6"* ]]; then
437 echo "Skipping $filename"
438 continue
439 fi
440
441 curl -F package=@"$filename" https://${GEMFURY_PUSH_TOKEN}:@push.fury.io/caddy/
442 done
443
444 # Publish only special tags (unstable/beta/rc) to the "testing" repo
445 # See https://cloudsmith.io/~caddy/repos/testing/
446 - name: Publish .deb to Cloudsmith (special tags)
447 if: ${{ steps.vars.outputs.tag_special != '' }}
448 env:
449 CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }}
450 run: |
451 for filename in dist/*.deb; do
452 # armv6 and armv7 are both "armhf" so we can skip the duplicate
453 if [[ "$filename" == *"armv6"* ]]; then
454 echo "Skipping $filename"
455 continue
456 fi
457
458 echo "Pushing $filename to 'testing'"
459 cloudsmith push deb caddy/testing/any-distro/any-version $filename
460 done
461
462 # Publish stable tags to Cloudsmith to both repos, "stable" and "testing"
463 # See https://cloudsmith.io/~caddy/repos/stable/
464 - name: Publish .deb to Cloudsmith (stable tags)
465 if: ${{ steps.vars.outputs.tag_special == '' }}
466 env:
467 CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }}
468 run: |
469 for filename in dist/*.deb; do
470 # armv6 and armv7 are both "armhf" so we can skip the duplicate
471 if [[ "$filename" == *"armv6"* ]]; then
472 echo "Skipping $filename"
473 continue
474 fi
475
476 echo "Pushing $filename to 'stable'"
477 cloudsmith push deb caddy/stable/any-distro/any-version $filename
478
479 echo "Pushing $filename to 'testing'"
480 cloudsmith push deb caddy/testing/any-distro/any-version $filename
481 done
482
483 - name: Update release proposal PR
484 if: needs.verify-tag.outputs.proposal_issue_number != ''
485 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
486 env:
487 PROPOSAL_NUMBER: ${{ needs.verify-tag.outputs.proposal_issue_number }}
488 with:
489 script: |
490 const prNumber = parseInt(process.env.PROPOSAL_NUMBER, 10);
491
492 if (prNumber) {
493 // Get PR details to find the branch
494 const pr = await github.rest.pulls.get({
495 owner: context.repo.owner,
496 repo: context.repo.repo,
497 pull_number: prNumber
498 });
499
500 const branchName = pr.data.head.ref;
501
502 // Remove in-progress label
503 try {
504 await github.rest.issues.removeLabel({
505 owner: context.repo.owner,
506 repo: context.repo.repo,
507 issue_number: prNumber,
508 name: 'release-in-progress'
509 });
510 } catch (e) {
511 console.log('Label not found:', e.message);
512 }
513
514 // Add released label
515 await github.rest.issues.addLabels({
516 owner: context.repo.owner,
517 repo: context.repo.repo,
518 issue_number: prNumber,
519 labels: ['released']
520 });
521
522 // Add final comment
523 await github.rest.issues.createComment({
524 owner: context.repo.owner,
525 repo: context.repo.repo,
526 issue_number: prNumber,
527 body: '## ✅ Release Published\n\nThe release has been successfully published and is now available.'
528 });
529
530 // Close the PR if it's still open
531 if (pr.data.state === 'open') {
532 await github.rest.pulls.update({
533 owner: context.repo.owner,
534 repo: context.repo.repo,
535 pull_number: prNumber,
536 state: 'closed'
537 });
538 console.log(`Closed PR #${prNumber}`);
539 }
540
541 // Delete the branch
542 try {
543 await github.rest.git.deleteRef({
544 owner: context.repo.owner,
545 repo: context.repo.repo,
546 ref: `heads/${branchName}`
547 });
548 console.log(`Deleted branch: ${branchName}`);
549 } catch (e) {
550 console.log(`Could not delete branch ${branchName}: ${e.message}`);
551 }
552 }