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
50 changes: 50 additions & 0 deletions .changeset/template-manifest-optional-manifest-id.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
"@objectstack/spec": minor
---

feat(spec): `manifestId` is optional on `TemplateManifestSchema`, and every shipped template manifest is now parsed against it (#7319)

`TemplateManifestSchema` describes the on-disk `objectstack.manifest.json`, and
the bundled blank template declares it as its `$schema` — but the file did not
satisfy it. The schema inherited `manifestId` from `CreatePackageRequestSchema`
as **required**, and no template has ever declared one. Measured on `main`, that
was the *only* complaint the shipped file produced:

```
manifestId: Invalid input: expected string, received undefined (invalid_type)
```

Nothing broke, because nothing parsed the file. `create-objectstack` reads and
rewrites it as raw JSON and does not depend on `@objectstack/spec` at all;
`objectstack package publish` also reads it raw, resolving the id as
`--manifest-id ?? manifest.manifestId ?? deriveManifestId(artifact, path)`. That
fallback is the measurement: on this file the id is a declarative **default**,
not a requirement — a template tree that declares none publishes fine, deriving
`local.<slug>` from the compiled artifact.

**The key is now optional on the on-disk descriptor**, declared locally rather
than by loosening the shared base:

- `TemplateManifestSchema` omits the inherited field and re-declares it
`.optional()`, reusing the publish field's value constraints — the same
omit-then-extend split `namespace` uses (#6861), in the other direction. A
malformed id is still rejected: optional is not unvalidated.
- **`CreatePackageRequestSchema` is untouched.** The publish request that reaches
the control plane still requires `manifestId` — the package row is addressed
by it and it is immutable once set. Widening the base would have made a publish
request with no package identity parse, which is the collapse the local
override exists to avoid.

Authoring is unchanged in the accepting direction: a manifest that declares a
`manifestId` still parses exactly as before, and every other required key
(`displayName`, `name`, `specVersion`) is still required.

**New gate — `check:template-manifests`.** Every `objectstack.manifest.json`
under `packages/create-objectstack/src/templates/` is parsed against
`TemplateManifestSchema` on every PR (unfiltered, in the required
`TypeScript Type Check` job). This is the check that would have caught both
drifts this file has now accumulated — #6861's silently stripped `namespace` and
this one — so the `$schema` line those files carry stops being an unverified
claim. It walks the template tree rather than a hand-kept file list, so a
template added later is covered on the day it lands, and it fails rather than
reporting success if it finds nothing to parse.
14 changes: 14 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1053,6 +1053,20 @@ jobs:
- name: Check the react-blocks contract is in sync with the spec
run: pnpm --filter @objectstack/spec check:react-blocks

# The shipped `objectstack.manifest.json` files declare TemplateManifestSchema
# as their `$schema`, and until #7319 nothing read that claim: both consumers
# take the file as raw JSON (create-objectstack does not depend on the spec at
# all), so the schema and the artifact drifted twice without a red build —
# #6861's silently stripped `namespace`, then a required `manifestId` the blank
# template has never carried. One parse per shipped manifest closes the class.
#
# Reads `src/` and the template trees, so it needs no build and belongs in this
# pre-build group with the other source audits. No paths filter and required,
# for the standard reason: a filter on packages/create-objectstack/** would go
# dormant on exactly the PR that tightens the schema instead.
- name: Check every shipped template manifest satisfies TemplateManifestSchema
run: pnpm --filter @objectstack/spec check:template-manifests

# Example apps are AI-authoring reference templates; a red typecheck is a
# bad signal to copy from. tsup transpiles them without a full typecheck,
# so build alone will not catch type drift — typecheck them explicitly.
Expand Down
11 changes: 9 additions & 2 deletions content/docs/references/cloud/template-manifest.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,14 @@ description: Template Manifest protocol schemas
`objectstack.manifest.json` — on-disk descriptor for a template / package
source tree. Strict projection of `CreatePackageRequestSchema` (server-
managed fields excluded) plus scaffold-time extras (name slug,
specVersion, namespace, skills, preview, scaffold, readmePath).
specVersion, namespace, skills, preview, scaffold, readmePath), with
`manifestId` locally relaxed to OPTIONAL — this file is a source tree, not
a publish request (#7319; the field's own TSDoc carries the measurement).

Every shipped `objectstack.manifest.json` is parsed against this schema by
`pnpm --filter @objectstack/spec check:template-manifests`, so the
`$schema` line those files carry is a verified claim rather than an
assertion nothing reads.

<Callout type="info">
**Source:** `packages/spec/src/cloud/template-manifest.zod.ts`
Expand All @@ -34,7 +41,6 @@ objectstack.manifest.json — template / package source descriptor

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **manifestId** | `string` | ✅ | Globally unique reverse-domain package identifier (e.g. com.acme.crm) |
| **displayName** | `string` | ✅ | Display name shown in Studio and Marketplace |
| **description** | `string` | optional | Short package description |
| **visibility** | `Enum<'private' \| 'org' \| 'marketplace'>` | optional | Package visibility: private = owner org only; org = all envs in owner org; marketplace = public registry |
Expand All @@ -46,6 +52,7 @@ objectstack.manifest.json — template / package source descriptor
| **publisher** | `Enum<'objectstack' \| 'partner' \| 'community' \| 'private'>` | optional | Package publisher provenance tier |
| **isStarter** | `boolean` | optional | |
| **translations** | `Record<string, { displayName?: string; description?: string; readme?: string; tagline?: string; … }>` | optional | Locale-keyed overrides; missing keys fall back to base columns |
| **manifestId** | `string` | optional | Optional declarative default for the published package id (reverse-domain, e.g. com.acme.crm). Absent on a template source tree: `objectstack package publish` falls back to --manifest-id and then to a derived `local.<slug>`. NOT optional on the publish request itself |
| **name** | `string` | ✅ | CLI slug (kebab-case, no namespace prefix) |
| **specVersion** | `string` | ✅ | Compatible @objectstack/spec semver range |
| **namespace** | `string` | optional | Scaffold-only: the template’s own metadata namespace, rewritten by create-objectstack at scaffold time and read back as the fallback source for the template’s original namespace. NOT the publish namespace — publish reads that off the compiled artifact’s manifest.namespace (ADR-0048 addendum §A.2) |
Expand Down
1 change: 1 addition & 0 deletions packages/spec/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@
"check:react-blocks": "tsx scripts/build-react-blocks-contract.ts --check",
"check:react-declaration-parity": "tsx scripts/check-react-blocks-declaration-parity.ts",
"check:skill-examples": "tsx scripts/check-skill-examples.ts --self-test && tsx scripts/check-skill-examples.ts",
"check:template-manifests": "tsx scripts/check-template-manifests.ts --self-test && tsx scripts/check-template-manifests.ts",
"check:test-typecheck": "tsx ../../scripts/check-test-typecheck.mts --self-test && tsx ../../scripts/check-test-typecheck.mts --package packages/spec --project tsconfig.test.json",
"gen:test-typecheck-debt": "tsx ../../scripts/check-test-typecheck.mts --update --package packages/spec --project tsconfig.test.json",
"check:scripts-typecheck": "tsc --noEmit -p tsconfig.scripts.json",
Expand Down
11 changes: 11 additions & 0 deletions packages/spec/scripts/check-generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,17 @@ const NO_GENERATOR: ReadonlyArray<{ check: string; why: string }> = [
{ check: 'check:liveness', why: 'audits whether declared spec properties have a reader — no artifact' },
{ check: 'check:empty-state', why: 'audits empty-state coverage — no artifact' },
{ check: 'check:skill-examples', why: 'validates skill examples parse — no artifact' },
// #7319. Reads `src/` and the shipped template trees and writes nothing: a
// failure is either a manifest to fix or a schema to fix, never a `gen:` to
// run. It audits the inverse direction from everything in GATED — those
// compare an artifact this package GENERATES against its source, this one
// compares a file another package SHIPS against the schema that claims to
// describe it. Two drifts had already accumulated in that blind spot (#6861's
// stripped `namespace`, #7319's required-but-absent `manifestId`).
{
check: 'check:template-manifests',
why: 'parses every shipped objectstack.manifest.json against TemplateManifestSchema — no artifact',
},
// Landed in #4177 while this ledger landed in #4183 — neither PR could see the
// other, so `main` carried an unclassified script and this reconciliation was
// failing on `main` itself. The doc it checks against is hand-written, so there
Expand Down
Loading
Loading