Skip to content

build(spec): drop the DTS pass's duplicate type check — 93% to 83% of its 6144 MB ceiling - #20483

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20419-spec-dts-heap-ceiling
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20419-spec-dts-heap-ceiling

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #20419
Clause-②: no

What changes

One file, packages/spec/tsup.config.ts:

  • The DTS pass now runs with compilerOptions: { noCheck: true }.
  • The pass's docblock now records today's measurements and the cause of the pass's weight (one TypeScript program per entry). It also records that the pass is not byte-stable. It keeps the ⛔ against raising the ceiling.

The build script in packages/spec/package.json is unchanged, and so is the 6144 ceiling.

This PR says Part of, not Fixes. It is a measured mitigation that stays inside the card's file surface. The root cause has a much larger fix, but that fix moves emitted declaration bytes in about a dozen packages. The dispatch routes that kind of change to a separate decision (last section), so the card stays open for it.

Measurement: the pass at its ceiling

How it was measured.

  • The DTS pass ran alone, spelled exactly as the build script spells it (NODE_OPTIONS=--max-old-space-size=6144 BUILD_DTS=true tsup), plus --trace-gc on node's argv.
  • It ran inside a cgroup-v1 memory cgroup capped at 8192 MB. The cap was proven to kill first: a 200 MB allocation in a 64 MB cap exits 137.
  • Live heap is the largest heap V8 kept after a mark-compact, read from --trace-gc.
  • Peak RSS is the cgroup's peak anonymous RSS, sampled every 50 ms.
  • Wall times come from a shared container, so compare them as ratios.
tree pass live heap of 6144 peak RSS wall
8cdbe0c6e5 (dispatch base) as on main, 3 runs 5633-5658 MB 92% 6161-6177 MB 181-194 s
8cdbe0c6e5 noCheck 5083 MB 83% 5889 MB 134 s
ec6a275177 (base + main at dc0ab6a2ed) as on main (reverse verification) 5708 MB 93% 6252 MB 183 s
ec6a275177 noCheck (this PR) 5090 MB 83% 5905 MB 131 s
  • Live heap grew fast. It went from 5658 to 5708 MB over the 12 main commits between 8cdbe0c6e5 (13:30Z) and dc0ab6a2ed (15:13Z). In that last reading, the largest heap before a mark-compact was 5984 MB. The worker's hard heap limit is 6192 MB (6144 old space plus the young generation). That is the margin where V8 gives up with ERR_WORKER_OUT_OF_MEMORY. Heap growth mid-pass on the Test Core run was not measured here.
  • Reverse verification was run from the committed state with scripts/ablation-replace.mjs:
    • The mutation removed the compilerOptions block (anchor 1 → 0).
    • The restore returned the file's blob to HEAD's 0bc508b29eab, and git diff HEAD was empty afterwards.
  • Full build at the final head 8645ad7e07. pnpm --filter @objectstack/spec build ran inside the same 8 GB cgroup:
    • It exited 0, with peak RSS 6000 MB, in 169 s (DTS pass 128 s).
    • check-dts-emitted: 36/36 declared declaration files present.
    • check-dts-references: 130 files, 394/394 relative references resolved.

Root cause: one ts.Program per entry

  • tsup 8.5.1 runs its DTS pass through a bundled copy of rollup-plugin-dts 6.1.1.
  • That plugin's createPrograms groups entries into programs by a directory key.
  • tsup always passes the tsconfig path. On that path, getCompilerOptions hits its config cache for every entry after the first, and a cache hit keys the entry by its own directory instead of the config's.
  • Every spec entry lives in its own directory, so the 18 entries get 18 programs. Each program parses, binds and declaration-emits its whole reachable graph again.
  • The same code is still in rollup-plugin-dts 6.5.1, the latest release. tsup 8.5.1 is also the latest tsup.

Evidence.

  • A ts.createProgram probe (a --require preload in the DTS worker) on a three-entry pass printed three programs, each with one root. They held 193, 123 and 274 non-declaration source files, and did 145, 0 and 69 emits.
  • Peak live heap grows with the number of entries, not with the graph:
    • each single entry alone: 330-810 MB, 3-27 s;
    • the 9-entry half with src/index.ts: 3261 MB;
    • the other 9-entry half: 1442 MB;
    • all 18 entries: 5658 MB.
  • For comparison, tsc --noEmit over the whole package peaks at 1103 MB in 18 s.
  • A copy of tsup with the cache-hit key corrected (outside this tree; the store's hard-linked original was not touched) built one program with 18 roots and 394 emits: 1379 MB live, 53 s.

Why noCheck is safe

  • rollup-plugin-dts forces noEmitOnError. Before each emit, every program therefore ran a full semantic check of the file it was emitting.
  • The package's typecheck script already does that check, as tsc --noEmit over the same tsconfig.json, in the required TypeScript Type Check job.
  • noCheck drops only the duplicate. Syntactic, option, global and declaration diagnostics still fail the pass, so a declaration that cannot be emitted still stops the build.
  • A plain type error in src/ is reported by typecheck, as it already was. AGENTS.md's Build & Test block already says tsup never type-checks.

Emitted declarations: the same types. Byte identity was never a property of main.

The dispatch asked for a byte-identical tree. That cannot be measured against main, because main does not produce the same bytes twice:

  • Three runs of 8cdbe0c6e5, same config, gave three tree digests: 7bf19190…, 5cf3234c…, 6fd2cecf…. The digest is one sha256 over the sorted list of per-file sha256 values.
  • Each run had the same 128 files and the same 30389539 bytes. The differences are union members, and the members of the object types built from them, printed in type-creation order. The content-hashed chunk names change with them.

So the trees were compared in an order-insensitive form. Each file is re-printed through the TypeScript printer with:

  • union members sorted,
  • type literals made only of property signatures sorted,
  • rollup's 8-character chunk hashes stripped from file names and specifiers.

Nothing else is normalised.

  • 8cdbe0c6e5: main run b, main run c and the noCheck run give one normalised digest, with 0 files differing.
  • ec6a275177: main behaviour, noCheck, and the tree from the full build give one normalised digest, with 0 files differing.
  • Negative controls: renaming one union member and deleting one property in a copied tree were both caught. The comparison named exactly the one and then two mutated files.

⇒ noCheck's tree differs from main's only in the ways two builds of main already differ from each other.

Why not split the pass across entries

The split was measured. Two halves peak at 3261 MB and 1442 MB, so memory would fit.

But a split redraws rollup's shared chunks. The halves emitted 60 + 32 files against the single pass's 128. A split publishes duplicated declarations across entries, and a class or unique symbol declared twice stops being one type. That changes what publishes, which the dispatch puts on a different card.

What reads the changed file

  • The DTS pass. This is the only place the change takes effect; the JS pass runs with dts: false.
  • turbo's build task. The file is one of spec's package inputs, so spec and its dependents rebuild once in CI.
  • scripts/check-dev-prereqs.mjs. Its build-input hash includes tsup.config.ts. A dev dist built before this change reads as stale until it is rebuilt, which is the intended behaviour.
  • The docs deploy. It builds spec through turbo run build --filter=@objectstack/docs, so it runs the same pass, now lighter.

No consumer of the build script invokes anything new.

Gates, final head 8645ad7e07

  • pnpm --filter @objectstack/spec build: exit 0 (8 GB cgroup, above).
  • pnpm --filter @objectstack/spec check:generated: exit 0. All 15 generated artifacts are up to date against the dist that build emitted, with a declaration stamp match.
  • pnpm check:turbo-task-graph: exit 0.
  • pnpm check:dts-closure: exit 0 (36/36).
  • pnpm check:nul-bytes: exit 0.
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 51 commands. --ran reconciled them as 51 accounted: 49 exit 0, and 2 NOT MEASURED (exit 3, prerequisite):
    • check:dual-build-cjs-loads needs every package built.
    • check:lean-entry-closure needs @objectstack/objectql built.
    • This change does not touch the JS pass either of them reads. Both are left to CI.
  • Spec's dist readers: check:browser-reachable-entries, check:entry-nameability, check:dual-source-exports and check:exported-any all exit 0.
  • Tests:
    • pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2: 568 files, 16691 passed, 1 todo.
    • The 8 test files that read tsup.config.ts as text also passed on their own run (130 tests).
  • typecheck was not run. The changed file is in none of the package's three tsc programs (tsconfig.json and tsconfig.test.json include src/**/*; tsconfig.scripts.json includes scripts/**/*), so its inputs match main's.

Changeset: none, and skip-changeset. The JS outputs and every shipped source file are untouched. The declaration tree matches main's in the order-insensitive form above. A byte digest cannot tell this build apart from another build of main.

Acceptance notes

  • The pass is not byte-stable run to run. This is the observation above, recorded in the docblock. Any gate or review that compares two declaration trees by byte digest will report changes that are not there.
  • The previous docblock said every completing ceiling emitted a byte-identical tree. That no longer holds at 8cdbe0c6e5. The docblock's measured table was replaced with today's numbers, dated by commit.
  • This is a mitigation, not the fix. noCheck restores about 620 MB of headroom (93% → 83% of the ceiling). At the growth measured above, that headroom is not durable, so the root cause needs the decision below.

Decision needed: the root cause

Cutting 18 programs to one takes the pass from about 5.7 GB to 1.4 GB live, and from about 185 s to 53 s. Measured on spec, the one-program tree matches main's once three more kinds of ordering are also normalised:

  • top-level statement order,
  • the order of names inside import / export braces,
  • one shared chunk's name (data-engine → analytics.zod, paired by content).

The same kinds of change would reach every other multi-entry tsup package (about eleven, including core, objectql, metadata, types and plugin-auth). The options are in the dispatch report on the card; the recommendation there is a pnpm patch of tsup's bundled rollup-plugin-dts plus an upstream report.


Generated by Claude Code

The spec DTS pass sat at 92% of its 6144 MB heap ceiling (5658 MB live
after mark-compact, 8 GB cgroup, 8cdbe0c), so CI runs near the margin
ended in ERR_WORKER_OUT_OF_MEMORY.

rollup-plugin-dts forces noEmitOnError, so the pass type-checked every file
it emitted, in each of the one-per-entry programs tsup's bundled
rollup-plugin-dts builds. That check duplicates the typecheck script over
the same tsconfig. noCheck drops it: 5083 MB live, 134s instead of
181-194s. The emitted declarations are the same types. They differ from
main only in union/property order, and main's own reruns differ that way
too.

The docblock now records the per-entry-program cause, today's
measurements, and the fact that the pass is not byte-stable.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see

Coarse fallback — 137 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 8e028591857980ae69b9f9badb380dfa61367e62 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from aa384d4a0f0d5d06fee2e027eec1dcb7fd679481 — the merge of head 8645ad7e07578e276c78225e9785c80b6465ab7e into base 8e028591857980ae69b9f9badb380dfa61367e62, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin aa384d4a0f0d5d06fee2e027eec1dcb7fd679481 && git checkout aa384d4a0f0d5d06fee2e027eec1dcb7fd679481
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8e028591857980ae69b9f9badb380dfa61367e62 8645ad7e07578e276c78225e9785c80b6465ab7e && git checkout -B drift-repro 8e028591857980ae69b9f9badb380dfa61367e62 && git merge --no-ff 8645ad7e07578e276c78225e9785c80b6465ab7e

node scripts/docs-audit/affected-docs.mjs --json 8e028591857980ae69b9f9badb380dfa61367e62

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet objectstack-fleet Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 28, 2026
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 28, 2026 17:31
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
Merged via the queue into main with commit 8113763 Sep 28, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20419-spec-dts-heap-ceiling branch September 28, 2026 17:59
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…in-dts grouping (spec 4997 → 882 MB live heap) (objectstack-ai#20499)

Fixes objectstack-ai#20419
Clause-②: no

## What changes

This is round 2 of the card: the root-cause fix. The seat ruled route A,
a pnpm patch of tsup's bundled rollup-plugin-dts, and the maintainer's
veto window runs until this PR lands. Round 1 (objectstack-ai#20483) landed the
`noCheck` mitigation.

- **`patches/tsup@8.5.1.patch`** changes 5 lines in `dist/rollup.js`,
which is tsup's bundled rollup-plugin-dts 6.1.1. When tsup passes a
tsconfig path, every entry is now grouped by the tsconfig's directory.
Before, an entry that hit the config cache kept its own directory, so
each entry directory got its own `ts.Program`. The patch was written
with `pnpm patch` / `pnpm patch-commit`.
- **`pnpm-workspace.yaml`** gets `patchedDependencies: tsup@8.5.1`, with
a comment on why the patch exists and what a tsup bump owes.
`patch-commit` wrote the key into `package.json`'s `pnpm` field; it was
moved here, and `package.json` is unchanged.
- **`pnpm-lock.yaml`** was regenerated by `pnpm install`. The diff is
the `patchedDependencies` block plus the `patch_hash` on each tsup
resolution (+16 / −11). No other resolution moved. The lockfile was
byte-identical before and after the key moved from `package.json` to the
yaml.
- **`packages/spec/tsup.config.ts`** changes only its docblock: the
one-program mechanism, the tsup-bump duty, today's table, and the wider
equality needed to compare trees. The 6144 ceiling is unchanged.

No package source changes.

## Census: which packages build more than one program

**Static census.** All 67 workspace packages whose scripts run tsup were
checked. Each config was loaded the way tsup loads it
(`bundle-require`), with the build script's `--config` and positional
entries applied, and the plugin's grouping simulated. 12 packages had
more than one program; the other 55 have one DTS entry.

**Empirical census.** The declaration pass of every package in the
census closure (31 packages) was built with a `ts.createProgram` probe
preloaded into the DTS worker. It confirms the 12. Programs before →
after the patch:

| package | before | after |
|---|---|---|
| `@objectstack/spec` | 18 | 1 |
| `@objectstack/platform-objects` | 11 | 1 |
| `@objectstack/metadata` | 3 (roots 1+3+1) | 1 |
| `@objectstack/metadata-core` (two config items) | 1+2 | 1+1 |
| `@objectstack/service-cluster` (two config items) | 1+2 | 1+1 |
| `@objectstack/core`, `lint`, `objectql`, `plugin-auth`,
`plugin-webhooks`, `service-datasource`, `types` | 2 each | 1 each |
| the 18 single-entry packages in the closure | 1 | 1 |

Round 1's estimate was off in both directions. It listed
`create-objectstack`, whose DTS pass has one entry (`dts.entry`), and it
missed `platform-objects`, whose object-form `entry` gave 11 programs.

## Measurements: spec at the 6144 ceiling in an 8192 MB cgroup

**Method.**
- The DTS pass ran exactly as the build script spells it, plus
`--trace-gc`.
- It ran in a cgroup-v1 memory cgroup capped at 8192 MB. Round 1 proved
that cap kills first.
- The tree is `8113763026` (with `noCheck`) against the same tree plus
the patch (`78cbf5dfbf`). Spec's source is identical in both.
- Wall times are shared-box readings.

| | programs | live heap after a mark-compact | largest heap before one
| peak RSS | wall |
|---|---|---|---|---|---|
| DTS pass, unpatched | 18 | 4997 MB | 5425 MB | 5694 MB | 153 s |
| DTS pass, patched | 1 (18 roots, 396 emits) | 882 MB (4 mark-compacts,
so a lower bound) | 1677 MB | 3537 MB | 41 s |
| whole `build` script, unpatched | | | | 5740 MB | 189 s (DTS 148 s) |
| whole `build` script, patched | | | | 3526 MB | 87 s (DTS 45 s) |

The ceiling now has about 4.5 GB of headroom. It stays at 6144; lowering
it is a separate change.

## The patch cannot rot silently (measured)

All three cases ran with the repo's pnpm 10.31.0, from `packageManager`.

- **tsup moves off 8.5.1** (a scratch project with the same patch file
and key, and tsup 8.5.0 installed): `pnpm install` exits 1 with
`ERR_PNPM_UNUSED_PATCH The following patches were not used: tsup@8.5.1`,
and `--frozen-lockfile` exits 1 with
`ERR_PNPM_LOCKFILE_CONFIG_MISMATCH`.
- **Positive control** (the same scratch project on tsup 8.5.1): the
install exits 0, and the installed `dist/rollup.js` carries the patch
marker once.
- **The yaml key is what pnpm reads** (in this tree): with the
`patchedDependencies` block removed, `pnpm install --frozen-lockfile`
exits 1 with `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH`, which is what CI's
frozen install would hit. The removal was done with
`scripts/ablation-replace.mjs`, and the restore was proven blob-equal to
HEAD.

## The emitted declarations are the same types

Per the seat's Q2 answer: canonical equality with negative controls
replaces byte identity.

**Trees compared.** All 31 packages of the census closure were built
twice, in dependency order: unpatched at `8113763026`, then patched at
`78cbf5dfbf`. Only tsup differs between the two builds. Every
`dist/**/*.d.{ts,mts,cts}` was saved each time.

**Canonical form.** Each file is re-printed through the TypeScript
printer with:
- union members sorted;
- type literals made only of property signatures sorted;
- top-level statements sorted;
- `import` / `export` specifiers sorted;
- rollup's chunk hashes stripped.

Shared chunks are paired by chunk-name-agnostic content, and the pairing
must be unique on both sides.

**Results.**
- 29 of 31 packages are byte-identical before and after, including 11 of
the 12 census packages.
- `@objectstack/spec`: 130 files, 0 differ canonically. One chunk is
paired by content: `data-engine` (unpatched) is `analytics.zod`
(patched). The files differ only in the ways round 1 recorded.
- `@objectstack/metadata-protocol` has one entry, so the patch does not
change its program count. It differs in bytes only, through union order
inlined from spec, and is canonically equal even with statement sorting
off.
- The other 29 packages are canonically equal as well: 0 differing
files, 0 pairing problems.

**Negative controls.** In a copy of each patched tree, the first real
`string` type node became `number`, located by the TypeScript parser and
never inside a comment. The control ran on:
- one entry file in each of the 12 census packages (for
`platform-objects`, `identity/index.d.ts`, because its `index.d.ts` only
re-exports);
- one chunk file in each census package that has chunks: `lint`,
`metadata-core`, `objectql`, and spec's renamed `analytics.zod` chunk.

Every control was caught, and each one named exactly the mutated file. A
mutated chunk also fails the content pairing ("no partner"). One control
(`platform-objects`) was first pointed at a directory, errored, and was
re-run on a real file. That first attempt is not counted.

## Gates, at the merged head (`57e5191f21` = this branch + `origin/main`
`fc0db22bcf`)

`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` derived 60 commands over 4 changed paths.

**Derived set.** All 60 derived gates exited 0. `--ran` reconciliation:
"60 derived famil(ies) accounted for — 60 run, 0 NOT-MEASURED". That
includes:
- `check:override-consistency`, `check:vendor-export-contract-resolve`,
`check:workspace-manifest-cycles` and `check:osv-exemptions`;
- `check:dts-closure`: 72 built packages, 166/166 declared declaration
files present;
- `check:dual-build-cjs-loads`;
- `check:lean-entry-closure`.

**Builds.**
- `turbo run build --force` over the census closure: 29/29 tasks, 0
cached.
- The rest of the workspace: `turbo run build
--filter=!@objectstack/docs`, 72/72 tasks.
- `pnpm --filter @objectstack/spec build` inside the 8192 MB cgroup:
exit 0, peak RSS 3680 MB, 77.6 s, DTS 38.7 s.
  - `check-dts-emitted`: 36/36.
  - `check-dts-references`: 130 files, 394/394.
- `pnpm --filter @objectstack/spec check:generated`: exit 0, all 15
artifacts up to date.
- `pnpm install --frozen-lockfile`: exit 0.

**Tests.** The 8 spec test files that read `tsup.config.ts` as text: 130
passed.

**Scope.** The patch reaches only the DTS worker: tsup's `index.js`
loads `dist/rollup.js` in one place, `new Worker(… "./rollup.js")`. The
JS passes are untouched.

**Changeset.** None; `skip-changeset`. Per Q2: canonical equality holds
for every census package, no JS output or shipped source moves, and 29
of 31 packages are byte-identical.

## Acceptance notes

- **The census counts DTS entries, not `entry`.** A tsup package whose
`dts.entry` narrows to one file (`create-objectstack`) has one program,
whatever its `entry` says. Object-form `entry` counts too
(`platform-objects`).
- **Chunk names and statement order moved in spec only.** The other 11
census packages emit identical bytes. Those are the kinds of drift two
unpatched builds of spec already show, so anything that compares spec's
declaration trees by byte digest reads phantom changes (round 1's note
stands).
- **Every tsup bump now owes the patch a decision.** pnpm refuses an
unused patch, as measured above, so the bump PR is where the patch is
re-derived or retired. Upstream: rollup-plugin-dts 6.5.1 still has the
same code. The report below is for the maintainer to file.
- **Upstream reporting stays with the maintainer.** Nothing was written
outside this org.

## Draft upstream report (for the maintainer to file at
rollup-plugin-dts)

> **Title:** `createPrograms` builds one `ts.Program` per entry
directory when a `tsconfig` path is passed
>
> **Version:** rollup-plugin-dts 6.5.1 (also 6.1.1, as bundled by tsup
8.5.1).
>
> **What happens:** In `getCompilerOptions`, when `overrideConfigPath`
(the plugin's `tsconfig` option) is set, the config cache key is that
path. The first entry misses the cache, and `dirName` becomes the
config's directory. Every later entry hits the cache, and `dirName`
stays `path.dirname(input)`. `createPrograms` starts a new program
whenever `dirName` changes between consecutive inputs, so entries in
different directories each get their own `ts.Program`. Each of those
programs parses, binds, and declaration-emits its whole reachable graph
again.
>
> **Impact:** In a package with 18 entries in 18 directories, the
declaration pass built 18 programs. It needed a 4997 MB live heap and
153 s. With one program it needed an 882 MB live heap and 41 s, and the
emitted declarations were the same types. tsup always passes `tsconfig`,
so every multi-entry tsup build takes this path.
>
> **Suggested fix:** On a cache hit with `overrideConfigPath` set,
return the config's directory. Equivalently: `if (overrideConfigPath)
dirName = path.dirname(path.resolve(process.cwd(),
overrideConfigPath));` before the cached options are read.
>
> **Reproduction:** Two entries, `src/a/index.ts` and `src/b/index.ts`,
passed with `tsconfig: 'tsconfig.json'`. Count the `ts.createProgram`
calls: 2. With the line above: 1.

---
_Generated by [Claude
Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/s skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants