Release v0.61.0
Date: 2026-04-10
Theme
v0.61.0 closes the Developer Blockers initiative — the four stages identified as the biggest friction points between “Pulp Engine API is running” and “external team is productive against it.” Shipping them together in one release is deliberate: each stage is usable on its own, but the value compounds when a developer can author a template with the CLI, validate it with a dryRun call, get precise expression error locations back, and see the full render trace in their tracing UI — all in the same session.
It also ships the first public Python SDK on PyPI, closing the “TypeScript-only client story” gap that’s been open since the SDK package was introduced, and the TypeScript SDK picks up matching enriched-error types and a dryRun() method so both clients expose the same surface.
Highlights
Developer Blockers Stage 1 — @pulp-engine/cli
New workspace CLI package with a pulp-engine binary:
pulp-engine push/pull— sync templates between a local directory and a Pulp Engine server.pulp-engine validate— local schema validation with no server round-trip.pulp-engine render— render a template against sample data and write the output to disk.pulp-engine dev— watch-mode development loop.pulp-engine codegen— generate typed TypeScript or Python bindings from a template’sinputSchema.
Depends on the new @pulp-engine/template-schema workspace package, which hoists TemplateDefinitionSchema, RenderConfigSchema, and formatValidationIssues out of apps/api so the CLI can validate templates locally without pulling in the server’s full dependency tree. This makes @pulp-engine/cli installable independently of the server.
Developer Blockers Stage 2 — Python SDK on PyPI
First-class Python client published to PyPI as pulp-engine. The package mirrors @pulp-engine/sdk 1:1 with snake_case method names and Pythonic conventions. Full resource surface:
client.templates— list / get / create / update / delete / schema / sample / validate / versions / get_version / restoreclient.render— pdf / html / csv / xlsx / docx / pptx / dry_run / validateclient.batch,client.pdf_transform,client.assetsclient.audit_events,client.admin,client.auth,client.health,client.schedules
Built on httpx>=0.27.0 and pydantic>=2.0.0. Supports Python 3.11, 3.12, and 3.13. Built with hatchling.
A new docs/sdk-generation-guide.md documents the full generation methodology for the TypeScript and Python SDKs, plus recipes for generating C#/.NET, Java, and Go clients from the same openapi.json spec using openapi-generator.
pip install pulp-engine
from pulp-engine import Pulp Engine
client = PulpEngine(base_url="https://your-pulp-engine.example.com", api_key="...")
# Fast pre-flight: no Chromium, no output bytes, just validation + expression eval.
result = client.render.dry_run(template="loan-approval-letter", data={"amount": 50000})
if not result.valid:
for err in result.expressions.errors:
print(f"{err.location.node_path}: {err.message}")
if err.suggestion:
print(f" did you mean: {', '.join(err.suggestion.suggestions)}?")
Developer Blockers Stage 3 — dryRun on all render routes + enriched expression errors
Every per-format render route now accepts dryRun: true in the request body:
POST /render(PDF),POST /render/html,POST /render/csv,POST /render/xlsx,POST /render/docx,POST /render/pptx- Plus the six
/render/preview/*variants — 12 routes total
When set, the pipeline runs data adaptation, schema validation, templateRef resolution, data-source resolution, and a full HTML generation pass (exercising every Handlebars expression), then discards the output and returns a structured DryRunResult:
{
"valid": true,
"validation": { "valid": true, "errors": [] },
"expressions": { "valid": true, "errors": [] },
"fieldMappingsApplied": 3,
"dataSourcesResolved": 1
}
Typically 5-50 ms — no Chromium launch, no DOCX/PPTX assembly. ~80× speedup vs. producing and discarding a real render. Designed for CI/CD pre-flight gates and integration tests that want to validate the full data-to-template binding without paying for a real render.
TemplateExpressionError now carries optional location and suggestion fields:
{
"error": "Template expression error",
"message": "The partial customre could not be found",
"code": "template_expression_error",
"location": {
"nodeId": "txt-042",
"nodeType": "text",
"nodePath": "Page 1 > Section \"Header\" > Text block 3",
"line": 12,
"column": 18
},
"suggestion": {
"variable": "customre",
"suggestions": ["customer", "customerId", "customerName"]
}
}
nodePath is computed by a new AST walker in packages/template-renderer/src/node-path.ts (breadcrumbs stop at templateRef boundaries so sub-template paths don’t leak into parent errors). suggestion.suggestions is computed via Levenshtein distance in packages/template-renderer/src/suggestions.ts (max 3 candidates, distance scaled to variable length). Both surface on the 422 error body and inside DryRunResult.expressions.errors[].
Developer Blockers Stage 4 — render accounting, phase timings, metrics, OpenTelemetry
Phase 1 — in-process instrumentation.
A new PhaseTracker in packages/template-renderer/src/telemetry/phase-tracker.ts records per-phase wall-clock for each render with a bounded label set:
data_adaptation | validation | ref_resolution | data_source_resolution
| html_generation | asset_inlining | dispatcher_render
| docx_generation | pptx_generation | csv_generation | xlsx_generation
Zero overhead when no tracker is passed — the tracker is optional on every render request. The API route handlers use it to populate three new Prometheus histograms in apps/api/src/lib/metrics.ts:
render_phase_duration_seconds— labels:phase,format. One observation per populated phase per render.render_output_size_bytes— label:format. Buckets 1 KB – 50 MB.render_page_count— PDF only. Buckets 1 – 500 pages.
Every render response now carries:
X-Render-Duration-MsX-Render-Size-BytesX-Render-Pages(PDF)X-Render-Memory-Delta-Bytes-Approx(child-process mode only; documented as GC-noisy and directional)X-Render-Trace-Id(when OTel is active)
A structured render_accounting log event is emitted once per render with requestId, templateKey, format, totalDurationMs, and the full phase breakdown — queryable via log aggregation for chargeback, cost analysis, and regression hunting.
OpenTelemetry SDK bootstrap in apps/api/src/lib/otel-bootstrap.ts activates automatically when OTEL_EXPORTER_OTLP_ENDPOINT is set. Kill-switch via OTEL_SDK_DISABLED=true. Service name via OTEL_SERVICE_NAME (default pulp-engine-api). Fastify auto-instrumentation emits one span per HTTP request, HTTP outbound instrumentation covers downstream calls, and W3C Trace Context propagation is automatic. Zero cold-start cost when disabled — the SDK packages are dynamically imported only when the env var is present.
Phase 2 — cross-process render spans.
With OTel active, operators now see a distinct pdf.render child span nested under the Fastify request span for every PDF render:
POST /render ← Fastify auto-instrumentation
└── pdf.render ← Phase 2
pulp-engine.render.dispatcher = "child-process" | "container" | "socket"
pulp-engine.render.format = "pdf"
pulp-engine.render.html_size_bytes = <utf8 bytes, not utf16 codepoints>
pulp-engine.render.output_size_bytes = <pdf bytes>
pulp-engine.render.memory_delta_bytes_approx = <heap delta> (child-process only)
pulp-engine.render.error.code = "render_timeout" (failure only)
status: OK | ERROR
Implemented as a span-synthesizing decorator at apps/api/src/lib/otel-render-span.ts (wrapDispatcherWithSpan(dispatcher, mode)) wrapping each concrete dispatcher at construction time in apps/api/src/routes/render/render.ts. Identity function when OTel is disabled — zero overhead. Parent linkage happens implicitly via AsyncLocalStorage; no trace context is plumbed through the renderer pipeline.
Crucially, the worker process still runs with no OTel SDK. This approach preserves the --network none isolation guarantee in container and socket modes and avoids forwarding the secret-bearing OTEL_EXPORTER_OTLP_HEADERS across the env: {} boundary in child-process mode. Every OTel call inside the wrapper is routed through safeStartSpan/safeSetAttr/safeEndOk/safeEndError helpers so a tracing failure can never break a render — same posture as the bootstrap file itself.
Deliberate trade-off. The worker is a child span node, not a distinct service in the topology view. Real cross-process propagation is fundamentally incompatible with --network none and was rejected during planning. The synthesized span’s wall-clock includes IPC overhead (process spawn for container mode, IPC send/receive for child-process mode), not just Puppeteer time — for “where did my render time go” this is the right granularity.
TypeScript SDK — matching enriched errors and dryRun()
@pulp-engine/sdk now exposes the Stage 3 enriched error shape:
PulpEngineErrorcarries readonlylocationandsuggestionfields parsed from the 422 response body via newparseLocation()/parseSuggestion()helpers inpackages/sdk-typescript/src/error.ts.- New
render.dryRun(params, format?)method (defaultformat='pdf', accepts all six formats) returns a typedDryRunResult. - New exported types at the package entry point:
DryRunFormat,DryRunResult,DryRunExpressionError,DryRunExpressionLocation,DryRunExpressionSuggestion.
import { PulpEngineClient, PulpEngineError } from '@pulp-engine/sdk'
const client = new PulpEngineClient({ baseUrl, apiKey })
try {
const result = await client.render.dryRun({ template: 'invoice', data })
if (!result.valid) {
for (const err of result.expressions.errors) {
console.error(`${err.location?.nodePath}: ${err.message}`)
}
}
} catch (err) {
if (err instanceof PulpEngineError && err.location) {
console.error(`at ${err.location.nodePath}:${err.location.line}:${err.location.column}`)
if (err.suggestion) {
console.error(`did you mean: ${err.suggestion.suggestions.join(', ')}?`)
}
}
}
PyPI publishing workflow
New GitHub Actions workflow at .github/workflows/publish-sdk-python.yml publishes the pulp-engine Python package to PyPI via Trusted Publishing — OIDC-based, no long-lived API tokens stored in the repo.
- Automatic trigger: push of a
v*.*.*tag publishes to production PyPI. - Manual trigger:
workflow_dispatchwithtarget=pypi|testpypifor TestPyPI dry runs. - Build:
hatchlingvia thepython -m buildflow;twine checkvalidates metadata. - Publish:
pypa/gh-action-pypi-publish@release/v1withskip-existing: trueso Docker-only patch releases (where the Python SDK is unchanged) are idempotent no-ops. - Environments: two GitHub environments (
pypi,testpypi) carry the OIDC trust configuration. - CI gate: verifies
ci.ymlcompleted successfully on the exact release commit (branch=main,event=push) before publishing — a failed CI on the tagged commit blocks the publish.
Changed
check-version.mjsnow enforcesdocs/release-vX.Y.Z.mdat tag time in addition to the existing package-version lockstep and CHANGELOG invariants. Off-tag development still only requires[Unreleased]at the top ofCHANGELOG.md; at tag time the script additionally verifies the release notes file exists, the CHANGELOG has a dated[X.Y.Z]section, and a link reference points to the matching GitHub release. The lockstep check has also been extended to includepackages/sdk-python/pyproject.tomlvia a targeted PEP 621 regex reader (no TOML parser dependency added).
Operator guidance
- Opt in to tracing by setting
OTEL_EXPORTER_OTLP_ENDPOINT(and optionallyOTEL_SERVICE_NAME) in the API process environment. No other configuration needed — the SDK wires up Fastify and HTTP instrumentations automatically, and Phase 2 cross-process render spans appear immediately. - No config change needed for accounting headers, Prometheus histograms, or the
render_accountinglog event — these ship on by default. - PyPI publication: the workflow is in place but has not yet been exercised against production PyPI. First publish should be a TestPyPI dry run via
workflow_dispatchbefore the tag push. - CLI distribution:
@pulp-engine/cliis published to the npm workspace but not yet to the public npm registry — that gate is deferred to a follow-up release.
Residual risk
- Known pre-existing test flake under load in the
apps/apisuite (render-dry-run,render-preview,render-batch,render-pdf-transform,render-xlsx,render.test.ts, and therender-accounting > X-Render-Trace-Id when OTel disabledassertion). All pass reliably in isolation and in small subsets; the failure set varies between full-suite runs. Consistent with the codebase’s documented pre-existing timeout flakiness under heavy parallel load. Not a v0.61.0 regression. - Environmental test failures when
ANTHROPIC_API_KEYis set in local.env: thetemplates-generate.route.test.ts“feature off” cases and thepptx-route.test.ts > formats.pptx = truecapabilities assertion both assume the key is unset. Developers running the local suite should unset it before runningpnpm --filter @pulp-engine/api testto avoid spurious failures. Follow-up worth scheduling: wirevi.stubEnv('ANTHROPIC_API_KEY', '')into the affectedbeforeEachblocks.