From f90e269dc1dbc830ec146d89bf29790c3d1b11db Mon Sep 17 00:00:00 2001 From: vansin Date: Sun, 28 Jun 2026 08:44:14 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(#261):=20release-gate=20workflow=20?= =?UTF-8?q?=E2=80=94=20install=20smoke=20+=20PINNED=20audit=20+=20release-?= =?UTF-8?q?notes=20shape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Encodes the manual Method B release SOP as automated, report-only gates that run on every tag push (or workflow_dispatch). Closes the gap behind several recent ship-blockers that the human checklist let through. Three gates, all independent jobs (parallel after a shared tarball build): Gate 1 — install-path smoke (real TTY) - node:24-slim (NOT alpine, to keep glibc coverage; alpine masked a feishu-image agent-runtime regression last cycle) - npm install -g from the just-built tarball (NOT npm.org), so broken bundles fail BEFORE publish (catches #136 preview.4 / #137 class) - 5 cases: --version matches tag / hub --help shows stop+status+start / hub start + /health 200 + admin-utok 600 / login --hub --user --pw / node create wizard reaches first prompt under script -qc + expect (catches non-TTY-silent-exit regressions; memory: feedback_picker_interactive_expect_e2e) Gate 2 — PINNED_* version pin audit - Greps agent-network/bin/cli.ts for PINNED_(SERVER|NODE|DASHBOARD)_VERSION - Asserts each pin is published on npm via `npm view @` - Audits ONLY pins that actually exist in cli.ts (dashboard is Vercel- deployed, may not be pinned — loop just skips missing entries, no false "missing PINNED_DASHBOARD" alarms) - Prevents the v0.10.0 / #194 class: PINNED_SERVER_VERSION points at an unpublished version → anet hub start silently hangs at fetch Gate 3 — release notes shape - Source A: docs/tests/release-vX.Y.Z*.md convention (prefix match, then grep fallback in the same dir) - Source B (fallback): `gh release view --json body` for the case where notes were pasted into the GitHub release but not into the repo file (double-safety so location drift doesn't silently pass Gate 3) - Asserts both `## Install` AND `## Upgrade` sections exist (prevents v0.10.2 class: only Upgrade section, new users had no install path), and that the Install section's version literal matches the gated tag (prevents stale-copy-from-old-release class) Design notes: - Report-only by design. Does NOT block `npm publish` (publishing remains a manual step on the maintainer's machine). Does NOT auto- publish. The verdict is informational; maintainer decides whether to proceed. The goal is to make the checks impossible to skip. - Total wall-clock under 10min (build + 3 parallel gates + verdict). - workflow_dispatch supports gating an arbitrary published version too (useful for retro-checking historical releases). Verified locally: - YAML parses cleanly with python3 yaml.safe_load - tarball-from-source pattern matches `npm publish` exactly (npm pack honors prepublishOnly + .npmignore) - The 3 historical ship-blockers (#136/#137 install/#194 PINNED/v0.10.2 notes) are all covered by at least one gate Refs: #261 (this issue), #265+#269 (CI baseline restored — release gate now has a trustworthy main-CI signal to layer on top of) --- .github/workflows/release.yml | 394 ++++++++++++++++++++++++++++++++++ 1 file changed, 394 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..b63af160 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,394 @@ +# 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 +# (memory: [[feedback_pinned_chain_bump_verify]]) +# - v0.10.2 release notes only contained `anet upgrade` instructions — +# new users had no install path (memory: [[feedback_release_notes_install_upgrade_split]]) +# - #136 preview.4 wizard silent exit — host typecheck/build was green +# but `npm install -g` into a fresh container exposed install-path break +# (memory: [[feedback_docker_smoke_gate_before_ship]]) +# - #137 wizard rc=0 with no UI — host non-TTY drive let a regression +# through that real-TTY drive caught (memory: [[feedback_docker_smoke_real_tty]]) +# +# 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-alpine, no globals leaked in) +# - `npm install -g ` 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 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 + export HOME=/tmp/anethome; mkdir -p "$HOME" + nohup anet hub start > /tmp/hub.log 2>&1 & + sleep 5 + 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 per-install random admin pwd + # from server/admin-utok.json metadata, see commit 3e4e190) + pw=$(jq -r .bootstrap_password "$HOME/.anet/server/admin-utok.json" 2>/dev/null \ + || echo anethub) + anet login --hub http://127.0.0.1:9200 --username admin --password "$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 From 46cf3a4ea12960c24dc950d462e99691d64936cc Mon Sep 17 00:00:00 2001 From: vansin Date: Sun, 28 Jun 2026 08:48:38 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(#261=20review):=20strip=20internal=20sl?= =?UTF-8?q?ugs=20/=20fix=20alpine=E2=86=92slim=20header=20/=20explicit=20-?= =?UTF-8?q?-password?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review fixes from 通信龙: 1. Remove 4 `[[feedback_*]]` internal memory slugs from the file header comment — these were private notes that should not land in the public OSS repo. The user-visible facts (v0.10.0 PINNED mismatch, v0.10.2 notes split, #136 / #137 silent-exit class) are kept. 2. Header comment said "Gate 1 — install-path smoke (node:24-alpine…)" but the actual job uses node:24-slim (chosen over alpine for glibc coverage). Header now reads node:24-slim. The body rationale comment still mentions alpine because that's the chosen-over-X reasoning. 3. Gate 1 case 4 originally read the admin password from `jq -r .bootstrap_password admin-utok.json` with `|| echo anethub` fallback. Neither path is reliable: 3e4e190 (#261 P0-2) does not persist the bootstrap password into admin-utok.json (only username / user_id / token / created_at), and the `anethub` literal was the pre-fix default that no longer applies. Replaced with an explicit `--password "$GATE_PW"` (random per-run) passed to both `anet hub start` and `anet login`, so the smoke is deterministic and the bootstrap-password storage shape can change without breaking the gate. Also widened the /health poll from a single `sleep 5` to 30×1s so slow Docker startups don't false-fail Gate 1. --- .github/workflows/release.yml | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b63af160..329131f2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,14 +13,12 @@ # # - v0.10.0 PINNED_SERVER_VERSION mismatch — caught last-minute, would # have user-side regressed announced functionality if shipped -# (memory: [[feedback_pinned_chain_bump_verify]]) # - v0.10.2 release notes only contained `anet upgrade` instructions — -# new users had no install path (memory: [[feedback_release_notes_install_upgrade_split]]) +# 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 install-path break -# (memory: [[feedback_docker_smoke_gate_before_ship]]) +# 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 (memory: [[feedback_docker_smoke_real_tty]]) +# 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 @@ -33,7 +31,7 @@ # @sleep2agi/agent-network or @sleep2agi/agent-node, or on manual dispatch # (target package + version supplied as inputs). # -# Gate 1 — install-path smoke (node:24-alpine, no globals leaked in) +# Gate 1 — install-path smoke (node:24-slim, no globals leaked in) # - `npm install -g ` from the built artifact (NOT from npm, # so we catch broken bundles BEFORE they're published) # - `anet --version` matches the tag @@ -220,20 +218,29 @@ jobs: 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" - nohup anet hub start > /tmp/hub.log 2>&1 & - sleep 5 + GATE_PW="ReleaseGate-$(head -c 16 /dev/urandom | xxd -p)" + 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 per-install random admin pwd - # from server/admin-utok.json metadata, see commit 3e4e190) - pw=$(jq -r .bootstrap_password "$HOME/.anet/server/admin-utok.json" 2>/dev/null \ - || echo anethub) - anet login --hub http://127.0.0.1:9200 --username admin --password "$pw" > /tmp/login.log 2>&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; } From f49425f02781ec14189f7f6cd3eb833f8f976a7f Mon Sep 17 00:00:00 2001 From: vansin Date: Sun, 28 Jun 2026 08:51:19 +0800 Subject: [PATCH 3/3] fix(#261 Gate 1): SIGPIPE-safe finite pipeline for password gen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tr -dc … < /dev/urandom | head -c 16` triggers SIGPIPE when head closes its input — under `set -o pipefail` the inner `tr` exits non-zero and the gate aborts with rc=141. Switched to a finite-input form: GATE_PW="ReleaseGate-$(head -c 32 /dev/urandom | sha256sum | head -c 16)" `head -c 32` reads a fixed 32 bytes then closes; the downstream `sha256sum` and `head -c 16` consume bounded output, so no upstream process gets SIGPIPE. Output is 16 lowercase hex chars from a cryptographic hash of 32 random bytes — sufficient unique per-run. Verified end-to-end in a clean `node:24-slim` container: case 3 — anet hub start with explicit --password ✓ /health 200 + admin-utok.json mode 600 case 4 — anet login with same --password ✓ login OK (matches "Logged in" marker) Both Gate 1 case 3 + case 4 PASS, rc=0. Locally confirms the new explicit-password approach works on the actual published preview tarball. --- .github/workflows/release.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 329131f2..847f230f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -225,7 +225,9 @@ jobs: # 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" - GATE_PW="ReleaseGate-$(head -c 16 /dev/urandom | xxd -p)" + # 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