Skip to content

Merge origin/main into claude/sweet-brown-i99jl3 — reconciled announc… #923

Merge origin/main into claude/sweet-brown-i99jl3 — reconciled announc…

Merge origin/main into claude/sweet-brown-i99jl3 — reconciled announc… #923

Workflow file for this run

name: Deploy to Railway
on:
push:
branches:
- "claude/sweet-brown-i99jl3"
paths:
- ".github/trigger-probe"
- ".github/trigger-deploy"
- ".github/trigger-test"
- ".github/trigger-paytest"
- ".github/trigger-purl"
- ".github/trigger-publish"
- ".github/trigger-drain"
- ".github/trigger-bazaar-refresh"
- ".github/trigger-bazaar-register"
- ".github/trigger-bazaar-solana"
workflow_dispatch:
inputs:
mode:
description: "probe, test, paytest, purl (Stripe client interop), publish (npm), bazaar-refresh, bazaar-register (one-shot: pay missing routes so the Coinbase harvester registers them), demo (run the company one-pager with the funded burner, upload output), or deploy"
default: "deploy"
burner_key:
description: "Existing funded burner key for paytest (hex). Leave empty to generate a fresh wallet."
default: ""
demo_ticker:
description: "Ticker for mode=demo (US symbol)."
default: "NVDA"
# Least privilege by default: jobs get a read-only GITHUB_TOKEN. The only job
# that needs more (publish → OIDC) overrides this with its own permissions block.
permissions:
contents: read
jobs:
# Single source of truth for marker parsing. Every downstream job gates on
# `needs.markers.outputs.<name> == 'true'` instead of calling
# `contains(join(github.event.commits.*.message), '[...]')` directly.
#
# Why: the old contains() check was a substring scan over both subject lines
# AND bodies, so prose in a commit message that mentioned a marker (even in
# negation, like "publish later via the publish marker") would trip the gate.
# This job scans only commit subject lines (%s) across the push range, and
# matches each marker as a standalone whitespace-delimited token.
markers:
runs-on: ubuntu-latest
timeout-minutes: 2
outputs:
test: ${{ steps.parse.outputs.test }}
deploy: ${{ steps.parse.outputs.deploy }}
publish: ${{ steps.parse.outputs.publish }}
probe: ${{ steps.parse.outputs.probe }}
drain: ${{ steps.parse.outputs.drain }}
paytest: ${{ steps.parse.outputs.paytest }}
purl: ${{ steps.parse.outputs.purl }}
bazaar_refresh: ${{ steps.parse.outputs.bazaar_refresh }}
bazaar_register: ${{ steps.parse.outputs.bazaar_register }}
bazaar_solana: ${{ steps.parse.outputs.bazaar_solana }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- id: parse
name: Parse markers from commit subject lines only
env:
BEFORE: ${{ github.event.before }}
AFTER: ${{ github.event.after }}
EVENT_NAME: ${{ github.event_name }}
run: |
set -euo pipefail
MARKERS="test deploy publish probe drain paytest purl bazaar-refresh bazaar-register bazaar-solana"
if [ "$EVENT_NAME" != "push" ]; then
echo "Event is $EVENT_NAME (not push) — emitting all markers as false."
for m in $MARKERS; do
echo "${m//-/_}=false" >> "$GITHUB_OUTPUT"
done
exit 0
fi
# Collect subject lines for every commit in this push range.
# Fall back to the head commit alone if BEFORE is empty/zero
# (new branch or force-push with no shared ancestor) or unreachable.
if [ -z "$BEFORE" ] || [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
TITLES=$(git log -1 --format='%s' "$AFTER")
else
TITLES=$(git log --format='%s' "$BEFORE..$AFTER" 2>/dev/null || git log -1 --format='%s' "$AFTER")
fi
echo "Subject lines scanned for markers:"
printf '%s\n' "$TITLES" | sed 's/^/ /'
# Standalone token match: marker must be bracketed and bounded by
# whitespace, line edges, or ANOTHER bracketED marker. So
# "feat: stuff (publish)" never matches the publish gate and
# "publish-config" never matches either — but "[test][deploy]"
# (no space) matches BOTH. Without the bracket boundary, that
# subject skipped every job while the workflow still reported
# success — a false-green deploy (2026-07-03 incident).
for m in $MARKERS; do
out_name="${m//-/_}"
if printf '%s\n' "$TITLES" | grep -qE "(^|[[:space:]]|\])\[${m}\]([[:space:]]|\[|\$)"; then
echo "${out_name}=true" >> "$GITHUB_OUTPUT"
echo "[${m}] matched"
else
echo "${out_name}=false" >> "$GITHUB_OUTPUT"
fi
done
paytest:
needs: markers
if: github.event_name == 'workflow_dispatch' && inputs.mode == 'paytest' || needs.markers.outputs.paytest == 'true'
runs-on: ubuntu-latest
timeout-minutes: 50
env:
TARGET_URL: https://agent402.tools
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 22
- name: Install dependencies
run: npm ci && npm install --no-save --ignore-scripts @x402/fetch viem
# Run FIRST (before the slow faucet-funding E2E) for fast iteration. Live
# proof of the agent402-client 402-inspection preflight (v0.6.0): a real Base
# burner buy on PROD, with a raw-payFetch CONTROL right beside the client buy
# so a 402 is attributed correctly (client bug vs funding/facilitator).
- name: Live client-SDK 402-inspection (real Base burner buy vs prod)
if: always()
continue-on-error: true
env:
BURNER_KEY: ${{ secrets.BURNER_KEY }}
POW_SECRET: ${{ secrets.POW_SECRET }}
TARGET_URL: https://agent402.tools
run: |
if [ -z "$BURNER_KEY" ]; then echo "BURNER_KEY unset — skipping live client paid test."; exit 0; fi
echo "::add-mask::$BURNER_KEY"
printf '%s' "$BURNER_KEY" > /tmp/client-burner-key
KEY_FILE=/tmp/client-burner-key node scripts/test-client-paid-live.js; rc=$?
shred -u /tmp/client-burner-key 2>/dev/null || rm -f /tmp/client-burner-key
exit $rc
# Real end-to-end proof of the IPv4 egress fix: buy the exact wallet-only tools
# that were 504ing on the IPv6 race (treasury-debt/avg-rates, gov-data) many
# times with the real burner and assert every paid call delivers data — zero
# charged failures. Runs early (before the slow faucet E2E) for fast iteration.
- name: Live IPv4-egress proof (real burner buys of the previously-504ing tools)
if: always()
continue-on-error: true
env:
BURNER_KEY: ${{ secrets.BURNER_KEY }}
POW_SECRET: ${{ secrets.POW_SECRET }}
TARGET_URL: https://agent402.tools
run: |
if [ -z "$BURNER_KEY" ]; then echo "BURNER_KEY unset — skipping live treasury paid test."; exit 0; fi
echo "::add-mask::$BURNER_KEY"
printf '%s' "$BURNER_KEY" > /tmp/treasury-burner-key
KEY_FILE=/tmp/treasury-burner-key node scripts/test-treasury-paid-live.js; rc=$?
shred -u /tmp/treasury-burner-key 2>/dev/null || rm -f /tmp/treasury-burner-key
exit $rc
# Wallet birth-to-first-purchase E2E: a keypair generated inside the
# runner (never printed, never persisted — only its address) is faucet-
# funded with testnet USDC and completes a REAL x402 purchase against a
# paid-mode base-sepolia server, then every byte of output + the server
# log is scanned for key material. Proves the whole onboarding flow a
# new user walks, and that no secret can leak from it.
- name: Wallet E2E — fresh key → faucet → real x402 settle → leak audit
# continue-on-error: testnet facilitator reverts don't block the PROD
# buy step. The E2E still reports FAIL in the log but doesn't gate.
continue-on-error: true
env:
CDP_API_KEY_ID: ${{ secrets.CDP_API_KEY_ID }}
CDP_API_KEY_SECRET: ${{ secrets.CDP_API_KEY_SECRET }}
FULL_E2E: "1"
run: node scripts/test-wallet-e2e.js
# Regression guard for the 2026-07-02 outage: a network listed in
# PAYMENT_NETWORKS but backed by NO facilitator (robinhood without
# ROBINHOOD_FACILITATOR_URL) must be DROPPED from the 402 offer, not
# poison it. Before the fix this threw while building the challenge and
# surfaced as HTTP 500 on EVERY paid endpoint. Boot paid mode with
# base(+CDP)+robinhood and NO robinhood facilitator, then assert a paid
# route still returns a 402 whose accepts EXCLUDE robinhood (eip155:4663).
# Gating — this locks the incident so it can never silently recur.
- name: Regression — facilitator-less network must not 500 the paywall
env:
WALLET_ADDRESS: ${{ secrets.WALLET_ADDRESS || '0xaBF4FAbd7c416fB67202E5f9002389Fc75e2a9D0' }}
CDP_API_KEY_ID: ${{ secrets.CDP_API_KEY_ID }}
CDP_API_KEY_SECRET: ${{ secrets.CDP_API_KEY_SECRET }}
ROBINHOOD_FACILITATOR_URL_SECRET: ${{ secrets.ROBINHOOD_FACILITATOR_URL }}
run: |
# robinhood in PAYMENT_NETWORKS; ROBINHOOD_FACILITATOR_URL deliberately UNSET.
PAYMENT_NETWORKS=base,robinhood NETWORK=base PORT=3795 \
node src/server.js > /tmp/reg-srv.log 2>&1 &
SRV=$!
for i in $(seq 1 40); do curl -sf http://localhost:3795/health >/dev/null 2>&1 && break; sleep 1; done
grep -iE 'dropping Robinhood|Accepting USDC|facilitator routing' /tmp/reg-srv.log | head -5 || true
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST http://localhost:3795/api/hash \
-H 'Content-Type: application/json' -d '{"text":"x"}')
HDR=$(curl -s -D - -o /dev/null -X POST http://localhost:3795/api/hash \
-H 'Content-Type: application/json' -d '{"text":"x"}' \
| tr -d '\r' | awk -F': ' 'tolower($1)=="payment-required"{print $2}' | head -1)
NETS=$(node -e 'const h=process.argv[1]||""; if(!h){console.log("[]");process.exit(0)} const d=JSON.parse(Buffer.from(h,"base64").toString("utf8")); console.log(JSON.stringify((d.accepts||[]).map(a=>a.network)))' "$HDR")
kill $SRV 2>/dev/null || true
echo "unpaid /api/hash -> HTTP $code (expect 402) | offered networks: $NETS"
fail=0
[ "$code" = "402" ] || { echo "FAIL: expected 402, got $code — facilitator-less network poisoned the paywall"; fail=1; }
case "$NETS" in *eip155:4663*) echo "FAIL: robinhood (eip155:4663) still offered with no facilitator"; fail=1;; esac
[ "$fail" = "0" ] && echo "PASS: robinhood dropped, base 402 intact" || exit 1
# Second regression (2026-07-02, part two): a robinhood-ONLY server
# (NETWORK=robinhood, the live-settle test's config) must BOOT with
# only ROBINHOOD_FACILITATOR_URL — the generic resolver used to
# demand CDP keys / FACILITATOR_URL and crash. Runs only when the
# secret is available; asserts boot + a 402 offering eip155:4663.
if [ -n "$ROBINHOOD_FACILITATOR_URL_SECRET" ]; then
ROBINHOOD_FACILITATOR_URL="$ROBINHOOD_FACILITATOR_URL_SECRET" \
NETWORK=robinhood PAYMENT_NETWORKS=robinhood PORT=3796 \
node src/server.js > /tmp/reg-rh.log 2>&1 &
SRV2=$!
for i in $(seq 1 40); do curl -sf http://localhost:3796/health >/dev/null 2>&1 && break; sleep 1; done
code2=$(curl -s -o /dev/null -w '%{http_code}' -X POST http://localhost:3796/api/hash \
-H 'Content-Type: application/json' -d '{"text":"x"}')
HDR2=$(curl -s -D - -o /dev/null -X POST http://localhost:3796/api/hash \
-H 'Content-Type: application/json' -d '{"text":"x"}' \
| tr -d '\r' | awk -F': ' 'tolower($1)=="payment-required"{print $2}' | head -1)
NETS2=$(node -e 'const h=process.argv[1]||""; if(!h){console.log("[]");process.exit(0)} const d=JSON.parse(Buffer.from(h,"base64").toString("utf8")); console.log(JSON.stringify((d.accepts||[]).map(a=>a.network)))' "$HDR2")
kill $SRV2 2>/dev/null || true
echo "robinhood-only /api/hash -> HTTP $code2 | offered: $NETS2"
if [ "$code2" != "402" ]; then
echo "FAIL: robinhood-only server did not serve a 402 (boot log tail):"
tail -8 /tmp/reg-rh.log; exit 1
fi
case "$NETS2" in *eip155:4663*) echo "PASS: robinhood-only server boots and offers USDG on 4663";;
*) echo "FAIL: robinhood-only 402 does not offer eip155:4663"; exit 1;; esac
else
echo "(ROBINHOOD_FACILITATOR_URL secret unset — robinhood-only boot check skipped)"
fi
- name: Load burner key
env:
# Provide a throwaway, low-value test wallet key as the BURNER_KEY
# repo secret (preferred) or via the workflow_dispatch input. Never
# commit a private key to the repo. With neither set, the script
# generates a fresh wallet and waits for funding.
BURNER_KEY: ${{ secrets.BURNER_KEY || inputs.burner_key }}
run: |
if [ -n "$BURNER_KEY" ]; then
echo "::add-mask::$BURNER_KEY"
printf '%s' "$BURNER_KEY" > "$RUNNER_TEMP/agent-key"
chmod 600 "$RUNNER_TEMP/agent-key"
fi
- name: Generate burner agent wallet
run: MODE=address KEY_FILE=$RUNNER_TEMP/agent-key node scripts/agent-e2e.js
- name: Publish funding address
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: agent-address
path: agent-address.txt
- name: Wait for funding, then buy from the live API
env:
# Marks every e2e buy with X-Heartbeat-Token → synthetic in the
# sales ledger + PostHog, so full-suite proof runs don't read as
# organic external demand in revenue analyses.
POW_SECRET: ${{ secrets.POW_SECRET }}
run: MODE=run KEY_FILE=$RUNNER_TEMP/agent-key node scripts/agent-e2e.js
# Forward-looking: prove the Robinhood Chain (chain 4663) params baked
# into x402-kit are correct and its public RPC is live, so the
# `robinhood` network on tx-status/gas-estimate actually works. Read-
# only, no secrets, non-gating (a day-old chain's RPC may flap).
- name: Robinhood Chain read probe (chain 4663 RPC live + params correct)
if: always()
run: node scripts/rh-chain-probe.js || true
# LIVE Robinhood Chain / USDG settlement. Boots a Robinhood-ONLY server (so
# the buyer's only option is USDG on chain 4663 via the configured external
# facilitator), confirms the burner holds USDG, then buys one cheap tool — a
# real on-chain USDG transfer. Retries with EIP-712 version=2 if 1's
# signature is rejected. Non-gating: it reports the settlement tx (or the
# facilitator's reason). Requires the burner (its address is published as
# the funding artifact) to hold USDG on Robinhood Chain, and the
# ROBINHOOD_FACILITATOR_URL secret to be set; otherwise it self-reports and skips.
- name: Live Robinhood Chain USDG settlement (burner-funded, real on-chain settle)
if: always()
env:
WALLET_ADDRESS: ${{ secrets.WALLET_ADDRESS || '0xaBF4FAbd7c416fB67202E5f9002389Fc75e2a9D0' }}
BURNER_KEY: ${{ secrets.BURNER_KEY }}
ROBINHOOD_FACILITATOR_URL: ${{ secrets.ROBINHOOD_FACILITATOR_URL }}
run: |
if [ -z "$ROBINHOOD_FACILITATOR_URL" ]; then
echo "ROBINHOOD_FACILITATOR_URL secret unset — skipping live Robinhood settlement."; exit 0
fi
printf '%s' "$BURNER_KEY" > /tmp/burner-key
settled=0
for V in 1 2; do
echo "=================== USDG EIP-712 version=$V ==================="
ROBINHOOD_USDG_EIP712_VERSION=$V \
NETWORK=robinhood PAYMENT_NETWORKS=robinhood \
PORT=3790 node src/server.js > /tmp/rh-settle-srv.log 2>&1 &
SRV=$!
for i in $(seq 1 40); do curl -sf http://localhost:3790/health >/dev/null 2>&1 && break; sleep 1; done
grep -iE 'facilitator|robinhood|accepting usdc|USDG' /tmp/rh-settle-srv.log | head -4 || true
if TARGET_URL=http://localhost:3790 KEY_FILE=/tmp/burner-key node scripts/rh-settle-test.js; then
echo ">>> SETTLED with USDG EIP-712 version=$V"; settled=1; kill $SRV 2>/dev/null || true; break
fi
echo "--- server-side facilitator/settle log (version=$V) ---"
grep -viE '\[tool-error\]' /tmp/rh-settle-srv.log | grep -iE 'facilitator|verify|settle|invalid|reject|error|USDG|payment' | tail -12 || true
kill $SRV 2>/dev/null || true
sleep 2
done
shred -u /tmp/burner-key 2>/dev/null || rm -f /tmp/burner-key
if [ "$settled" = "1" ]; then echo "RESULT: ✅ real USDG settlement on Robinhood Chain succeeded"; else echo "RESULT: settlement did not succeed (see reasons above)"; fi
# Polygon USDC settlement via PayAI facilitator. Boots a Polygon-ONLY
# server so the buyer's only option is USDC on chain 137, then buys one
# cheap tool. Non-gating: PayAI free-tier flaps must not block the run.
- name: Polygon USDC settlement probe (PayAI facilitator, chain 137)
if: always()
env:
WALLET_ADDRESS: ${{ secrets.WALLET_ADDRESS || '0xaBF4FAbd7c416fB67202E5f9002389Fc75e2a9D0' }}
CDP_API_KEY_ID: ${{ secrets.CDP_API_KEY_ID }}
CDP_API_KEY_SECRET: ${{ secrets.CDP_API_KEY_SECRET }}
BURNER_KEY: ${{ secrets.BURNER_KEY }}
run: |
if [ ! -f "$RUNNER_TEMP/agent-key" ]; then
if [ -z "$BURNER_KEY" ]; then echo "No burner key — skipping Polygon settlement."; exit 0; fi
printf '%s' "$BURNER_KEY" > "$RUNNER_TEMP/agent-key"
chmod 600 "$RUNNER_TEMP/agent-key"
fi
NETWORK=polygon PAYMENT_NETWORKS=polygon \
FACILITATOR_URL=https://facilitator.payai.network \
PORT=3791 node src/server.js > /tmp/poly-srv.log 2>&1 &
SRV=$!
for i in $(seq 1 40); do curl -sf http://localhost:3791/health >/dev/null 2>&1 && break; sleep 1; done
grep -iE 'facilitator|polygon|accepting usdc|multi-chain' /tmp/poly-srv.log | head -4 || true
CHAIN=polygon TARGET_URL=http://localhost:3791 KEY_FILE="$RUNNER_TEMP/agent-key" \
node scripts/evm-settle-test.js || true
kill $SRV 2>/dev/null || true
# Arbitrum USDC settlement via PayAI facilitator. Same pattern as Polygon
# but chain 42161. Non-gating.
- name: Arbitrum USDC settlement probe (PayAI facilitator, chain 42161)
if: always()
env:
WALLET_ADDRESS: ${{ secrets.WALLET_ADDRESS || '0xaBF4FAbd7c416fB67202E5f9002389Fc75e2a9D0' }}
CDP_API_KEY_ID: ${{ secrets.CDP_API_KEY_ID }}
CDP_API_KEY_SECRET: ${{ secrets.CDP_API_KEY_SECRET }}
BURNER_KEY: ${{ secrets.BURNER_KEY }}
run: |
if [ ! -f "$RUNNER_TEMP/agent-key" ]; then
if [ -z "$BURNER_KEY" ]; then echo "No burner key — skipping Arbitrum settlement."; exit 0; fi
printf '%s' "$BURNER_KEY" > "$RUNNER_TEMP/agent-key"
chmod 600 "$RUNNER_TEMP/agent-key"
fi
NETWORK=arbitrum PAYMENT_NETWORKS=arbitrum \
FACILITATOR_URL=https://facilitator.payai.network \
PORT=3792 node src/server.js > /tmp/arb-srv.log 2>&1 &
SRV=$!
for i in $(seq 1 40); do curl -sf http://localhost:3792/health >/dev/null 2>&1 && break; sleep 1; done
grep -iE 'facilitator|arbitrum|accepting usdc|multi-chain' /tmp/arb-srv.log | head -4 || true
CHAIN=arbitrum TARGET_URL=http://localhost:3792 KEY_FILE="$RUNNER_TEMP/agent-key" \
node scripts/evm-settle-test.js || true
kill $SRV 2>/dev/null || true
- name: Shred burner key
if: always()
run: |
# Defense in depth: even though hosted runners are ephemeral, scrub
# the on-disk key as soon as the consuming steps are done so any
# later step (or rerun reusing cached layers) can't read it.
if [ -f "$RUNNER_TEMP/agent-key" ]; then
shred -u "$RUNNER_TEMP/agent-key" 2>/dev/null || rm -f "$RUNNER_TEMP/agent-key"
fi
bazaar-solana:
# One-shot sweep: pays every catalog route priced <= $0.05 once VIA SOLANA
# (SVM client -> PayAI facilitator), so settlement-driven indexes register
# each tool for Solana — PayAI's merchant listing, x402scan's facilitator
# view — and every harvester re-observes the live 402 with the full
# multi-chain accepts. Skips the LLM/image/audio/code proxies (real
# upstream credit per call). Payments recycle burner -> revenue wallet.
needs: markers
if: github.event_name == 'workflow_dispatch' && inputs.mode == 'bazaar-solana' || needs.markers.outputs.bazaar_solana == 'true'
runs-on: ubuntu-latest
timeout-minutes: 150
env:
TARGET_URL: https://agent402.tools
MODE: sweep
PAY_NETWORK: solana
MAX_PRICE_USD: "0.05"
MAX_SPEND_USD: "8"
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 22
- name: Install dependencies
run: npm ci && npm install --no-save --ignore-scripts @x402/[email protected] @x402/[email protected] @x402/[email protected] @x402/[email protected] @solana/kit viem
- name: Sweep the catalog via Solana settlement
env:
SOLANA_BURNER_KEY: ${{ secrets.SOLANA_BURNER_KEY }}
run: |
[ -n "$SOLANA_BURNER_KEY" ] || { echo "No SOLANA_BURNER_KEY — cannot pay the Solana sweep"; exit 1; }
echo "::add-mask::$SOLANA_BURNER_KEY"
node scripts/refresh-bazaar.js
bazaar-refresh:
# One-shot job: makes a tiny paid request against every Agent402 Bazaar
# listing whose serviceName is stale, so the CDP harvester re-observes
# the current 402 challenge metadata (description, serviceName, tags).
needs: markers
if: github.event_name == 'workflow_dispatch' && inputs.mode == 'bazaar-refresh' || needs.markers.outputs.bazaar_refresh == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
env:
TARGET_URL: https://agent402.tools
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 22
- name: Install dependencies
run: npm ci && npm install --no-save --ignore-scripts @x402/fetch @x402/core @x402/evm viem
- name: Refresh stale Bazaar listings
env:
BURNER_KEY: ${{ secrets.BURNER_KEY || inputs.burner_key }}
run: |
[ -n "$BURNER_KEY" ] || { echo "No BURNER_KEY — cannot refresh paid listings"; exit 1; }
echo "::add-mask::$BURNER_KEY"
node scripts/refresh-bazaar.js
bazaar-register:
# One-shot job: discovers catalog routes that the Coinbase CDP Bazaar
# harvester has never observed and pays one tiny request against each so
# the harvester registers them. Idempotent — already-registered routes
# drop out of the missing set, so partial runs can be resumed.
#
# Cost is bounded by MAX_SPEND_USD; the script refuses to run if the
# estimate exceeds that ceiling. With 42 skill packs ($0.05-$1.50 each)
# the missing set can sum to ~$11 on a fresh pass.
#
# Ordering: buys hit PROD, so when this run also deploys, wait for the
# deploy — otherwise the sweep buys routes the old build still serves
# (2026-07-09: three runs bought a route whose fix was sitting in the
# same run's undeployed commit — settled, then 502'd). deploy skipped
# (no [deploy] marker) still runs the sweep immediately.
needs: [markers, deploy]
if: always() && (github.event_name == 'workflow_dispatch' && inputs.mode == 'bazaar-register' || needs.markers.outputs.bazaar_register == 'true') && (needs.deploy.result == 'success' || needs.deploy.result == 'skipped')
runs-on: ubuntu-latest
timeout-minutes: 45
env:
TARGET_URL: https://agent402.tools
MODE: missing
MAX_SPEND_USD: "12"
SLUGS: ${{ vars.BAZAAR_REGISTER_SLUGS || '' }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 22
- name: Install dependencies
run: npm ci && npm install --no-save --ignore-scripts @x402/fetch @x402/core @x402/evm viem
- name: Register missing Bazaar routes
env:
BURNER_KEY: ${{ secrets.BURNER_KEY || inputs.burner_key }}
run: |
[ -n "$BURNER_KEY" ] || { echo "No BURNER_KEY — cannot pay to register missing routes"; exit 1; }
echo "::add-mask::$BURNER_KEY"
node scripts/refresh-bazaar.js
bazaar-sweep:
# One-shot job (workflow_dispatch mode=bazaar-sweep): pays one tiny Base
# settlement against every cheap catalog route (≤ MAX_PRICE_USD) so
# settlement-driven indexes (x402scan) OBSERVE and list them. Distinct from
# bazaar-register (MODE=missing, only routes the Bazaar has never registered):
# this re-settles every affordable route to refresh the observation those
# indexes key on. Split into BATCH_COUNT passes (BATCH_INDEX 0..N) so no
# single pass runs long enough to time out; each pass is bounded by
# MAX_SPEND_USD (the script refuses to run a batch whose estimate exceeds it).
# Full pass ≈ 1,293 routes ≤ $0.01 ≈ $1.75 total; 4 batches ≈ $0.44 each.
needs: [markers, deploy]
if: always() && github.event_name == 'workflow_dispatch' && inputs.mode == 'bazaar-sweep' && (needs.deploy.result == 'success' || needs.deploy.result == 'skipped')
runs-on: ubuntu-latest
timeout-minutes: 90
env:
TARGET_URL: https://agent402.tools
MODE: sweep
PAY_NETWORK: base
MAX_PRICE_USD: "0.01"
MAX_SPEND_USD: "1"
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 22
- name: Install dependencies
run: npm ci && npm install --no-save --ignore-scripts @x402/fetch @x402/core @x402/evm viem
- name: Sweep the catalog via Base settlement (4 batches)
env:
BURNER_KEY: ${{ secrets.BURNER_KEY || inputs.burner_key }}
run: |
[ -n "$BURNER_KEY" ] || { echo "No BURNER_KEY — cannot pay the Base sweep"; exit 1; }
echo "::add-mask::$BURNER_KEY"
for i in 0 1 2 3; do
echo "::group::sweep batch $((i+1))/4"
BATCH_COUNT=4 BATCH_INDEX=$i node scripts/refresh-bazaar.js || echo "batch $((i+1)) reported errors (continuing)"
echo "::endgroup::"
[ "$i" -lt 3 ] && sleep 45 || true
done
drain:
needs: markers
if: github.event_name == 'workflow_dispatch' && inputs.mode == 'drain' || needs.markers.outputs.drain == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 22
- name: Install dependencies
run: npm ci && npm install --no-save --ignore-scripts @x402/fetch @x402/core @x402/evm viem
- name: Drain the burner into the revenue wallet via paid tool calls
env:
BURNER_KEY: ${{ secrets.BURNER_KEY || inputs.burner_key }}
TARGET_URL: https://agent402.tools
run: |
[ -n "$BURNER_KEY" ] || { echo "No BURNER_KEY — nothing to drain"; exit 0; }
echo "::add-mask::$BURNER_KEY"
printf '%s' "$BURNER_KEY" > "$RUNNER_TEMP/agent-key" && chmod 600 "$RUNNER_TEMP/agent-key"
KEY_FILE="$RUNNER_TEMP/agent-key" node scripts/drain-burner.js
- name: Shred burner key
if: always()
run: |
if [ -f "$RUNNER_TEMP/agent-key" ]; then
shred -u "$RUNNER_TEMP/agent-key" 2>/dev/null || rm -f "$RUNNER_TEMP/agent-key"
fi
# Interop check against Stripe's open-source x402 client (github.com/stripe/purl).
# Stripe's machine-payments quickstart points buyers at purl, so "purl can pay
# us" means every developer following Stripe's docs can buy from us unchanged.
purl:
needs: markers
if: github.event_name == 'workflow_dispatch' && inputs.mode == 'purl' || needs.markers.outputs.purl == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
env:
TARGET_URL: https://agent402.tools
steps:
- name: Install Stripe purl
run: |
if brew install stripe/purl/purl 2>&1; then
echo "$(brew --prefix)/bin" >> "$GITHUB_PATH"
else
echo "brew install failed — building from source"
git clone --depth 1 https://github.com/stripe/purl /tmp/purl
cargo install --locked --path /tmp/purl/cli
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
fi
- name: Show purl CLI surface (for the logs)
run: |
purl --version || true
purl --help || true
purl wallet --help || true
purl wallet add --help || true
- name: Import burner wallet (non-interactive keystore)
env:
BURNER_KEY: ${{ secrets.BURNER_KEY || inputs.burner_key }}
PURL_PASSWORD: ci-throwaway
run: |
[ -n "$BURNER_KEY" ] || { echo "no burner key — skipping wallet import"; exit 0; }
echo "::add-mask::$BURNER_KEY"
purl wallet add --name burner --type evm -k "$BURNER_KEY" -p "$PURL_PASSWORD" --set-active=true
purl wallet list
- name: "Interop gate: purl must parse our 402 quote (--dry-run)"
env:
PURL_PASSWORD: ci-throwaway
run: |
set +e
OUT=$(purl --dry-run "$TARGET_URL/api/dns?name=example.com&type=A" 2>&1)
STATUS=$?
set -e
echo "$OUT"
echo "(exit $STATUS)"
# Pass if purl surfaced our payment requirements (price/asset/network),
# i.e. it understood the 402 challenge our server returns.
echo "$OUT" | grep -Eiq 'usdc|0\.001|eip155|base|payment' \
|| { echo "::error::purl could not parse the x402 challenge from $TARGET_URL"; exit 1; }
echo "purl parsed our x402 quote — Stripe-tooling compatible"
- name: Real paid call via purl (best effort, burner dust)
env:
BURNER_KEY: ${{ secrets.BURNER_KEY || inputs.burner_key }}
PURL_PASSWORD: ci-throwaway
# $0.001 price = 1000 atomic USDC units; cap at 2000 to also exercise
# purl's client-side spend control.
PURL_MAX_AMOUNT: "2000"
run: |
[ -n "$BURNER_KEY" ] || { echo "no burner key — skipping paid call"; exit 0; }
set +e
OUT=$(purl "$TARGET_URL/api/dns?name=example.com&type=A" 2>&1)
STATUS=$?
set -e
echo "$OUT" | head -40
if [ $STATUS -eq 0 ] && echo "$OUT" | grep -q 'records'; then
echo "PAID CALL SETTLED via Stripe's purl client ✓"
else
echo "paid call did not complete (likely dust balance) — not failing the job"
fi
# On-demand: run the company one-pager demo against prod with the funded
# burner, real USDC buys end to end, and upload the terminal output as an
# artifact. Dispatch-only (gh workflow run deploy.yml -f mode=demo
# [-f demo_ticker=AAPL]) — no marker, repeatable any time.
demo:
needs: markers
if: github.event_name == 'workflow_dispatch' && inputs.mode == 'demo'
runs-on: ubuntu-latest
timeout-minutes: 15
env:
TARGET_URL: https://agent402.tools
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 22
- name: Install dependencies
run: npm ci && npm install --no-save --ignore-scripts @x402/fetch viem
- name: Run the demo (real paid buys from the burner)
env:
AGENT_KEY: ${{ secrets.BURNER_KEY }}
# Marks the buys as internal traffic in the sales ledger (see the
# note in scripts/demo-company-onepager.js); on-chain txs stay real.
POW_SECRET: ${{ secrets.POW_SECRET }}
run: |
[ -n "$AGENT_KEY" ] || { echo "::error::BURNER_KEY secret is not set"; exit 1; }
node scripts/demo-company-onepager.js "${{ inputs.demo_ticker || 'NVDA' }}" | tee demo-output.txt
- name: Upload demo output
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: demo-output
path: demo-output.txt
test:
# Test job runs on its own [test] marker AND auto-runs whenever a commit
# asks to deploy or publish — so deploy/publish can hard-gate on it via
# `needs: test`. This makes "ship without running tests" impossible by
# design, not by developer discipline.
needs: markers
if: github.event_name == 'workflow_dispatch' && (inputs.mode == 'test' || inputs.mode == 'deploy' || inputs.mode == 'publish') || needs.markers.outputs.test == 'true' || needs.markers.outputs.deploy == 'true' || needs.markers.outputs.publish == 'true'
runs-on: ubuntu-latest
env:
BRAVE_API_KEY: ${{ secrets.BRAVE_API_KEY }}
FRED_API_KEY: ${{ secrets.FRED_API_KEY }}
FRED_API_KEY_V2: ${{ secrets.FRED_API_KEY_V2 }}
E2B_API_KEY: ${{ secrets.E2B_API_KEY }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 22
- name: Install and start in free mode
run: |
npm ci
npx playwright install --with-deps chromium
# Raise the MCP rate limit so the full-catalog connector test can sweep
# every tool (production keeps the default 20/min, 120/hr).
AGENT402_MCP_MAX_PER_MIN=1000000 AGENT402_MCP_MAX_PER_HOUR=1000000 \
X402_SYNC_ON_START=false FREE_MODE=true PORT=3000 node src/server.js &
for i in $(seq 1 20); do curl -s localhost:3000/health >/dev/null && break; sleep 1; done
# Deterministic correctness proofs run FIRST — a flaky live upstream
# (e.g. data.gov downtime) must never mask whether the tools actually work.
- name: Memory v2 unit tests (grants, counters, audit chain, recall)
run: node scripts/test-memory.js
- name: Kit2 tool tests (36 tools, exact-output assertions)
run: node scripts/test-kit2.js
- name: Security regressions (proto-pollution, input-DoS, 500-hardening)
run: node scripts/test-security.js
- name: Fetch-guard error attribution (upstream 4xx→422, 5xx→502; media Content-Type fail-fast)
run: node scripts/test-fetch-guard.js
- name: Cert-transparency crt.sh→certspotter fallback (flaky-upstream resilience)
run: node scripts/test-cert-transparency.js
- name: Discovery & trust surfaces (/.well-known/x402, /api/reliability)
run: node scripts/test-discovery.js
- name: Revenue detector classification (real x402 buys vs funding/tests)
run: node scripts/test-revenue-scan.js
- name: Solana revenue scanner (token-balance decode + classification)
run: node scripts/test-revenue-scan-solana.js
- name: USDC settle-receipt network attribution (viaUSDCByNetwork)
run: node scripts/test-stats-network.js
- name: Builder Code suffix parser (ERC-8021 round-trip vs @x402/extensions encoder)
run: node scripts/test-builder-attribution.js
- name: LLM gateway validation (OpenAI-compat tiers, caps, env-gated 503)
run: node scripts/test-llm-gateway.js
- name: Route-and-execute (SOR executing surface — resolution, caps, receipts — offline)
run: node scripts/test-route-execute.js
- name: Usage report (payment-keyed identity, payer-scoped ledger view, spoof lock — offline)
run: node scripts/test-usage.js
- name: STT margin cap (duration enforced locally before any OpenAI spend — offline)
run: node scripts/test-stt-cap.js
- name: Pricing margin invariant (worst-case upstream < price on every gateway tier, failover links included; cap-before-spend; 410 tool_gone telemetry — offline)
run: node scripts/test-pricing-margin.js
- name: Secret-leak guard (canary keys never reach responses, caches, or errors; gateway cost-strip — offline)
run: node scripts/test-leak-guard.js
- name: Deploy quiet gate (lull detection, fail-open paths — offline)
run: node scripts/test-quiet-gate.js
- name: Paid-canary verdict (pages on real buying breaks, warns on upstream)
run: node scripts/test-paid-canary.js
- name: New-tool coverage lock (The 500 slugs swept by test-all; canary leg prices honest — offline)
run: node scripts/test-canary-coverage.js
- name: Tool resolver (/api/find ranking, guards)
run: node scripts/test-find.js
- name: Catalog floor invariant (floorViolation — null at ≥400, kit-missing message)
run: node scripts/test-sync-count-floor.js
- name: Self-check semantic invariants are meaningful (good passes, broken fails)
run: node scripts/test-invariants.js
- name: x402-market-pulse category classifier (path-aware, closed taxonomy)
run: node scripts/test-ecosystem-category.js
- name: x402-market-pulse supply-mix per-seller tool cap
run: node scripts/test-ecosystem-supply.js
- name: Discovery strict-source filters (testnet + junk origins)
run: node scripts/test-discovery-filter.js
- name: Find ranker — live-catalog smoke test (boots free-mode, locks top-1 for agent queries)
run: node scripts/test-find-ranking.js
- name: /health flag shape contract (every documented flag present + boolean-typed)
run: node scripts/test-health-flags.js
- name: Tool pages backlink to the skill packs they belong to
run: node scripts/test-tool-page-backlinks.js
- name: /skills/<slug> contract (every pack renders workflow + tools + claudePrompt + JSON-LD)
run: node scripts/test-skill-page-contract.js
- name: /openapi.json coverage (every CATALOG tool has a path + operationId + x-price)
run: node scripts/test-openapi-coverage.js
- name: /.well-known/x402 manifest contract (envelope + payment + capabilities + discovery)
run: node scripts/test-x402-manifest.js
- name: /sitemap.xml coverage (every skill pack, sample tools, static surfaces)
run: node scripts/test-sitemap-coverage.js
- name: /.well-known/glama.json maintainer email env pass-through (with + without override)
run: node scripts/test-glama-manifest.js
- name: /llms.txt content lock (tool count + discovery URLs + PoW/MCP/wallet signals)
run: node scripts/test-llms-txt.js
- name: Rails-copy lock (rails.js ↔ payments.js + every key page renders every rail)
run: TARGET_URL=http://localhost:3000 node scripts/test-rails.js
- name: Revenue ledger unit tests (all-time external accounting, idempotent rescans, CI self-gate)
run: node scripts/test-revenue-ledger.js
- name: CDP kit unit tests (JWT signing both key formats, validation, faucet gate — offline)
run: node scripts/test-cdp-kit.js
- name: Economy history unit tests (daily upsert idempotency + week-over-week math — offline)
run: node scripts/test-x402-economy.js
- name: PostHog funnel (discovery→402→settlement capture; paid-mode boot vs mock facilitator — offline)
run: node scripts/test-posthog-funnel.js
- name: Self-check policy (transient-filtering, timeout, unknown-slug — the prod tool-failure alarm — offline)
run: node scripts/test-selfcheck.js
- name: Payer identity (reads X-PAYMENT + legacy header; rejects unsigned/invalid — money-path regression lock — offline)
run: node scripts/test-payer.js
- name: Safe-regex guard (rejects ReDoS patterns for caller-supplied regexes — DoS lock — offline)
run: node scripts/test-safe-regex.js
- name: Tool-count sync (static surfaces match the live catalog total — no count drift)
run: node scripts/sync-count.js --check
- name: Sales ledger unit tests (named sales, internal/external classification, revenue math — offline)
run: node scripts/test-sales-ledger.js
- name: "Cache hygiene (M5 — Attack III: paid responses no-store, free discovery stays cacheable)"
run: node scripts/test-cache-hygiene.js
- name: "Payment-nonce replay guard (M3 — Attack II: concurrent replay collapses to one grant, release-on-failure)"
run: node scripts/test-replay-guard.js
- name: CDP kit live check (real balances + onramp session — skips without secrets)
env:
CDP_API_KEY_ID: ${{ secrets.CDP_API_KEY_ID }}
CDP_API_KEY_SECRET: ${{ secrets.CDP_API_KEY_SECRET }}
run: node scripts/test-cdp-live.js
- name: Wallet E2E offline leg (keygen + leak audit; funded on-chain flow runs in paytest)
run: node scripts/test-wallet-e2e.js
- name: /api/skill-packs.json shape (what stdio agent402-mcp consumes at startup)
run: node scripts/test-skill-packs-json.js
- name: /robots.txt policy (every LLM bot explicitly allowed + /api/memory disallowed + sitemap)
run: node scripts/test-robots-policy.js
- name: MCP prompts/list + prompts/get (one prompt per skill pack; substitution renders)
run: node scripts/test-mcp-prompts.js
- name: /api/pow/challenge envelope (free-tier gate — full shape + slug scoping + 404s)
run: node scripts/test-pow-challenge-envelope.js
- name: /api/leaderboard envelope (spec, defaults, per-row shape — MCP top_x402_sellers piggybacks on this)
run: node scripts/test-leaderboard-envelope.js
- name: /api/route neutral cross-seller router envelope (per-row shape, include filter, missing-query)
run: node scripts/test-route-envelope.js
- name: "Router Sybil resistance (M6 — Attack IV: per-seller diversity cap, metadata-injection listings dropped)"
run: node scripts/test-router-sybil.js
- name: "x402-audit grader (deterministic scoring: TLS, cache hygiene, terms, info-leak → letter grade)"
run: node scripts/test-x402-audit.js
- name: /api/reliability envelope (guarantees claim+verify pairing — every listing portal reads this)
run: node scripts/test-reliability-envelope.js
- name: PoW solve roundtrip (paid-mode boot, honest solver unlocks /api/hash, replay rejected)
run: node scripts/test-pow-solve-roundtrip.js
- name: Static page renders smoke (every HTML surface returns 200 with the right page-specific title)
run: node scripts/test-static-pages.js
- name: /index page tests (row cap + show-all link, sortable leaderboard-joined USDC/calls columns)
run: node scripts/test-index-page.js
- name: /api/stats envelope (M2M economy counters — rail breakdown, revenue proof, tools floor)
run: node scripts/test-stats-envelope.js
- name: /api/find envelope + top-1 ranking lock (per-row shape + known queries → known top slugs)
run: node scripts/test-find-envelope.js
- name: /api/pricing envelope contract (every scraper reads this — per-endpoint shape, slug uniqueness)
run: node scripts/test-pricing-envelope.js
- name: MCP call_tool contract (canonical + flattened + stringified + wallet-refusal + self-correction envelope)
run: node scripts/test-mcp-call-tool-contract.js
- name: /health envelope + .flags shape (boolean discipline locks the Yahoo-relay-flap class)
run: node scripts/test-health-envelope.js
- name: Skill-pack invariants (toolSlugs resolve in catalog; substitution renders)
run: node scripts/test-skill-packs.js
- name: x402 Bazaar discovery shape (POST→bodyType, GET→queryParams, example types match schema)
run: node scripts/check-bazaar.mjs
- name: x402 Leaderboard pure helpers (offline; Bazaar parsing + ranking)
run: node scripts/test-x402-leaderboard.js
- name: Leaderboard scan memory bound (300k synthetic transfers folded in batches; proves the 7d-OOM fix)
run: node scripts/test-leaderboard-memory.js
- name: Leaderboard SEO/discovery surfaces (robots, sitemap, llms.txt, manifest, landing JSON-LD)
run: node scripts/test-leaderboard-surface.js
- name: /data volume contract — stats + memory DBs fail loud in prod when missing
run: node scripts/test-stats-persistence.js
- name: Idempotency (opt-in safe retry of paid/proven calls; boots paid-mode)
run: node scripts/test-idempotency.js
- name: Deep security audit (payment gate + PoW replay/slug-bind/tamper + SSRF + attribution; boots paid-mode)
env:
POW_SECRET: ${{ secrets.POW_SECRET }}
run: |
# Boot a paid-mode server (x402 paywall ACTIVE) so the gate is real,
# then run the deep audit against it. PoW/SSRF/attribution assertions
# are facilitator-independent; the unpaid-402 shape check lights up
# when the base-sepolia facilitator syncs, and fails CLOSED otherwise
# (the audit treats fail-closed as no-bypass, only a 200 is a hard fail).
WALLET_ADDRESS=0x0000000000000000000000000000000000000001 NETWORK=base-sepolia \
POW_SECRET="${POW_SECRET:-ci-audit-secret}" POW_DIFFICULTY=12 POW_ALLOW_EPHEMERAL=true \
PORT=3778 node src/server.js > /tmp/audit-deep-srv.log 2>&1 &
SRV_PID=$!
for i in $(seq 1 30); do curl -s localhost:3778/api/pow >/dev/null && break; sleep 1; done
BASE_URL=http://localhost:3778 node scripts/audit-deep.mjs; rc=$?
kill $SRV_PID 2>/dev/null || true
exit $rc
- name: Tool correctness regressions (20 wrong-answer bugs from the review; boots free-mode, TZ=UTC)
run: node scripts/test-correctness-fixes.js
- name: Buyer SDK (agent402-client — resolve + auto-pay via PoW; boots paid-mode)
run: node client/test.js
- name: Buyer SDK spending caps (concurrency reservation + 402-amount inspection — wallet-safety lock — offline)
run: node scripts/test-client-caps.js
- name: Adapter tests (openai-tools + anthropic-tools + ai-sdk + langchain + openai-agents + llamaindex + strands + google-adk + langchain-py)
run: |
X402_SYNC_ON_START=false FREE_MODE=true PORT=3091 node src/server.js > /tmp/a402-test.log 2>&1 &
ADAPTER_PID=$!
for i in $(seq 1 20); do curl -sf http://localhost:3091/health >/dev/null && break || sleep 0.5; done
curl -sf http://localhost:3091/health >/dev/null || { echo "FREE_MODE server failed to boot"; cat /tmp/a402-test.log; kill $ADAPTER_PID; exit 1; }
# Each adapter test self-installs its framework peer (and agent402-client)
# into its own node_modules; tests verify tool-shape + execute() round-trip.
# ai-sdk/langchain/openai-agents tests exercise the framework-agnostic
# spec path (no peer install needed); peer wrappers are checked in
# consumers' projects via optional peerDeps.
AGENT402_BASE_URL=http://localhost:3091 node adapters/openai-tools/test.js
( cd adapters/anthropic-tools && npm install ../../client --no-save --silent )
AGENT402_BASE_URL=http://localhost:3091 node adapters/anthropic-tools/test.js
AGENT402_BASE_URL=http://localhost:3091 node adapters/ai-sdk/test.js
AGENT402_BASE_URL=http://localhost:3091 node adapters/langchain/test.js
AGENT402_BASE_URL=http://localhost:3091 node adapters/openai-agents/test.js
AGENT402_BASE_URL=http://localhost:3091 node adapters/llamaindex/test.js
AGENT402_BASE_URL=http://localhost:3091 node adapters/strands/test.js
AGENT402_BASE_URL=http://localhost:3091 node adapters/google-adk/test.js
# Python adapter (agent402-langchain): requests-only keeps CI lean; the
# test skips the langchain-native path gracefully without langchain-core,
# so this locks the spec + free proof-of-work call behavior.
python3 -m pip install --quiet requests
AGENT402_BASE_URL=http://localhost:3091 python3 adapters/langchain-py/test.py
kill $ADAPTER_PID 2>/dev/null || true
- name: PDF toolkit tests (merge/split/rotate/info/images-to-pdf)
run: node scripts/test-pdf.js
- name: Demand-kit tests (pdf-to-markdown)
run: node scripts/test-demand.js
- name: Media-kit tests (ffprobe, mp4-to-mp3, loudnorm — real ffmpeg)
run: node scripts/test-media.js
- name: Agent-kit tests (token-count, text-chunk, json-validate, jsonl)
run: node scripts/test-agent-kit.js
- name: Finance-math-kit tests (TVM, Black-Scholes, bonds, CAGR, Sharpe — vs textbook values)
run: node scripts/test-finance-math-kit.js
- name: Barcode-kit tests (QR/barcode decode round-trip, PNG + JPEG)
run: node scripts/test-barcode.js
- name: Image-kit tests (resize, convert, thumbnail — jimp, exact dims)
run: node scripts/test-image.js
- name: Data-kit tests (barcode-lookup, fx-rate, weather-forecast — live keyless APIs)
run: node scripts/test-data-kit.js
- name: Macro-kit tests (FRED yield curve, Treasury debt + avg rates, ECB FX series, World Bank — live keyless APIs)
run: node scripts/test-macro-kit.js
- name: EDGAR-kit tests (SEC ticker→CIK, filings, XBRL company-concept/facts/frames — live keyless APIs)
run: node scripts/test-edgar-kit.js
- name: Search-kit tests (Brave web/news/images/suggest — validation always runs; live calls opt-in via BRAVE_LIVE_TEST=1, otherwise paid-canary covers post-deploy)
run: node scripts/test-search-kit.js
env:
BRAVE_API_KEY: ${{ secrets.BRAVE_API_KEY }}
- name: Finance-kit tests (Yahoo chart quote+history, Nasdaq earnings calendar — keyless live APIs)
run: node scripts/test-finance-kit.js
- name: Crypto-kit tests (CoinGecko price/market/history/trending/global — keyless live API)
run: node scripts/test-crypto-kit.js
- name: x402-kit tests (transfer-auth typed data + live Base RPC reads)
run: node scripts/test-x402-kit.js
- name: x402-trending momentum tool (organic score, sorts, WoW activation, history persistence — offline)
run: node scripts/test-x402-trending.js
- name: Demand-radar seller intel (signalType, near-threshold, noise flag, wallet-only — offline)
run: node scripts/test-demand-radar.js
- name: Bestsellers catalog-demand tool (lenses, trend windows, organic score, window-query filters, wallet-only — offline)
run: node scripts/test-bestsellers.js
- name: Util-kit tests (jwt-sign, uuid-v5, group-by, json-to-xml, geo-distance, color-contrast)
run: node scripts/test-util-kit.js
- name: Webhook signature verify (per-provider HMAC vectors, replay tolerance, raw-body guard)
run: node scripts/test-webhook-verify.js
- name: B20 decode unit tests (offline, synthetic logs in both indexed layouts)
run: node scripts/test-b20-decode.js
- name: Stellar marketplace page tests (offline, fixture snapshot)
run: node scripts/test-stellar-page.js
- name: Algorand marketplace page tests (offline, fixture snapshot)
run: node scripts/test-algorand-page.js
- name: Base/Solana/Polygon/Arbitrum/Robinhood marketplace page tests (offline, fixture snapshot)
run: node scripts/test-market-pages.js
- name: Base activity via CDP SQL — guard/fail-safe paths (offline)
run: node scripts/test-base-activity.js
- name: Sell hub page tests (offline, fixture snapshot)
run: node scripts/test-sell-page.js
- name: Index self-serve registration tests (offline)
run: node scripts/test-index-register.js
- name: Agent wish loop tests (caps, dedup clustering, rate limits, file cap, aggregate shape — offline)
run: node scripts/test-wish.js
- name: Stellar activity scan tests (offline, fixture Horizon records)
run: node scripts/test-stellar-activity.js
- name: EVM/Solana/Robinhood activity scan tests (offline, fixture Alchemy/RPC/Blockscout records)
run: node scripts/test-chain-activity.js
- name: Html-kit tests (html-select, html-table, html-strip, html-links, html-meta)
run: node scripts/test-html-kit.js
- name: Compression-kit tests (gzip, gunzip, brotli, compress-compare — pure CPU, zip-bomb guard)
run: node scripts/test-compression-kit.js
- name: Stats-kit tests (summary, correlation, regression, moving-average, outliers — pure CPU)
run: node scripts/test-stats-kit.js
- name: Code-run-kit tests (E2B sandbox — validation always runs; live calls opt-in via E2B_LIVE_TEST=1)
run: node scripts/test-code-run-kit.js
env:
E2B_LIVE_TEST: ${{ secrets.E2B_API_KEY && '1' || '' }}
E2B_API_KEY: ${{ secrets.E2B_API_KEY }}
- name: Tollbooth tests (pay-per-crawl gate — node + edge/Web-Crypto)
run: node tollbooth/test.js && node tollbooth/edge.test.js && node tollbooth/features.test.js
- name: Tollbooth dashboard runtime smoke (boots CLI, hits /__tollbooth + /__tollbooth/stats over HTTP)
run: node scripts/test-tollbooth-runtime.js
- name: Conversion tools (~970 endpoints, exact + round-trip)
run: node scripts/test-convert.js
- name: Every tool responds to its own example (all ~1070 endpoints)
run: TARGET_URL=http://localhost:3000 node scripts/test-all.js
- name: Remote MCP connector (/mcp — initialize, search, free call, paid refusal)
run: TARGET_URL=http://localhost:3000 node scripts/test-mcp-http.js
- name: "Every tool through the MCP connector (call_tool — free executes, wallet refuses)"
run: TARGET_URL=http://localhost:3000 node scripts/test-mcp-all.js
- name: MCP search_tools — live-catalog smoke test (locks top-1 for agent queries)
run: node scripts/test-mcp-search-ranking.js
- name: Exercise tools against live sites
run: |
echo "=== extract: BBC News article ==="
curl -s -X POST localhost:3000/api/extract -H 'Content-Type: application/json' \
-d '{"url":"https://www.bbc.com/news"}' | jq '{title, siteName, wordCount, sample: (.markdown | .[0:400])}'
echo "=== extract: Wikipedia x402-adjacent page ==="
curl -s -X POST localhost:3000/api/extract -H 'Content-Type: application/json' \
-d '{"url":"https://en.wikipedia.org/wiki/List_of_HTTP_status_codes"}' | jq '{title, wordCount, sample: (.markdown | .[0:400])}'
echo "=== meta: github.com ==="
curl -s 'localhost:3000/api/meta?url=https://github.com' | jq .
echo "=== dns: MX for gmail.com ==="
curl -s 'localhost:3000/api/dns?name=gmail.com&type=MX' | jq .
echo "=== dns: TXT for openai.com ==="
curl -s 'localhost:3000/api/dns?name=openai.com&type=TXT' | jq '.records | length as $n | {records_found: $n}'
echo "=== SSRF guard: cloud metadata endpoint must be blocked ==="
curl -s -X POST localhost:3000/api/extract -H 'Content-Type: application/json' \
-d '{"url":"http://169.254.169.254/latest/meta-data"}' | jq .
echo "=== render: JS-only page (plain extract cannot read this) ==="
curl -s -X POST localhost:3000/api/render -H 'Content-Type: application/json' \
-d '{"url":"https://react.dev"}' | jq '{title, wordCount, rendered, sample: (.markdown | .[0:300])}'
echo "=== screenshot: example.com ==="
curl -s 'localhost:3000/api/screenshot?url=https://example.com' -o shot.png
file shot.png && ls -la shot.png
echo "=== pdf: arXiv 'Attention Is All You Need' ==="
curl -s -X POST localhost:3000/api/pdf -H 'Content-Type: application/json' \
-d '{"url":"https://arxiv.org/pdf/1706.03762"}' | jq 'if .error then . else {pages, wordCount, sample: (.text | .[0:150])} end'
echo "=== memory: write/read/isolation ==="
curl -s -X POST 'localhost:3000/api/memory?ns=ci' -H 'Content-Type: application/json' -d '{"key":"a","value":{"n":1}}' | jq .
curl -s 'localhost:3000/api/memory?ns=ci&key=a' | jq .
curl -s 'localhost:3000/api/memory?ns=other&key=a' | jq .
echo "=== memory v2: counter / recall / audit log ==="
curl -s -X POST 'localhost:3000/api/memory/incr?ns=ci' -H 'Content-Type: application/json' -d '{"key":"jobs","by":3}' | jq -c .
curl -s -X POST 'localhost:3000/api/memory/remember?ns=ci' -H 'Content-Type: application/json' -d '{"text":"The Railway deploy failed: build out of memory"}' | jq -c '{stored}'
curl -s -X POST 'localhost:3000/api/memory/remember?ns=ci' -H 'Content-Type: application/json' -d '{"text":"pineapple pizza recipe"}' | jq -c '{stored}'
curl -s -X POST 'localhost:3000/api/memory/recall?ns=ci' -H 'Content-Type: application/json' -d '{"query":"deployment ran out of memory","k":1}' | jq -c '.results[0].text'
curl -s 'localhost:3000/api/memory/log?ns=ci&limit=3' | jq -c '.entries|map(.action)'
echo "=== kit: encoding/data/text/time/validation ==="
curl -s -X POST localhost:3000/api/hash -H 'Content-Type: application/json' -d '{"text":"hello"}' | jq -c .
curl -s -X POST localhost:3000/api/json-to-csv -H 'Content-Type: application/json' -d '{"json":[{"a":1,"b":{"c":2}}]}' | jq -c .
curl -s -X POST localhost:3000/api/cron-next -H 'Content-Type: application/json' -d '{"expr":"0 9 * * 1-5","count":2}' | jq -c .
curl -s -X POST localhost:3000/api/email-validate -H 'Content-Type: application/json' -d '{"email":"[email protected]"}' | jq -c '{syntaxValid, deliverableDomain}'
curl -s 'localhost:3000/api/uuid?version=7&count=2' | jq -c .
curl -s 'localhost:3000/api/qr?text=https://agent402.tools' -o qr.png && file qr.png
echo "=== brand assets ==="
curl -s localhost:3000/logo.svg | grep -q '<svg' && echo "logo.svg ✓"
curl -sL localhost:3000/logo.png -o logo.png && file logo.png | grep -q 'PNG image' && echo "logo.png ✓"
curl -s localhost:3000/card.svg | grep -q '<svg' && echo "card.svg ✓"
curl -sL localhost:3000/card.png -o card.png && file card.png | grep -q 'PNG image' && echo "card.png (1200x630 social card) ✓"
echo "=== search: live web search (opt-in via BRAVE_LIVE_TEST=1) ==="
# Every [test] run otherwise burns the Brave subscription with calls the
# daily paid-canary (scripts/paid-canary.js) already covers post-deploy.
# When the key is set but BRAVE_LIVE_TEST!=1, just verify the route is
# registered (route returns either 200 or a structured 503 — never 404).
if [ "$BRAVE_LIVE_TEST" = "1" ] && [ -n "$BRAVE_API_KEY" ]; then
curl -s 'localhost:3000/api/search?q=x402+payment+protocol&count=3' | jq '{query, count, top: .results[0].url}'
curl -s 'localhost:3000/api/search?q=x402+payment+protocol&count=3' | jq -e '.count >= 1' >/dev/null || { echo "search returned no results"; exit 1; }
elif [ -z "$BRAVE_API_KEY" ]; then
echo "(BRAVE_API_KEY secret not set — verifying the unconfigured 503 path)"
code=$(curl -s -o /dev/null -w '%{http_code}' 'localhost:3000/api/search?q=test')
[ "$code" = "503" ] || { echo "expected 503 without key, got $code"; exit 1; }
else
echo "(BRAVE_LIVE_TEST!=1 — skipping live Brave call; route registration is covered by the /openapi.json + test-search-kit.js validation paths, and paid-canary covers post-deploy live verification)"
fi
echo "=== demand kit: live (pdf-to-markdown) ==="
curl -s -X POST localhost:3000/api/pdf-to-markdown -H 'Content-Type: application/json' \
-d '{"url":"https://arxiv.org/pdf/1706.03762"}' | jq '{pages, wordCount, md: (.markdown | .[0:160])}'
echo "=== live public data (data.gov / NWS / USGS) ==="
# data.gov has frequent upstream outages — exercise gov-data but don't
# hard-fail the suite on its availability (the tool returns an honest
# 502 when data.gov is down). NWS + USGS below are reliable and strict.
GD=$(curl -s 'localhost:3000/api/gov-data?q=electric+vehicle+charging&rows=3')
echo "$GD" | jq '{totalFound, top: .results[0].title, error}'
echo "$GD" | jq -e '.totalFound >= 1' >/dev/null && echo " gov-data OK" || echo " (gov-data upstream flaky right now — not failing the suite)"
curl -s 'localhost:3000/api/weather-alerts?area=CA' | jq '{count, top: .alerts[0].event}'
curl -s 'localhost:3000/api/weather-alerts?area=CA' | jq -e 'has("alerts")' >/dev/null || { echo "weather-alerts malformed"; exit 1; }
curl -s 'localhost:3000/api/earthquakes?minMag=2.5&period=day' | jq '{count, top: .quakes[0].place}'
curl -s 'localhost:3000/api/earthquakes?minMag=2.5&period=day' | jq -e '.count >= 1' >/dev/null || { echo "earthquakes returned nothing"; exit 1; }
echo "=== kit: live network tools ==="
curl -s -X POST localhost:3000/api/http-check -H 'Content-Type: application/json' -d '{"url":"https://example.com"}' | jq -c '{up, status, latencyMs}'
curl -s -X POST localhost:3000/api/tls-cert -H 'Content-Type: application/json' -d '{"host":"github.com"}' | jq -c '{subject, issuer, daysRemaining, chainTrusted}'
curl -s -X POST localhost:3000/api/whois -H 'Content-Type: application/json' -d '{"domain":"github.com"}' | jq -c '{registrar, created, expires}'
curl -s -X POST localhost:3000/api/robots-check -H 'Content-Type: application/json' -d '{"url":"https://www.google.com/search","userAgent":"TestBot"}' | jq -c '{allowed, matchedRule}'
curl -s -X POST localhost:3000/api/sitemap -H 'Content-Type: application/json' -d '{"url":"https://www.sitemaps.org/sitemap.xml"}' | jq -c '{type, count}'
echo "=== discovery surfaces ==="
N=$(curl -s localhost:3000/api/pricing | jq '.endpoints | length'); echo "catalog endpoints: $N (expect >= 500)"; [ "$N" -ge 500 ]
curl -s localhost:3000/openapi.json | jq '{paths: (.paths | length)}'
curl -s localhost:3000/tools | grep -oiE -m1 '<h1[^>]*>'
curl -s localhost:3000/tools/memory-write | grep -o '<title>[^<]*</title>'
curl -s localhost:3000/sitemap.xml | grep -c '<loc>'
- name: Multi-agent coordination demo (two wallets, one namespace)
run: node scripts/demo-coordination.js
- name: /api/stats surfaces the M2M economy
run: |
curl -s localhost:3000/api/hash -X POST -H 'Content-Type: application/json' -d '{"text":"x"}' >/dev/null
curl -s localhost:3000/api/stats | jq -e '.tools >= 500 and .toolCallsServed.total >= 1 and (.summary | test("machine-to-machine"))' >/dev/null \
&& echo "/api/stats OK" || { echo "/api/stats wrong"; curl -s localhost:3000/api/stats; exit 1; }
- name: Proof-of-work tier (gate bypass with payments enabled)
run: |
# Start a second instance with the x402 paywall ACTIVE so the PoW gate
# is meaningful (in FREE_MODE everything is free). The facilitator is
# never contacted (X402_SYNC_ON_START=false); unpaid+unproven requests
# are rejected, a valid proof-of-work is served.
WALLET_ADDRESS=0x000000000000000000000000000000000000dEaD NETWORK=base \
FACILITATOR_URL=https://facilitator.payai.network X402_SYNC_ON_START=false \
POW_DIFFICULTY=16 PORT=3001 node src/server.js &
for i in $(seq 1 20); do curl -s localhost:3001/api/pow >/dev/null && break; sleep 1; done
node --input-type=module -e '
const B = "http://localhost:3001";
const { createHash } = await import("node:crypto");
const lz = (b) => { let t = 0; for (const x of b) { if (!x) { t += 8; continue; } t += Math.clz32(x) - 24; break; } return t; };
const solve = (c) => { let n = 0; while (lz(createHash("sha256").update(c.challenge + ":" + n).digest()) < c.difficulty) n++; return n; };
const fail = (m) => { console.error("FAIL:", m); process.exit(1); };
const info = await (await fetch(B + "/api/pow")).json();
if (info.eligibleTools.length < 150) fail("expected >=150 PoW-eligible tools, got " + info.eligibleTools.length);
if (info.eligibleTools.includes("render")) fail("render must NOT be PoW-eligible");
const c = await (await fetch(B + "/api/pow/challenge?slug=hash")).json();
const n = solve(c);
const ok = await fetch(B + "/api/hash", { method: "POST", headers: { "Content-Type": "application/json", "X-Pow-Solution": c.token + ":" + n }, body: JSON.stringify({ text: "hello world" }) });
if (ok.status !== 200) fail("valid PoW should return 200, got " + ok.status);
if ((await ok.json()).hex.slice(0, 8) !== "b94d27b9") fail("wrong hash result");
console.log("valid PoW -> 200 ✓");
const replay = await fetch(B + "/api/hash", { method: "POST", headers: { "Content-Type": "application/json", "X-Pow-Solution": c.token + ":" + n }, body: JSON.stringify({ text: "x" }) });
if (replay.status === 200) fail("replayed challenge must be rejected");
console.log("replay -> " + replay.status + " ✓");
const noPow = await fetch(B + "/api/hash", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "x" }) });
if (noPow.status === 200) fail("no payment + no PoW must be rejected");
console.log("no PoW -> " + noPow.status + " ✓");
const c2 = await (await fetch(B + "/api/pow/challenge?slug=hash")).json();
const n2 = solve(c2);
const walletOnly = await fetch(B + "/api/render", { method: "POST", headers: { "Content-Type": "application/json", "X-Pow-Solution": c2.token + ":" + n2 }, body: JSON.stringify({ url: "https://example.com" }) });
if (walletOnly.status === 200) fail("PoW must NOT bypass the wallet-only /api/render");
console.log("PoW on wallet-only render -> " + walletOnly.status + " ✓");
console.log("proof-of-work gate: all assertions passed");
'
- name: Bridge routes removed (no token grants free access; direct unpaid call still 402s)
run: |
# Paywall ACTIVE. The old marketplace bridge (/mkt/<token>/<slug> +
# X-Mkt-Bypass header) must be fully gone: no token value may skip the
# x402 paywall, and a direct unpaid call still 402s.
WALLET_ADDRESS=0x000000000000000000000000000000000000dEaD NETWORK=base \
FACILITATOR_URL=https://facilitator.payai.network X402_SYNC_ON_START=false \
MARKETPLACE_TOKEN=test-bridge-token PORT=3004 node src/server.js &
for i in $(seq 1 20); do curl -s localhost:3004/health >/dev/null && break; sleep 1; done
node --input-type=module -e '
import { createHmac } from "node:crypto";
const B = "http://localhost:3004"; const fail = (m) => { console.error("FAIL:", m); process.exit(1); };
const MASTER = "test-bridge-token";
const tok = (slug) => createHmac("sha256", MASTER).update(slug).digest("hex").slice(0, 32);
// the old per-slug bridge URL must not exist (even with the env var still set)
const perSlug = await fetch(B + "/mkt/" + tok("hash") + "/hash", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "hello world" }) });
if (perSlug.status === 200) fail("removed bridge route must not serve a result");
console.log("bridge per-slug URL -> " + perSlug.status + " ✓ (removed)");
// the old master-token bypass header must not skip the paywall
const bypass = await fetch(B + "/api/hash", { method: "POST", headers: { "Content-Type": "application/json", "X-Mkt-Bypass": MASTER }, body: JSON.stringify({ text: "x" }) });
if (bypass.status === 200) fail("X-Mkt-Bypass header must not skip the paywall");
console.log("bypass header -> " + bypass.status + " ✓ (paywalled)");
// direct unpaid call to the tool still requires payment
const direct = await fetch(B + "/api/hash", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "x" }) });
if (direct.status === 200) fail("direct unpaid call must still be paywalled");
console.log("direct unpaid -> " + direct.status + " ✓");
console.log("bridge removal: all assertions passed");
'
- name: MCP server e2e (catalog, search, proof-of-work settlement, wallet guidance)
run: |
npm --prefix mcp ci
node mcp/test.js
- name: Multi-chain USDC — code registers all chains (hard gate) + live 402 (informational)
run: |
# HARD GATE: our code must boot multi-chain and advertise the chains in
# /api/pricing. The live 402 from an external facilitator is captured for
# diagnostics but does not fail the build (it depends on the facilitator
# settling each chain, which is verified out-of-band before enabling).
# SOLANA_WALLET_ADDRESS is the system program id — a syntactically valid
# base58 pubkey used only as a dummy payTo so the Solana accept is built
# (without it payments.js omits the Solana option from every 402, which
# is exactly the silent misconfig this gate must not reproduce).
WALLET_ADDRESS=0x000000000000000000000000000000000000dEaD NETWORK=base \
SOLANA_WALLET_ADDRESS=11111111111111111111111111111111 \
PAYMENT_NETWORKS=base,solana,polygon,arbitrum,stellar FACILITATOR_URL=https://facilitator.payai.network \
PORT=3002 node src/server.js >/tmp/mc.log 2>&1 &
for i in $(seq 1 30); do curl -s localhost:3002/health >/dev/null && break; sleep 1; done
grep -i "Accepting USDC" /tmp/mc.log
NETS=$(curl -s localhost:3002/api/pricing | jq -c '.payment.networks')
echo "pricing.payment.networks = $NETS"
[ "$NETS" = '["base","solana","polygon","arbitrum","stellar"]' ] || { echo "code did not register all chains"; exit 1; }
echo "--- live 402 against PayAI (informational status; hard gate on the accepts below) ---"
curl -s -D /tmp/402.h -o /tmp/402.body -w "status %{http_code}\n" -X POST localhost:3002/api/hash \
-H 'Content-Type: application/json' -H 'Accept: application/json' -d '{"text":"x"}' || true
# x402 v2 puts the requirements in the base64 PAYMENT-REQUIRED header
# (the body is {}); v1 kept them in the body. Decode whichever exists.
HDR=$(tr -d '\r' < /tmp/402.h | awk -F': ' 'tolower($1)=="payment-required"{print $2}' | head -1)
if [ -n "$HDR" ]; then echo "$HDR" | base64 -d > /tmp/402req.json 2>/dev/null || cp /tmp/402.body /tmp/402req.json; else cp /tmp/402.body /tmp/402req.json; fi
echo "402 requirements:"; jq -c '{x402Version, accepts: ([.accepts[]?.network])}' /tmp/402req.json 2>/dev/null || head -c 400 /tmp/402req.json || true
SOLANA_402=$(jq -r '[.accepts[]? | select((.network // "") | startswith("solana:"))] | length' /tmp/402req.json 2>/dev/null || echo 0)
[ "$SOLANA_402" -ge 1 ] || { echo "402 accepts is missing the Solana option despite SOLANA_WALLET_ADDRESS being set"; exit 1; }
- name: Check production is serving the new catalog
# Non-blocking: this is a smoke test against live prod, not a pre-deploy
# gate. Making it blocking creates a deadlock — if a bad deploy ever
# crashes prod, this check fails on the *next* push and prevents the
# rescue deploy from running (the deploy job has needs: test). Railway's
# own "Verify deployment" step in the deploy job is the real gate.
continue-on-error: true
run: |
PROD=https://agent402.tools
# The deploy job and this test job run concurrently, so production may
# still be swapping to the new image when we get here. Poll for up to
# ~2 min before deciding it's a real failure (avoids rollout-race flakes).
N=0; paid=000
for i in $(seq 1 12); do
N=$(curl -s --max-time 10 "$PROD/api/pricing" | jq '.endpoints | length' 2>/dev/null || echo 0)
paid=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 -X POST "$PROD/api/hash" -H 'Content-Type: application/json' -H 'Accept: application/json' -d '{"text":"x"}' || echo 000)
[ "${N:-0}" -ge 500 ] && [ "$paid" = "402" ] && break
echo " attempt $i: endpoints=$N hash=$paid — production still rolling out, retrying in 10s…"
sleep 10
done
echo "production endpoint count: $N (expect >= 500); hash unpaid → HTTP $paid (expect 402)"
[ "${N:-0}" -ge 500 ] && [ "$paid" = "402" ] || { echo "Production not serving the catalog after ~2 min — real problem, investigate."; exit 1; }
curl -s "$PROD/api/pricing" | jq -r '.endpoints[] | "\(.method) \(.path) \(.price)"'
echo "=== SEO + discovery surfaces on production (hard gate) ==="
# These have all been live in production for a while — a miss now is a
# real regression, not rollout lag. NOTE: this step runs BEFORE the
# deploy job, so it checks the PREVIOUS build's homepage — a fix to
# these surfaces goes green on the run AFTER the one that ships it.
FAILS=0
ck() { if eval "$2"; then echo " $1 ✓"; else echo " $1 MISSING"; FAILS=$((FAILS+1)); fi; }
HTML=$(curl -s "$PROD/")
ck "FAQPage JSON-LD" 'echo "$HTML" | grep -q "\"FAQPage\""'
# The ledger homepage renders the FAQ section with a "$ GET /faq"
# kicker (its h2 is styled, so the old <h2>FAQ</h2> literal is gone).
ck "visible FAQ" 'echo "$HTML" | grep -q "GET /faq"'
ck "AggregateOffer" 'echo "$HTML" | grep -q AggregateOffer'
ck "robots welcomes AI crawlers" 'curl -s "$PROD/robots.txt" | grep -q GPTBot'
ck "MCP connector on landing" 'echo "$HTML" | grep -q agent402-mcp'
ck "MCP connector in llms.txt" 'curl -s "$PROD/llms.txt" | grep -q agent402-mcp'
ck "unit-convert tool page" 'curl -s "$PROD/tools/unit-convert" | grep -q "Unit convert"'
ck "guides with TechArticle JSON-LD" 'curl -s "$PROD/guides/x402-in-5-minutes" | grep -q TechArticle'
[ "$FAILS" -eq 0 ] || { echo "$FAILS production surface(s) regressed"; exit 1; }
echo "=== proof-of-work tier live on production (no wallet) ==="
node --input-type=module -e '
const B = "'"$PROD"'";
const { createHash } = await import("node:crypto");
const lz = (b) => { let t = 0; for (const x of b) { if (!x) { t += 8; continue; } t += Math.clz32(x) - 24; break; } return t; };
const cr = await fetch(B + "/api/pow/challenge?slug=hash");
const ct = cr.headers.get("content-type") || "";
if (!ct.includes("application/json")) { console.error("Production not yet serving /api/pow (still building the new image?) — re-run shortly."); process.exit(1); }
const c = await cr.json();
let n = 0; while (lz(createHash("sha256").update(c.challenge + ":" + n).digest()) < c.difficulty) n++;
const r = await fetch(B + "/api/hash", { method: "POST", headers: { "Content-Type": "application/json", "X-Pow-Solution": c.token + ":" + n }, body: JSON.stringify({ text: "hello world" }) });
if (r.status !== 200) { console.error("PoW on production returned " + r.status); process.exit(1); }
console.log("solved difficulty " + c.difficulty + " -> /api/hash HTTP 200, X-Pow-Accepted:", r.headers.get("x-pow-accepted"));
'
echo "=== remote MCP connector live on production (paid mode, real paywall mount order) ==="
# /mcp is new — the old image 404s it, so poll through the rollout window.
MCP_OK=0
for i in $(seq 1 8); do
if TARGET_URL=$PROD node scripts/test-mcp-http.js; then MCP_OK=1; break; fi
echo " attempt $i: /mcp not serving yet — retrying in 15s…"; sleep 15
done
[ "$MCP_OK" = "1" ] || { echo "Remote MCP connector not live on production"; exit 1; }
echo "=== watch an agent pay an agent (live, no wallet) ==="
TARGET_URL=https://agent402.tools node scripts/demo-payment.js
echo "=== live M2M stats (informational; present once deployed) ==="
curl -s https://agent402.tools/api/stats | jq '{tools, wallet, onchainRevenueProof, served: .toolCallsServed}' || echo "(/api/stats not live yet — will appear after this image rolls out)"
publish:
needs: [markers, test]
if: github.event_name == 'workflow_dispatch' && inputs.mode == 'publish' || needs.markers.outputs.publish == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # GitHub OIDC → authorize the io.github.mikeypetrillo/* MCP Registry namespace
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 22
registry-url: https://registry.npmjs.org
- name: MCP e2e gate before publishing
run: |
npm ci
npm --prefix mcp ci
node mcp/test.js
- name: mcp version consistency (package.json ↔ server.json)
# Guards against the drift that bit us once already: mcp-publisher reads
# server.json's version, npm reads package.json's, and the registry
# rejects a duplicate (name, version) at the storage layer (not the
# search index, so checks against the public listing can mislead).
# Also asserts the MCP Registry's ≤100-char limit on server.json's
# description, which is enforced by the registry at publish time —
# fail-fast here so we don't burn an npm publish on a registry-only
# validation error (caught us once: 0.8.1 shipped to npm but bounced
# at the registry with a 287-char description).
# Fail CI here, before we waste a publish attempt and a version bump.
run: |
node -e '
const pkg = require("./mcp/package.json");
const srv = require("./mcp/server.json");
const a = pkg.version;
const b = srv.version;
const c = srv.packages && srv.packages[0] && srv.packages[0].version;
if (a !== b || a !== c) {
console.error("mcp version drift:");
console.error(" mcp/package.json.version =", a);
console.error(" mcp/server.json.version =", b);
console.error(" mcp/server.json.packages[0].ver =", c);
process.exit(1);
}
const dlen = (srv.description || "").length;
if (dlen > 100) {
console.error("mcp/server.json.description is", dlen, "chars — MCP Registry limit is 100");
console.error(" current:", JSON.stringify(srv.description));
process.exit(1);
}
console.log("mcp versions aligned at", a, "· server.json.description =", dlen, "chars");
'
- name: Publish agent402-mcp to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
[ -n "$NODE_AUTH_TOKEN" ] || { echo "NPM_TOKEN secret missing — add an npm granular token (read/write, all packages, 2FA bypass) as the NPM_TOKEN repo secret"; exit 1; }
cd mcp
VERSION=$(node -p "require('./package.json').version")
# Idempotent: a re-run after a successful publish is a no-op.
if npm view "agent402-mcp@$VERSION" version >/dev/null 2>&1; then
echo "agent402-mcp@$VERSION is already on npm — nothing to do"
exit 0
fi
npm publish --provenance
echo "published agent402-mcp@$VERSION → https://www.npmjs.com/package/agent402-mcp"
- name: Verify the public npx path resolves
run: |
for i in $(seq 1 10); do
npm view agent402-mcp version >/dev/null 2>&1 && { echo "agent402-mcp visible on npm ✓"; break; }
sleep 15
done
- name: Publish agent402-tollbooth to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
[ -n "$NODE_AUTH_TOKEN" ] || { echo "NPM_TOKEN secret missing"; exit 1; }
node tollbooth/test.js && node tollbooth/edge.test.js # gate: node + edge logic must pass before shipping
cd tollbooth
VERSION=$(node -p "require('./package.json').version")
# Idempotent: a re-run after a successful publish is a no-op.
if npm view "agent402-tollbooth@$VERSION" version >/dev/null 2>&1; then
echo "agent402-tollbooth@$VERSION is already on npm — nothing to do"
else
npm publish --provenance --access public
echo "published agent402-tollbooth@$VERSION → https://www.npmjs.com/package/agent402-tollbooth"
fi
- name: Publish agent402-client to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
[ -n "$NODE_AUTH_TOKEN" ] || { echo "NPM_TOKEN secret missing"; exit 1; }
node client/test.js # gate: the buyer SDK must pass before shipping
node scripts/test-client-caps.js # gate: spending-cap safety must pass too
cd client
VERSION=$(node -p "require('./package.json').version")
# Idempotent: a re-run after a successful publish is a no-op.
if npm view "agent402-client@$VERSION" version >/dev/null 2>&1; then
echo "agent402-client@$VERSION is already on npm — nothing to do"
else
npm publish --provenance --access public
echo "published agent402-client@$VERSION → https://www.npmjs.com/package/agent402-client"
fi
- name: Boot FREE_MODE server for adapter tests
run: |
FREE_MODE=true PORT=3090 node src/server.js > /tmp/a402-adapter.log 2>&1 &
echo $! > /tmp/a402-adapter.pid
for i in $(seq 1 20); do
curl -sf http://localhost:3090/health >/dev/null && { echo "server up"; break; }
sleep 0.5
done
curl -sf http://localhost:3090/health >/dev/null || { echo "FREE_MODE server failed to boot"; cat /tmp/a402-adapter.log; exit 1; }
- name: Publish agent402-openai-tools to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
[ -n "$NODE_AUTH_TOKEN" ] || { echo "NPM_TOKEN secret missing"; exit 1; }
# Gate: tool list builds + execute() returns a real result against the FREE_MODE server.
AGENT402_BASE_URL=http://localhost:3090 node adapters/openai-tools/test.js
cd adapters/openai-tools
VERSION=$(node -p "require('./package.json').version")
# Idempotent: a re-run after a successful publish is a no-op.
if npm view "agent402-openai-tools@$VERSION" version >/dev/null 2>&1; then
echo "agent402-openai-tools@$VERSION is already on npm — nothing to do"
else
npm publish --provenance --access public
echo "published agent402-openai-tools@$VERSION → https://www.npmjs.com/package/agent402-openai-tools"
fi
- name: Publish agent402-anthropic-tools to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
[ -n "$NODE_AUTH_TOKEN" ] || { echo "NPM_TOKEN secret missing"; exit 1; }
# Install agent402-client locally for the test (same self-bootstrap as openai-tools/test.js).
( cd adapters/anthropic-tools && npm install ../../client --no-save --silent )
AGENT402_BASE_URL=http://localhost:3090 node adapters/anthropic-tools/test.js
cd adapters/anthropic-tools
VERSION=$(node -p "require('./package.json').version")
# Idempotent: a re-run after a successful publish is a no-op.
if npm view "agent402-anthropic-tools@$VERSION" version >/dev/null 2>&1; then
echo "agent402-anthropic-tools@$VERSION is already on npm — nothing to do"
else
npm publish --provenance --access public
echo "published agent402-anthropic-tools@$VERSION → https://www.npmjs.com/package/agent402-anthropic-tools"
fi
- name: Publish agent402-ai-sdk to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
[ -n "$NODE_AUTH_TOKEN" ] || { echo "NPM_TOKEN secret missing"; exit 1; }
# Test self-bootstraps agent402-client + ai (Vercel AI SDK) into its own node_modules.
AGENT402_BASE_URL=http://localhost:3090 node adapters/ai-sdk/test.js
cd adapters/ai-sdk
VERSION=$(node -p "require('./package.json').version")
if npm view "agent402-ai-sdk@$VERSION" version >/dev/null 2>&1; then
echo "agent402-ai-sdk@$VERSION is already on npm — nothing to do"
else
npm publish --provenance --access public
echo "published agent402-ai-sdk@$VERSION → https://www.npmjs.com/package/agent402-ai-sdk"
fi
- name: Publish agent402-langchain to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
[ -n "$NODE_AUTH_TOKEN" ] || { echo "NPM_TOKEN secret missing"; exit 1; }
# Test self-bootstraps agent402-client + @langchain/core + zod.
AGENT402_BASE_URL=http://localhost:3090 node adapters/langchain/test.js
cd adapters/langchain
VERSION=$(node -p "require('./package.json').version")
if npm view "agent402-langchain@$VERSION" version >/dev/null 2>&1; then
echo "agent402-langchain@$VERSION is already on npm — nothing to do"
else
npm publish --provenance --access public
echo "published agent402-langchain@$VERSION → https://www.npmjs.com/package/agent402-langchain"
fi
- name: Publish agent402-openai-agents to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
[ -n "$NODE_AUTH_TOKEN" ] || { echo "NPM_TOKEN secret missing"; exit 1; }
# Test runs framework-agnostic spec path; @openai/agents is an
# optional peerDep so consumers install it on their side.
AGENT402_BASE_URL=http://localhost:3090 node adapters/openai-agents/test.js
cd adapters/openai-agents
VERSION=$(node -p "require('./package.json').version")
if npm view "agent402-openai-agents@$VERSION" version >/dev/null 2>&1; then
echo "agent402-openai-agents@$VERSION is already on npm — nothing to do"
else
npm publish --provenance --access public
echo "published agent402-openai-agents@$VERSION → https://www.npmjs.com/package/agent402-openai-agents"
fi
- name: Publish agent402-llamaindex to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
[ -n "$NODE_AUTH_TOKEN" ] || { echo "NPM_TOKEN secret missing"; exit 1; }
# Test self-bootstraps agent402-client + llamaindex.
AGENT402_BASE_URL=http://localhost:3090 node adapters/llamaindex/test.js
cd adapters/llamaindex
VERSION=$(node -p "require('./package.json').version")
if npm view "agent402-llamaindex@$VERSION" version >/dev/null 2>&1; then
echo "agent402-llamaindex@$VERSION is already on npm — nothing to do"
else
npm publish --provenance --access public
echo "published agent402-llamaindex@$VERSION → https://www.npmjs.com/package/agent402-llamaindex"
fi
- name: Publish agent402-strands to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
[ -n "$NODE_AUTH_TOKEN" ] || { echo "NPM_TOKEN secret missing"; exit 1; }
# Test self-bootstraps agent402-client + stubs @strands-agents/sdk
# so we don't pull AWS Bedrock + uuid + yaml transitively just to
# verify the adapter shape.
AGENT402_BASE_URL=http://localhost:3090 node adapters/strands/test.js
cd adapters/strands
VERSION=$(node -p "require('./package.json').version")
if npm view "agent402-strands@$VERSION" version >/dev/null 2>&1; then
echo "agent402-strands@$VERSION is already on npm — nothing to do"
else
npm publish --provenance --access public
echo "published agent402-strands@$VERSION → https://www.npmjs.com/package/agent402-strands"
fi
- name: Publish agent402-google-adk to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
[ -n "$NODE_AUTH_TOKEN" ] || { echo "NPM_TOKEN secret missing"; exit 1; }
AGENT402_BASE_URL=http://localhost:3090 node adapters/google-adk/test.js
cd adapters/google-adk
VERSION=$(node -p "require('./package.json').version")
if npm view "agent402-google-adk@$VERSION" version >/dev/null 2>&1; then
echo "agent402-google-adk@$VERSION is already on npm — nothing to do"
else
npm publish --provenance --access public
echo "published agent402-google-adk@$VERSION → https://www.npmjs.com/package/agent402-google-adk"
fi
- name: Stop adapter-test server
if: always()
run: kill "$(cat /tmp/a402-adapter.pid 2>/dev/null)" 2>/dev/null || true
- name: Publish to the official MCP Registry (GitHub OIDC, no secret)
env:
# Pin the publisher release with a repo variable (e.g. v1.2.3); leave
# unset to track the latest. Optionally hard-pin the exact tarball
# digest with MCP_PUBLISHER_SHA256 for full supply-chain integrity.
MCP_PUBLISHER_VERSION: ${{ vars.MCP_PUBLISHER_VERSION }}
MCP_PUBLISHER_SHA256: ${{ vars.MCP_PUBLISHER_SHA256 }}
run: |
set -euo pipefail
# The registry verifies ownership by matching mcp/package.json's
# "mcpName" to the server.json name and confirming the npm package is
# published — so this runs after the npm publish above.
VER=$(node -p "require('./mcp/package.json').version")
if curl -s "https://registry.modelcontextprotocol.io/v0/servers?search=io.github.MikeyPetrillo/agent402" | grep -q "\"version\":\"$VER\""; then
echo "io.github.mikeypetrillo/agent402@$VER already in the MCP Registry — nothing to do"; exit 0
fi
asset="mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz"
if [ -n "${MCP_PUBLISHER_VERSION:-}" ]; then base="https://github.com/modelcontextprotocol/registry/releases/download/$MCP_PUBLISHER_VERSION"; else base="https://github.com/modelcontextprotocol/registry/releases/latest/download"; fi
# Download to disk and fail fast (no curl|tar of unverified bytes).
curl -fsSL -o "$asset" "$base/$asset"
# Verify the binary before executing it. Fail CLOSED: require either a
# hard-pinned digest (MCP_PUBLISHER_SHA256 repo var) or the release's
# published checksums.txt — never run an unverified download.
verified=""
if [ -n "${MCP_PUBLISHER_SHA256:-}" ]; then
echo "$MCP_PUBLISHER_SHA256 $asset" | sha256sum -c - && verified=1
fi
if [ -z "$verified" ]; then
# As of registry v1.7.0+ the checksums file is versioned —
# registry_<ver>_checksums.txt — instead of a flat checksums.txt.
# Resolve the actual filename from the GitHub release API rather
# than guessing, then verify with sha256sum.
tag_path="${MCP_PUBLISHER_VERSION:-}"
if [ -z "$tag_path" ]; then
tag_path=$(curl -fsSL "https://api.github.com/repos/modelcontextprotocol/registry/releases/latest" | grep -oP '"tag_name":\s*"\K[^"]+' | head -1)
fi
checksums_name=$(curl -fsSL "https://api.github.com/repos/modelcontextprotocol/registry/releases/tags/$tag_path" \
| grep -oP '"name":\s*"\K[^"]*_checksums\.txt(?=")' | head -1)
if [ -n "$checksums_name" ]; then
curl -fsSL -o "$checksums_name" "https://github.com/modelcontextprotocol/registry/releases/download/$tag_path/$checksums_name" 2>/dev/null || true
if [ -s "$checksums_name" ] && grep -q " $asset\$" "$checksums_name"; then
grep " $asset\$" "$checksums_name" | sha256sum -c - && verified=1
fi
fi
# Legacy fallback: pre-v1.7.0 releases used a flat checksums.txt.
if [ -z "$verified" ]; then
curl -fsSL -o checksums.txt "$base/checksums.txt" 2>/dev/null || true
if [ -s checksums.txt ] && grep -q " $asset\$" checksums.txt; then
grep " $asset\$" checksums.txt | sha256sum -c - && verified=1
fi
fi
fi
[ -n "$verified" ] || { echo "mcp-publisher could not be verified (no MCP_PUBLISHER_SHA256 and no checksums file) — refusing to execute"; exit 1; }
tar -xzf "$asset" mcp-publisher
cd mcp
../mcp-publisher login github-oidc
../mcp-publisher publish
echo "published io.github.mikeypetrillo/agent402@$VER → https://registry.modelcontextprotocol.io"
probe:
needs: markers
if: github.event_name == 'workflow_dispatch' && inputs.mode == 'probe' || needs.markers.outputs.probe == 'true'
runs-on: ubuntu-latest
env:
RAILWAY_API_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
# Multi-chain ground truth. /health flags show which env-gated features are
# live; the 402 challenge shows the payment options a buyer is ACTUALLY
# offered (payments.js silently omits the Solana accept when
# SOLANA_WALLET_ADDRESS is unset, so config intent and buyer-visible truth
# can differ). If Solana is offered, scan its payTo for USDC received —
# the Base-only revenue check below can't see Solana settlements.
- name: Multi-chain payment surface (health flags, live 402 accepts, Solana revenue)
run: |
PROD=https://agent402.tools
echo "=== production /health — env-gated feature flags live right now ==="
curl -s --max-time 15 "$PROD/health" | jq '{ok, checks, flags}' || true
echo
echo "=== live 402 on a wallet-only route — network + payTo per payment option ==="
# x402 v2 carries the requirements in the base64 PAYMENT-REQUIRED
# header (the 402 body is {}); v1 kept them in the body. Read both.
curl -s --max-time 15 -D /tmp/402.h -o /tmp/402.body "$PROD/api/search?q=probe" -H "Accept: application/json" || true
HDR=$(tr -d '\r' < /tmp/402.h | awk -F': ' 'tolower($1)=="payment-required"{print $2}' | head -1)
if [ -n "$HDR" ]; then CH=$(echo "$HDR" | base64 -d 2>/dev/null || true); else CH=$(cat /tmp/402.body); fi
echo "$CH" | jq -r '.accepts // [] | .[] | "\(.network) payTo=\(.payTo) amount=\(.maxAmountRequired // .price // "?")"' \
|| { echo "could not parse 402 requirements:"; echo "$CH" | head -c 400; }
SOLANA_PAYTO=$(echo "$CH" | jq -r '[.accepts // [] | .[] | select((.network // "") | startswith("solana:"))][0].payTo // empty' 2>/dev/null || true)
echo
if [ -n "$SOLANA_PAYTO" ]; then
echo "Solana IS offered to buyers (payTo=$SOLANA_PAYTO)."
echo "=== Solana revenue scan — USDC received by $SOLANA_PAYTO ==="
SOLANA_REVENUE_WALLET="$SOLANA_PAYTO" node scripts/revenue-scan-solana.js || true
else
echo "!! Solana is NOT among the live 402 payment options. Either PAYMENT_NETWORKS on"
echo "!! Railway does not include 'solana', or SOLANA_WALLET_ADDRESS is unset (the"
echo "!! Solana accept is silently omitted in that case — src/payments.js). No Solana"
echo "!! revenue is possible until a buyer can see a Solana payment option."
fi
echo
# Every accepted EVM chain pays the same 0x wallet but on a different
# native-USDC contract — scan each so Polygon/Arbitrum revenue is as
# visible as Base's (balance + recent external per-call payments).
EVM_PAYTO=$(echo "$CH" | jq -r '[.accepts // [] | .[] | select((.network // "") | startswith("eip155:"))][0].payTo // empty' 2>/dev/null || true)
if [ -n "$EVM_PAYTO" ]; then
for NET_NAME in polygon arbitrum robinhood; do
case "$NET_NAME" in
polygon) CAIP2="eip155:137" ;;
arbitrum) CAIP2="eip155:42161" ;;
robinhood) CAIP2="eip155:4663" ;; # settles USDG, same 6-decimal scan
esac
if echo "$CH" | jq -e --arg c "$CAIP2" '[.accepts // [] | .[] | select(.network == $c)] | length > 0' >/dev/null 2>&1; then
echo "=== $NET_NAME revenue scan — stablecoin received by $EVM_PAYTO ==="
SCAN_NETWORK="$NET_NAME" REVENUE_WALLET="$EVM_PAYTO" node scripts/revenue-scan.js || true
echo
else
echo "$NET_NAME: not among the live 402 payment options — skipped"
fi
done
fi
- name: Railway deployment history (which image is live, what failed)
run: |
gql() {
curl -s -X POST https://backboard.railway.app/graphql/v2 \
-H "Authorization: Bearer $RAILWAY_API_TOKEN" -H "Content-Type: application/json" \
--data @-
}
PROJECT_ID=$(jq -n '{query:"query { projects { edges { node { id name } } } }"}' | gql \
| jq -r '.data.projects.edges[] | select(.node.name=="agent402") | .node.id' | head -1)
ENV_ID=$(jq -n --arg id "$PROJECT_ID" '{query:"query($id:String!) { project(id: $id) { environments { edges { node { id name } } } } }", variables:{id:$id}}' | gql \
| jq -r '.data.project.environments.edges[] | select(.node.name=="production") | .node.id' | head -1)
SERVICE_ID=$(jq -n --arg id "$PROJECT_ID" '{query:"query($id:String!) { project(id: $id) { services { edges { node { id name } } } } }", variables:{id:$id}}' | gql \
| jq -r '.data.project.services.edges[] | select(.node.name=="agent402") | .node.id' | head -1)
echo "=== last 10 deployments (newest first) ==="
DEPLOYS=$(jq -n --arg p "$PROJECT_ID" --arg e "$ENV_ID" --arg s "$SERVICE_ID" \
'{query:"query($p:String!, $e:String!, $s:String!) { deployments(first: 10, input: { projectId: $p, environmentId: $e, serviceId: $s }) { edges { node { id status createdAt meta } } } }", variables:{p:$p,e:$e,s:$s}}' | gql)
echo "$DEPLOYS" | jq -r '.data.deployments.edges[]?.node | "\(.createdAt) \(.status) \(.id) commit=\(.meta.commitHash // .meta.commit // "?" | tostring | .[0:8]) msg=\((.meta.commitMessage // "" | tostring | .[0:60]) | gsub("\n";" "))"' || echo "$DEPLOYS" | head -c 1500
LATEST_ID=$(echo "$DEPLOYS" | jq -r '.data.deployments.edges[0].node.id // empty')
LATEST_STATUS=$(echo "$DEPLOYS" | jq -r '.data.deployments.edges[0].node.status // empty')
echo; echo "latest deployment: $LATEST_ID status=$LATEST_STATUS"
if [ -n "$LATEST_ID" ] && [ "$LATEST_STATUS" != "SUCCESS" ]; then
echo "=== build logs (tail) for latest deployment ==="
jq -n --arg d "$LATEST_ID" '{query:"query($d:String!) { buildLogs(deploymentId: $d, limit: 60) { timestamp message } }", variables:{d:$d}}' | gql \
| jq -r '.data.buildLogs[]? | "\(.timestamp) \(.message)"' | tail -60 || true
fi
# Runtime logs unconditionally: settlement failures (facilitator
# verify/settle errors, sync warnings) only surface here — a
# SUCCESS deployment can still be failing every USDC purchase.
# Two views: a raw tail, and a payment/boot-line filter that
# survives tool-error floods (a sweep's 400s drown a plain tail).
if [ -n "$LATEST_ID" ]; then
jq -n --arg d "$LATEST_ID" '{query:"query($d:String!) { deploymentLogs(deploymentId: $d, limit: 5000) { timestamp message } }", variables:{d:$d}}' | gql \
| jq -r '.data.deploymentLogs[]? | "\(.timestamp) \(.message)"' > /tmp/runtime.log || true
echo "=== runtime logs (raw tail, latest deployment) ==="
tail -40 /tmp/runtime.log || true
echo "=== runtime logs — payment/facilitator/boot lines ==="
grep -viE '\[tool-error\]' /tmp/runtime.log \
| grep -iE 'facilitator|x402 payments|multi-chain|accepting usdc|builder code|solana payto|warn|error|fail|settle|verify|listening' \
| tail -80 || echo "(no matching lines)"
fi
- name: Revenue + real-usage check (stats vs on-chain receipts)
run: |
echo "=== production /api/stats (calls served, split by settlement) ==="
STATS=$(curl -s https://agent402.tools/api/stats)
echo "$STATS" | jq '{tools, served: .toolCallsServed, topTools, topPaidTools, estimatedRevenueUsd}' || true
echo
echo "=== production /api/stats — last 25 served calls (slug + rail + time) ==="
echo "$STATS" | jq '.recentCalls' || true
echo
node --input-type=module -e '
const WALLET = "0xaBF4FAbd7c416fB67202E5f9002389Fc75e2a9D0".toLowerCase();
const USDC = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913";
const TRANSFER = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
const pad = (a) => "0x" + "0".repeat(24) + a.replace(/^0x/, "");
const RPCS = ["https://mainnet.base.org", "https://base-rpc.publicnode.com", "https://base.llamarpc.com"];
const rpc = async (m, p) => { for (const u of RPCS) { try { const r = await fetch(u, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: m, params: p }) }); const j = await r.json(); if (j.result !== undefined) return j.result; } catch {} } throw new Error("RPC failed " + m); };
const latest = parseInt(await rpc("eth_blockNumber", []), 16);
const SPAN = 40000; // ~24h of Base blocks
const logs = await rpc("eth_getLogs", [{ fromBlock: "0x" + (latest - SPAN).toString(16), toBlock: "latest", address: USDC, topics: [TRANSFER, null, pad(WALLET)] }]);
// distinct payers (real source = facilitator relayer; payer is in the auth, so group by tx origin instead)
const tsCache = {};
const ts = async (blk) => { if (!tsCache[blk]) { const b = await rpc("eth_getBlockByNumber", [blk, false]); tsCache[blk] = parseInt(b.timestamp, 16); } return tsCache[blk]; };
let total = 0n; const rows = [];
for (const l of logs) {
const amt = BigInt(l.data); total += amt;
const t = await ts(l.blockNumber);
const tx = await rpc("eth_getTransactionByHash", [l.transactionHash]);
rows.push({ usd: Number(amt) / 1e6, when: new Date(t * 1000).toISOString(), submitter: tx.from.toLowerCase(), tx: l.transactionHash });
}
// Decode the REAL payer from each tx input. USDC x402 settles via
// transferWithAuthorization(from, to, value, …) — "from" is the first
// 32-byte word after the 4-byte selector. That is the actual buyer.
const OURS = new Set(["0xfeda7403aabe9a492ed70e810b396d8548a4a022"]); // our test burner
for (const r of rows) {
try {
const tx = await rpc("eth_getTransactionByHash", [r.tx]);
const input = tx.input || "0x";
r.payer = input.length >= 10 + 64 ? "0x" + input.slice(10 + 24, 10 + 64) : null;
} catch { r.payer = null; }
}
rows.sort((a, b) => a.when.localeCompare(b.when));
console.log(`\nUSDC received in ~24h: ${rows.length} payments, total $${(Number(total) / 1e6).toFixed(4)}`);
const payers = {};
for (const r of rows) { const p = (r.payer || "unknown").toLowerCase(); payers[p] = (payers[p] || 0) + 1; }
console.log("payments grouped by REAL payer (decoded from tx input):");
for (const [p, n] of Object.entries(payers).sort((a, b) => b[1] - a[1])) {
const tag = OURS.has(p) ? " (our test burner)" : p === "unknown" ? "" : " <-- NOT our burner — possible external customer";
console.log(` ${n} payment(s) from ${p}${tag}`);
}
const external = rows.filter((r) => r.payer && !OURS.has(r.payer.toLowerCase()));
if (external.length) {
console.log(`\n*** ${external.length} payment(s) from wallets that are NOT our test burner: ***`);
for (const r of external) console.log(` ${r.when} $${r.usd.toFixed(6)} payer ${r.payer} ${r.tx}`);
} else {
console.log("\nAll decoded payments came from our own test burner — no external customer in this 24h window yet.");
}
console.log("Full history: https://basescan.org/address/" + WALLET + "#tokentxns");
' || echo "Base on-chain check skipped — public RPC flake (chain is still the source of truth: https://basescan.org/address/0xaBF4FAbd7c416fB67202E5f9002389Fc75e2a9D0#tokentxns)"
- name: Check which facilitator production is configured to use
run: |
gql() {
curl -s -X POST https://backboard.railway.app/graphql/v2 \
-H "Authorization: Bearer $RAILWAY_API_TOKEN" -H "Content-Type: application/json" \
--data @-
}
PROJECT_ID=$(jq -n '{query:"query { projects { edges { node { id name } } } }"}' | gql \
| jq -r '.data.projects.edges[] | select(.node.name=="agent402") | .node.id' | head -1)
ENV_ID=$(jq -n --arg id "$PROJECT_ID" '{query:"query($id:String!) { project(id: $id) { environments { edges { node { id name } } } } }", variables:{id:$id}}' | gql \
| jq -r '.data.project.environments.edges[] | select(.node.name=="production") | .node.id' | head -1)
SERVICE_ID=$(jq -n --arg id "$PROJECT_ID" '{query:"query($id:String!) { project(id: $id) { services { edges { node { id name } } } } }", variables:{id:$id}}' | gql \
| jq -r '.data.project.services.edges[] | select(.node.name=="agent402") | .node.id' | head -1)
echo "Variable NAMES set on the production service (values withheld):"
jq -n --arg p "$PROJECT_ID" --arg e "$ENV_ID" --arg s "$SERVICE_ID" \
'{query:"query($p:String!, $e:String!, $s:String!) { variables(projectId: $p, environmentId: $e, serviceId: $s) }", variables:{p:$p,e:$e,s:$s}}' | gql \
| jq -r '.data.variables | keys[]'
echo
echo "If CDP_API_KEY_ID + CDP_API_KEY_SECRET are absent, the server uses"
echo "FACILITATOR_URL (PayAI) and will NEVER appear in the CDP Bazaar."
- name: Search full discovery indexes for agent402
run: |
WALLET="0xabf4fabd7c416fb67202e5f9002389fc75e2a9d0"
for base in \
"https://api.cdp.coinbase.com/platform/v2/x402/discovery/resources" \
"https://facilitator.payai.network/discovery/resources"; do
echo "=== $base (paginated) ==="
offset=0; total=0; found=0
while true; do
BODY=$(curl -s --max-time 30 "$base?limit=100&offset=$offset") || break
N=$(echo "$BODY" | jq '.items | length' 2>/dev/null || echo "")
if [ -z "$N" ] || [ "$N" = "0" ] || [ "$N" = "null" ]; then break; fi
total=$((total+N))
HITS=$(echo "$BODY" | jq -c --arg w "$WALLET" \
'.items[] | select(((.resource // "") | ascii_downcase | contains("agent402")) or (any(.accepts[]?; (.payTo // "" | ascii_downcase) == $w)))' \
2>/dev/null || true)
if [ -n "$HITS" ]; then
# Count matches; print only the first page of hits. Dumping
# every listed resource (~1,300 rows) floods the job log so
# badly the logs API's tail window can't reach the output of
# any earlier step.
if [ "$found" = "0" ]; then
echo ">>> FOUND (first hits at offset $offset; sample):"
echo "$HITS" | head -3 | jq '{resource, type, lastUpdated, payTo: [.accepts[]?.payTo]}'
fi
found=$((found + $(echo "$HITS" | wc -l)))
fi
if [ "$N" -lt 100 ]; then break; fi
offset=$((offset+100))
if [ "$offset" -ge 20000 ]; then echo "(stopping after 20000 items)"; break; fi
done
if [ "$found" -gt 0 ]; then
echo ">>> RESULT: agent402 IS LISTED in this index ($found resources across $total scanned items)"
else
echo ">>> RESULT: agent402 NOT FOUND (scanned $total items)"
fi
echo
done
# Base Builder Code (ERC-8021) attribution, end-to-end: is the extension
# declared on live 402s (BASE_BUILDER_CODE on Railway), and do recent
# on-chain settlements actually carry the calldata suffix the Base
# dashboard indexes? Dependency-free script — no npm ci in this job.
# LAST step on purpose: the logs API serves a bounded tail of the job
# log, so the verdict must sit at the end to stay retrievable.
# ~8 days of history (43,200 blocks/day), evenly sampled: the per-day
# attribution table separates "indexer hasn't caught up with a recent
# rollout" from "suffixes have landed for a week and the dashboard
# still counts zero" (a Base-side bug report with tx-hash evidence).
- name: Builder Code attribution check (live 402 declaration + on-chain suffix scan)
run: SPAN_BLOCKS=350000 MAX_TXS=80 node scripts/check-builder-attribution.js || true
deploy:
needs: [markers, test]
if: github.event_name == 'workflow_dispatch' && inputs.mode == 'deploy' || needs.markers.outputs.deploy == 'true'
runs-on: ubuntu-latest
env:
RAILWAY_API_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
# The key ID and wallet address are not sensitive (the wallet address is
# public on-chain); only the Railway token and CDP secret live in secrets.
CDP_KEY_ID: ${{ secrets.CDP_API_KEY_ID || 'ff16e708-0590-4a24-8a17-b0e978143f68' }}
CDP_KEY_SECRET: ${{ secrets.CDP_API_KEY_SECRET }}
BRAVE_API_KEY: ${{ secrets.BRAVE_API_KEY }}
FRED_API_KEY: ${{ secrets.FRED_API_KEY }}
FRED_API_KEY_V2: ${{ secrets.FRED_API_KEY_V2 }}
# Cloudflare Worker relay for Yahoo Finance (workers/yfinance-relay).
# Both must be set for the relay to engage; otherwise finance-kit hits
# Yahoo directly. Surfaced as flags.yahooRelay on /health.
YAHOO_RELAY_URL: ${{ secrets.YAHOO_RELAY_URL }}
YAHOO_RELAY_TOKEN: ${{ secrets.YAHOO_RELAY_TOKEN }}
# /v1/audio/speech rollout gate (server.js GATEWAY_TOOLS_ENABLED) — a
# repo VARIABLE (not secret) so flipping it is one `gh variable set`
# away and the value is auditable. Injected only when set.
OPENROUTER_TTS_ENABLED: ${{ vars.OPENROUTER_TTS_ENABLED }}
WALLET_ADDRESS: ${{ secrets.WALLET_ADDRESS || '0xaBF4FAbd7c416fB67202E5f9002389Fc75e2a9D0' }}
NETWORK: ${{ vars.NETWORK || 'base' }}
# CDP keys take priority in the server's facilitator selection, so this
# default is harmless once CDP_API_KEY_ID/SECRET secrets are added.
FACILITATOR_URL: ${{ vars.FACILITATOR_URL || 'https://facilitator.payai.network' }}
PROJECT_NAME: agent402
CUSTOM_DOMAIN: ${{ vars.CUSTOM_DOMAIN || 'agent402.tools' }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.ref_name }}
steps:
- name: Check inputs
run: |
[ -n "$RAILWAY_API_TOKEN" ] && echo "::add-mask::$RAILWAY_API_TOKEN"
[ -n "$CDP_KEY_SECRET" ] && echo "::add-mask::$CDP_KEY_SECRET"
[ -n "$BRAVE_API_KEY" ] && echo "::add-mask::$BRAVE_API_KEY"
[ -n "$FRED_API_KEY" ] && echo "::add-mask::$FRED_API_KEY"
[ -n "$FRED_API_KEY_V2" ] && echo "::add-mask::$FRED_API_KEY_V2"
[ -n "$YAHOO_RELAY_TOKEN" ] && echo "::add-mask::$YAHOO_RELAY_TOKEN"
[ -n "$RAILWAY_API_TOKEN" ] || { echo "No Railway token provided"; exit 1; }
[ -n "$WALLET_ADDRESS" ] || { echo "No wallet address provided"; exit 1; }
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- name: Find or create project
id: project
run: |
gql() {
curl -s -X POST https://backboard.railway.app/graphql/v2 \
-H "Authorization: Bearer $RAILWAY_API_TOKEN" -H "Content-Type: application/json" \
--data @-
}
PROJECT_ID=$(jq -n '{query:"query { projects { edges { node { id name } } } }"}' | gql \
| jq -r --arg n "$PROJECT_NAME" '.data.projects.edges[] | select(.node.name==$n) | .node.id' | head -1)
if [ -z "$PROJECT_ID" ]; then
RESP=$(jq -n --arg n "$PROJECT_NAME" '{query:"mutation($n:String) { projectCreate(input: { name: $n }) { id } }", variables:{n:$n}}' | gql)
echo "$RESP"
PROJECT_ID=$(echo "$RESP" | jq -r '.data.projectCreate.id // empty')
fi
[ -n "$PROJECT_ID" ] || { echo "Could not find or create project"; exit 1; }
ENV_ID=$(jq -n --arg id "$PROJECT_ID" '{query:"query($id:String!) { project(id: $id) { environments { edges { node { id name } } } } }", variables:{id:$id}}' | gql \
| jq -r '.data.project.environments.edges[] | select(.node.name=="production") | .node.id' | head -1)
[ -n "$ENV_ID" ] || { echo "No production environment found"; exit 1; }
echo "project_id=$PROJECT_ID" >> "$GITHUB_OUTPUT"
echo "env_id=$ENV_ID" >> "$GITHUB_OUTPUT"
echo "Project: $PROJECT_ID Environment: $ENV_ID"
- name: Find or create service (connected to GitHub repo)
id: service
run: |
gql() {
curl -s -X POST https://backboard.railway.app/graphql/v2 \
-H "Authorization: Bearer $RAILWAY_API_TOKEN" -H "Content-Type: application/json" \
--data @-
}
PROJECT_ID='${{ steps.project.outputs.project_id }}'
SERVICE_ID=$(jq -n --arg id "$PROJECT_ID" '{query:"query($id:String!) { project(id: $id) { services { edges { node { id name } } } } }", variables:{id:$id}}' | gql \
| jq -r --arg n "$PROJECT_NAME" '.data.project.services.edges[] | select(.node.name==$n) | .node.id' | head -1)
if [ -z "$SERVICE_ID" ]; then
RESP=$(jq -n --arg p "$PROJECT_ID" --arg n "$PROJECT_NAME" --arg r "$REPO" --arg b "$BRANCH" \
'{query:"mutation($p:String!, $n:String, $r:String!, $b:String) { serviceCreate(input: { projectId: $p, name: $n, branch: $b, source: { repo: $r } }) { id } }", variables:{p:$p,n:$n,r:$r,b:$b}}' | gql)
echo "$RESP"
SERVICE_ID=$(echo "$RESP" | jq -r '.data.serviceCreate.id // empty')
fi
[ -n "$SERVICE_ID" ] || { echo "Could not find or create service. If the error above mentions GitHub, install the Railway GitHub app: https://railway.com/account/integrations"; exit 1; }
echo "service_id=$SERVICE_ID" >> "$GITHUB_OUTPUT"
echo "Service: $SERVICE_ID"
- name: Ensure a persistent volume at /data (stats + memory + pow survive redeploys)
run: |
gql() {
curl -s -X POST https://backboard.railway.app/graphql/v2 \
-H "Authorization: Bearer $RAILWAY_API_TOKEN" -H "Content-Type: application/json" \
--data @-
}
PROJECT_ID='${{ steps.project.outputs.project_id }}'
ENV_ID='${{ steps.project.outputs.env_id }}'
SERVICE_ID='${{ steps.service.outputs.service_id }}'
# Without a volume, the SQLite files (stats/memory/pow) live on the
# ephemeral container fs and reset on every redeploy — wiping the
# odometer AND agents' paid memory. Create one mounted at /data once.
EXISTING=$(jq -n --arg p "$PROJECT_ID" \
'{query:"query($p:String!){project(id:$p){volumes{edges{node{id volumeInstances{edges{node{mountPath serviceId}}}}}}}}",variables:{p:$p}}' | gql)
HAS_DATA=$(echo "$EXISTING" | jq -r --arg s "$SERVICE_ID" \
'[.data.project.volumes.edges[]?.node.volumeInstances.edges[]?.node | select(.serviceId==$s and .mountPath=="/data")] | length' 2>/dev/null || echo 0)
if [ "$HAS_DATA" = "0" ]; then
echo "No /data volume on the service — creating one…"
RESP=$(jq -n --arg p "$PROJECT_ID" --arg e "$ENV_ID" --arg s "$SERVICE_ID" \
'{query:"mutation($p:String!,$e:String!,$s:String!){volumeCreate(input:{projectId:$p,environmentId:$e,serviceId:$s,mountPath:\"/data\"}){id}}",variables:{p:$p,e:$e,s:$s}}' | gql)
echo "$RESP" | jq -e '.data.volumeCreate.id' >/dev/null \
&& echo "Created persistent volume at /data ✓ (redeploys now keep stats + memory)" \
|| { echo "volumeCreate response:"; echo "$RESP"; echo "(if it errors that a volume already exists at this path, that's fine)"; }
else
echo "Persistent /data volume already attached ✓"
fi
- name: Create public domain
id: domain
run: |
gql() {
curl -s -X POST https://backboard.railway.app/graphql/v2 \
-H "Authorization: Bearer $RAILWAY_API_TOKEN" -H "Content-Type: application/json" \
--data @-
}
ENV_ID='${{ steps.project.outputs.env_id }}'
SERVICE_ID='${{ steps.service.outputs.service_id }}'
queryDomain() {
jq -n --arg e "$ENV_ID" --arg s "$SERVICE_ID" \
'{query:"query($e:String!, $s:String!) { domains(environmentId: $e, serviceId: $s) { serviceDomains { domain } } }", variables:{e:$e,s:$s}}' | gql \
| jq -r '.data.domains.serviceDomains[0].domain // empty'
}
DOMAIN=$(queryDomain)
if [ -z "$DOMAIN" ]; then
RESP=$(jq -n --arg e "$ENV_ID" --arg s "$SERVICE_ID" \
'{query:"mutation($e:String!, $s:String!) { serviceDomainCreate(input: { environmentId: $e, serviceId: $s }) { domain } }", variables:{e:$e,s:$s}}' | gql)
echo "$RESP"
DOMAIN=$(echo "$RESP" | jq -r '.data.serviceDomainCreate.domain // empty')
# If creation failed (e.g. plan domain-limit reached because one
# already exists), re-query and reuse it rather than failing.
[ -z "$DOMAIN" ] && DOMAIN=$(queryDomain)
fi
# The public Railway domain is only used to poll /health below; the
# real address is the custom domain. Fall back to it so a domain quirk
# never blocks the actual deploy (env vars + trigger come after).
[ -z "$DOMAIN" ] && DOMAIN="${CUSTOM_DOMAIN}"
[ -n "$DOMAIN" ] || { echo "No domain available to verify against"; exit 1; }
echo "domain=$DOMAIN" >> "$GITHUB_OUTPUT"
echo "PUBLIC URL: https://$DOMAIN"
- name: Attach custom domain
if: env.CUSTOM_DOMAIN != ''
run: |
gql() {
curl -s -X POST https://backboard.railway.app/graphql/v2 \
-H "Authorization: Bearer $RAILWAY_API_TOKEN" -H "Content-Type: application/json" \
--data @-
}
ENV_ID='${{ steps.project.outputs.env_id }}'
SERVICE_ID='${{ steps.service.outputs.service_id }}'
EXISTING=$(jq -n --arg e "$ENV_ID" --arg s "$SERVICE_ID" \
'{query:"query($e:String!, $s:String!) { domains(environmentId: $e, serviceId: $s) { customDomains { domain } } }", variables:{e:$e,s:$s}}' | gql \
| jq -r '.data.domains.customDomains[]?.domain')
echo "existing custom domains: ${EXISTING:-none}"
if ! echo "$EXISTING" | grep -qx "$CUSTOM_DOMAIN"; then
RESP=$(jq -n --arg p '${{ steps.project.outputs.project_id }}' --arg e "$ENV_ID" --arg s "$SERVICE_ID" --arg d "$CUSTOM_DOMAIN" \
'{query:"mutation($p:String!, $e:String!, $s:String!, $d:String!) { customDomainCreate(input: { projectId: $p, environmentId: $e, serviceId: $s, domain: $d }) { id domain status { dnsRecords { hostlabel requiredValue recordType currentValue status } } } }", variables:{p:$p,e:$e,s:$s,d:$d}}' | gql)
echo "$RESP" | jq .
fi
# Hold the deploy until external paid traffic has a lull, so a container
# swap never lands in the middle of a buyer's burst. Fail-open by design
# (stats down / sustained traffic past the max wait → proceed with a
# warning) — the in-server SIGTERM drain plus the 90s Railway grace set
# below still protect whatever is in flight. Runs BEFORE the variable
# upsert because that upsert can itself trigger a redeploy.
- name: Quiet gate — wait for a lull in external paid traffic
env:
TARGET_URL: https://${{ env.CUSTOM_DOMAIN || steps.domain.outputs.domain }}
QUIET_GATE: ${{ vars.QUIET_GATE }}
MAX_WAIT_SECS: ${{ vars.QUIET_GATE_MAX_WAIT || '1200' }}
run: node scripts/deploy-quiet-gate.js
- name: Set environment variables (triggers deploy)
run: |
gql() {
curl -s -X POST https://backboard.railway.app/graphql/v2 \
-H "Authorization: Bearer $RAILWAY_API_TOKEN" -H "Content-Type: application/json" \
--data @-
}
BASE="https://${CUSTOM_DOMAIN:-${{ steps.domain.outputs.domain }}}"
# RAILWAY_DEPLOYMENT_DRAINING_SECONDS: Railway's default grace between
# SIGTERM and SIGKILL is 0 seconds — without this, the server's
# graceful drain never runs and every deploy hard-kills in-flight
# (already paid-for) requests. 90s covers the slowest single call
# (transcribe's 60s OpenAI timeout) plus the server's 75s deadline.
VARS=$(jq -n --arg w "$WALLET_ADDRESS" --arg n "$NETWORK" --arg f "$FACILITATOR_URL" --arg b "$BASE" \
'{WALLET_ADDRESS:$w, NETWORK:$n, FACILITATOR_URL:$f, BASE_URL:$b, RAILWAY_DEPLOYMENT_DRAINING_SECONDS:"90"}')
if [ -n "$CDP_KEY_ID" ] && [ -n "$CDP_KEY_SECRET" ]; then
VARS=$(echo "$VARS" | jq --arg i "$CDP_KEY_ID" --arg s "$CDP_KEY_SECRET" '. + {CDP_API_KEY_ID:$i, CDP_API_KEY_SECRET:$s}')
fi
if [ -n "$BRAVE_API_KEY" ]; then
VARS=$(echo "$VARS" | jq --arg b "$BRAVE_API_KEY" '. + {BRAVE_API_KEY:$b}')
fi
if [ -n "$FRED_API_KEY" ]; then
VARS=$(echo "$VARS" | jq --arg f "$FRED_API_KEY" '. + {FRED_API_KEY:$f}')
fi
if [ -n "$FRED_API_KEY_V2" ]; then
VARS=$(echo "$VARS" | jq --arg f "$FRED_API_KEY_V2" '. + {FRED_API_KEY_V2:$f}')
fi
# Yahoo relay: only inject when BOTH are set — partial config would
# silently fall through to direct Yahoo and re-trigger the canary.
if [ -n "$YAHOO_RELAY_URL" ] && [ -n "$YAHOO_RELAY_TOKEN" ]; then
VARS=$(echo "$VARS" | jq --arg u "$YAHOO_RELAY_URL" --arg t "$YAHOO_RELAY_TOKEN" '. + {YAHOO_RELAY_URL:$u, YAHOO_RELAY_TOKEN:$t}')
fi
if [ -n "$OPENROUTER_TTS_ENABLED" ]; then
VARS=$(echo "$VARS" | jq --arg v "$OPENROUTER_TTS_ENABLED" '. + {OPENROUTER_TTS_ENABLED:$v}')
fi
RESP=$(jq -n --arg p '${{ steps.project.outputs.project_id }}' --arg e '${{ steps.project.outputs.env_id }}' --arg s '${{ steps.service.outputs.service_id }}' --argjson v "$VARS" \
'{query:"mutation($p:String!, $e:String!, $s:String!, $v:EnvironmentVariables!) { variableCollectionUpsert(input: { projectId: $p, environmentId: $e, serviceId: $s, variables: $v }) }", variables:{p:$p,e:$e,s:$s,v:$v}}' | gql)
echo "$RESP"
echo "$RESP" | jq -e '.data.variableCollectionUpsert' >/dev/null || { echo "Variable upsert failed"; exit 1; }
- name: Trigger deploy of latest commit
id: trigger
run: |
gql() {
curl -s -X POST https://backboard.railway.app/graphql/v2 \
-H "Authorization: Bearer $RAILWAY_API_TOKEN" -H "Content-Type: application/json" \
--data @-
}
RESP=$(jq -n --arg e '${{ steps.project.outputs.env_id }}' --arg s '${{ steps.service.outputs.service_id }}' --arg c "$GITHUB_SHA" \
'{query:"mutation($e:String!, $s:String!, $c:String) { serviceInstanceDeployV2(environmentId: $e, serviceId: $s, commitSha: $c) }", variables:{e:$e,s:$s,c:$c}}' | gql)
echo "$RESP"
# A swallowed trigger error is a FALSE-GREEN DEPLOY: the job passes,
# prod keeps serving the old build, and nobody notices until a human
# looks at the site (2026-07-03 incident). Fail loudly instead.
if echo "$RESP" | jq -e '.errors' >/dev/null; then
echo "::error::Railway rejected the deploy trigger — prod was NOT updated. With repo auto-deploy disabled, this job is the only deploy path, so this is a hard failure."
exit 1
fi
DEPLOY_ID=$(echo "$RESP" | jq -r '.data.serviceInstanceDeployV2 // empty')
echo "deploy_id=$DEPLOY_ID" >> "$GITHUB_OUTPUT"
echo "triggered deployment: ${DEPLOY_ID:-'(no id returned — status poll will use the latest deployment)'}"
- name: Wait for the NEW deployment to reach SUCCESS
run: |
gql() {
curl -s -X POST https://backboard.railway.app/graphql/v2 \
-H "Authorization: Bearer $RAILWAY_API_TOKEN" -H "Content-Type: application/json" \
--data @-
}
DEPLOY_ID='${{ steps.trigger.outputs.deploy_id }}'
# Fall back to the newest deployment for this service+env when the
# mutation returned no id (API shape drift) — it is the one we just
# triggered.
if [ -z "$DEPLOY_ID" ]; then
DEPLOY_ID=$(jq -n --arg e '${{ steps.project.outputs.env_id }}' --arg s '${{ steps.service.outputs.service_id }}' \
'{query:"query($e:String!,$s:String!){ deployments(input:{environmentId:$e, serviceId:$s}, first:1){ edges { node { id status } } } }", variables:{e:$e,s:$s}}' \
| gql | jq -r '.data.deployments.edges[0].node.id // empty')
echo "using latest deployment: $DEPLOY_ID"
[ -n "$DEPLOY_ID" ] || { echo "::error::could not resolve a deployment id to watch"; exit 1; }
fi
# Poll until the build+deploy actually completes. The old 'Verify'
# step probed /health seconds after the trigger — the OLD build
# answers 200 and the job goes green without prod ever changing.
for i in $(seq 1 90); do
STATUS=$(jq -n --arg id "$DEPLOY_ID" '{query:"query($id:String!){ deployment(id:$id){ status } }", variables:{id:$id}}' \
| gql | jq -r '.data.deployment.status // "UNKNOWN"')
echo "[$i] deployment $DEPLOY_ID -> $STATUS"
case "$STATUS" in
SUCCESS) echo "new build is LIVE"; exit 0 ;;
FAILED|CRASHED|REMOVED|SKIPPED) echo "::error::deployment ended $STATUS — prod is still on the previous build (check Railway build logs)"; exit 1 ;;
esac
sleep 10
done
echo "::error::deployment did not reach SUCCESS within 15 minutes"; exit 1
- name: Verify deployment
run: |
URL='${{ steps.domain.outputs.domain }}'
# The Railway hostname now 301-redirects to the canonical domain
# (see "Redirect Railway hostname to canonical domain"), so probing it
# returns 301, never 200, and the gate below would false-fail every
# deploy. Verify against the canonical domain — the real public surface
# that doesn't redirect — when configured; fall back to the Railway URL.
[ -n "$CUSTOM_DOMAIN" ] && URL="$CUSTOM_DOMAIN"
echo "Waiting for https://$URL/health …"
code=000
for i in $(seq 1 60); do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "https://$URL/health" || echo 000)
[ "$code" = "200" ] && break
sleep 10
done
echo "health: $code"
[ "$code" = "200" ] || { echo "Service did not become healthy in 10 minutes"; exit 1; }
echo "=== pricing ==="
curl -s --max-time 15 "https://$URL/api/pricing"
echo; echo "=== 402 check (paywall engaged) ==="
# Retry the paywall probe — a single blip (cold container warming up, a
# transient network hiccup) must NOT mark a healthy deploy as failed.
# A red deploy that isn't real is monitoring noise; only a paywall that
# stays un-engaged across several attempts is a genuine regression.
paid=000
for i in 1 2 3 4 5; do
paid=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 -X POST "https://$URL/api/extract" -H 'Content-Type: application/json' -H 'Accept: application/json' -d '{"url":"https://example.com"}' || echo 000)
[ "$paid" = "402" ] && break
echo "attempt $i: HTTP $paid (expect 402) — retrying in 6s"
sleep 6
done
echo "unpaid request returned HTTP $paid (expect 402)"
[ "$paid" = "402" ] || { echo "paywall not engaged after 5 attempts — real regression"; exit 1; }
echo
echo "🚀 LIVE: https://$URL"
if [ -n "$CUSTOM_DOMAIN" ]; then
echo "=== custom domain check (informational — DNS may still be propagating) ==="
for i in $(seq 1 12); do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "https://$CUSTOM_DOMAIN/health" || echo 000)
[ "$code" = "200" ] && break
sleep 10
done
echo "https://$CUSTOM_DOMAIN/health → HTTP $code"
[ "$code" = "200" ] && echo "🌐 CUSTOM DOMAIN LIVE: https://$CUSTOM_DOMAIN" || echo "(not resolving yet — Railway-managed DNS usually completes within minutes)"
fi
- name: Ping IndexNow (instant Bing/Copilot/DDG re-indexing — best-effort, never fails the deploy)
env:
INDEXNOW_KEY: ${{ secrets.INDEXNOW_KEY }}
run: |
if [ -z "$INDEXNOW_KEY" ]; then echo "INDEXNOW_KEY secret not set — skipping"; exit 0; fi
node scripts/indexnow-submit.js || echo "IndexNow ping failed (non-fatal — the sitemap remains the source of truth)"