Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions packages/cli/src/lib/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, string>): string {
return renderTokenSubstitutions(renderConditionalBlocks(source, context), context);
}

function renderTokenSubstitutions(source: string, context: Record<string, string>): 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, string>): 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;
}
54 changes: 54 additions & 0 deletions packages/cli/src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
Expand Down Expand Up @@ -104,10 +124,16 @@ export function tokenContext(manifest: PrecisaManifest): Record<string, string>
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,
};
}
Expand Down Expand Up @@ -143,6 +169,34 @@ export function validateManifest(raw: unknown): ManifestValidationError[] {
if (!Array.isArray(m.commitScopes)) {
Comment thread
rlueder marked this conversation as resolved.
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 {
Expand Down
21 changes: 11 additions & 10 deletions templates/github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -53,6 +51,7 @@ jobs:
with:
Comment thread
rlueder marked this conversation as resolved.
pr_number: ${{ github.event.pull_request.number }}
secrets: inherit
# {{#if PUBLISHES_TO_NPM}}

# ── Release (main only, guarded on package changes) ────────────
release:
Expand All @@ -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}}
Loading