@smbcloud / cli / commits / b455192

Add Scoop and winget release channels

Two new workflows, dispatched by release-github.yml alongside the Homebrew tap update after a release is published: - release-scoop.yml updates smbcloudXYZ/scoop-bucket with a generated smb.json manifest (rename-on-download so the shim ends up as smb.exe, plus checkver/autoupdate for scoop's own updater). - release-winget.yml submits smbCloudXYZ.smbcloud-cli to microsoft/winget-pkgs via wingetcreate: "update" once the package exists there, or a first-time "submit" rendered from the templates in packaging/winget/. release-github.yml gets the plumbing both channels need: every release asset now gets a .sha256 sidecar (previously only the macOS Homebrew tarballs had one), and the dispatch step fans out to all three package manager workflows with per-channel failure isolation. Requires two new secrets: SCOOP_BUCKET_TOKEN (push access to the bucket repo) and WINGET_TOKEN (public_repo PAT so wingetcreate can fork microsoft/winget-pkgs and open the manifest PR). Co-Authored-By: OndeCode <noreply@ondeinference.com>

keypair34 committed Oct 4, 2026 at 13:07 UTC b4551928bedfe7bf61768d6cac32d823d94f4aa0
6 files changed +431 -10
.github/workflows/release-github.yml
+49 -10
@@ -119,6 +119,27 @@ jobs:
119 echo "Archive: ${ARCHIVE_NAME}"
120 echo "SHA256: $(cat "${ARCHIVE_NAME}.sha256")"
121
122 + # Homebrew has always had a checksum because the tap needs one. Scoop
123 + # and winget each need one too, and they consume the raw Windows
124 + # binaries rather than the macOS tarball, so every asset gets a
125 + # sidecar. Windows runners use bash from Git for Windows, which has
126 + # sha256sum; macOS only has shasum.
127 + - name: Checksum release assets
128 + shell: bash
129 + run: |
130 + cd release
131 + for asset in *; do
132 + case "${asset}" in *.sha256) continue ;; esac
133 +
134 + if command -v sha256sum >/dev/null 2>&1; then
135 + sha256sum "${asset}" | awk '{print $1}' > "${asset}.sha256"
136 + else
137 + shasum -a 256 "${asset}" | awk '{print $1}' > "${asset}.sha256"
138 + fi
139 +
140 + echo "${asset}: $(cat "${asset}.sha256")"
141 + done
142 +
143 - name: Upload binary artifact
144 uses: actions/upload-artifact@v7
145 with:
@@ -171,17 +192,35 @@ jobs:
192 tag_name: ${{ steps.tag.outputs.tag }}
193 files: release/*
194
174 - - name: Trigger Homebrew release
195 + # These channels all publish somewhere outside this repo (a tap, a
196 + # Scoop bucket, microsoft/winget-pkgs) and every one of them reads
197 + # checksums off the release created above, so they hang off this job
198 + # rather than firing on the tag directly.
199 + - name: Trigger OS package manager releases
200 uses: actions/github-script@v8
201 with:
202 script: |
178 - await github.rest.actions.createWorkflowDispatch({
179 - owner: context.repo.owner,
180 - repo: context.repo.repo,
181 - workflow_id: 'release-homebrew.yml',
182 - ref: '${{ steps.tag.outputs.tag }}',
183 - inputs: {
184 - tag: '${{ steps.tag.outputs.tag }}'
203 + const tag = '${{ steps.tag.outputs.tag }}'
204 + const workflows = [
205 + 'release-homebrew.yml',
206 + 'release-scoop.yml',
207 + 'release-winget.yml',
208 + ]
209 +
210 + for (const workflow_id of workflows) {
211 + try {
212 + await github.rest.actions.createWorkflowDispatch({
213 + owner: context.repo.owner,
214 + repo: context.repo.repo,
215 + workflow_id,
216 + ref: tag,
217 + inputs: { tag },
218 + })
219 + console.log(`Dispatched ${workflow_id} for tag ${tag}`)
220 + } catch (error) {
221 + // One packaging channel being unconfigured (a missing secret,
222 + // a bucket repo that does not exist yet) should not take the
223 + // rest of the fan-out down with it.
224 + core.warning(`Failed to dispatch ${workflow_id}: ${error.message}`)
225 }
186 - })
187 - console.log('Dispatched release-homebrew.yml for tag ${{ steps.tag.outputs.tag }}')
226 + }
.github/workflows/release-scoop.yml new
+148
@@ -0,0 +1,148 @@
1 +name: Scoop Release
2 +
3 +# Dispatched by release-github.yml once the release and its checksums exist.
4 +on:
5 + workflow_dispatch:
6 + inputs:
7 + tag:
8 + description: "Release tag (e.g. v0.6.0)"
9 + required: true
10 +
11 +concurrency:
12 + # A re-pushed or re-dispatched tag fires this a second time. Queue the
13 + # duplicate behind the original instead of cancelling it: cancelling a run
14 + # mid-publish can leave a channel half-uploaded, whereas a queued duplicate
15 + # just hits the "already published" check and exits clean.
16 + group: ${{ github.workflow }}-${{ github.event.inputs.tag || github.ref_name }}
17 + cancel-in-progress: false
18 +
19 +permissions:
20 + contents: read
21 +
22 +env:
23 + REPO: smbcloudXYZ/smbcloud-cli
24 +
25 +jobs:
26 + update-scoop-bucket:
27 + name: Update Scoop bucket
28 + runs-on: ubuntu-latest
29 +
30 + steps:
31 + - name: Resolve tag and version
32 + id: release
33 + shell: bash
34 + run: |
35 + TAG="${{ github.event.inputs.tag }}"
36 + VERSION="${TAG#v}"
37 +
38 + echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
39 + echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
40 +
41 + - name: Read SHA256 checksums from release
42 + id: sha
43 + env:
44 + GH_TOKEN: ${{ github.token }}
45 + shell: bash
46 + run: |
47 + mkdir -p artifacts
48 +
49 + gh release download "${{ steps.release.outputs.tag }}" \
50 + --repo "${{ env.REPO }}" \
51 + --pattern "smb-win-*.exe.sha256" \
52 + --dir artifacts/
53 +
54 + AMD64_SHA=$(cat artifacts/smb-win-amd64.exe.sha256)
55 + ARM64_SHA=$(cat artifacts/smb-win-arm64.exe.sha256)
56 +
57 + echo "amd64=${AMD64_SHA}" >> "$GITHUB_OUTPUT"
58 + echo "arm64=${ARM64_SHA}" >> "$GITHUB_OUTPUT"
59 +
60 + echo "AMD64 SHA256: ${AMD64_SHA}"
61 + echo "ARM64 SHA256: ${ARM64_SHA}"
62 +
63 + - name: Checkout Scoop bucket
64 + uses: actions/checkout@v6
65 + with:
66 + repository: smbcloudXYZ/scoop-bucket
67 + token: ${{ secrets.SCOOP_BUCKET_TOKEN }}
68 + path: scoop-bucket
69 +
70 + - name: Generate manifest
71 + shell: bash
72 + run: |
73 + VERSION="${{ steps.release.outputs.version }}"
74 + TAG="${{ steps.release.outputs.tag }}"
75 + AMD64_SHA="${{ steps.sha.outputs.amd64 }}"
76 + ARM64_SHA="${{ steps.sha.outputs.arm64 }}"
77 +
78 + mkdir -p scoop-bucket/bucket
79 +
80 + # The `#/smb.exe` URL fragment is Scoop's rename-on-download
81 + # syntax: the release asset is smb-win-amd64.exe, but the shim has
82 + # to end up as smb.exe for `smb` to work on PATH.
83 + cat > scoop-bucket/bucket/smb.json <<MANIFEST
84 + {
85 + "version": "${VERSION}",
86 + "description": "smbCloud command line interface: Mail, Auth, Deploy, and GresIQ from your terminal.",
87 + "homepage": "https://smbcloud.xyz",
88 + "license": "Apache-2.0",
89 + "architecture": {
90 + "64bit": {
91 + "url": "https://github.com/${REPO}/releases/download/${TAG}/smb-win-amd64.exe#/smb.exe",
92 + "hash": "${AMD64_SHA}"
93 + },
94 + "arm64": {
95 + "url": "https://github.com/${REPO}/releases/download/${TAG}/smb-win-arm64.exe#/smb.exe",
96 + "hash": "${ARM64_SHA}"
97 + }
98 + },
99 + "bin": "smb.exe",
100 + "checkver": {
101 + "github": "https://github.com/${REPO}"
102 + },
103 + "autoupdate": {
104 + "architecture": {
105 + "64bit": {
106 + "url": "https://github.com/${REPO}/releases/download/v\$version/smb-win-amd64.exe#/smb.exe"
107 + },
108 + "arm64": {
109 + "url": "https://github.com/${REPO}/releases/download/v\$version/smb-win-arm64.exe#/smb.exe"
110 + }
111 + },
112 + "hash": {
113 + "url": "\$url.sha256"
114 + }
115 + }
116 + }
117 + MANIFEST
118 +
119 + echo "Generated manifest:"
120 + cat scoop-bucket/bucket/smb.json
121 +
122 + - name: Validate manifest JSON
123 + shell: bash
124 + run: |
125 + if ! jq empty scoop-bucket/bucket/smb.json; then
126 + echo "::error::Generated Scoop manifest is not valid JSON" >&2
127 + exit 1
128 + fi
129 +
130 + for field in version bin architecture; do
131 + if [ "$(jq -r "has(\"${field}\")" scoop-bucket/bucket/smb.json)" != "true" ]; then
132 + echo "::error::Scoop manifest is missing the '${field}' field" >&2
133 + exit 1
134 + fi
135 + done
136 +
137 + - name: Commit and push
138 + shell: bash
139 + run: |
140 + VERSION="${{ steps.release.outputs.version }}"
141 +
142 + cd scoop-bucket
143 + git config user.name "github-actions[bot]"
144 + git config user.email "github-actions[bot]@users.noreply.github.com"
145 + git add bucket/smb.json
146 + git diff --cached --quiet && echo "No changes to commit" && exit 0
147 + git commit -m "Update smb to ${VERSION}"
148 + git push
.github/workflows/release-winget.yml new
+178
@@ -0,0 +1,178 @@
1 +name: winget Release
2 +
3 +# Dispatched by release-github.yml once the release and its checksums exist.
4 +#
5 +# winget manifests live in microsoft/winget-pkgs, and `wingetcreate update`
6 +# only works on a package that is already there. So this workflow branches:
7 +#
8 +# - package already in winget-pkgs -> `wingetcreate update`, which rewrites
9 +# the URLs and re-hashes the installers for us.
10 +# - package not there yet -> render the manifests in packaging/winget/ and
11 +# `wingetcreate submit` them as a new package.
12 +#
13 +# The second path only runs once, but keeping it here means a first release
14 +# does not need someone to sit at a Windows box running `wingetcreate new`
15 +# interactively.
16 +on:
17 + workflow_dispatch:
18 + inputs:
19 + tag:
20 + description: "Release tag (e.g. v0.6.0)"
21 + required: true
22 +
23 +concurrency:
24 + # A re-pushed or re-dispatched tag fires this a second time. Queue the
25 + # duplicate behind the original instead of cancelling it: cancelling a run
26 + # mid-publish can leave a channel half-uploaded, whereas a queued duplicate
27 + # just hits the "already published" check and exits clean.
28 + group: ${{ github.workflow }}-${{ github.event.inputs.tag || github.ref_name }}
29 + cancel-in-progress: false
30 +
31 +permissions:
32 + contents: read
33 +
34 +env:
35 + REPO: smbcloudXYZ/smbcloud-cli
36 + PACKAGE_IDENTIFIER: smbCloudXYZ.smbcloud-cli
37 +
38 +jobs:
39 + submit-winget-manifest:
40 + name: Submit winget manifest
41 + # wingetcreate is a Windows-only tool.
42 + runs-on: windows-latest
43 +
44 + steps:
45 + - name: Check out repository
46 + uses: actions/checkout@v6
47 +
48 + - name: Resolve tag, version and release date
49 + id: release
50 + shell: bash
51 + env:
52 + GH_TOKEN: ${{ github.token }}
53 + run: |
54 + TAG="${{ github.event.inputs.tag }}"
55 + VERSION="${TAG#v}"
56 + # wingetcreate wants ReleaseDate as YYYY-MM-DD.
57 + DATE=$(gh release view "$TAG" --repo "$REPO" --json publishedAt --jq '.publishedAt[0:10]')
58 +
59 + echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
60 + echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
61 + echo "date=${DATE}" >> "$GITHUB_OUTPUT"
62 +
63 + - name: Check whether the package is already in winget-pkgs
64 + id: exists
65 + shell: bash
66 + env:
67 + GH_TOKEN: ${{ github.token }}
68 + run: |
69 + # manifests are filed under the lowercased first letter of the
70 + # publisher, e.g. manifests/s/smbCloudXYZ/smbcloud-cli.
71 + PUBLISHER="${PACKAGE_IDENTIFIER%%.*}"
72 + NAME="${PACKAGE_IDENTIFIER#*.}"
73 + FIRST=$(echo "${PUBLISHER:0:1}" | tr '[:upper:]' '[:lower:]')
74 + PATH_IN_REPO="manifests/${FIRST}/${PUBLISHER}/${NAME}"
75 +
76 + if gh api "repos/microsoft/winget-pkgs/contents/${PATH_IN_REPO}" >/dev/null 2>&1; then
77 + echo "Found ${PACKAGE_IDENTIFIER} at ${PATH_IN_REPO}; submitting an update."
78 + echo "found=true" >> "$GITHUB_OUTPUT"
79 + else
80 + echo "No ${PACKAGE_IDENTIFIER} at ${PATH_IN_REPO}; submitting it as a new package."
81 + echo "found=false" >> "$GITHUB_OUTPUT"
82 + fi
83 +
84 + - name: Render manifests for a first submission
85 + if: steps.exists.outputs.found == 'false'
86 + shell: bash
87 + env:
88 + GH_TOKEN: ${{ github.token }}
89 + run: |
90 + TAG="${{ steps.release.outputs.tag }}"
91 + VERSION="${{ steps.release.outputs.version }}"
92 +
93 + mkdir -p artifacts manifests
94 +
95 + gh release download "$TAG" \
96 + --repo "$REPO" \
97 + --pattern "smb-win-*.exe.sha256" \
98 + --dir artifacts/
99 +
100 + # The schema wants 64 hex characters; the community validation
101 + # pipeline writes them uppercase, so match that.
102 + SHA_AMD64=$(tr -d '[:space:]' < artifacts/smb-win-amd64.exe.sha256 | tr '[:lower:]' '[:upper:]')
103 + SHA_ARM64=$(tr -d '[:space:]' < artifacts/smb-win-arm64.exe.sha256 | tr '[:lower:]' '[:upper:]')
104 +
105 + RELEASE_DATE="${{ steps.release.outputs.date }}"
106 +
107 + for template in packaging/winget/*.yaml.in; do
108 + out="manifests/$(basename "$template" .in)"
109 + sed \
110 + -e "s|@VERSION@|${VERSION}|g" \
111 + -e "s|@TAG@|${TAG}|g" \
112 + -e "s|@REPO@|${REPO}|g" \
113 + -e "s|@SHA_AMD64@|${SHA_AMD64}|g" \
114 + -e "s|@SHA_ARM64@|${SHA_ARM64}|g" \
115 + -e "s|@RELEASE_DATE@|${RELEASE_DATE}|g" \
116 + "$template" > "$out"
117 +
118 + echo "--- ${out} ---"
119 + cat "$out"
120 + done
121 +
122 + - name: Download wingetcreate
123 + shell: pwsh
124 + run: Invoke-WebRequest -Uri "https://aka.ms/wingetcreate/latest" -OutFile wingetcreate.exe
125 +
126 + - name: Submit new package
127 + if: steps.exists.outputs.found == 'false'
128 + shell: pwsh
129 + env:
130 + # wingetcreate reads the token from this variable. Passing it as
131 + # --token instead makes the tool warn that the token can end up in
132 + # logs, which on a shared runner is not a warning worth ignoring.
133 + WINGET_CREATE_GITHUB_TOKEN: ${{ secrets.WINGET_TOKEN }}
134 + run: |
135 + if (-not $env:WINGET_CREATE_GITHUB_TOKEN) {
136 + Write-Error "WINGET_TOKEN is not set. It needs a PAT with public_repo scope so wingetcreate can fork microsoft/winget-pkgs and open the manifest PR."
137 + }
138 +
139 + $version = "${{ steps.release.outputs.version }}"
140 +
141 + # --no-open because the runner has no browser to open the PR in.
142 + .\wingetcreate.exe submit manifests `
143 + --prtitle "New package: $env:PACKAGE_IDENTIFIER version $version" `
144 + --no-open
145 +
146 + if ($LASTEXITCODE -ne 0) {
147 + Write-Error "wingetcreate submit failed with exit code $LASTEXITCODE"
148 + }
149 +
150 + - name: Submit manifest update
151 + if: steps.exists.outputs.found == 'true'
152 + shell: pwsh
153 + env:
154 + WINGET_CREATE_GITHUB_TOKEN: ${{ secrets.WINGET_TOKEN }}
155 + run: |
156 + if (-not $env:WINGET_CREATE_GITHUB_TOKEN) {
157 + Write-Error "WINGET_TOKEN is not set. It needs a PAT with public_repo scope so wingetcreate can fork microsoft/winget-pkgs and open the manifest PR."
158 + }
159 +
160 + $tag = "${{ steps.release.outputs.tag }}"
161 + $version = "${{ steps.release.outputs.version }}"
162 + $base = "https://github.com/${env:REPO}/releases/download/$tag"
163 +
164 + # The trailing |x64 and |arm64 tell wingetcreate which installer
165 + # entry each URL replaces. Without them it guesses from the file
166 + # name, and "smb-win-amd64.exe" is not a spelling it recognises.
167 + .\wingetcreate.exe update $env:PACKAGE_IDENTIFIER `
168 + --version $version `
169 + --urls "$base/smb-win-amd64.exe|x64" "$base/smb-win-arm64.exe|arm64" `
170 + --release-notes-url "https://github.com/${env:REPO}/releases/tag/$tag" `
171 + --release-date "${{ steps.release.outputs.date }}" `
172 + --prtitle "Update $env:PACKAGE_IDENTIFIER to version $version" `
173 + --submit `
174 + --no-open
175 +
176 + if ($LASTEXITCODE -ne 0) {
177 + Write-Error "wingetcreate failed with exit code $LASTEXITCODE"
178 + }
packaging/winget/smbCloudXYZ.smbcloud-cli.installer.yaml.in new
+18
@@ -0,0 +1,18 @@
1 +# yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.6.0.schema.json
2 +PackageIdentifier: smbCloudXYZ.smbcloud-cli
3 +PackageVersion: @VERSION@
4 +InstallerType: portable
5 +# `Commands` is what winget names the shim it drops on PATH. The release
6 +# assets are smb-win-<arch>.exe, but the command has to be `smb`.
7 +Commands:
8 + - smb
9 +ReleaseDate: "@RELEASE_DATE@"
10 +Installers:
11 + - Architecture: x64
12 + InstallerUrl: https://github.com/@REPO@/releases/download/@TAG@/smb-win-amd64.exe
13 + InstallerSha256: @SHA_AMD64@
14 + - Architecture: arm64
15 + InstallerUrl: https://github.com/@REPO@/releases/download/@TAG@/smb-win-arm64.exe
16 + InstallerSha256: @SHA_ARM64@
17 +ManifestType: installer
18 +ManifestVersion: 1.6.0
packaging/winget/smbCloudXYZ.smbcloud-cli.locale.en-US.yaml.in new
+32
@@ -0,0 +1,32 @@
1 +# yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.1.6.0.schema.json
2 +PackageIdentifier: smbCloudXYZ.smbcloud-cli
3 +PackageVersion: @VERSION@
4 +PackageLocale: en-US
5 +Publisher: smbCloud
6 +PublisherUrl: https://smbcloud.xyz/
7 +PublisherSupportUrl: https://github.com/@REPO@/issues
8 +PackageName: smbCloud CLI
9 +PackageUrl: https://smbcloud.xyz/
10 +License: Apache-2.0
11 +LicenseUrl: https://github.com/@REPO@/blob/main/LICENSE
12 +ShortDescription: smbCloud command line interface.
13 +Description: |-
14 + smbCloud Mail, Auth, Deploy, and GresIQ from your terminal or your AI
15 + agent. `smb` is the command-line interface for smbCloud: send mail with
16 + smbCloud Mail, manage authentication apps on smbCloud Auth, and deploy
17 + Rust, Node.js, Ruby, or Swift projects from one binary.
18 +Moniker: smb
19 +Tags:
20 + - cli
21 + - cloud
22 + - deploy
23 + - developer-tools
24 + - email
25 + - smbcloud
26 + - terminal
27 +ReleaseNotesUrl: https://github.com/@REPO@/releases/tag/@TAG@
28 +Documentations:
29 + - DocumentLabel: Readme
30 + DocumentUrl: https://github.com/@REPO@/blob/main/README.MD
31 +ManifestType: defaultLocale
32 +ManifestVersion: 1.6.0
packaging/winget/smbCloudXYZ.smbcloud-cli.yaml.in new
+6
@@ -0,0 +1,6 @@
1 +# yaml-language-server: $schema=https://aka.ms/winget-manifest.version.1.6.0.schema.json
2 +PackageIdentifier: smbCloudXYZ.smbcloud-cli
3 +PackageVersion: @VERSION@
4 +DefaultLocale: en-US
5 +ManifestType: version
6 +ManifestVersion: 1.6.0