release-watch #1060
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: release-watch | |
| # Pull-based cascade trigger — closes the sender gap in the release | |
| # fan-out. | |
| # | |
| # The push-based design (each sibling calls notify.yml on tag push) | |
| # was never wired: every zone repo got the cascade.yml RECEIVER but no | |
| # repo got the notify.yml SENDER, so `package-released` never fired and | |
| # the orchestrator only ever ran by hand (last: 2026-05-29; app-store | |
| # v1.0.1 on 2026-06-23 cascaded nowhere). Rather than provision an | |
| # ORCH_TOKEN secret into 20 sibling repos, this watcher follows the | |
| # same philosophy the distribution channels already use (see | |
| # pilotprotocol release.yml: homebrew / sdk-node / sdk-python all poll): | |
| # poll every producer repo's latest release, and for each unseen tag | |
| # fire the orchestrator via THIS repo's own workflow-dispatch — which | |
| # the built-in GITHUB_TOKEN is allowed to do. Zero cross-repo secrets. | |
| # | |
| # State lives in state/release-watch.json (node -> last dispatched | |
| # tag), committed back by the run. First run against a missing node | |
| # entry only RECORDS the current tag (no dispatch) so onboarding a | |
| # node never replays a stale release. The orchestrator's own | |
| # (package, version) dedupe is a second line of defense. | |
| on: | |
| schedule: | |
| - cron: '*/30 * * * *' | |
| workflow_dispatch: | |
| permissions: | |
| contents: write # commit state/release-watch.json | |
| actions: write # gh workflow run orchestrator.yml (same repo) | |
| # One watcher at a time; a queued tick after a slow run is redundant. | |
| concurrency: | |
| group: release-watch | |
| cancel-in-progress: false | |
| jobs: | |
| watch: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Mint App token | |
| # The state commit pushes to this repo's protected main; the | |
| # default token cannot (GH006, observed on the first scheduled | |
| # run 2026-07-13 09:54). pilot-release-bot has a | |
| # bypass_pull_request_allowances entry on main. | |
| id: app | |
| uses: actions/create-github-app-token@v1 | |
| with: | |
| app-id: ${{ secrets.RELEASE_APP_ID }} | |
| private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} | |
| - name: Scan producer repos and fire cascade for new releases | |
| env: | |
| # Two tokens: the default token CAN dispatch same-repo | |
| # workflows (actions: write below) but CANNOT push to | |
| # protected main; the App token is the exact inverse | |
| # (no actions permission on the installation, but a | |
| # bypass allowance on main). Use each where it works. | |
| GH_TOKEN: ${{ github.token }} | |
| APP_TOKEN: ${{ steps.app.outputs.token }} | |
| run: | | |
| set -euo pipefail | |
| # Producer nodes: Go modules whose releases have downstream | |
| # dependents. Distribution endpoints (homebrew-pilot, sdk-*) | |
| # and zone-4 surfaces (website) are consumers, not producers — | |
| # their cascades would compute empty closures, so they are not | |
| # watched. Node name == repo name except the hub: | |
| # web4 -> pilot-protocol/pilotprotocol. | |
| NODES="common beacon gateway dataexchange eventstream handshake nameserver policy rendezvous runtime skillinject trustedagents updater webhook libpilot app-store examples cosift pilot-ca wallet web4" | |
| STATE=state/release-watch.json | |
| mkdir -p state | |
| [ -f "$STATE" ] || echo '{}' > "$STATE" | |
| CHANGED=0 | |
| for node in $NODES; do | |
| repo="pilot-protocol/$node" | |
| [ "$node" = "web4" ] && repo="pilot-protocol/pilotprotocol" | |
| # Poll TAGS, not releases. Zone-1 is the "tag-only" zone: the | |
| # canonical producer signal is the git tag (the README: | |
| # "downstream cascades trigger on tag push"). Polling releases | |
| # missed every zone-1 producer, because their high stable tags | |
| # (common v0.5.7, gateway v0.2.1, trustedagents v0.2.4, …) have | |
| # no GitHub release — so the cascade dead-ended at depth 1 and | |
| # existing pins never reconciled. Highest strict-semver tag, | |
| # prereleases included (a beta upstream dispatches with | |
| # is_prerelease=true and the receivers' stable_only gate | |
| # decides). Sort -V, tail -1. | |
| latest=$(gh api "repos/$repo/tags?per_page=100" \ | |
| --jq '.[].name' 2>/dev/null \ | |
| | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$' \ | |
| | sort -V | tail -1 || true) | |
| [ -n "$latest" ] || continue | |
| # Same strict-semver gate as notify.yml (PILOT-315). | |
| if ! echo "$latest" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$'; then | |
| echo "::warning::$node: tag '$latest' is not strict semver — skipping" | |
| continue | |
| fi | |
| seen=$(jq -r --arg n "$node" '.[$n] // empty' "$STATE") | |
| if [ -z "$seen" ]; then | |
| # Never-watched node: record without dispatching so a | |
| # fresh state file doesn't replay history. | |
| echo "::notice::$node: seeding state at $latest (no dispatch)" | |
| jq --arg n "$node" --arg v "$latest" '.[$n] = $v' "$STATE" > "$STATE.tmp" && mv "$STATE.tmp" "$STATE" | |
| CHANGED=1 | |
| continue | |
| fi | |
| [ "$latest" = "$seen" ] && continue | |
| # Same L3 rule tag-bump applies when it picks the base stable | |
| # tag: the marker globs must not require a dot after the | |
| # marker, or a dotless prerelease (v1.0.0-rc1, v1.0.0-beta2) | |
| # misses every arm and is classified STABLE. | |
| IS_PRE=false | |
| case "$latest" in | |
| *-beta*|*-rc*|*-alpha*|*-pre*) IS_PRE=true ;; | |
| esac | |
| # Only STABLE tags drive the cascade. A beta tag is a cascade | |
| # OUTPUT (tag-bump produces -beta.N), and every receiver is | |
| # stable_only, so dispatching a beta upstream fans out only to | |
| # get skipped everywhere — pure waste (one orchestrator run + | |
| # N no-op dispatches per beta). Still advance state so the beta | |
| # isn't re-detected next tick; just don't fire. | |
| if [ "$IS_PRE" = "true" ]; then | |
| echo "::notice::$node: $seen -> $latest is a prerelease — tracking, not cascading" | |
| jq --arg n "$node" --arg v "$latest" '.[$n] = $v' "$STATE" > "$STATE.tmp" && mv "$STATE.tmp" "$STATE" | |
| CHANGED=1 | |
| continue | |
| fi | |
| echo "::notice::$node: $seen -> $latest (stable) — firing orchestrator" | |
| gh workflow run orchestrator.yml \ | |
| -f package="$node" \ | |
| -f version="$latest" \ | |
| -f is_prerelease="$IS_PRE" \ | |
| -f dry_run=false | |
| jq --arg n "$node" --arg v "$latest" '.[$n] = $v' "$STATE" > "$STATE.tmp" && mv "$STATE.tmp" "$STATE" | |
| CHANGED=1 | |
| done | |
| if [ "$CHANGED" = "1" ]; then | |
| git config user.name "pilot-release-bot" | |
| git config user.email "release-bot@pilotprotocol.network" | |
| git config --local --unset-all http.https://github.com/.extraheader || true | |
| git remote set-url origin "https://x-access-token:${APP_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" | |
| git add "$STATE" | |
| git commit -m "release-watch: update last-seen state [skip ci]" | |
| # Rebase-push so two near-simultaneous runs (schedule + | |
| # manual) don't clobber each other; concurrency group makes | |
| # this a rare race anyway. | |
| git pull --rebase origin "${GITHUB_REF_NAME:-main}" | |
| git push origin "HEAD:${GITHUB_REF_NAME:-main}" | |
| else | |
| echo "no new releases" | |
| fi |