From 5d5406c490f0f44882d28a56fe37612abe5f8c93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20L=C3=BCder?= Date: Fri, 24 Apr 2026 17:30:19 -0500 Subject: [PATCH] feat(cli): tokens e blocos condicionais pro ci.yml render por repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O template ci.yml tinha hardcoded o package list do fhir-brasil (packages/core + packages/calculators) e um deploy-site block com placeholders {{PROJECT_NAME}}/{{SITE_FILTER}} que nao eram renderizados. Consequencia: precisa sync quebrava ci.yml em qualquer repo consumidor. Mudancas: 1. Schema .precisa.json ganha tres campos: - publishPackages: string[] (obrigatorio quando publishesToNpm: true) - siteProjectName: string (obrigatorio quando hasSite: true) - siteFilter: string (obrigatorio quando hasSite: true) 2. tokenContext() novos: - PUBLISH_PACKAGES_YAML — joina entradas com newline+8espacos pra caber sob packages: |. Primeiro entry fica no slot indentado do template; os demais replicam o indent. - SITE_PROJECT_NAME, SITE_FILTER — valor direto 3. Preprocessador de blocos condicionais no template engine: # {{#if PUBLISHES_TO_NPM}} ...bloco renderizado so quando truthy... # {{/if}} Marcadores ficam dentro de comentario da linguagem host (prefixo # ou // ou qualquer coisa antes do {{), mantendo o template valido pra syntax-aware tools (prettier). Truthy = nao-vazio e != false. Blocos aninhados resolvem via fixed-point loop. 4. Template ci.yml usa PUBLISH_PACKAGES_YAML pra lista e {{#if}} pra gatear release+publish (PUBLISHES_TO_NPM) e deploy-site (HAS_SITE). Testado end-to-end contra tres perfis sinteticos: - dbc-like (npm, sem site): publish com package correto, sem deploy - viz-like (npm + site): publish + deploy-site com project_name e site_filter vindos do manifest - scripts-only (nem npm nem site): so checks + review Follow-up: consumers precisam adicionar os 3 campos em .precisa.json antes de rodar precisa sync; PRs per-repo virao. --- packages/cli/src/lib/templates.ts | 43 ++++++++++++++++++++++++ packages/cli/src/manifest.ts | 54 +++++++++++++++++++++++++++++++ templates/github/workflows/ci.yml | 21 ++++++------ 3 files changed, 108 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/lib/templates.ts b/packages/cli/src/lib/templates.ts index eb3a465..9c7ad5f 100644 --- a/packages/cli/src/lib/templates.ts +++ b/packages/cli/src/lib/templates.ts @@ -55,9 +55,52 @@ export function readTemplateSource(entry: TemplateEntry): string { * Substitute `{{TOKEN}}` placeholders with values from `context`. Unknown * tokens are left untouched (keeps the double braces visible so bad keys * are easy to spot in rendered output). + * + * Also processes conditional blocks: + * + * {{#if HAS_SITE}} + * ...lines rendered only when HAS_SITE is truthy... + * {{/if}} + * + * Markers can appear inside a host-language comment so the template stays + * parseable by syntax-aware tools (prettier on YAML, for example): + * + * # {{#if HAS_SITE}} + * ... + * # {{/if}} + * + * A token is "truthy" when its string value is non-empty and not `"false"`. + * Blocks may nest; they are expanded before token substitution so a block's + * body can itself reference tokens. */ export function renderTokens(source: string, context: Record): string { + return renderTokenSubstitutions(renderConditionalBlocks(source, context), context); +} + +function renderTokenSubstitutions(source: string, context: Record): string { return source.replace(/\{\{([A-Z_]+)\}\}/g, (match, token: string) => { return Object.prototype.hasOwnProperty.call(context, token) ? context[token]! : match; }); } + +function renderConditionalBlocks(source: string, context: Record): string { + // Match an entire line containing `{{#if TOKEN}}` (possibly wrapped in a + // host-language comment like `# {{#if TOKEN}}` or `// {{#if TOKEN}}`), + // up through the matching `{{/if}}` line. The whole marker line is + // consumed so commented-out markers don't leak into rendered output. + const pattern = + /^[ \t]*[^\S\n]*[^\n]*\{\{#if ([A-Z_]+)\}\}[^\n]*\n([\s\S]*?)^[ \t]*[^\n]*\{\{\/if\}\}[^\n]*\n?/gm; + let prev: string; + let out = source; + // Re-run to handle nested blocks. Simple fixed-point loop keeps the + // replacement rules uniform. + do { + prev = out; + out = out.replace(pattern, (_match, token: string, body: string) => { + const value = context[token]; + const truthy = value !== undefined && value !== '' && value !== 'false'; + return truthy ? body : ''; + }); + } while (out !== prev); + return out; +} diff --git a/packages/cli/src/manifest.ts b/packages/cli/src/manifest.ts index db1640a..8c6e7d9 100644 --- a/packages/cli/src/manifest.ts +++ b/packages/cli/src/manifest.ts @@ -36,8 +36,28 @@ export interface PrecisaManifest { /** Publishes any workspace package to npm? */ publishesToNpm: boolean; + /** + * Workspace package directories (relative to repo root) that the + * `publish` job in `ci.yml` should pass to `_publish.yml`. Only read + * when `publishesToNpm: true`. Example: `["packages/core", "packages/cli"]`. + */ + publishPackages?: string[]; + /** Schema version of this manifest file. Bump when the schema changes. */ schemaVersion: 1; + + /** + * pnpm filter selector for the site package, passed to the deploy + * workflow. Only read when `hasSite: true`. Example: `@my-repo/site`. + */ + siteFilter?: string; + + /** + * Cloudflare Pages project name for the deploy-site workflow. Only + * read when `hasSite: true`. + */ + siteProjectName?: string; + /** Public-OSS or private-internal. Controls which templates are rendered. */ visibility: 'oss' | 'private'; } @@ -104,10 +124,16 @@ export function tokenContext(manifest: PrecisaManifest): Record NODE_VERSION: manifest.nodeVersion ?? '22', OWNER_ORG: manifest.owner, PNPM_VERSION: manifest.pnpmVersion ?? '9.15.9', + // YAML block-scalar body for the `packages: |` input of `_publish.yml`. + // The template provides the first entry's indent; subsequent entries + // get 8 spaces explicitly to match. Empty when no packages declared. + PUBLISH_PACKAGES_YAML: (manifest.publishPackages ?? []).join('\n '), PUBLISHES_TO_NPM: String(manifest.publishesToNpm), REPO_NAME: manifest.name, REPO_SLUG: `${manifest.owner}/${manifest.name}`, SECURITY_EMAIL: manifest.contactEmails.security, + SITE_FILTER: manifest.siteFilter ?? '', + SITE_PROJECT_NAME: manifest.siteProjectName ?? '', VISIBILITY: manifest.visibility, }; } @@ -143,6 +169,34 @@ export function validateManifest(raw: unknown): ManifestValidationError[] { if (!Array.isArray(m.commitScopes)) { errors.push({ message: 'must be an array of strings', path: 'commitScopes' }); } + if (m.publishPackages !== undefined) { + if (!Array.isArray(m.publishPackages)) { + errors.push({ message: 'must be an array of strings', path: 'publishPackages' }); + } else if (m.publishPackages.some((p) => typeof p !== 'string' || !p)) { + errors.push({ message: 'entries must be non-empty strings', path: 'publishPackages' }); + } + } + if (m.publishesToNpm) { + if (!Array.isArray(m.publishPackages) || m.publishPackages.length === 0) { + errors.push({ + message: 'must have at least one entry when publishesToNpm is true', + path: 'publishPackages', + }); + } + } + if (m.hasSite === true) { + for (const key of ['siteProjectName', 'siteFilter'] as const) { + if (typeof m[key] !== 'string' || !m[key]) { + errors.push({ message: 'required and non-empty when hasSite is true', path: key }); + } + } + } + if (m.siteProjectName !== undefined && typeof m.siteProjectName !== 'string') { + errors.push({ message: 'must be a string', path: 'siteProjectName' }); + } + if (m.siteFilter !== undefined && typeof m.siteFilter !== 'string') { + errors.push({ message: 'must be a string', path: 'siteFilter' }); + } if (!m.contactEmails || typeof m.contactEmails !== 'object') { errors.push({ message: 'must be an object', path: 'contactEmails' }); } else { diff --git a/templates/github/workflows/ci.yml b/templates/github/workflows/ci.yml index 0bf95ff..7c15af1 100644 --- a/templates/github/workflows/ci.yml +++ b/templates/github/workflows/ci.yml @@ -10,11 +10,9 @@ name: CI/CD # review.yml — automated Anthropic → OpenAI code review # publish-tag.yml — manual recovery (workflow_dispatch) # -# This orchestrator wires the pieces together. Each repo adapts: -# - Which reusable workflows to call (comment out what doesn't apply) -# - The publish `packages` list -# - The site `project_name` and `site_filter` -# - Extra repo-specific workflows (e.g. fhir's publish-ig.yml) +# Per-repo customization flows through `.precisa.json`: +# publishesToNpm + publishPackages[] gate the `publish` job +# hasSite + siteProjectName + siteFilter gate the `deploy-site` job on: push: @@ -53,6 +51,7 @@ jobs: with: pr_number: ${{ github.event.pull_request.number }} secrets: inherit + # {{#if PUBLISHES_TO_NPM}} # ── Release (main only, guarded on package changes) ──────────── release: @@ -70,16 +69,18 @@ jobs: release_sha: ${{ needs.release.outputs.release_sha }} # Newline-separated list of package directories. One per line. packages: | - packages/core - packages/calculators + {{PUBLISH_PACKAGES_YAML}} secrets: inherit + # {{/if}} + # {{#if HAS_SITE}} - # ── Deploy site (repos with a site — remove if not applicable) ── + # ── Deploy site (Cloudflare Pages + Slack notify) ────────────── deploy-site: needs: checks if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' uses: ./.github/workflows/_deploy-site.yml with: - project_name: '{{PROJECT_NAME}}' - site_filter: '@{{SITE_FILTER}}' + project_name: '{{SITE_PROJECT_NAME}}' + site_filter: '{{SITE_FILTER}}' secrets: inherit + # {{/if}}