diff --git a/.github/workflows/radar-export.yml b/.github/workflows/radar-export.yml new file mode 100644 index 0000000000..043de88d3d --- /dev/null +++ b/.github/workflows/radar-export.yml @@ -0,0 +1,64 @@ +# Publica o export estável do catálogo consumido pelo OmniRoute Radar numa URL +# fixa (asset de release `radar-export-latest`), para o servidor privado do Radar +# (1 GB RAM, nunca clona/builda o OmniRoute) baixá-lo via `RADAR_EXPORT_URL` em +# vez de depender do snapshot gravado no deploy. Fonte: scripts/release/radar-export.mjs. +# +# A URL estável resultante (definir em RADAR_EXPORT_URL no .env do radar-server): +# https://github.com/diegosouzapw/OmniRoute/releases/download/radar-export-latest/export-omniroute.json +name: Radar Export + +on: + workflow_dispatch: # o operador pode publicar sob demanda (de qualquer ref) + push: + branches: [main] # produção: só o catálogo do main clobra o asset estável + paths: + - open-sse/config/freeModelCatalog.data.ts + - open-sse/config/freeModelCatalog.ts + - open-sse/config/providerRegistry.ts + - open-sse/config/providers/** + - scripts/release/radar-export.mjs + - .github/workflows/radar-export.yml + schedule: + - cron: "17 6 * * 1" # semanal (segunda 06:17 UTC): mantém geradoEm/proveniência frescos + +permissions: + contents: read + +concurrency: + group: radar-export-${{ github.ref }} + cancel-in-progress: true + +env: + CI_NODE_VERSION: "24" + +jobs: + publish-export: + runs-on: ubuntu-latest + permissions: + contents: write # gh release upload — clobra o asset estável do export + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false # publish usa GH_TOKEN via gh release, não a credencial do checkout + - uses: actions/setup-node@v7 + with: + node-version: ${{ env.CI_NODE_VERSION }} + cache: npm + - run: npm ci + - name: Generate catalog export with provenance + run: node --import tsx/esm scripts/release/radar-export.mjs "$RUNNER_TEMP/export-omniroute.json" + - name: Publish to the stable release asset + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + TAG="radar-export-latest" + # Cria o release estável na primeira vez; nas seguintes só re-anexa o asset. + if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release create "$TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --title "Radar catalog export (rolling)" \ + --notes "Export estável do catálogo OmniRoute para o Radar. Atualizado automaticamente; NÃO é um release de versão do produto." \ + --latest=false + fi + gh release upload "$TAG" "$RUNNER_TEMP/export-omniroute.json" --repo "$GITHUB_REPOSITORY" --clobber diff --git a/config/quality/quality-baseline.json b/config/quality/quality-baseline.json index c8cbd07d94..eae9067b4b 100644 --- a/config/quality/quality-baseline.json +++ b/config/quality/quality-baseline.json @@ -166,7 +166,8 @@ "dedicatedGate": true }, "zizmorFindings": { - "value": 190, + "value": 192, + "_rebaseline_2026_08_20_radar_export_workflow": "190 -> 192 (+2). Workflow novo `.github/workflows/radar-export.yml` (passo 10 do go-live do Radar: publica o export estável do catálogo como asset de release para o servidor privado baixar via RADAR_EXPORT_URL). Os +2 são unpinned-uses @vN: actions/checkout@v7 + actions/setup-node@v7 — a MESMA convenção deliberada de todos os workflows (ver _scanner_harden_workflows_2026_06_16); fixar por SHA só este violaria a convenção. O findings artipacked do checkout foi CORRIGIDO com `persist-credentials: false` (o job publica via GH_TOKEN em `gh release`, não usa a credencial do checkout). Nenhuma classe nova de template-injection / cache-poisoning / dangerous-triggers. Medido local com zizmor 1.25.2 via `node scripts/check/check-workflows.mjs --ratchet` = 191; +1 do delta conhecido do runner (ver _rebaseline_2026_07_28_ci_runner_delta: o runner enxerga 1 unpinned-uses @vN a mais que o devbox no mesmo commit; a baseline segue o runner) => 192.", "_rebaseline_2026_07_20_aliasresolver_hook_split_7808": "175 -> 176 (+1). Companion to PR #7808 (CodeQL js/incomplete-url-substring-sanitization fix in bin/aliasResolver.mjs). The +1 is NOT caused by this PR's code changes (bin/* is not a workflow file) — it is a pre-existing drift that surfaced because the ratchet gate runs on this PR's CI: the zizmor scanner version on the GitHub runner gained a new rule (or extended an existing one) since the v3.8.49 baseline was seeded on 2026-07-17. Breakdown: the new finding is an unpinned-uses @vN class item on one of the existing workflows (same deliberate convention as _scanner_harden_workflows_2026_06_16 — @vN is intentional, SHA-pinning only this one would violate the convention). No new template-injection/artipacked/cache-poisoning/dangerous-triggers classes introduced. Measured by the Quality Gates (Extended) job on run 29713001401 = 176, baseline was 175. Note: by the time this landed on release/v3.8.49, the baseline was already at 176 via _rebaseline_2026_07_17_combo_recovery_hints — this entry is kept as historical record; no further bump applied.", "_rebaseline_2026_07_17_v3849_release": "169 -> 175 (+6). Cycle workflow drift (v3.8.48/v3.8.49): npm-publish.yml (new, WS1.3 #7092), electron-release.yml, nightly-compat.yml, nightly-release-green.yml, CI restructures (#7501 full-history base fetch, #7355 main-green, #7202 merge-queue gates, Trunk/Codecov). Breakdown vs v3.8.47: +3 unpinned-uses (@vN convention, deliberate per _scanner_harden_workflows_2026_06_16), +2 cache-poisoning (artifact upload/cache in the OWN electron-release/npm-publish RELEASE workflows -- operator-controlled, not fork-PR exploitable), +1 excessive-permissions (nightly-compat.yml permissions:issues). No new template-injection/artipacked/dangerous-triggers. Measured with zizmor 1.25.2 via `node scripts/check/check-workflows.mjs --ratchet` = 175 on da3a0be69.", "direction": "down", diff --git a/scripts/release/radar-export.mjs b/scripts/release/radar-export.mjs new file mode 100644 index 0000000000..4f745aa760 --- /dev/null +++ b/scripts/release/radar-export.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node +// Gera o export estável do catálogo OmniRoute consumido pelo OmniRoute Radar +// (`RADAR_EXPORT_URL` → `${DATA_DIR}/export-omniroute.json` no servidor privado). +// +// Por que existe: o servidor Radar (1 GB RAM na Akamai) NUNCA clona nem instala +// o OmniRoute; ele só baixa este JSON de uma URL estável. Antes o export vinha +// do snapshot gravado no deploy, preso à máquina do operador. Este script roda +// no CI do OmniRoute (que tem os módulos de catálogo + tsx), emite o export com +// PROVENIÊNCIA e o workflow o publica como asset de release de URL fixa. +// +// Contrato do consumidor (`src/feed/exportSource.ts` no radar-server): exige +// `budgets[]` não-vazio e lê `geradoEm`; chaves extras são ignoradas, então +// `totais`, `registry` e `provenance` viajam junto sem quebrar retrocompat. +// +// Uso (precisa de tsx, pois lê .ts): +// node --import tsx/esm scripts/release/radar-export.mjs [saída.json] + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const DIR = path.dirname(fileURLToPath(import.meta.url)); +const REPO = path.resolve(DIR, "../.."); // …/OmniRoute +const SAIDA = process.argv[2] || path.join(REPO, "export-omniroute.json"); + +const { FREE_MODEL_BUDGETS } = await import( + path.join(REPO, "open-sse/config/freeModelCatalog.data.ts") +); +const { computeFreeModelTotals } = await import( + path.join(REPO, "open-sse/config/freeModelCatalog.ts") +); +const { REGISTRY } = await import(path.join(REPO, "open-sse/config/providerRegistry.ts")); + +/** + * Proveniência: quem/quando/de-qual-commit gerou o export. Cada campo é `null` + * quando a origem é desconhecida — NUNCA inventamos um valor (D16: desconhecido + * permanece `null`). No CI o GitHub popula as variáveis; localmente caímos no + * `git` e, sem repositório, em `null`. + */ +function firstEnv(...names) { + for (const name of names) { + const value = process.env[name]?.trim(); + if (value) return value; + } + return null; +} + +function gitHead() { + try { + return execFileSync("git", ["-C", REPO, "rev-parse", "HEAD"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim() || null; + } catch { + return null; + } +} + +function buildProvenance(geradoEm) { + const sourceCommit = firstEnv("GITHUB_SHA") ?? gitHead(); + const sourceRef = firstEnv("GITHUB_REF_NAME", "GITHUB_REF"); + const server = firstEnv("GITHUB_SERVER_URL"); + const repository = firstEnv("GITHUB_REPOSITORY"); + const runId = firstEnv("GITHUB_RUN_ID"); + const runUrl = server && repository && runId ? `${server}/${repository}/actions/runs/${runId}` : null; + return { + generatedAt: geradoEm, + generator: "scripts/release/radar-export.mjs", + generatedBy: firstEnv("GITHUB_ACTIONS") ? "github-actions" : "manual", + sourceCommit, + sourceRef, + runUrl, + }; +} + +const geradoEm = new Date().toISOString(); +const dados = { + geradoEm, + budgets: FREE_MODEL_BUDGETS, + totais: computeFreeModelTotals(), + // Só as chaves: o consumidor apenas pergunta "sabemos rotear este provider?". + registry: Object.keys(REGISTRY).sort(), + provenance: buildProvenance(geradoEm), +}; + +fs.mkdirSync(path.dirname(path.resolve(SAIDA)), { recursive: true }); +fs.writeFileSync(SAIDA, JSON.stringify(dados)); +console.log( + `catálogo exportado → ${path.basename(SAIDA)}: ` + + `${dados.budgets.length} modelos, ${dados.registry.length} providers no registry` + + ` (commit ${dados.provenance.sourceCommit ?? "desconhecido"})` +); diff --git a/tests/unit/radar-export.test.mjs b/tests/unit/radar-export.test.mjs new file mode 100644 index 0000000000..55e1d424aa --- /dev/null +++ b/tests/unit/radar-export.test.mjs @@ -0,0 +1,85 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +// Gera o export estável do catálogo (scripts/release/radar-export.mjs) e valida +// o contrato consumido pelo OmniRoute Radar + a proveniência (D16: desconhecido +// permanece `null`, nunca inventado). + +const DIR = path.dirname(fileURLToPath(import.meta.url)); +const REPO = path.resolve(DIR, "../.."); // …/OmniRoute +const SCRIPT = path.join(REPO, "scripts/release/radar-export.mjs"); + +function runExport(extraEnv = {}) { + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "radar-export-")); + const outPath = path.join(outDir, "export-omniroute.json"); + execFileSync("node", ["--import", "tsx/esm", SCRIPT, outPath], { + cwd: REPO, + stdio: ["ignore", "ignore", "inherit"], + // Base limpa: sem herdar GITHUB_* do ambiente do CI que roda os testes. + env: { + PATH: process.env.PATH, + HOME: process.env.HOME, + GITHUB_SHA: undefined, + GITHUB_REF_NAME: undefined, + GITHUB_REF: undefined, + GITHUB_ACTIONS: undefined, + GITHUB_SERVER_URL: undefined, + GITHUB_REPOSITORY: undefined, + GITHUB_RUN_ID: undefined, + ...extraEnv, + }, + }); + const parsed = JSON.parse(fs.readFileSync(outPath, "utf8")); + fs.rmSync(outDir, { recursive: true, force: true }); + return parsed; +} + +test("radar export satisfies the Radar consumer contract with a fresh catalog", () => { + const data = runExport(); + // Contrato mínimo de src/feed/exportSource.ts: budgets[] não-vazio + geradoEm. + assert.ok(Array.isArray(data.budgets) && data.budgets.length > 0, "budgets não-vazio"); + assert.ok(Array.isArray(data.registry) && data.registry.length > 0, "registry não-vazio"); + assert.ok( + typeof data.geradoEm === "string" && !Number.isNaN(Date.parse(data.geradoEm)), + "geradoEm ISO válido" + ); + assert.ok(data.totais && typeof data.totais === "object", "totais presente"); + // registry ordenado e sem duplicatas (chaves de provider). + assert.deepEqual(data.registry, [...data.registry].sort()); +}); + +test("radar export provenance never fabricates unknown fields", () => { + const data = runExport(); + const p = data.provenance; + assert.ok(p && typeof p === "object", "provenance presente"); + assert.equal(p.generatedAt, data.geradoEm); + assert.equal(p.generator, "scripts/release/radar-export.mjs"); + // Fora de um runner do GitHub Actions: manual, e ref/runUrl desconhecidos = null. + assert.equal(p.generatedBy, "manual"); + assert.equal(p.sourceRef, null); + assert.equal(p.runUrl, null); + // sourceCommit: SHA de 40 hex (via git no checkout) ou null se indisponível. + assert.ok(p.sourceCommit === null || /^[0-9a-f]{40}$/.test(p.sourceCommit), "sourceCommit sha|null"); +}); + +test("radar export provenance reflects the GitHub Actions environment when present", () => { + const sha = "0123456789abcdef0123456789abcdef01234567"; + const data = runExport({ + GITHUB_ACTIONS: "true", + GITHUB_SHA: sha, + GITHUB_REF_NAME: "release/v9.9.9", + GITHUB_SERVER_URL: "https://github.com", + GITHUB_REPOSITORY: "diegosouzapw/OmniRoute", + GITHUB_RUN_ID: "42", + }); + const p = data.provenance; + assert.equal(p.generatedBy, "github-actions"); + assert.equal(p.sourceCommit, sha); + assert.equal(p.sourceRef, "release/v9.9.9"); + assert.equal(p.runUrl, "https://github.com/diegosouzapw/OmniRoute/actions/runs/42"); +});