feat(radar): stable catalog export workflow with provenance (#10826)

Passo 10 of the Radar go-live: publish the OmniRoute catalog export the Radar
server consumes to a stable URL, so the 1 GB-RAM private server pulls it via
RADAR_EXPORT_URL instead of depending on the deploy-time snapshot.

- scripts/release/radar-export.mjs: emits {geradoEm, budgets, totais, registry,
  provenance} from the catalog config modules. Provenance (sourceCommit,
  sourceRef, runUrl, generatedBy) is never fabricated — unknown fields stay null.
- .github/workflows/radar-export.yml: on main catalog changes / manual dispatch /
  weekly, generates the export and clobbers the stable 'radar-export-latest'
  release asset (gh release, GH_TOKEN — checkout persist-credentials:false).
- tests/unit/radar-export.test.mjs: consumer contract (budgets[] non-empty) +
  provenance null-when-unknown + GitHub-env reflection.

Stable URL for RADAR_EXPORT_URL:
https://github.com/diegosouzapw/OmniRoute/releases/download/radar-export-latest/export-omniroute.json

Re-baselines zizmorFindings 190->192 (+2 unpinned-uses @vN, the repo-wide
deliberate convention; artipacked auto-fixed).

Co-authored-by: Xiangzhe <bakryun0718@proton.me>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-20 08:09:42 -03:00
committed by GitHub
parent 5089c17b44
commit 0f13fe4221
4 changed files with 244 additions and 1 deletions

64
.github/workflows/radar-export.yml vendored Normal file
View File

@@ -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

View File

@@ -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",

View File

@@ -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"})`
);

View File

@@ -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");
});