GitHub Actions workflows that discover, download, OCR, and submit PNCP public procurement notices to a Render backend for AI processing and serving.
The pipeline is split between GitHub Actions (discovery, download, OCR, submission) and Render (AI processing, persistence, serving, Drive sync).
- Discover active PNCP procurement records (modalities 6/8/4) via
/publicacao,/proposta, and/atualizacaoendpoints - Filter notices explicitly to
anoCompra >= 2026before download/OCR/submission - Download each candidate PDF once with bounded HTTP (SSRF protection, size limits, retry)
- Validate PDFs via magic-byte and structure checks
- OCR extracted PDFs to markdown using PaddleOCR
- Submit candidates with metadata, markdown, and content hash to Render
/api/pipeline/candidates - Submit stable API/web opportunities, including zero-document and non-renderable-attachment records, to Render
/api/pipeline/opportunities
- Trust and persist authenticated submissions
- Run AI processing (model-only inference on submitted markdown)
- Serve direct URLs for processed notices
- Sync Drive for opted-in users
| Workflow | Trigger | Purpose |
|---|---|---|
pipeline-pncp-discovery.yml |
Hourly cron + manual | Combined discover → download → OCR → submit (PNCP) |
pipeline-pncp-backfill.yml |
Manual only | Claims active pending PNCP candidates from Render, downloads/OCRs them in Actions, and submits worker results back to Render |
pipeline-bndes-discovery.yml |
Hourly cron + manual | Combined discover → download → OCR → submit (BNDES pilot, Phase 2) |
pipeline-brde-discovery.yml |
Hourly cron + manual | Combined discover → download → OCR → submit (BRDE, Phase 3) |
pipeline-fapergs-discovery.yml |
Hourly cron + manual | Combined discover → download → OCR → submit (FAPERGS, Phase 3) |
pipeline-funbio-discovery.yml |
Hourly cron + manual | Combined discover → download → OCR → submit (FUNBIO, Phase 3) |
pipeline-iis-rio-discovery.yml |
Hourly cron + manual | Combined discover → download → OCR → submit (IIS-Rio, Phase 3) |
pipeline-sema-rs-discovery.yml |
Hourly cron + manual | Combined discover → download → OCR → submit (SEMA-RS, Phase 3) |
pipeline-tnc-discovery.yml |
Hourly cron + manual | Combined discover → download → OCR → submit (TNC, Phase 3) |
pipeline-wwf-discovery.yml |
Hourly cron + manual | Combined discover → download → OCR → submit (WWF, Phase 3) |
pipeline-unep-discovery.yml |
Hourly cron + manual | Combined discover → download → OCR → submit (UNEP, Phase 3; Cloudflare bypass via browser User-Agent) |
pipeline-govbr-mma-discovery.yml |
Hourly cron + manual | Combined discover → download → OCR → submit (GOVBR-MMA, Phase 3) |
pipeline-worldbank-discovery.yml |
Hourly cron + manual | Combined discover → download → OCR → submit (WorldBank, Phase 3; BS4 primary + Playwright fallback) |
pipeline-fao-discovery.yml |
Hourly cron + manual | Combined discover → download → OCR → submit (FAO, Phase 4; Playwright-based listing) |
pipeline-kfw-discovery.yml |
Hourly cron + manual | Combined discover → download → OCR → submit (KfW, Phase 4; Playwright-based listing) |
pipeline-fundacao-grupo-boticario-discovery.yml |
Hourly cron + manual | Combined discover → download → OCR → submit (Fundação Grupo Boticário, Phase 4; Playwright-based listing) |
pipeline-msgov-discovery.yml |
Hourly cron + manual | Combined discover → download → OCR → submit (MSGOV, Phase 4; pure-Playwright with shadow-DOM probing; magic-byte check rejects .doc annex leakage at download time) |
pipeline-all-discovery.yml |
Hourly cron + manual | Unified orchestrator over the non-PNCP sources (Phase 5); reads SOURCES from the workflow_dispatch input. The Plan-03 MMA feeds are selectable but remain outside the scheduled default until their staged live gates pass. |
pipeline-ai.yml |
After PNCP discovery + hourly cron | Trigger Render AI processing (daytime Pacific gate) |
pipeline-ingest.yml |
Manual only | Legacy Render ingest (rollback) |
pipeline-ocr.yml |
Manual only | Legacy Render OCR worker (backfill) |
pipeline-scrape.yml |
Manual only | Legacy Render scrape (rollback) |
pipeline-run.yml |
Manual only | Legacy full pipeline run (rollback) |
RENDER_APP_URL— Render service base URLPIPELINE_SECRET— Bearer token for Render API authentication
PNCP_MIN_NOTICE_YEAR=2026— do not process notices before 2026 (PNCP discoverer)BNDES_MIN_NOTICE_YEAR=2026— per-source year guard for the BNDE discoverer (the unified orchestrator-levelMIN_NOTICE_YEARlands in Phase 5; until then each non-PNCP discoverer carries its own*_MIN_NOTICE_YEARknob)BRDE_MIN_NOTICE_YEAR=2026— per-source year guard for the BRDE discovererFAPERGS_MIN_NOTICE_YEAR=2026— per-source year guard for the FAPERGS discovererFUNBIO_MIN_NOTICE_YEAR=2026— per-source year guard for the FUNBIO discovererIIS_RIO_MIN_NOTICE_YEAR=2026— per-source year guard for the IIS-Rio discovererSEMA_RS_MIN_NOTICE_YEAR=2026— per-source year guard for the SEMA-RS discovererTNC_MIN_NOTICE_YEAR=2026— per-source year guard for the TNC discovererWWF_MIN_NOTICE_YEAR=2026— per-source year guard for the WWF discovererUNEP_MIN_NOTICE_YEAR=2026— per-source year guard for the UNEP discovererGOVBR_MMA_MIN_NOTICE_YEAR=2026— per-source year guard for the GOVBR-MMA discovererWORLDBANK_MIN_NOTICE_YEAR=2026— per-source year guard for the WorldBank discovererFAO_MAX_CANDIDATES_PER_RUN=50— same cap on the FAO discovererKFW_MAX_CANDIDATES_PER_RUN=50— same cap on the KfW discovererFUNDACAO_GRUPO_BOTICARIO_MAX_CANDIDATES_PER_RUN=50— same cap on the Fundação Grupo Boticário discovererFUNDACAO_GRUPO_BOTICARIO_MAX_DETAILS_PER_RUN=20— bound the number of detail-page fetches per Fundação Grupo Boticário runMSGOV_MAX_CANDIDATES_PER_RUN=50— same cap on the MSGOV discovererMSGOV_MAX_DETAILS_PER_RUN=40— bound the number of detail-page navigations per MSGOV run (Playwright is the primary path, so detail fetching is more expensive)PNCP_MAX_CANDIDATES_PER_RUN=50— keep a larger discovery pool so a few invalid PDFs do not starve valid noticesBNDES_MAX_CANDIDATES_PER_RUN=50— same cap on the BNDE discovererBRDE_MAX_CANDIDATES_PER_RUN=50— same cap on the BRDE discovererBRDE_MAX_DETAILS_PER_RUN=20— bound the number of detail-page fetches per BRDE runFAPERGS_MAX_CANDIDATES_PER_RUN=50— same cap on the FAPERGS discovererFAPERGS_MAX_DETAILS_PER_RUN=20— bound the number of detail-page fetches per FAPERGS runFUNBIO_MAX_CANDIDATES_PER_RUN=50— same cap on the FUNBIO discovererFUNBIO_MAX_DETAILS_PER_RUN=20— bound the number of detail-page fetches per FUNBIO runIIS_RIO_MAX_CANDIDATES_PER_RUN=50— same cap on the IIS-Rio discovererIIS_RIO_MAX_DETAILS_PER_RUN=30— bound the number of detail-page fetches per IIS-Rio runSEMA_RS_MAX_CANDIDATES_PER_RUN=50— same cap on the SEMA-RS discovererSEMA_RS_MAX_DETAILS_PER_RUN=40— bound the number of detail-page fetches per SEMA-RS runTNC_MAX_CANDIDATES_PER_RUN=50— same cap on the TNC discovererTNC_MAX_DETAILS_PER_RUN=20— bound the number of detail-page fetches per TNC runWWF_MAX_CANDIDATES_PER_RUN=50— same cap on the WWF discovererWWF_MAX_DETAILS_PER_RUN=20— bound the number of detail-page fetches per WWF runUNEP_MAX_CANDIDATES_PER_RUN=50— same cap on the UNEP discovererGOVBR_MMA_MAX_CANDIDATES_PER_RUN=50— same cap on the GOVBR-MMA discovererGOVBR_MMA_MAX_DETAILS_PER_RUN=20— bound the number of detail-page fetches per GOVBR-MMA runGOVBR_MMA_PUBLIC_CALLS_MIN_NOTICE_YEAR=2026— per-source year guard for the GOVBR-MMA public-calls (participation-social) discoverer (Plan 03)GOVBR_MMA_PUBLIC_CALLS_MAX_CANDIDATES_PER_RUN=50— same cap on the GOVBR-MMA public-calls discovererGOVBR_MMA_PUBLIC_CALLS_MAX_DETAILS_PER_RUN=20— bound the number of detail-page fetches per GOVBR-MMA public-calls runGOVBR_MMA_FNMA_MIN_NOTICE_YEAR=2026— per-source year guard for the GOVBR-MMA FNMA discoverer (Plan 03)GOVBR_MMA_FNMA_MAX_CANDIDATES_PER_RUN=50— same cap on the GOVBR-MMA FNMA discovererGOVBR_MMA_FNMA_INCLUDE_TDR=0— source-specific opt-in (set1) to surface FNMA terms-of-reference (termo de referencia) as principal candidates. Default0keeps TDR as RELATED metadata and does NOT change the globalFILTER_POLICYdefault.WORLDBANK_MAX_CANDIDATES_PER_RUN=50— same cap on the WorldBank discovererPNCP_MAX_PROCESSED_CANDIDATES_PER_RUN=20— bound download/OCR attempts per Actions runPNCP_MAX_SUBMITTABLE_CANDIDATES_PER_RUN=5— stop once enough valid candidates are ready to submit incrementallyPNCP_FETCH_MAX_ATTEMPTS=3— retry transient PNCP connection timeouts before marking a search/document lookup failedPNCP_OPPORTUNITY_V2_ENABLED=false— opt into one PNCP parent per control number; the manual workflow exposes this separately from the legacy pathPNCP_OPPORTUNITY_V2_SHADOW=true— write the normalized PNCP inventory artifact without production submissionFINEP_MAX_OPPORTUNITIES_PER_RUN=10— bound structured FINEP API submissionsFBDS_MAX_DETAILS_PER_RUN=20— bound Restaura Amazônia detail parsing and ZIP inspectionFUNBIO_NEWS_ENABLED=false— enable bounded news resolution; unresolved articles remain artifacts and are never submittedFUNBIO_NEWS_LOOKBACK_DAYS=45/FUNBIO_NEWS_MAX_DETAILS=20— bound news inventory workOPPORTUNITY_ATTACHMENT_MAX_BYTES=15000000— compressed-size limit for non-PDF attachmentsSCRAPE_MAX_PDF_BYTES=15000000— reject candidate PDFs larger than this many bytes during downloadSCRAPE_MAX_PDFS_PER_RUN=5— generic per-run cap on successful PDF downloads/OCR completions (used bypipeline_core.pdf_download_limit_reached)RENDER_SUBMIT_BATCH_SIZE=30— candidates per Render/api/pipeline/candidatesPOST batchRENDER_SUBMIT_TIMEOUT=90— per-batch HTTP timeout in secondsRENDER_SUBMIT_MAX_ATTEMPTS=4— retry budget for transient Render submit failures (5xx, 408, 425, 429)RENDER_SUBMIT_BACKOFF_BASE=5— exponential backoff base seconds between Render submit retriesRENDER_SUBMIT_MAX_MARKDOWN_CHARS=1000000— truncate OCR markdown longer than this before submitting to RenderFLAGS_use_mkldnn=0— disables Paddle oneDNN on CPU runners; required to avoid the current PaddleOCR runtime failure seen in GitHub ActionsPADDLE_PDX_ENABLE_MKLDNN_BYDEFAULT=0— disables PaddleX's default oneDNN path used by PaddleOCRSOURCES(unified orchestrator only) — comma-separated source names; whitespace is tolerated and duplicates are removed. Valid structured sources includefinep,fbds,funbio, andtnc, in addition to the legacy PDF source keys. PNCP remains in its dedicated workflow.DISCOVERY_AUDIT_ONLY=true— performs discovery and writes fidelity artifacts without OCR or Render submission; manual all-sources runs default to this safe mode.OPPORTUNITY_SOURCES— comma-separated sources allowed to use the structured opportunity endpoint. Scheduled runs default to the legacy candidate path until a source passes its rollout gates; set the repository variable to enable sources reversibly.DISCOVERY_AUDIT_DIR— when set, every structured source writes stablesource_inventory.json,discovery.json,opportunities.json, andstats.jsonartifacts.MIN_NOTICE_YEAR(Phase 5 unified orchestrator only, default2026) — generic year guard forwarded to BS4 discoverers as theirmin_yearargument. Plan §9 recommends a generic name (notPNCP_MIN_NOTICE_YEAR) so the unified orchestrator does not couple non-PNCP sources to PNCP-specific env vars. Playwright sources (fao,fundacao_grupo_boticario,kfw,msgov) do not acceptmin_yearand run with their own internal filtering.FILTER_POLICY(Phase 5 unified orchestrator only, defaultdefault) — EDITAL inclusion/exclusion policy forwarded to BS4 discoverers (default|include_tdr|no_prefilter). Ignored by Playwright sources.
pipeline-pncp-backfill.yml is a manual integration workflow for draining the active PNCP backlog in scrape_candidates. It calls the Render claim endpoint for active pending PNCP candidates, reuses the same download/OCR worker path as PNCP discovery, and submits successful worker results to /api/pipeline/candidates.
Use conservative inputs until the post-deploy testing gate passes:
claim_limit=1process_limit=1
After Render is confirmed healthy and the first manual run moves an active candidate into editais, the limits can be increased gradually. Do not schedule this workflow until the active backlog behavior is verified in production.
The PNCP workflow fails if PNCP search fails and produces no candidates, if discovered candidates all fail download/OCR, or if no discovered candidate is submitted to Render, so the PNCP checkpoint is not advanced over unprocessed notices. The BNDE workflow mirrors the same shape but has no checkpoint to advance.
The Phase 5 plan recommends keeping pipeline-pncp-discovery.yml separate from the new pipeline-all-discovery.yml orchestrator rather than folding PNCP into it. Reasoning (plan §5):
- PNCP has hard-won guardrails (
PNCP_MIN_NOTICE_YEAR,PNCP_MAX_CANDIDATES_PER_RUN, modality filtering, document-type priorities, the/atualizacaocheckpoint) that are not directly applicable to other sources. - The unified orchestrator uses the generic
MIN_NOTICE_YEAR/FILTER_POLICY/SOURCESenv vars; binding PNCP to those would couple its knobs to the new generic ones prematurely. - The PNCP discoverer also has a different return shape (
(stats, candidates, checkpoint)vs(stats, candidates)), which would require either forcing it into the new shape or special-casing it.
Operators who want to discover non-PNCP sources in a single Actions run should use pipeline-all-discovery.yml. PNCP continues to run via pipeline-pncp-discovery.yml unchanged. Folding PNCP in is tracked as a follow-up PR after the unified orchestrator stabilises.
discover_wwf_candidates.py discovers WWF editais by structurally parsing the EDITAIS ABERTOS (status open) and EDITAIS ENCERRADOS (status closed) sections of the acquisitions page, following only those rows' detail URLs, and extracting PDFs only from the record content area. Generic supplier documents (documentos-necessarios, requisitos-basicos, proposal-model, and supplier-portal) are rejected; record-bound divulgação, retification, and annex PDFs are retained. A missing section, zero parsed rows, or missing detail content selector is reported as a failure rather than a healthy zero-result run.
Run a live no-submit audit and write Plan-01-compatible inputs with:
python scripts/discover_wwf_candidates.py --audit-dir artifacts/wwfThis writes source_inventory.json, normalized discovery.json, raw candidates.json, and stats.json. The manual WWF workflow runs in audit-only mode by default and uploads this directory as an artifact.
discover_govbr_mma_public_calls_candidates.py and discover_govbr_mma_fnma_candidates.py add two SEPARATE MMA feeds without touching the existing govbr_mma procurement discoverer:
govbr_mma_public_calls— the participation-social public-call (chamamento) index.govbr_mma_fnma— the FNMA editais / terms-of-reference page.
Both parse only the gov.br editorial body (#content-core #parent-fieldname-text, legacy #content-core, or the current #content cover body), associate heading/callout year markers with following edital links, and emit a Plan-01-compatible inventory for deterministic fidelity checks. resultado / retificacao / errata / historical / annex PDFs are treated as RELATED metadata of the parent opportunity using accent-normalized anchor text and filenames. The principal edital PDF is the candidate. Explicit publication/status/deadline text is preserved; otherwise status remains unknown.
The FNMA feed accepts an opt-in GOVBR_MMA_FNMA_INCLUDE_TDR=1 to surface terms-of-reference as principal candidates; this is a SOURCE-SPECIFIC switch that does NOT mutate the global FILTER_POLICY default.
Enable govbr_mma_public_calls first, then govbr_mma_fnma (do not enable both in the same first production run). Every candidate carries metadata.source_record_id + metadata.detail_url (where applicable) and traces to an inventory record.
Run each source without OCR/submission and produce fidelity inputs with:
python scripts/discover_govbr_mma_public_calls_candidates.py --audit-dir artifacts/mma-public
python scripts/discover_govbr_mma_fnma_candidates.py --audit-dir artifacts/mma-fnmaEach command writes source_inventory.json, normalized discovery.json, raw candidates.json, and stats.json. A detail/news lead that currently exposes no principal PDF is retained as unresolved_news_lead, not silently discarded or submitted.
# Install OCR worker dependencies
pip install -r requirements-ocr-worker.txt
# Run tests
python -m pytest -v
# Run discovery locally (requires env vars)
RENDER_APP_URL=https://your-render.onrender.com PIPELINE_SECRET=token python scripts/discover_pncp_candidates.pyscripts/audit_source_fidelity.py produces a deterministic, offline (no LLM/network) report comparing the authoritative source inventory, discovery candidates, and an optional dashboard export. It classifies each disagreement as a blocking or non-blocking exception and writes summary.json, matches.json, exceptions.json, and report.md. Inventory accounting is scoped to open, in-scope records, so a source can retain closed rows for provenance without falsely reporting them as missing current submissions.
python scripts/audit_source_fidelity.py \
--source-inventory tests/fixtures/audit/source_inventory.json \
--discovery tests/fixtures/audit/discovery.json \
--dashboard tests/fixtures/audit/dashboard.json \
--out ./reportExit codes: 0 = no blocking exceptions, 1 = fidelity failures, 2 = invalid input/config. The matching ladder is, in descending authority: (source_key, source_record_id), canonical URL (fragment removed, query params preserved), exact document SHA-256, then exact normalized document URL. Title similarity never establishes identity. See plans/opportunity-sources/01-deterministic-source-fidelity.md for the full contract and the quantitative success gates.
Input files must contain arrays of record objects. document_urls and document_hashes are arrays of strings; hashes are 64-character SHA-256 hex digests. A record explicitly rejected by policy may set reason_code to out_of_scope; an unsubmitted unresolved announcement may use unresolved_news_lead. Include an evidence object for either disposition. A record with renderable: true must also include content_type_validated: true and hash_validated: true.
The unified orchestrator detects discover_opportunities() modules and uses
the stable opportunity endpoint instead of the legacy PDF-only endpoint:
finepconsumes the paginated official/o/c/chamadapublicasJSON API, keys records by APIid, excludes site-wide manuals/tutorials, and accepts complete records without a PDF.fbdsparses only the Restaura Amazônia edital portal. ZIPs remain official, non-renderable attachments; archive members are inspected and OCRed in memory with path, encryption, nesting, member-count, size, ratio, symlink, and PDF-magic guards.tncparses only the consultancy/service section oftrabalhe-conosco, usesNOVO PRAZOoverPRAZO, classifies every record asconsultancy, and reports malformed blocks instead of traversing general news.funbiokeeps the calls portal as canonical. News resolution uses exact call URLs/slugs only; canonical fields win, institutional news is ignored, and unresolved likely calls are artifact-only.
Structured sources remain outside the scheduled default until two consecutive
live fidelity reports pass. Run a manual workflow with
DISCOVERY_AUDIT_DIR=artifacts/source-audits, then compare each source's
inventory and discovery files with audit_source_fidelity.py.
Set PNCP_OPPORTUNITY_V2_ENABLED=true and keep
PNCP_OPPORTUNITY_V2_SHADOW=true for artifact-only parent/document discovery.
Only after two passing shadow comparisons should a reviewed backend
reconciliation be applied. The backend command is:
python scripts/reconcile_pncp_opportunities.py --mode rehearsal --report pncp-plan.json
python scripts/reconcile_pncp_opportunities.py --mode apply --reviewed-report pncp-plan.reviewed.json --report pncp-applied.jsonApply mode requires the exact current plan_hash with reviewed: true and
aborts when one user has distinct uploaded Drive files on rows that would be
merged.
- Operations guide — runbooks, monitoring, rollback procedures