@@ -189,3 +189,195 @@ jobs:
189189 generate_release_notes : true
190190 draft : false
191191 prerelease : ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta') }}
192+
193+ # ---------------------------------------------------------------------------
194+ # Downstream fan-out. After the GitHub Release (binaries + checksums) is
195+ # published, propagate THIS tag's version to every distribution channel:
196+ # PyPI, npm, Homebrew. Each leg is independent: one failing channel does not
197+ # block the others, but every failure is surfaced (the final "gate" step
198+ # fails the job if any leg failed). Skipped for -rc/-beta prereleases.
199+ #
200+ # Version-locking: every channel publishes ${GITHUB_REF_NAME} (the daemon
201+ # tag), NOT the stale version sitting in each downstream repo's source.
202+ #
203+ # PyPI/npm are triggered via `gh workflow run publish.yml -f version=...`
204+ # against the SDK repos. This MUST use a cross-repo PAT — a release created
205+ # with GITHUB_TOKEN cannot trigger another repo's workflow, and GITHUB_TOKEN
206+ # has no write scope on sibling repos. See RELEASE_DISPATCH_TOKEN below.
207+ publish-downstream :
208+ name : Publish to PyPI / npm / Homebrew
209+ needs : release
210+ if : ${{ !(contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta')) }}
211+ runs-on : ubuntu-latest
212+ permissions :
213+ contents : read
214+ steps :
215+ - name : Compute version
216+ id : ver
217+ run : |
218+ TAG="${GITHUB_REF_NAME}"
219+ echo "tag=$TAG" >> "$GITHUB_OUTPUT"
220+ echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
221+
222+ # --- PyPI -------------------------------------------------------------
223+ - name : Dispatch sdk-python publish
224+ id : pypi
225+ continue-on-error : true
226+ env :
227+ GH_TOKEN : ${{ secrets.RELEASE_DISPATCH_TOKEN }}
228+ run : |
229+ if [ -z "$GH_TOKEN" ]; then
230+ echo "::error::RELEASE_DISPATCH_TOKEN is not set — cannot dispatch sdk-python"
231+ exit 1
232+ fi
233+ gh workflow run publish.yml \
234+ --repo pilot-protocol/sdk-python \
235+ --ref main \
236+ -f version="${{ steps.ver.outputs.version }}"
237+ echo "Dispatched sdk-python publish @ ${{ steps.ver.outputs.version }}"
238+
239+ # --- npm --------------------------------------------------------------
240+ - name : Dispatch sdk-node publish
241+ id : npm
242+ continue-on-error : true
243+ env :
244+ GH_TOKEN : ${{ secrets.RELEASE_DISPATCH_TOKEN }}
245+ run : |
246+ if [ -z "$GH_TOKEN" ]; then
247+ echo "::error::RELEASE_DISPATCH_TOKEN is not set — cannot dispatch sdk-node"
248+ exit 1
249+ fi
250+ gh workflow run publish.yml \
251+ --repo pilot-protocol/sdk-node \
252+ --ref main \
253+ -f version="${{ steps.ver.outputs.version }}"
254+ echo "Dispatched sdk-node publish @ ${{ steps.ver.outputs.version }}"
255+
256+ # --- Homebrew ---------------------------------------------------------
257+ # Regenerate the formula from this release's checksums + tag and push it
258+ # to the tap. HOMEBREW_TAP_TOKEN must be a PAT with write access to the
259+ # tap repo (the tap is in a different owner, so GITHUB_TOKEN can't push).
260+ - name : Update Homebrew formula
261+ id : brew
262+ continue-on-error : true
263+ env :
264+ GH_TOKEN : ${{ secrets.HOMEBREW_TAP_TOKEN }}
265+ TAP_REPO : TeoSlayer/homebrew-pilot
266+ FORMULA_PATH : Formula/pilotprotocol.rb
267+ run : |
268+ set -euo pipefail
269+ if [ -z "$GH_TOKEN" ]; then
270+ echo "::error::HOMEBREW_TAP_TOKEN is not set — cannot update the tap"
271+ exit 1
272+ fi
273+ TAG="${{ steps.ver.outputs.tag }}"
274+ VERSION="${{ steps.ver.outputs.version }}"
275+
276+ gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern checksums.txt --clobber
277+ cat checksums.txt
278+
279+ sum() { grep "pilot-$1.tar.gz" checksums.txt | awk '{print $1}'; }
280+ DA=$(sum darwin-arm64); DX=$(sum darwin-amd64)
281+ LA=$(sum linux-arm64); LX=$(sum linux-amd64)
282+ for v in "$DA" "$DX" "$LA" "$LX"; do
283+ [ -n "$v" ] || { echo "::error::missing checksum in checksums.txt"; exit 1; }
284+ done
285+
286+ base="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}"
287+ cat > pilotprotocol.rb <<RUBY
288+ class Pilotprotocol < Formula
289+ desc "The network stack for AI agents - addresses, ports, tunnels, encryption, trust"
290+ homepage "https://pilotprotocol.network"
291+ version "${VERSION}"
292+ license "AGPL-3.0"
293+
294+ on_macos do
295+ if Hardware::CPU.arm?
296+ url "${base}/pilot-darwin-arm64.tar.gz"
297+ sha256 "${DA}"
298+ else
299+ url "${base}/pilot-darwin-amd64.tar.gz"
300+ sha256 "${DX}"
301+ end
302+ end
303+
304+ on_linux do
305+ if Hardware::CPU.arm?
306+ url "${base}/pilot-linux-arm64.tar.gz"
307+ sha256 "${LA}"
308+ else
309+ url "${base}/pilot-linux-amd64.tar.gz"
310+ sha256 "${LX}"
311+ end
312+ end
313+
314+ def install
315+ bin.install "daemon" => "pilot-daemon"
316+ bin.install "pilotctl" => "pilotctl"
317+ bin.install "updater" => "pilot-updater"
318+ end
319+
320+ def post_install
321+ (var/"pilot").mkpath
322+ config_dir = Pathname.new(Dir.home)/".pilot"
323+ config_dir.mkpath
324+ (config_dir/"bin").mkpath
325+ (config_dir/"bin/.pilot-version").write "v#{version}\n"
326+ end
327+
328+ def caveats
329+ <<~EOS
330+ Get started:
331+ pilotctl daemon start --hostname my-agent --email you@example.com
332+ pilotctl info
333+ Docs: https://pilotprotocol.network/docs
334+ EOS
335+ end
336+
337+ service do
338+ run [opt_bin/"pilot-daemon", "-socket", "/tmp/pilot.sock", "-encrypt"]
339+ keep_alive crashed: true
340+ log_path var/"log/pilot-daemon.log"
341+ error_log_path var/"log/pilot-daemon.log"
342+ end
343+
344+ test do
345+ assert_match "pilotctl", shell_output("#{bin}/pilotctl --help 2>&1", 0)
346+ end
347+ end
348+ RUBY
349+ sed -i 's/^ //' pilotprotocol.rb
350+ cat pilotprotocol.rb
351+
352+ CONTENT=$(base64 -w 0 pilotprotocol.rb)
353+ SHA=$(gh api "repos/${TAP_REPO}/contents/${FORMULA_PATH}" --jq '.sha' 2>/dev/null || echo "")
354+ if [ -n "$SHA" ]; then
355+ gh api "repos/${TAP_REPO}/contents/${FORMULA_PATH}" -X PUT \
356+ -f message="pilotprotocol ${TAG}" -f content="$CONTENT" -f sha="$SHA"
357+ else
358+ gh api "repos/${TAP_REPO}/contents/${FORMULA_PATH}" -X PUT \
359+ -f message="pilotprotocol ${TAG}" -f content="$CONTENT"
360+ fi
361+ echo "Updated ${TAP_REPO}/${FORMULA_PATH} -> ${TAG}"
362+
363+ # --- Surface failures -------------------------------------------------
364+ - name : Fan-out gate
365+ if : always()
366+ run : |
367+ echo "PyPI dispatch : ${{ steps.pypi.outcome }}"
368+ echo "npm dispatch : ${{ steps.npm.outcome }}"
369+ echo "Homebrew : ${{ steps.brew.outcome }}"
370+ {
371+ echo "## Downstream fan-out (${{ steps.ver.outputs.tag }})"
372+ echo "| Channel | Result |"
373+ echo "|---------|--------|"
374+ echo "| PyPI (sdk-python dispatch) | ${{ steps.pypi.outcome }} |"
375+ echo "| npm (sdk-node dispatch) | ${{ steps.npm.outcome }} |"
376+ echo "| Homebrew (tap formula) | ${{ steps.brew.outcome }} |"
377+ } >> "$GITHUB_STEP_SUMMARY"
378+ if [ "${{ steps.pypi.outcome }}" != "success" ] || \
379+ [ "${{ steps.npm.outcome }}" != "success" ] || \
380+ [ "${{ steps.brew.outcome }}" != "success" ]; then
381+ echo "::error::one or more downstream channels failed to dispatch/update"
382+ exit 1
383+ fi
0 commit comments