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. Thecheck-cijob (scoped tobranch=main,event=push) queries the GitHub Actions API and fails the pipeline if no successfulci.ymlrun exists for the exact release commit. It also verifies the commit is an ancestor oforigin/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 viacheck-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.tsextract-openapi.tsreads the version from the rootpackage.json, so every routine version bump produces a 1-lineinfo.versiondiff inopenapi.jsonplus the matching SDK typed-artifact diff. Skipping this step makes CI’sVerify openapi.json is freshjob (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.ymlcheck-ci job succeeded — CI gate verified (ancestry + successful ci.yml run) -
release.ymldocker 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
latesttag policy:latestalways follows the most recent tag push through this workflow. If cutting a patch release from an older branch, omit thelatesttag entry inrelease.ymlbefore 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=trueconfirmed 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_TOKENexits 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=trueconfirmed in deployment env with all 7 controls satisfied (see hardening checklist below) -
./scripts/validate-deploy.sh $BASE_URL $API_KEY_ADMIN "" $METRICS_TOKENexits 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=trueconfirmed 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_TOKENexits 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=trueset 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, andEDITOR_USERS_JSONorALLOW_SHARED_KEY_EDITOR=true - If OIDC/SSO is enabled, the two conditional OIDC controls are also satisfied:
OIDC_REDIRECT_URIuseshttps://, andOIDC_EDITOR_GROUPSis 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_ORIGINSset to specific trusted origins — no startup warning in the log (note: wildcard*silences the warning but is rejected in hardened mode) -
DOCS_ENABLED=falseconfirmed (Docker image default) orDOCS_ENABLED=trueacknowledged — no startup warning in the log -
METRICS_TOKENset and Prometheus scraper updated withAuthorization: Bearer <token> -
TRUST_PROXY=trueandREQUIRE_HTTPS=trueset when behind a TLS-terminating reverse proxy -
BLOCK_REMOTE_RESOURCES=trueset — render pipeline cannot fetch arbitrary external resources -
EDITOR_USERS_JSONconfigured for per-user audit trails, orALLOW_SHARED_KEY_EDITOR=trueexplicitly 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_detectedwarning — 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/uploadand update template definitions - Delete each legacy SVG with
DELETE /assets/:idonce no templates reference it - Restart the server and confirm
legacy_svg_detectedno longer appears in the startup log
See the runbook § Asset upload validation for the full workflow.
Post-deploy monitoring (first 15 minutes)
-
GET /health/readyreturns 200 -
GET /metricshas non-zero request counts (or returns 401 ifMETRICS_TOKENis 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/readyreturns 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
-
.envcreated from.env.example -
DATABASE_URLset and reachable (postgresql://...) -
HOSTandPORTset (0.0.0.0/3000for dev) -
NODE_ENVset (developmentorproduction) - Node 22–24 installed (
node --version) - pnpm 10.32.1 installed (
pnpm --version) -
pnpm installcompleted without errors
2. Database
- PostgreSQL instance running and accessible at the configured host/port
- Database
pulp-engineexists (created by migration ordb push) - Schema applied — either:
- Dev:
pnpm db:migrate(Prisma migrate dev) - CI/fresh:
prisma db push(applies schema directly)
- Dev:
- Prisma client generated:
pnpm db:generate - Both tables exist:
templates,template_versions
3. Sample Templates
-
pnpm db:seedrun successfully — output shows both templates seeded:loan-approval-letter@1.0.0sample-invoice@1.0.0
-
GET /templatesreturns both templates -
loan-approval-letter— currency formatArgs confirmed as["AUD"]on theamounttable column -
sample-invoice— all three{{currency ...}}content strings include"AUD";unitPricecolumn hasformatArgs: ["AUD"] - Pagination hints in place on
loan-approval-letter:section-signaturehasbreakInside: "avoid" -
loan-approval-letter—rich-openingnode present insection-body; contains at least oneparagraph, oneunorderedListwith a nestedsublist(one level deep — maximum supported), onelink, and onelineBreak
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-optestscript; now wired tovitest runand 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 testsNodeWrapper.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 releaseerror-handler.test.ts— disconnect guard, 4xx pass-through, 500 fallbackfile-template.store.test.ts— file-mode template storefile-asset.store.test.ts— file-mode asset storeeditor-session.test.ts— session token mint, verification, expiry, actor bindingauth-scopes.test.ts— credential scope routing, 401/403 responsesasset-store-binary.test.ts— IAssetBinaryStore filesystem implementations3-asset-binary.store.test.ts— S3 implementation (mocked)server-static.test.ts— static route conditional registrationasset-access-mode.test.ts— public/private mode delivery, proxy auth, S3 URL shapeasset-inline.test.ts— base64 inlining, MIME types, deduplication, path traversalnamed-users.test.ts— named-user login, actor derivation, scope, per-user revocationtemplate-validate.test.ts— response shape regression guard ({ valid, issues }, not{ valid, errors })
- (CI)
pnpm --filter @pulp-engine/editor test:e2e(or CItest-e2ejob) — all 12 Playwright scenarios pass. The suite starts two servers automatically viaplaywright.config.ts(reuseExistingServer: falsefor both — Playwright owns the processes):- API (dynamically allocated port): started via
e2e/start-api.mjswithSTORAGE_MODE=file, ephemeralTEMPLATES_DIR/ASSETS_DIR, noAPI_KEY_*keys →GET /auth/statusreturns{ authRequired: false }→LoginGateskips 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_URLset 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)
- API (dynamically allocated port): started via
- (CI)
pnpm --filter @pulp-engine/editor test:e2e:auth(or CItest-e2e-authjob) — 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) withAPI_KEY_ADMINandEDITOR_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.ymlpresent and targetingmainbranch; includestest-e2ejob andtest-e2e-authjob; both install Playwright browsers and upload report on failure -
.github/workflows/release.ymlpresent — triggers on tag push (v*) and manual dispatch - (CI)
pnpm lintpasses —Lintstep in thecijob is a hard-fail gate (errors block CI; warnings are permitted) -
eslint.config.mjspresent at repo root — ESLint 9 flat config; confirm it includestypescript-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 /health→200 { "status": "ok", "version": "..." } -
POST /render/pdfwith 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/htmlwith same payload →200, HTML contains recipient name and fee table - richText smoke test — render
loan-approval-letterviaPOST /render/htmland verify:- “Dear [applicant name],” present — Handlebars resolved in a text leaf
<strong>conditionally approved</strong>present — bold mark renderedstyleattribute containingcolor:#c05000present — colour text leaf rendered<ul>with a nested<ul>inside the second<li>— one-level sublist rendered<a href="https://example.com/terms">present — safehttps://link rendered<br />present in the contact paragraph — line break rendered- Open
rich-openingin 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-openingnode 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/pdfwithdata: {}→422 { "error": "Validation Failed", "issues": [...] } -
POST /render/pdfwith"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/validatewith sample payload →{ "valid": true } -
POST /render/pdfwith 20-row fee table (pagination test) → PDF has 2+ pages, table header repeats on page 2 -
GET /assets→200, paginated envelope withitemsarray (emptyitemsis valid on a fresh install) -
POST /assets/uploadwith a PNG file →201, response includesid,url,filename -
DELETE /assets/:idwith the returned ID →204 No Content
6. Documentation
-
README.mdpresent at repo root — covers prerequisites, first-time setup, dev/test/seed commands -
.env.examplepresent — 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:
| Constraint | Detail |
|---|---|
| Render-time memory | Chrome 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 absent | exists/notExists operators treat null the same as undefined |
dataPath bracket indexing restrictions | items[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 supported | Locale 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:
| Feature | Status |
|---|---|
Drag-and-drop template editor (apps/editor) | Implemented and in internal pilot |
| Authentication / authorisation | Implemented — 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 pinning | Implemented — optional version field in POST /render/pdf body |
| inputSchema + fieldMappings editors | Implemented — 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 mappings | Implemented — jexl expression field evaluated in DataAdapter.adapt() |
conditionExpression jexl escape hatch | Implemented — evaluated by html-renderer; takes precedence over structured condition when non-empty |
| Image asset storage | Implemented — 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/reorder | Implemented — 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 coverage | Implemented — 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 attribution | Implemented — 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) |