3 files changed
+214
-123
.agents/skills/ci-cd/SKILL.md
+146
-7
@@ -1,18 +1,19 @@
1
---
2
name: ci-cd
3
-description: Use when creating, debugging, or improving GitHub Actions workflows for this repo — especially release workflows for crates.io, PyPI, npm, GitHub Releases, or Debian packages. Also covers SDK distribution README branding alignment across npm, PyPI, and the main repo. Covers OpenSSL vendoring, Windows vs non-Windows platform-conditional dependencies, manylinux containers, Perl module gaps, workspace scoping, native vs QEMU arm64 runners, trusted publishing, artifact actions, toolchain parity, and idempotency patterns.
3
+description: Use when creating, debugging, or improving GitHub Actions workflows for this repo — especially release workflows for crates.io, PyPI, npm, GitHub Releases, Homebrew tap, or Debian packages. Also covers SDK distribution README branding alignment across npm, PyPI, and the main repo. Covers OpenSSL vendoring, Windows vs non-Windows platform-conditional dependencies, manylinux containers, Perl module gaps, workspace scoping, native vs QEMU arm64 runners, trusted publishing, artifact actions, toolchain parity, idempotency patterns, Homebrew formula generation, and cross-workflow chaining.
4
---
5
6
# CI/CD
7
8
## Source of truth for all release workflows
9
10
-| Distribution | Workflow file |
11
-| ----------------------- | -------------------------------------- |
12
-| crates.io | `.github/workflows/release-crate.yml` |
13
-| PyPI | `.github/workflows/release-pypi.yml` |
14
-| npm | `.github/workflows/release-npm.yml` |
15
-| GitHub Release binaries | `.github/workflows/release-github.yml` |
10
+| Distribution | Workflow file |
11
+| ----------------------- | ---------------------------------------- |
12
+| crates.io | `.github/workflows/release-crate.yml` |
13
+| PyPI | `.github/workflows/release-pypi.yml` |
14
+| npm | `.github/workflows/release-npm.yml` |
15
+| GitHub Release binaries | `.github/workflows/release-github.yml` |
16
+| Homebrew tap | `.github/workflows/release-homebrew.yml` |
17
18
All four workflows share the same structural patterns. When editing one, check the others for consistency.
19
@@ -392,6 +393,132 @@ This ensures all platform binaries are collected before any are attached to the
393
394
---
395
396
+## Chaining workflows — `GITHUB_TOKEN` cannot fire downstream events
397
+
398
+GitHub intentionally does **not** fire `release`, `push`, `pull_request`, or any other repository events when the action that creates them is authenticated with `GITHUB_TOKEN`. This is a deliberate anti-loop safeguard.
399
+
400
+**Symptom:** `release-github.yml` uses `softprops/action-gh-release` (with the implicit `GITHUB_TOKEN`) to publish a release. `release-homebrew.yml` has `on: release: types: [published]`. The Homebrew workflow never runs.
401
+
402
+**Fix:** Have the upstream workflow dispatch the downstream one explicitly via the GitHub API after it completes. This requires `actions: write` in `permissions`.
403
+
404
+```yaml
405
+# release-github.yml
406
+
407
+permissions:
408
+ contents: write
409
+ actions: write # required for createWorkflowDispatch
410
+
411
+# At the end of the release job, after softprops/action-gh-release:
412
+- name: Trigger Homebrew release
413
+ uses: actions/github-script@v7
414
+ with:
415
+ script: |
416
+ await github.rest.actions.createWorkflowDispatch({
417
+ owner: context.repo.owner,
418
+ repo: context.repo.repo,
419
+ workflow_id: 'release-homebrew.yml',
420
+ ref: 'main',
421
+ inputs: {
422
+ tag: '${{ steps.tag.outputs.tag }}'
423
+ }
424
+ })
425
+```
426
+
427
+The downstream workflow (`release-homebrew.yml`) must expose a `workflow_dispatch` trigger with a `tag` input — it no longer needs `on: release: types: [published]` (which is dead code in this pattern).
428
+
429
+**Alternative:** Use a Personal Access Token (PAT) with `repo` scope in `softprops/action-gh-release` via `token: ${{ secrets.RELEASE_TOKEN }}`. A PAT is treated as a real user action and does fire the `release` event. This avoids changing permissions but requires managing an extra secret.
430
+
431
+---
432
+
433
+## Homebrew tap release pattern
434
+
435
+`release-homebrew.yml` is triggered by `release-github.yml` via `workflow_dispatch` (see above). It runs a single cheap `ubuntu-latest` job — no Rust compilation, no macOS runners.
436
+
437
+### Key principle: compute SHA256 once, at build time
438
+
439
+The Homebrew formula's `sha256` field is the checksum of the tarball that Homebrew downloads at `brew install` time. The tarball lives at the GitHub Release URL. CI only needs to _write_ that checksum into `cli.rb` — it does not need to download the tarball again.
440
+
441
+**In `release-github.yml` (build job, macOS legs only):**
442
+
443
+```yaml
444
+- name: Package macOS tarball for Homebrew
445
+ if: contains(matrix.target, 'apple-darwin')
446
+ shell: bash
447
+ run: |
448
+ ARCHIVE_NAME="${PROJECT_NAME}-${{ matrix.name }}.tar.gz"
449
+
450
+ mkdir -p staging-brew
451
+ cp "./release/${PROJECT_NAME}-${{ matrix.name }}" "staging-brew/${PROJECT_NAME}"
452
+ strip "staging-brew/${PROJECT_NAME}"
453
+
454
+ tar -C staging-brew -czf "${ARCHIVE_NAME}" "${PROJECT_NAME}"
455
+
456
+ # Compute and save the checksum alongside the archive
457
+ shasum -a 256 "${ARCHIVE_NAME}" | awk '{print $1}' > "${ARCHIVE_NAME}.sha256"
458
+
459
+- name: Upload macOS Homebrew artifacts
460
+ if: contains(matrix.target, 'apple-darwin')
461
+ uses: actions/upload-artifact@v7
462
+ with:
463
+ name: homebrew-${{ matrix.name }}
464
+ path: |
465
+ ${{ env.PROJECT_NAME }}-${{ matrix.name }}.tar.gz
466
+ ${{ env.PROJECT_NAME }}-${{ matrix.name }}.tar.gz.sha256
467
+```
468
+
469
+Both the `.tar.gz` and the `.sha256` file are downloaded in the release job and attached to the GitHub Release via `files: release/*`.
470
+
471
+**In `release-homebrew.yml` (single job):**
472
+
473
+```yaml
474
+- name: Read SHA256 checksums from release
475
+ id: sha
476
+ env:
477
+ GH_TOKEN: ${{ github.token }}
478
+ shell: bash
479
+ run: |
480
+ mkdir -p artifacts
481
+
482
+ # Download only the tiny .sha256 text files — not the full tarballs
483
+ gh release download "${{ steps.release.outputs.tag }}" \
484
+ --repo "${{ env.REPO }}" \
485
+ --pattern "smb-macos-*.tar.gz.sha256" \
486
+ --dir artifacts/
487
+
488
+ ARM64_SHA=$(cat artifacts/smb-macos-arm64.tar.gz.sha256)
489
+ AMD64_SHA=$(cat artifacts/smb-macos-amd64.tar.gz.sha256)
490
+
491
+ echo "arm64=${ARM64_SHA}" >> "$GITHUB_OUTPUT"
492
+ echo "amd64=${AMD64_SHA}" >> "$GITHUB_OUTPUT"
493
+```
494
+
495
+`gh` (GitHub CLI) is pre-installed on all GitHub-hosted runners. `GH_TOKEN: ${{ github.token }}` is sufficient for downloading from a public repo's releases.
496
+
497
+### Formula URL convention
498
+
499
+The formula `url:` references the release asset directly. Homebrew fetches it at install time — CI never downloads it:
500
+
501
+```ruby
502
+on_macos do
503
+ on_arm do
504
+ url 'https://github.com/smbcloudXYZ/smbcloud-cli/releases/download/v0.3.36/smb-macos-arm64.tar.gz'
505
+ sha256 '<computed during build>'
506
+ end
507
+ on_intel do
508
+ url 'https://github.com/smbcloudXYZ/smbcloud-cli/releases/download/v0.3.36/smb-macos-amd64.tar.gz'
509
+ sha256 '<computed during build>'
510
+ end
511
+end
512
+```
513
+
514
+The tarball must contain the binary named `smb` (not `smb-macos-arm64`) because the formula's install step is `bin.install 'smb'`.
515
+
516
+### Homebrew tap permissions
517
+
518
+`release-homebrew.yml` only needs `contents: read` on `smbcloud-cli`. Writing to the tap repo (`smbcloudXYZ/homebrew-tap`) is authenticated via `secrets.HOMEBREW_TAP_TOKEN` in the `actions/checkout` step, not via workflow-level permissions.
519
+
520
+---
521
+
522
## `fail-fast: false`
523
524
Always set `fail-fast: false` on release build matrices:
@@ -494,3 +621,15 @@ When the main `README.MD` changes its tagline, logo URL, quick start commands, o
621
622
**Platform support table missing new targets**
623
When a new platform is added to any release matrix (e.g. `linux-arm64`), update the platform support table in `npm/smbcloud-cli/README.md` and `pypi/README.md` in the same PR.
624
+
625
+**`on: release: types: [published]` does not fire when the release is created by automation**
626
+When `softprops/action-gh-release` (or any action) creates a GitHub Release using the implicit `GITHUB_TOKEN`, GitHub does not dispatch the `release` event to other workflows. The downstream workflow simply never runs — no error, no log entry. Use `workflow_dispatch` via `actions/github-script@v7` to chain workflows explicitly, and add `actions: write` to the upstream workflow's `permissions`.
627
+
628
+**Downloading full tarballs in the Homebrew workflow to compute SHA256**
629
+The Homebrew formula only needs the SHA256 hash — Homebrew itself downloads the tarball at install time. Compute the SHA256 during the build in `release-github.yml`, save it as a `.sha256` file, attach it to the GitHub Release, and then have `release-homebrew.yml` download only those tiny text files with `gh release download --pattern "*.sha256"`. Do not re-download multi-megabyte binaries and do not re-run Rust compilation.
630
+
631
+**Rebuilding macOS binaries in `release-homebrew.yml`**
632
+`release-github.yml` already builds macOS binaries. Running a second Rust compilation in `release-homebrew.yml` wastes two macOS runner slots and risks producing binaries with different checksums if the environment differs. Build once, reuse the output.
633
+
634
+**Homebrew tarball contains wrong binary name**
635
+The formula uses `bin.install 'smb'`. The tarball must contain the binary named `smb`, not `smb-macos-arm64`. Strip and repackage correctly in the "Package macOS tarball for Homebrew" step.
.github/workflows/release-github.yml
+51
-2
@@ -12,6 +12,7 @@ on:
12
13
permissions:
14
contents: write
15
+ actions: write
16
17
env:
18
PROJECT_NAME: smb
@@ -109,12 +110,38 @@ jobs:
110
mkdir -p ./release
111
mv "${BIN_OUTPUT}" "./release/${BIN_RELEASE}"
112
112
- - name: Upload artifact
113
+ - name: Package macOS tarball for Homebrew
114
+ if: contains(matrix.target, 'apple-darwin')
115
+ shell: bash
116
+ run: |
117
+ ARCHIVE_NAME="${PROJECT_NAME}-${{ matrix.name }}.tar.gz"
118
+
119
+ mkdir -p staging-brew
120
+ cp "./release/${PROJECT_NAME}-${{ matrix.name }}" "staging-brew/${PROJECT_NAME}"
121
+ strip "staging-brew/${PROJECT_NAME}"
122
+
123
+ tar -C staging-brew -czf "${ARCHIVE_NAME}" "${PROJECT_NAME}"
124
+
125
+ shasum -a 256 "${ARCHIVE_NAME}" | awk '{print $1}' > "${ARCHIVE_NAME}.sha256"
126
+
127
+ echo "Archive: ${ARCHIVE_NAME}"
128
+ echo "SHA256: $(cat "${ARCHIVE_NAME}.sha256")"
129
+
130
+ - name: Upload binary artifact
131
uses: actions/upload-artifact@v7
132
with:
133
name: binary-${{ matrix.name }}
134
path: release/*
135
136
+ - name: Upload macOS Homebrew artifacts
137
+ if: contains(matrix.target, 'apple-darwin')
138
+ uses: actions/upload-artifact@v7
139
+ with:
140
+ name: homebrew-${{ matrix.name }}
141
+ path: |
142
+ ${{ env.PROJECT_NAME }}-${{ matrix.name }}.tar.gz
143
+ ${{ env.PROJECT_NAME }}-${{ matrix.name }}.tar.gz.sha256
144
+
145
release:
146
name: Create GitHub Release
147
needs: build
@@ -132,15 +159,37 @@ jobs:
159
echo "tag=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
160
fi
161
135
- - name: Download all artifacts
162
+ - name: Download binary artifacts
163
uses: actions/download-artifact@v7
164
with:
165
pattern: "binary-*"
166
path: release
167
merge-multiple: true
168
169
+ - name: Download Homebrew artifacts
170
+ uses: actions/download-artifact@v7
171
+ with:
172
+ pattern: "homebrew-*"
173
+ path: release
174
+ merge-multiple: true
175
+
176
- name: Release
177
uses: softprops/action-gh-release@v2
178
with:
179
tag_name: ${{ steps.tag.outputs.tag }}
180
files: release/*
181
+
182
+ - name: Trigger Homebrew release
183
+ uses: actions/github-script@v7
184
+ with:
185
+ script: |
186
+ await github.rest.actions.createWorkflowDispatch({
187
+ owner: context.repo.owner,
188
+ repo: context.repo.repo,
189
+ workflow_id: 'release-homebrew.yml',
190
+ ref: 'main',
191
+ inputs: {
192
+ tag: '${{ steps.tag.outputs.tag }}'
193
+ }
194
+ })
195
+ console.log('Dispatched release-homebrew.yml for tag ${{ steps.tag.outputs.tag }}')
.github/workflows/release-homebrew.yml
+17
-114
@@ -1,117 +1,21 @@
1
name: Homebrew Release
2
3
on:
4
- release:
5
- types: [published]
4
workflow_dispatch:
5
inputs:
6
tag:
9
- description: "Release tag (e.g. v0.3.34)"
7
+ description: "Release tag (e.g. v0.3.36)"
8
required: true
9
10
permissions:
13
- contents: write
11
+ contents: read
12
13
env:
16
- PROJECT_NAME: smb
17
- CARGO_TERM_COLOR: always
14
+ REPO: smbcloudXYZ/smbcloud-cli
15
16
jobs:
20
- build-macos:
21
- name: Build macOS (${{ matrix.arch }})
22
- runs-on: ${{ matrix.runner }}
23
-
24
- strategy:
25
- fail-fast: false
26
- matrix:
27
- include:
28
- - arch: arm64
29
- runner: macos-latest
30
- target: aarch64-apple-darwin
31
- - arch: amd64
32
- runner: macos-15-intel
33
- target: x86_64-apple-darwin
34
-
35
- steps:
36
- - name: Resolve release tag
37
- id: tag
38
- shell: bash
39
- run: |
40
- if [[ "${{ github.event_name }}" == "release" ]]; then
41
- TAG="${{ github.event.release.tag_name }}"
42
- else
43
- TAG="${{ github.event.inputs.tag }}"
44
- fi
45
- echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
46
-
47
- - name: Checkout
48
- uses: actions/checkout@v4
49
- with:
50
- ref: ${{ steps.tag.outputs.tag }}
51
-
52
- - name: Read Rust toolchain
53
- shell: bash
54
- run: |
55
- rust_toolchain="$(sed -n 's/^channel = "\(.*\)"/\1/p' rust-toolchain.toml | head -n 1)"
56
- if [ -z "$rust_toolchain" ]; then
57
- echo "Failed to read Rust toolchain from rust-toolchain.toml" >&2
58
- exit 1
59
- fi
60
- echo "RUST_TOOLCHAIN=${rust_toolchain}" >> "$GITHUB_ENV"
61
-
62
- - name: Install Rust toolchain
63
- uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9
64
- with:
65
- toolchain: ${{ env.RUST_TOOLCHAIN }}
66
- targets: ${{ matrix.target }}
67
-
68
- - name: Setup Rust cache
69
- uses: Swatinem/rust-cache@v2
70
- with:
71
- key: homebrew-${{ matrix.target }}
72
-
73
- - name: Create .env file
74
- run: |
75
- echo "CLI_CLIENT_SECRET=${{ secrets.CLI_CLIENT_SECRET }}" > .env
76
-
77
- - name: Build
78
- shell: bash
79
- run: |
80
- cargo build --release --locked --package smbcloud-cli --target ${{ matrix.target }}
81
-
82
- - name: Package tarball
83
- shell: bash
84
- run: |
85
- ARCHIVE_NAME="smb-macos-${{ matrix.arch }}.tar.gz"
86
-
87
- mkdir -p staging
88
- cp "target/${{ matrix.target }}/release/${PROJECT_NAME}" staging/
89
- strip "staging/${PROJECT_NAME}"
90
-
91
- tar -C staging -czf "${ARCHIVE_NAME}" "${PROJECT_NAME}"
92
-
93
- shasum -a 256 "${ARCHIVE_NAME}" | awk '{print $1}' > "${ARCHIVE_NAME}.sha256"
94
-
95
- echo "Archive: ${ARCHIVE_NAME}"
96
- echo "SHA256: $(cat "${ARCHIVE_NAME}.sha256")"
97
-
98
- - name: Upload artifact
99
- uses: actions/upload-artifact@v4
100
- with:
101
- name: smb-macos-${{ matrix.arch }}
102
- path: |
103
- smb-macos-${{ matrix.arch }}.tar.gz
104
- smb-macos-${{ matrix.arch }}.tar.gz.sha256
105
-
106
- - name: Attach to GitHub Release
107
- uses: softprops/action-gh-release@v2
108
- with:
109
- tag_name: ${{ steps.tag.outputs.tag }}
110
- files: smb-macos-${{ matrix.arch }}.tar.gz
111
-
17
update-homebrew-tap:
18
name: Update Homebrew tap
114
- needs: build-macos
19
runs-on: ubuntu-latest
20
21
steps:
@@ -119,27 +23,27 @@ jobs:
23
id: release
24
shell: bash
25
run: |
122
- if [[ "${{ github.event_name }}" == "release" ]]; then
123
- TAG="${{ github.event.release.tag_name }}"
124
- else
125
- TAG="${{ github.event.inputs.tag }}"
126
- fi
26
+ TAG="${{ github.event.inputs.tag }}"
27
VERSION="${TAG#v}"
28
29
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
30
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
31
132
- - name: Download artifacts
133
- uses: actions/download-artifact@v4
134
- with:
135
- path: artifacts/
136
-
137
- - name: Read SHA256 checksums
32
+ - name: Read SHA256 checksums from release
33
id: sha
34
+ env:
35
+ GH_TOKEN: ${{ github.token }}
36
shell: bash
37
run: |
141
- ARM64_SHA=$(cat artifacts/smb-macos-arm64/smb-macos-arm64.tar.gz.sha256)
142
- AMD64_SHA=$(cat artifacts/smb-macos-amd64/smb-macos-amd64.tar.gz.sha256)
38
+ mkdir -p artifacts
39
+
40
+ gh release download "${{ steps.release.outputs.tag }}" \
41
+ --repo "${{ env.REPO }}" \
42
+ --pattern "smb-macos-*.tar.gz.sha256" \
43
+ --dir artifacts/
44
+
45
+ ARM64_SHA=$(cat artifacts/smb-macos-arm64.tar.gz.sha256)
46
+ AMD64_SHA=$(cat artifacts/smb-macos-amd64.tar.gz.sha256)
47
48
echo "arm64=${ARM64_SHA}" >> "$GITHUB_OUTPUT"
49
echo "amd64=${AMD64_SHA}" >> "$GITHUB_OUTPUT"
@@ -161,7 +65,6 @@ jobs:
65
TAG="${{ steps.release.outputs.tag }}"
66
ARM64_SHA="${{ steps.sha.outputs.arm64 }}"
67
AMD64_SHA="${{ steps.sha.outputs.amd64 }}"
164
- REPO="smbcloudXYZ/smbcloud-cli"
68
69
mkdir -p homebrew-tap/Formula
70
@@ -208,6 +111,6 @@ jobs:
111
git config user.name "github-actions[bot]"
112
git config user.email "github-actions[bot]@users.noreply.github.com"
113
git add Formula/cli.rb
211
- git diff --cached --quiet && echo "No changes" && exit 0
114
+ git diff --cached --quiet && echo "No changes to commit" && exit 0
115
git commit -m "Update smb to ${VERSION}"
116
git push