release-gate (v0) #3
Workflow file for this run
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
| # Release gate — npm-tarball install + functional smoke + version-pin audit | |
| # | |
| # Tracked in https://github.com/sleep2agi/agent-network/issues/261 (P1) | |
| # | |
| # WHY THIS EXISTS | |
| # =============== | |
| # Up to v0.10.x we shipped via a manual "Method B" SOP that lived in | |
| # docs/tests/release-gate-playbook.md. Several real ship-blockers slipped | |
| # past it because each check ran on the publisher's local machine, not | |
| # a clean environment, and the "remember to grep PINNED_* / split Install | |
| # vs Upgrade in the release notes" steps depended on a human reading the | |
| # playbook every time: | |
| # | |
| # - v0.10.0 PINNED_SERVER_VERSION mismatch — caught last-minute, would | |
| # have user-side regressed announced functionality if shipped | |
| # - v0.10.2 release notes only contained `anet upgrade` instructions — | |
| # new users had no install path | |
| # - #136 preview.4 wizard silent exit — host typecheck/build was green | |
| # but `npm install -g` into a fresh container exposed an install-path break | |
| # - #137 wizard rc=0 with no UI — host non-TTY drive let a regression | |
| # through that real-TTY drive caught | |
| # | |
| # This workflow encodes the manual SOP as automated gates that run on | |
| # every tag push and any workflow_dispatch invocation. It is **report-only | |
| # by design** (does not block `npm publish` itself) — the publisher decides | |
| # whether to proceed. The goal is to make the checks impossible to skip. | |
| # | |
| # WHAT IT GATES | |
| # ============= | |
| # Triggered on tag push (v*.*.* and v*.*.*-preview.*) for either | |
| # @sleep2agi/agent-network or @sleep2agi/agent-node, or on manual dispatch | |
| # (target package + version supplied as inputs). | |
| # | |
| # Gate 1 — install-path smoke (node:24-slim, no globals leaked in) | |
| # - `npm install -g <tarball>` from the built artifact (NOT from npm, | |
| # so we catch broken bundles BEFORE they're published) | |
| # - `anet --version` matches the tag | |
| # - `anet hub --help` shows the expected subcommands | |
| # - `anet hub start` boots + /health 200 + admin-utok.json mode 600 | |
| # - `anet login --hub http://127.0.0.1:9200 --username admin --password ...` | |
| # - `anet node create` reaches the wizard's first prompt under expect-driven | |
| # real-TTY (catches non-TTY-silent-exit regressions) | |
| # | |
| # Gate 2 — PINNED_*_VERSION audit | |
| # - greps `agent-network/bin/cli.ts` for `PINNED_SERVER_VERSION` / | |
| # `PINNED_NODE_VERSION` / `PINNED_DASHBOARD_VERSION` | |
| # - asserts each pin matches what `npm view <package> versions` actually | |
| # exposes (preview chain pins must include the `-preview.N` suffix) | |
| # - prevents the v0.10.0 / #194 class of ship-blockers (anet hub start | |
| # silently hangs when PINNED_SERVER_VERSION points at a non-published | |
| # version) | |
| # | |
| # Gate 3 — release notes shape | |
| # - asserts the most-recent release notes file (docs/tests/release-*.md | |
| # or the GitHub release body) contains BOTH a "## Install" section | |
| # (new-user `npm install -g @sleep2agi/agent-network@X.Y.Z`) AND an | |
| # "## Upgrade" section (existing-user `anet upgrade`) | |
| # - prevents the v0.10.2 class of "new users can't install" misses | |
| # | |
| # WHAT IT DOES NOT DO | |
| # =================== | |
| # - Does NOT publish to npm. Publishing remains a manual `npm publish` step | |
| # on the maintainer's machine. This workflow runs against the locally | |
| # built tarball + tag metadata, then reports verdict. | |
| # - Does NOT run the full v0.11 onboarding 5-scenario suite from | |
| # /tmp/p-v0.11-onboarding (that's the contract-test layer). Release gate | |
| # is a smaller, faster confidence check focused on install-path + version | |
| # pin + release-notes hygiene. | |
| # - Does NOT block merge. If a gate fails on a tag, the publisher reverts | |
| # the tag and re-tags after fixing — the workflow does not gate the | |
| # tarball that's already on disk. | |
| name: release-gate (v0) | |
| on: | |
| push: | |
| tags: | |
| - 'v*.*.*' | |
| - 'v*.*.*-preview.*' | |
| - 'agent-network@v*' | |
| - 'agent-node@v*' | |
| workflow_dispatch: | |
| inputs: | |
| package: | |
| description: 'Package to gate (agent-network / agent-node)' | |
| required: true | |
| default: 'agent-network' | |
| type: choice | |
| options: | |
| - agent-network | |
| - agent-node | |
| version: | |
| description: 'Version to gate (e.g. 2.2.22-preview.4 or 2.2.21)' | |
| required: true | |
| type: string | |
| concurrency: | |
| group: release-gate-${{ github.ref }} | |
| cancel-in-progress: false # never cancel — even superseded runs surface a verdict | |
| jobs: | |
| build-tarball: | |
| name: build tarball | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| outputs: | |
| package: ${{ steps.resolve.outputs.package }} | |
| version: ${{ steps.resolve.outputs.version }} | |
| tarball: ${{ steps.pack.outputs.tarball }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Setup Bun | |
| uses: oven-sh/setup-bun@v2 | |
| - name: Resolve target package + version | |
| id: resolve | |
| run: | | |
| set -euo pipefail | |
| if [ "${{ github.event_name }}" = 'workflow_dispatch' ]; then | |
| pkg='${{ inputs.package }}' | |
| ver='${{ inputs.version }}' | |
| else | |
| tag='${{ github.ref_name }}' | |
| case "$tag" in | |
| agent-node@v*) pkg=agent-node; ver="${tag#agent-node@v}" ;; | |
| agent-network@v*) pkg=agent-network; ver="${tag#agent-network@v}" ;; | |
| v*) pkg=agent-network; ver="${tag#v}" ;; | |
| *) echo "::error::unknown tag shape: $tag"; exit 1 ;; | |
| esac | |
| fi | |
| echo "package=$pkg" >> "$GITHUB_OUTPUT" | |
| echo "version=$ver" >> "$GITHUB_OUTPUT" | |
| echo "Gating $pkg @ $ver" | |
| - name: Install + build target package | |
| working-directory: ${{ steps.resolve.outputs.package }} | |
| run: | | |
| bun install --frozen-lockfile | |
| bun run build | |
| - name: Pack tarball | |
| id: pack | |
| working-directory: ${{ steps.resolve.outputs.package }} | |
| run: | | |
| set -euo pipefail | |
| # npm pack honors prepublishOnly + .npmignore, exactly what `npm publish` would ship | |
| tarball=$(npm pack --json | bun -e 'const a=JSON.parse(require("fs").readFileSync(0,"utf8"));console.log(a[0].filename)') | |
| echo "tarball=${{ steps.resolve.outputs.package }}/$tarball" >> "$GITHUB_OUTPUT" | |
| ls -la "$tarball" | |
| - name: Upload tarball | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: gated-tarball | |
| path: ${{ steps.pack.outputs.tarball }} | |
| retention-days: 7 | |
| gate-1-install-smoke: | |
| name: gate 1 — install-path smoke (real TTY) | |
| needs: build-tarball | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/download-artifact@v4 | |
| with: | |
| name: gated-tarball | |
| path: ./tarball | |
| - name: Verify --version, --help, hub start, login, node-create wizard | |
| env: | |
| GATED_PKG: ${{ needs.build-tarball.outputs.package }} | |
| GATED_VER: ${{ needs.build-tarball.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| tarball=$(ls ./tarball/*.tgz) | |
| echo "Gating tarball: $tarball" | |
| # Real TTY drive via `script -qc` — captures the wizard's interactive | |
| # prompts that a plain shell `< /dev/null` would silently skip. | |
| # The harness installs the tarball as the LOCAL agent-network into a | |
| # clean slim image (no globals leaked in) and exercises 5 cases. | |
| # node:24-slim chosen over alpine to match real user environment | |
| # (glibc vs musl): alpine masked a feishu-image agent-runtime regression | |
| # last cycle — staying on slim keeps glibc-only binaries in scope. | |
| docker run --rm \ | |
| -v "$PWD/tarball:/tarball:ro" \ | |
| -e GATED_PKG -e GATED_VER \ | |
| node:24-slim bash -c ' | |
| set -euo pipefail | |
| apt-get update -qq && apt-get install -y --no-install-recommends \ | |
| bash curl ca-certificates jq expect util-linux procps > /dev/null | |
| rm -rf /var/lib/apt/lists/* | |
| # bun is required by anet hub start (commhub-server is bun-only) | |
| curl -fsSL https://bun.sh/install | bash > /dev/null | |
| export PATH="/root/.bun/bin:$PATH" | |
| # Install from local tarball — proves the published bundle works, | |
| # not just the source tree (catches missing dist files / broken | |
| # postinstall / wrong "files" array in package.json). | |
| npm install -g /tarball/*.tgz > /tmp/install.log 2>&1 \ | |
| || { echo "::error::npm install -g failed"; cat /tmp/install.log; exit 1; } | |
| # case 1 — version matches tag | |
| ver=$(anet --version | tr -d "v\n ") | |
| echo "anet --version → $ver (expected $GATED_VER)" | |
| [ "$ver" = "$GATED_VER" ] \ | |
| || { echo "::error::version mismatch — anet says $ver, gating $GATED_VER"; exit 1; } | |
| # case 2 — hub --help routes correctly + lists stop/status/start | |
| anet hub --help > /tmp/hub-help.log 2>&1 | |
| for sub in start stop status; do | |
| grep -q "\\b$sub\\b" /tmp/hub-help.log \ | |
| || { echo "::error::anet hub --help missing subcommand: $sub"; exit 1; } | |
| done | |
| # case 3 — anet hub start brings up /health and creates admin-utok.json mode 600 | |
| # We pass an explicit --password so login (case 4) is deterministic; | |
| # since 3e4e190 (#261 P0-2) the bootstrap password is random when not | |
| # supplied, and it is printed to stdout but never persisted into | |
| # admin-utok.json (which holds only username/user_id/token/created_at). | |
| # Operator-supplied --password takes precedence over the random path, | |
| # so this is the supported way to drive a non-interactive smoke. | |
| export HOME=/tmp/anethome; mkdir -p "$HOME" | |
| # Finite upstream pipeline (head reads 32 bytes then closes) so | |
| # SIGPIPE under `set -o pipefail` cannot kill the inner command. | |
| GATE_PW="ReleaseGate-$(head -c 32 /dev/urandom | sha256sum | head -c 16)" | |
| nohup anet hub start --host 127.0.0.1 --port 9200 \ | |
| --username admin --password "$GATE_PW" > /tmp/hub.log 2>&1 & | |
| for i in $(seq 1 30); do | |
| curl -sf http://127.0.0.1:9200/health > /dev/null && break | |
| sleep 1 | |
| done | |
| curl -sf http://127.0.0.1:9200/health > /dev/null \ | |
| || { echo "::error::/health did not respond 2xx"; cat /tmp/hub.log; exit 1; } | |
| mode=$(stat -c %a "$HOME/.anet/server/admin-utok.json") | |
| [ "$mode" = "600" ] \ | |
| || { echo "::error::admin-utok.json mode $mode (expected 600)"; exit 1; } | |
| # case 4 — login resolves (uses the explicit --password we passed | |
| # to hub start above) | |
| anet login --hub http://127.0.0.1:9200 --username admin --password "$GATE_PW" > /tmp/login.log 2>&1 \ | |
| || { echo "::error::anet login failed"; cat /tmp/login.log; exit 1; } | |
| grep -q "Logged in" /tmp/login.log \ | |
| || { echo "::error::login output missing success marker"; cat /tmp/login.log; exit 1; } | |
| # case 5 — node create wizard reaches first prompt under real TTY drive. | |
| # The expect script is built with printf (no heredoc) so the YAML | |
| # parser stays happy — heredoc EOF markers at column 1 collide with | |
| # YAML mapping syntax inside a `run: |` block. | |
| { | |
| printf "set timeout 30\n" | |
| printf "spawn anet node create r-node\n" | |
| printf "expect {\n" | |
| printf " -re \"(vendor|runtime|provider).*\\\\?\" { puts \"[wizard] reached first prompt\"; exit 0 }\n" | |
| printf " timeout { puts \"[wizard] TIMEOUT — no prompt within 30s\"; exit 2 }\n" | |
| printf " eof { puts \"[wizard] EOF before prompt — silent exit regression\"; exit 3 }\n" | |
| printf "}\n" | |
| } > /tmp/wizard.exp | |
| script -qc "expect /tmp/wizard.exp" /tmp/wizard.log | |
| grep -q "reached first prompt" /tmp/wizard.log \ | |
| || { echo "::error::wizard did not reach first prompt under real TTY"; cat /tmp/wizard.log; exit 1; } | |
| echo "✅ Gate 1 — install-path + 5 smoke cases PASS" | |
| ' | |
| gate-2-pinned-audit: | |
| name: gate 2 — PINNED_* version pin audit | |
| needs: build-tarball | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 3 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Grep PINNED_* + assert npm has those versions | |
| env: | |
| GATED_PKG: ${{ needs.build-tarball.outputs.package }} | |
| run: | | |
| set -euo pipefail | |
| # Walk every PINNED_*_VERSION assignment that *actually exists* in | |
| # cli.ts. Pattern: | |
| # const PINNED_SERVER_VERSION = "0.8.2"; | |
| # const PINNED_NODE_VERSION = "2.4.8-preview.0"; | |
| # If a given PINNED_*_VERSION isn't in cli.ts (e.g. dashboard is | |
| # Vercel-deployed, not pinned in CLI), the loop simply doesn't emit | |
| # an entry for it — Gate 2 audits *only what's wired in*, no false | |
| # "missing PINNED_DASHBOARD" alarms. | |
| # If any *existing* pin points at a version that's not on npm, | |
| # anet hub start will silently hang (#194 class of bug). Fail loud. | |
| cli="$GATED_PKG/bin/cli.ts" | |
| [ -f "$cli" ] || { echo "::error::no $cli — cannot audit pins"; exit 1; } | |
| missing=0 | |
| while IFS=$'\t' read -r var ver; do | |
| case "$var" in | |
| PINNED_SERVER_VERSION) pkg='@sleep2agi/commhub-server' ;; | |
| PINNED_NODE_VERSION) pkg='@sleep2agi/agent-node' ;; | |
| PINNED_DASHBOARD_VERSION) pkg='@sleep2agi/agent-network-dashboard' ;; | |
| *) continue ;; | |
| esac | |
| echo "audit $var=$ver → $pkg" | |
| if ! npm view "$pkg@$ver" version > /dev/null 2>&1; then | |
| echo "::error::$var=$ver not published on npm for $pkg" | |
| missing=$((missing+1)) | |
| fi | |
| done < <(grep -E 'PINNED_(SERVER|NODE|DASHBOARD)_VERSION\s*=' "$cli" \ | |
| | sed -E 's/.*PINNED_(SERVER|NODE|DASHBOARD)_VERSION[^"]*"([^"]+)".*/PINNED_\1_VERSION\t\2/') | |
| [ "$missing" -eq 0 ] || { echo "::error::$missing PINNED_* pin(s) point at unpublished versions"; exit 1; } | |
| echo "✅ Gate 2 — all PINNED_* versions exist on npm" | |
| gate-3-release-notes: | |
| name: gate 3 — release notes shape (Install + Upgrade) | |
| needs: build-tarball | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 2 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Assert release notes have both Install and Upgrade sections | |
| env: | |
| GATED_VER: ${{ needs.build-tarball.outputs.version }} | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| # ── Source A — docs/tests/release-vX.Y.Z*.md convention ──────────── | |
| # Convention: release notes land at docs/tests/release-vX.Y.Z*.md. | |
| # Try prefix match first, then grep fallback inside that dir. | |
| notes_body="" | |
| src="" | |
| notes_file=$(ls docs/tests/release-v"$GATED_VER"*.md 2>/dev/null | head -1 || true) | |
| if [ -z "$notes_file" ]; then | |
| notes_file=$(grep -rl "v$GATED_VER" docs/tests/release-*.md 2>/dev/null | head -1 || true) | |
| fi | |
| if [ -n "$notes_file" ]; then | |
| notes_body=$(cat "$notes_file") | |
| src="file:$notes_file" | |
| fi | |
| # ── Source B (fallback) — GitHub release body for this tag ──────── | |
| # If the file convention was missed (notes pasted only into the | |
| # GitHub release), fall back to `gh release view`. This is the | |
| # double-safety net 通信龙 asked for: location can drift without | |
| # silently passing Gate 3. | |
| # workflow_dispatch has no tag → only file source is available. | |
| if [ -z "$notes_body" ] && [ "${{ github.event_name }}" = 'push' ]; then | |
| tag='${{ github.ref_name }}' | |
| if release_body=$(gh release view "$tag" --json body --jq .body 2>/dev/null); then | |
| notes_body="$release_body" | |
| src="gh-release:$tag" | |
| fi | |
| fi | |
| [ -n "$notes_body" ] || { | |
| echo "::error::no release notes for v$GATED_VER — looked in docs/tests/release-v*.md and gh release view" | |
| exit 1 | |
| } | |
| echo "release notes source: $src" | |
| # ── The hygiene checks — both sections must exist ───────────────── | |
| # v0.10.2 shipped without ## Install and new users had no install | |
| # path. v0.10.7 had stale @version in Install section. | |
| echo "$notes_body" | grep -qE '^## Install\b' \ | |
| || { echo "::error::$src missing '## Install' section (new-user install path)"; exit 1; } | |
| echo "$notes_body" | grep -qE '^## Upgrade\b' \ | |
| || { echo "::error::$src missing '## Upgrade' section (existing-user upgrade path)"; exit 1; } | |
| # Spot-check that Install section actually contains the gated version | |
| # — catches stale notes where someone copied an old file but forgot | |
| # to bump the version inside. | |
| install_block=$(echo "$notes_body" | awk '/^## Install/,/^## /' | head -50) | |
| echo "$install_block" | grep -qE "@$GATED_VER\b" \ | |
| || { echo "::error::'## Install' section in $src does not mention @$GATED_VER"; exit 1; } | |
| echo "✅ Gate 3 — release notes contain both Install + Upgrade for v$GATED_VER ($src)" | |
| verdict: | |
| name: verdict (aggregate) | |
| needs: [gate-1-install-smoke, gate-2-pinned-audit, gate-3-release-notes] | |
| runs-on: ubuntu-latest | |
| if: always() | |
| steps: | |
| - name: Summarize | |
| run: | | |
| set -e | |
| g1='${{ needs.gate-1-install-smoke.result }}' | |
| g2='${{ needs.gate-2-pinned-audit.result }}' | |
| g3='${{ needs.gate-3-release-notes.result }}' | |
| echo "Gate 1 (install smoke): $g1" | |
| echo "Gate 2 (PINNED audit): $g2" | |
| echo "Gate 3 (release notes): $g3" | |
| if [ "$g1" = success ] && [ "$g2" = success ] && [ "$g3" = success ]; then | |
| echo "✅ RELEASE GATE CLEAN — safe to npm publish (or promote to @latest)" | |
| else | |
| echo "::warning::release gate has at least one failure — review before publishing" | |
| # Non-blocking: report-only by design (see header). Maintainer decides. | |
| exit 0 | |
| fi |