|
| 1 | +name: live-collection |
| 2 | + |
| 3 | +# THE ONLY WORKFLOW THAT SPENDS MONEY, AND THE ONLY ONE THAT TOUCHES A VENDOR. |
| 4 | +# |
| 5 | +# `offline-suite` deliberately cannot reach the network, which is right: a reviewer or a |
| 6 | +# fork must be able to re-run every check without being given a key. But that means the |
| 7 | +# real network boundary is represented entirely by stubs, and the suite stays green while |
| 8 | +# real collection fails on a changed payload, a proxy, a platform TLS quirk, a 429, or a |
| 9 | +# partial response. |
| 10 | +# |
| 11 | +# This exists to exercise that boundary on purpose, with the cost visible and bounded. |
| 12 | +# |
| 13 | +# It is: |
| 14 | +# manual only no push, no pull_request, no schedule - nothing runs it by |
| 15 | +# accident, and a fork cannot trigger it |
| 16 | +# budgeted `max_pages` is an input with a small default, and the run |
| 17 | +# reports the credits it actually spent |
| 18 | +# credential-scoped one secret, read from the environment, never written to disk |
| 19 | +# corpus-isolated it collects into a scratch project under RUNNER_TEMP and |
| 20 | +# commits nothing. The repository's own corpus is evidence for |
| 21 | +# its own unknowns, not a place to put test captures |
| 22 | +# redacted the key never reaches a log line; the adapter refuses a query |
| 23 | +# containing it before transmit, and `command()` renders |
| 24 | +# pre-redacted |
| 25 | +# |
| 26 | +# WHEN TO RUN IT |
| 27 | +# - before trusting a Firecrawl CLI version this adapter has not seen |
| 28 | +# - after changing anything under lib/firecrawl.mjs, lib/http-transport.mjs, |
| 29 | +# lib/serpapi.mjs or lib/collect.mjs |
| 30 | +# - when a real collection fails in a way the offline suite cannot reproduce |
| 31 | +# |
| 32 | +# WHAT A FAILURE HERE MEANS |
| 33 | +# The offline suite passing and this failing is the interesting case, and the expected |
| 34 | +# one: it means the stubs and the vendor have diverged. Read the version report first. |
| 35 | + |
| 36 | +on: |
| 37 | + workflow_dispatch: |
| 38 | + inputs: |
| 39 | + max_pages: |
| 40 | + description: 'Pages to collect (each costs at least one credit)' |
| 41 | + required: true |
| 42 | + default: '2' |
| 43 | + transport: |
| 44 | + description: 'Which fetch route to exercise' |
| 45 | + required: true |
| 46 | + default: 'firecrawl-cli' |
| 47 | + type: choice |
| 48 | + options: [firecrawl-cli, http-keyless] |
| 49 | + |
| 50 | +permissions: |
| 51 | + contents: read |
| 52 | + |
| 53 | +jobs: |
| 54 | + live: |
| 55 | + name: live |
| 56 | + runs-on: ubuntu-latest |
| 57 | + timeout-minutes: 15 |
| 58 | + # A budget nobody can raise by editing an input: the job itself refuses a large run. |
| 59 | + environment: live-collection |
| 60 | + defaults: |
| 61 | + run: |
| 62 | + shell: bash |
| 63 | + steps: |
| 64 | + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4, resolved 2026-09-21 |
| 65 | + |
| 66 | + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4, resolved 2026-09-21 |
| 67 | + with: |
| 68 | + node-version: '22' |
| 69 | + |
| 70 | + - name: refuse an unbounded run |
| 71 | + run: | |
| 72 | + pages='${{ inputs.max_pages }}' |
| 73 | + case "$pages" in (''|*[!0-9]*) echo "max_pages must be a whole number"; exit 1;; esac |
| 74 | + if [ "$pages" -lt 1 ] || [ "$pages" -gt 10 ]; then |
| 75 | + echo "max_pages=$pages is outside 1..10." |
| 76 | + echo "This workflow spends real credits; a large run belongs on a machine where" |
| 77 | + echo "somebody is watching the meter, not in CI." |
| 78 | + exit 1 |
| 79 | + fi |
| 80 | + echo "budget: $pages page(s)" |
| 81 | +
|
| 82 | + - name: install the vendor CLI, and report the version this run used |
| 83 | + if: inputs.transport == 'firecrawl-cli' |
| 84 | + run: | |
| 85 | + npm install -g @mendable/firecrawl-cli >/dev/null 2>&1 || npm install -g firecrawl >/dev/null 2>&1 || true |
| 86 | + installed=$(firecrawl --version 2>/dev/null || echo 'not installed') |
| 87 | + echo "firecrawl CLI: $installed" |
| 88 | + # The version is the first thing to read when this workflow fails, so it goes in |
| 89 | + # the summary rather than only in the log. |
| 90 | + echo "| firecrawl CLI | \`$installed\` |" >> "$GITHUB_STEP_SUMMARY" |
| 91 | + node -e ' |
| 92 | + import("./research-kit/lib/firecrawl.mjs").then((m) => { |
| 93 | + const c = m.cliCompatibility(); |
| 94 | + console.log(`${c.level}: ${c.detail}`); |
| 95 | + if (!c.supported) { console.error(c.remedy); process.exit(1); } |
| 96 | + }); |
| 97 | + ' |
| 98 | +
|
| 99 | + - name: the key is present, and is never printed |
| 100 | + env: |
| 101 | + FIRECRAWL_API_KEY: ${{ secrets.FIRECRAWL_API_KEY }} |
| 102 | + run: | |
| 103 | + if [ -z "${FIRECRAWL_API_KEY:-}" ] && [ '${{ inputs.transport }}' = 'firecrawl-cli' ]; then |
| 104 | + echo "FIRECRAWL_API_KEY is not set for this environment." |
| 105 | + echo "Add it under Settings > Environments > live-collection, or run with transport=http-keyless." |
| 106 | + exit 1 |
| 107 | + fi |
| 108 | + echo "credential present: ${FIRECRAWL_API_KEY:+yes}${FIRECRAWL_API_KEY:-no (keyless route)}" |
| 109 | +
|
| 110 | + - name: collect into a scratch project, never the repository's corpus |
| 111 | + env: |
| 112 | + FIRECRAWL_API_KEY: ${{ secrets.FIRECRAWL_API_KEY }} |
| 113 | + RESEARCH_KIT_TRANSPORT: ${{ inputs.transport }} |
| 114 | + run: | |
| 115 | + set -e |
| 116 | + kit="$PWD/research-kit" |
| 117 | + scratch="$RUNNER_TEMP/live-project" |
| 118 | + mkdir -p "$scratch" |
| 119 | + cd "$scratch" |
| 120 | +
|
| 121 | + # A scratch project, so nothing this run collects can reach the repository's |
| 122 | + # hash-chained ledger. That ledger proves the corpus answers ITS OWN questions; |
| 123 | + # a CI capture in it would be evidence of nothing. |
| 124 | + node "$kit/bin/new-project.mjs" . --topic "live transport check" |
| 125 | +
|
| 126 | + node "$kit/bin/doctor.mjs" || true |
| 127 | + node "$kit/bin/research.mjs" --dry-run --depth quick |
| 128 | + node "$kit/bin/research.mjs" --depth quick --max-scrapes '${{ inputs.max_pages }}' |
| 129 | +
|
| 130 | + echo "--- what it cost ---" |
| 131 | + node "$kit/bin/research.mjs" --status || true |
| 132 | +
|
| 133 | + - name: the corpus that arrived verifies |
| 134 | + env: |
| 135 | + RESEARCH_KIT_TRANSPORT: ${{ inputs.transport }} |
| 136 | + run: | |
| 137 | + cd "$RUNNER_TEMP/live-project" |
| 138 | + # The point of collecting at all: a real capture must pass the same checks a |
| 139 | + # synthetic one does - chain verifies, every cited capture on disk, bodies |
| 140 | + # unmodified. |
| 141 | + node "$GITHUB_WORKSPACE/research-kit/bin/handoff.mjs" || true |
| 142 | + node "$GITHUB_WORKSPACE/research-kit/bin/preflight.mjs" --json > preflight.json || true |
| 143 | + node -e ' |
| 144 | + const r = JSON.parse(require("fs").readFileSync("preflight.json", "utf8")); |
| 145 | + console.log(`preflight: ${r.status ?? r.verdict}`); |
| 146 | + const chain = (r.checks ?? []).find((c) => /chain|ledger/.test(c.name ?? "")); |
| 147 | + if (chain) console.log(` ${chain.name}: ${chain.status}`); |
| 148 | + ' |
| 149 | +
|
| 150 | + - name: the repository is unchanged |
| 151 | + run: | |
| 152 | + # The guarantee this workflow rests on. If a live run ever wrote into the |
| 153 | + # checkout, every later claim about corpus provenance would be suspect. |
| 154 | + if [ -n "$(git status --porcelain)" ]; then |
| 155 | + echo "a live collection modified the repository:" |
| 156 | + git status --porcelain |
| 157 | + exit 1 |
| 158 | + fi |
| 159 | + echo "repository untouched" |
| 160 | +
|
| 161 | + - name: sanitized diagnostics |
| 162 | + if: always() |
| 163 | + run: | |
| 164 | + cd "$RUNNER_TEMP/live-project" || exit 0 |
| 165 | + # Deliberately NOT the captures: they are someone else's pages, and this is a |
| 166 | + # public log. Only the shape of what happened. |
| 167 | + node -e ' |
| 168 | + const fs = require("fs"); |
| 169 | + const led = "research/raw/.fetches.jsonl"; |
| 170 | + if (!fs.existsSync(led)) { console.log("no ledger written"); process.exit(0); } |
| 171 | + const rows = fs.readFileSync(led, "utf8").trim().split("\n").filter(Boolean).map(JSON.parse); |
| 172 | + for (const r of rows) { |
| 173 | + console.log([r.op, r.statusCode ?? "-", r.transport ?? "-", r.completeness ?? "-", (r.url ?? "").slice(0, 60)].join(" ")); |
| 174 | + } |
| 175 | + ' >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true |
0 commit comments