Pulp Engine Document Rendering
Get started

Pulp Engine — Release Checklists


Release Process Checklist (per-release, v0.18.0+)

Work through each section in order for every tagged release. Follows the Docker-first artifact model.

Pre-release

  • CI must be green on the commit being tagged — enforced automatically by release.yml. The check-ci job (scoped to branch=main, event=push) queries the GitHub Actions API and fails the pipeline if no successful ci.yml run exists for the exact release commit. It also verifies the commit is an ancestor of origin/main. This check runs before Docker build or GitHub Release creation; there is nothing to tick here.
  • Release notes drafted in docs/release-v0.X.Y.md (follow existing file format) — CI enforces file presence during tagged release validation (docker job via check-version.mjs); content accuracy is a human check
  • Breaking changes identified; upgrade path documented in release notes
  • Any postgres migrations committed to apps/api/src/prisma/migrations/
  • Any SQL Server schema changes committed to apps/api/src/storage/sqlserver/migrations/
  • OpenAPI spec + SDK types regenerated after the version bump: bash pnpm extract-openapi # writes openapi.json with the new info.version pnpm --filter @pulp-engine/sdk codegen # writes packages/sdk-typescript/openapi.d.ts git add openapi.json packages/sdk-typescript/openapi.d.ts extract-openapi.ts reads the version from the root package.json, so every routine version bump produces a 1-line info.version diff in openapi.json plus the matching SDK typed-artifact diff. Skipping this step makes CI’s Verify openapi.json is fresh job (the freshness gate) red on the first PR after the bump.

Recording verification evidence

The release notes’ Validation section should make three things legible to any reader: what the CI matrix already proved for this commit, what the release author verified by hand, and what was not directly verified. Use this structure in the Validation section of each release note:

CI-verified — list the CI jobs that passed on the release commit by workflow job name (e.g. ci, test-file-mode, test-sqlserver, test-e2e, test-e2e-auth, docker-build-smoke). The release.yml check-ci gate enforces this, so this subsection is a record, not a claim.

Locally verified — list commands the release author actually ran outside CI, with results. Include verify-release.sh output summary if used. Targeted test commands for code touched in this release are more valuable than a bare pnpm test.

Not verified — state what was not directly tested for this release and why. Common entries: SQL Server path (no local instance), E2E suites (CI-only), deployment rehearsal (not performed). Omitting this subsection means “everything was verified” — do not omit it unless that is true.

Deployment rehearsal (optional) — if a local Docker build, migration dry-run, or compose-up was performed, note it here. If not performed, say so in “Not verified” instead. Do not invent rehearsal evidence.

Keeping this honest is more important than keeping it complete. A release that says “SQL Server path not tested — CI-covered by test-sqlserver job” is more trustworthy than one that lists commands without context.

Tag and artifact

git tag v0.X.Y
git push origin v0.X.Y
  • release.yml check-ci job succeeded — CI gate verified (ancestry + successful ci.yml run)
  • release.yml docker job succeeded — all three images pushed to GHCR:
    • ghcr.io/OWNER/pulp-engine:v0.X.Y — API server (all storage/render modes)
    • ghcr.io/OWNER/pulp-engine-worker:v0.X.Y — PDF render worker (container/socket mode)
    • ghcr.io/OWNER/pulp-engine-controller:v0.X.Y — render controller (socket mode only)
  • GitHub Release created with auto-generated notes

latest tag policy: latest always follows the most recent tag push through this workflow. If cutting a patch release from an older branch, omit the latest tag entry in release.yml before pushing, then revert the workflow change after.

Deployment (postgres mode)

# Run migrations using the same image tag
docker run --rm --entrypoint /app/node_modules/.bin/prisma \
  -e DATABASE_URL=... ghcr.io/OWNER/pulp-engine:v0.X.Y \
  migrate deploy --schema /app/src/prisma/schema.prisma

# Pull and start the new container
docker pull ghcr.io/OWNER/pulp-engine:v0.X.Y
docker stop pulp-engine && docker rm pulp-engine
docker run -d --name pulp-engine [same -p/-e/-v flags] ghcr.io/OWNER/pulp-engine:v0.X.Y
  • Migrations applied without errors
  • HARDEN_PRODUCTION=true confirmed in deployment env with all 7 controls satisfied (see hardening checklist below)
  • ./scripts/validate-deploy.sh $BASE_URL $API_KEY_ADMIN loan-approval-letter $METRICS_TOKEN exits 0, 0 warns

Deployment (file mode)

docker pull ghcr.io/OWNER/pulp-engine:v0.X.Y
docker stop pulp-engine && docker rm pulp-engine
docker run -d --name pulp-engine [same -p/-e/-v flags] ghcr.io/OWNER/pulp-engine:v0.X.Y
  • HARDEN_PRODUCTION=true confirmed in deployment env with all 7 controls satisfied (see hardening checklist below)
  • ./scripts/validate-deploy.sh $BASE_URL $API_KEY_ADMIN "" $METRICS_TOKEN exits 0, 0 warns

Deployment (sqlserver mode)

# Run migrations using the same image tag
docker run --rm --entrypoint node \
  -e SQL_SERVER_URL=... ghcr.io/OWNER/pulp-engine:v0.X.Y \
  /app/dist/scripts/migrate-sqlserver.js

# Pull and start the new container
docker pull ghcr.io/OWNER/pulp-engine:v0.X.Y
docker stop pulp-engine && docker rm pulp-engine
docker run -d --name pulp-engine [same -p/-e/-v flags] ghcr.io/OWNER/pulp-engine:v0.X.Y
  • Migrations applied without errors
  • HARDEN_PRODUCTION=true confirmed in deployment env with all 7 controls satisfied (see hardening checklist below)
  • ./scripts/validate-deploy.sh $BASE_URL $API_KEY_ADMIN loan-approval-letter $METRICS_TOKEN exits 0, 0 warns

Hardening checklist

Required for supported production deployments. Running without HARDEN_PRODUCTION=true is evaluation posture — advisory warnings are logged but controls are not enforced.

  • HARDEN_PRODUCTION=true set in production environment — startup fails with a combined error listing every unconfigured control
  • All 7 controls configured: CORS_ALLOWED_ORIGINS (specific origins, not *), DOCS_ENABLED (explicitly set), METRICS_TOKEN, REQUIRE_HTTPS=true, TRUST_PROXY=true, BLOCK_REMOTE_RESOURCES=true, and EDITOR_USERS_JSON or ALLOW_SHARED_KEY_EDITOR=true
  • If OIDC/SSO is enabled, the two conditional OIDC controls are also satisfied: OIDC_REDIRECT_URI uses https://, and OIDC_EDITOR_GROUPS is set explicitly (not left at the default *, which grants editor scope to every authenticated SSO user)
  • Startup log shows security.corsOriginsConfigured: true, security.metricsTokenRequired: true, security.requireHttps: true

Note: HARDEN_PRODUCTION is enforced by default when NODE_ENV=production. Existing deployments must configure all seven controls (plus the conditional OIDC controls when OIDC is enabled) or set HARDEN_PRODUCTION=false to opt out. See deployment-guide.md § Hardened Production Mode.

Security hardening checks (additional verification for new deployments):

  • CORS_ALLOWED_ORIGINS set to specific trusted origins — no startup warning in the log (note: wildcard * silences the warning but is rejected in hardened mode)
  • DOCS_ENABLED=false confirmed (Docker image default) or DOCS_ENABLED=true acknowledged — no startup warning in the log
  • METRICS_TOKEN set and Prometheus scraper updated with Authorization: Bearer <token>
  • TRUST_PROXY=true and REQUIRE_HTTPS=true set when behind a TLS-terminating reverse proxy
  • BLOCK_REMOTE_RESOURCES=true set — render pipeline cannot fetch arbitrary external resources
  • EDITOR_USERS_JSON configured for per-user audit trails, or ALLOW_SHARED_KEY_EDITOR=true explicitly acknowledged

Legacy SVG asset remediation (v0.34.0+, upgrading from v0.26.x or v0.27.x):

If this deployment previously accepted SVG uploads (before v0.27.0), complete this after upgrade:

  • Check startup log for legacy_svg_detected warning — if absent, no action needed
  • If warning is present: GET /assets?legacySvg=true (admin credentials) to enumerate affected assets
  • For each returned asset: identify which templates reference it before deleting
  • Upload raster replacements (PNG/WebP) via POST /assets/upload and update template definitions
  • Delete each legacy SVG with DELETE /assets/:id once no templates reference it
  • Restart the server and confirm legacy_svg_detected no longer appears in the startup log

See the runbook § Asset upload validation for the full workflow.

Post-deploy monitoring (first 15 minutes)

  • GET /health/ready returns 200
  • GET /metrics has non-zero request counts (or returns 401 if METRICS_TOKEN is set — expected)
  • No ERROR-level log lines
  • Render failure metric at baseline
  • P99 PDF latency within acceptable range

Rollback triggers

Initiate rollback if any of the following occur in the first 15 minutes:

  • Validation script exits non-zero
  • GET /health/ready returns 503
  • Render failure spike in metrics
  • Unacceptable P99 latency regression

Rollback

docker stop pulp-engine && docker rm pulp-engine
docker run -d --name pulp-engine [same flags] ghcr.io/OWNER/pulp-engine:v0.PREV.Y
./scripts/validate-deploy.sh http://localhost:3000 $API_KEY_ADMIN
  • Rollback validation passed
  • Metrics and logs back to baseline
  • Incident documented; root cause identified before re-attempting upgrade

MVP Readiness Checklist (one-time, archived)

Tick each item before declaring the MVP ready for first use. Items marked (CI) are verified automatically on every push.


1. Environment / Setup

  • .env created from .env.example
  • DATABASE_URL set and reachable (postgresql://...)
  • HOST and PORT set (0.0.0.0 / 3000 for dev)
  • NODE_ENV set (development or production)
  • Node 22–24 installed (node --version)
  • pnpm 10.32.1 installed (pnpm --version)
  • pnpm install completed without errors

2. Database

  • PostgreSQL instance running and accessible at the configured host/port
  • Database pulp-engine exists (created by migration or db push)
  • Schema applied — either:
    • Dev: pnpm db:migrate (Prisma migrate dev)
    • CI/fresh: prisma db push (applies schema directly)
  • Prisma client generated: pnpm db:generate
  • Both tables exist: templates, template_versions

3. Sample Templates

  • pnpm db:seed run successfully — output shows both templates seeded:
    • loan-approval-letter@1.0.0
    • sample-invoice@1.0.0
  • GET /templates returns both templates
  • loan-approval-letter — currency formatArgs confirmed as ["AUD"] on the amount table column
  • sample-invoice — all three {{currency ...}} content strings include "AUD"; unitPrice column has formatArgs: ["AUD"]
  • Pagination hints in place on loan-approval-letter: section-signature has breakInside: "avoid"
  • loan-approval-letterrich-opening node present in section-body; contains at least one paragraph, one unorderedList with a nested sublist (one level deep — maximum supported), one link, and one lineBreak

4. Build / Test / CI

  • (CI) pnpm build — all packages compile without errors
  • (CI) pnpm test — all suites pass:
    • @pulp-engine/html-renderer: 101 tests (node renderers including richText and chart, escape utilities, formatArgs, footer alignment, conditionExpression)
    • @pulp-engine/chart-renderer: 23 tests (bar, line, pie SVG output)
    • @pulp-engine/data-adapter: 87 tests across 3 files (adapter, condition evaluation, resolver/bracket indexing)
    • @pulp-engine/schema-validator: 5 tests
    • @pulp-engine/template-model: 36 tests (rich-text colour validation) — previously a no-op test script; now wired to vitest run and genuinely executed
    • @pulp-engine/editor: ~423 tests across 11+ files (see test run output for current count):
      • template-validator.test.ts — 28 tests (template structure validation)
      • rich-text-tiptap.test.ts — 20 tests (AST↔Tiptap conversion layer)
      • RichTextVisualEditor.test.tsx — 60 tests (toolbar accessibility, roving tabindex, colour palette, link popover, protocol validation, URL normalisation)
      • RichTextNodeView.test.tsx — 20 tests (inline canvas editing, empty-state CSS classes, setInlineEditingNode calls, keyboard accessibility)
      • RichTextNodeProps.test.tsx — 15 tests (inline-editing notice, tab initialisation, role=“status”, role=“alert”, aria-expanded)
      • TemplatePickerDialog.test.tsx — dialog freshness / sequence counter tests
      • NodeWrapper.test.tsx — 7 tests (drag handle tabIndex, isEditing suppression, aria-label)
      • node-label.test.ts — 28 tests (section/heading/text/type-name labels, Handlebars strip, truncation, makeLabelFor)
    • @pulp-engine/api: ~355 tests across 16+ files (see test run output for current count):
      • render.test.ts — render integration (PDF, HTML, 422, 404, version pinning)
      • concurrency-cap.test.ts — semaphore slot cap, FIFO release
      • error-handler.test.ts — disconnect guard, 4xx pass-through, 500 fallback
      • file-template.store.test.ts — file-mode template store
      • file-asset.store.test.ts — file-mode asset store
      • editor-session.test.ts — session token mint, verification, expiry, actor binding
      • auth-scopes.test.ts — credential scope routing, 401/403 responses
      • asset-store-binary.test.ts — IAssetBinaryStore filesystem implementation
      • s3-asset-binary.store.test.ts — S3 implementation (mocked)
      • server-static.test.ts — static route conditional registration
      • asset-access-mode.test.ts — public/private mode delivery, proxy auth, S3 URL shape
      • asset-inline.test.ts — base64 inlining, MIME types, deduplication, path traversal
      • named-users.test.ts — named-user login, actor derivation, scope, per-user revocation
      • template-validate.test.ts — response shape regression guard ({ valid, issues }, not { valid, errors })
  • (CI) pnpm --filter @pulp-engine/editor test:e2e (or CI test-e2e job) — all 12 Playwright scenarios pass. The suite starts two servers automatically via playwright.config.ts (reuseExistingServer: false for both — Playwright owns the processes):
    • API (dynamically allocated port): started via e2e/start-api.mjs with STORAGE_MODE=file, ephemeral TEMPLATES_DIR/ASSETS_DIR, no API_KEY_* keys → GET /auth/status returns { authRequired: false }LoginGate skips login. The port is selected at runtime to avoid conflicts with Docker Desktop, WSL relays, or other local services (override: E2E_API_PORT). The wrapper script explicitly deletes any inherited auth/DB env vars before spawning the API, so the result is the same regardless of developer shell or CI secrets.
    • Editor dev server (dynamically allocated port): pnpm dev --port <port> (Vite); VITE_API_URL set explicitly in the webServer env so the editor targets the E2E API (override: E2E_EDITOR_PORT).
    • rich-text.spec.ts — 7 browser scenarios (inline enter/exit, bold persistence, link popover, Visual/JSON coordination, undo/redo, Handlebars survival)
    • keyboard-reorder.spec.ts — 3 browser scenarios (drag handle tabindex, Space+Arrow reorder, Escape cancel)
    • editor-workflows.spec.ts — 2 browser scenarios (publish end-to-end, version history modal)
  • (CI) pnpm --filter @pulp-engine/editor test:e2e:auth (or CI test-e2e-auth job) — all 7 Playwright scenarios pass. Separate auth-enabled config (playwright.auth.config.ts) uses dynamically allocated ports (override: E2E_AUTH_API_PORT / E2E_AUTH_EDITOR_PORT) with API_KEY_ADMIN and EDITOR_USERS_JSON (named admin user):
    • auth-flows.spec.ts — 7 browser scenarios (login gate visible, wrong key error, correct named-user login, pre-seeded session bypass, publish under auth, version restore end-to-end, asset upload/delete)
  • .github/workflows/ci.yml present and targeting main branch; includes test-e2e job and test-e2e-auth job; both install Playwright browsers and upload report on failure
  • .github/workflows/release.yml present — triggers on tag push (v*) and manual dispatch
  • (CI) pnpm lint passes — Lint step in the ci job is a hard-fail gate (errors block CI; warnings are permitted)
  • eslint.config.mjs present at repo root — ESLint 9 flat config; confirm it includes typescript-eslint, react-hooks, and _-prefix unused-var pattern
  • CI workflow uses PostgreSQL service container with health check

5. API Verification

Run these manually against the running API before release:

  • GET /health200 { "status": "ok", "version": "..." }
  • POST /render/pdf with valid loan-approval-letter payload → 200, binary PDF, first bytes %PDF
  • Open the PDF — confirm AUD currency, formatted dates, guarantor section conditional on flag
  • POST /render/html with same payload → 200, HTML contains recipient name and fee table
  • richText smoke test — render loan-approval-letter via POST /render/html and verify:
    • “Dear [applicant name],” present — Handlebars resolved in a text leaf
    • <strong>conditionally approved</strong> present — bold mark rendered
    • style attribute containing color:#c05000 present — colour text leaf rendered
    • <ul> with a nested <ul> inside the second <li> — one-level sublist rendered
    • <a href="https://example.com/terms"> present — safe https:// link rendered
    • <br /> present in the contact paragraph — line break rendered
    • Open rich-opening in the editor’s richText Visual mode — bold/colour marks visible; colour palette button present in toolbar
    • Switch to JSON mode — full RichTextBlock[] array is editable
    • Double-click the rich-opening node on the canvas — inline editor opens in place; Escape exits
    • Verify keyboard reorder: tab to any drag handle, Space to pick up, ArrowDown, Space to drop
  • POST /render/pdf with data: {}422 { "error": "Validation Failed", "issues": [...] }
  • POST /render/pdf with "template": "does-not-exist"404 { "error": "Not Found" }
  • GET /templates/loan-approval-letter/sample → returns a valid sample payload object
  • POST /templates/loan-approval-letter/validate with sample payload → { "valid": true }
  • POST /render/pdf with 20-row fee table (pagination test) → PDF has 2+ pages, table header repeats on page 2
  • GET /assets200, paginated envelope with items array (empty items is valid on a fresh install)
  • POST /assets/upload with a PNG file → 201, response includes id, url, filename
  • DELETE /assets/:id with the returned ID → 204 No Content

6. Documentation

  • README.md present at repo root — covers prerequisites, first-time setup, dev/test/seed commands
  • .env.example present — documents all required env vars
  • docs/api-guide.md — covers seeding, /render, /render/html, errors, template CRUD, discovery, C#/JS/PHP/VB.NET examples
  • docs/mvp-technical-spec.md — template structure, all node types, field mapping, helpers, limitations
  • docs/editor-guide.md — visual editor usage, save modes, version history, drag-and-drop, keyboard reorder, richText inline editing, toolbar accessibility
  • docs/api-test.http — REST Client file with full request set for manual verification
  • docs/release-checklist.md — this file

7. Known Limitations

See the canonical Current limitations table in mvp-scope.md — that is the authoritative list. The engine-level constraints most relevant to release sign-off are:

ConstraintDetail
Render-time memoryChrome PDF generation and HTML rendering still use process memory; the Node.js PDF buffer is eliminated by streaming on both PDF routes. A concurrency limiter caps simultaneous Chrome pages at 5; excess requests queue rather than spawning unbounded pages
null equals absentexists/notExists operators treat null the same as undefined
dataPath bracket indexing restrictionsitems[0].sub syntax is supported in repeater, table, and chart dataPath. Only non-negative integer indices are valid; negative, float, quoted, and bare bracket-first paths throw DataPathError at render time.
Per-request locale override not supportedLocale is configured at template level via renderConfig.locale

8. Features shipped beyond initial MVP scope

The following capabilities were originally considered deferred but were implemented before first release:

FeatureStatus
Drag-and-drop template editor (apps/editor)Implemented and in internal pilot
Authentication / authorisationImplemented — scoped API keys (API_KEY_ADMIN, API_KEY_RENDER, API_KEY_PREVIEW, API_KEY_EDITOR), HMAC-signed editor session tokens, named-user mode (EDITOR_USERS_JSON), and OIDC/PKCE (opt-in). See api-guide.md and oidc-guide.md.
Template version pinningImplemented — optional version field in POST /render/pdf body
inputSchema + fieldMappings editorsImplemented — structured UI panels in apps/editor
Streaming PDF response (both PDF routes)Implemented — Puppeteer createPDFStream() → Node.js Readable → Fastify reply; applies to POST /render/pdf and POST /render/preview/pdf
Expression-based field mappingsImplemented — jexl expression field evaluated in DataAdapter.adapt()
conditionExpression jexl escape hatchImplemented — evaluated by html-renderer; takes precedence over structured condition when non-empty
Image asset storageImplemented — POST /assets/upload, GET /assets, DELETE /assets/:id; files stored in ASSETS_DIR and served statically at /assets/*; editor validates image src references against asset list; v0.22.0 added S3-compatible object storage via ASSET_BINARY_STORE=s3; v0.23.0 added ASSET_ACCESS_MODE=private for authenticated asset proxy and server-side PDF inlining
richText node (structured rich text content)Implemented — paragraph, ordered/unordered list (one-level nested), bold, italic, underline, text colour (8-preset palette, #rrggbb/rgb()/hsl() formats), links (safe protocols, bare-domain normalisation), line breaks. Safe HTML renderer with whitelisted tags. Visual and JSON editing modes in apps/editor; inline canvas editing (double-click or Enter); WCAG-compliant toolbar with roving tabindex.
Keyboard drag/reorderImplemented — Space+ArrowDown/Up on drag handle to pick up/move/drop; Escape to cancel; human-readable live-region announcements via resolveNodeLabel.
Browser-level (Playwright) E2E coverageImplemented — auth-disabled suite: rich-text.spec.ts (7 scenarios), keyboard-reorder.spec.ts (3 scenarios), editor-workflows.spec.ts (2 scenarios: publish end-to-end, version history modal); auth-enabled suite: auth-flows.spec.ts (7 scenarios: login gate, wrong key, named-user login, pre-seeded session, auth publish, version restore, asset upload/delete); CI test-e2e and test-e2e-auth jobs both upload report on failure.
Named-user credentials and audit attributionImplemented — EDITOR_USERS_JSON registry; server-derived identity; individual roles (editor/admin); per-user tokenIssuedAfter; createdBy on template versions and assets; identity pill in editor header; identityMode in auth/status (v0.23.0)