Merge origin/main into claude/sweet-brown-i99jl3 — reconciled announc… #923
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: 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)" |