Skip to content

Restructure the repository into a pnpm workspace with the package under packages/trilean - #13

Merged
Mearman merged 6 commits into
mainfrom
refactor/monorepo-restructure
Sep 3, 2026
Merged

Restructure the repository into a pnpm workspace with the package under packages/trilean#13
Mearman merged 6 commits into
mainfrom
refactor/monorepo-restructure

Conversation

@Mearman

@Mearman Mearman commented Sep 3, 2026

Copy link
Copy Markdown
Member

Moves the package into packages/trilean/ and makes the repository root a pnpm workspace with a turbo pipeline, so a second package can be added later without reshaping anything. The published package is unaffected: same name, same version sequence, same tarball.

This is a structural change with no behavioural change intended. Nothing under packages/trilean/src/ was edited — the move commit is a 100% rename for every path.

What changed

The package's own files (src/, test/, scripts/, its manifest, tsconfigs, tsdown/vitest/wrangler configs, its ESLint config, README and CHANGELOG) moved under packages/trilean/. The root now holds workspace-level configuration only: the pnpm workspace definition, the turbo pipeline, the shared tsconfig base, and the tooling configs.

Releases move from a single semantic-release run to @exadev/semantic-release-workspace, which releases each package from its own path-filtered commit history with tags in name@version form. That tag format differs from the single-package one (v1.3.0 then, trilean@1.3.0 now), so a trilean@1.3.0 tag was created at the same commit v1.3.0 points at — without it semantic-release would find no previous release and restart the package at 1.0.0.

CI is now one workspace-wide workflow driving every check through turbo from the root, with the repeated pnpm/Node/install/cache preamble extracted into a composite action. On pull requests, tasks run with --affected; pushes to main run the whole workspace.

Verification performed

I verified this from a completely fresh clone, independently of the implementation, and against a main baseline checked out separately.

Git history survived the move. git log --follow --oneline -- packages/trilean/src/tree.ts returns 6 commits — the move plus the 5 that predate it — which is exactly the count git log --oneline origin/main -- src/tree.ts returns for the same file before the move. git show --numstat --find-renames=100% on the move commit shows every path as a pure rename with zero added or deleted lines; the only non-rename entry is LICENSE, deliberately copied so the published tarball and the repository root each have one, and byte-identical to main's.

Zero behavioural drift. Rather than diffing file by file, I compared git tree object hashes at branch HEAD: packages/trilean/src, packages/trilean/test and packages/trilean/scripts are the identical tree objects as main's src, test and scripts. That is a stronger result than a clean diff — it also proves no commit after the move touched them, which is the scope-creep check.

Ran the full matrix from scratch on both branches. Counts match exactly:

Suite main this branch
unit 11 files, 444 tests 11 files, 444 tests
integration 8 files, 65 tests 8 files, 65 tests
smoke 1 file, 69 tests 1 file, 69 tests
workers 1 file, 12 tests 1 file, 12 tests

pnpm install --frozen-lockfile, pnpm build, pnpm lint, pnpm typecheck (including attw --pack, no problems found) all pass. The working tree is clean afterwards, so eslint --fix changed nothing.

The published artifact is provably unchanged. I packed both branches and compared the tarballs: identical file lists (58 entries), and every published file byte-identical. Comparing the 19 consumer-facing package.json fields — name, version, dependencies, peerDependencies, engines, exports, files, publishConfig and the rest — gives zero differences. The only manifest changes are repository.directory, homepage, workspace tooling moved out of devDependencies, and the attw script, none of which reach a consumer.

Version continuity. packages/trilean/package.json is still "name": "trilean" (bare, unscoped) at "version": "1.3.0", with publishConfig.access: public; npm's latest is 1.3.0. I confirmed trilean@1.3.0 exists on the remote and points at 460864c, the same commit as v1.3.0. I then simulated the post-merge state — a bare remote whose main is this branch's tip, carrying the real tags, cloned fresh — and ran the release orchestrator's dry run against it. It reports Found git tag trilean@1.3.0 associated with version 1.3.0 on branch main, then The next release version is 1.3.1. It continues from 1.3.0 rather than restarting.

CI is complete and valid. All three workflow files parse as valid YAML. The job set is identical to main's — same 11 keys and same display names: Commitlint, Lint, Typecheck, Test, Integration test, Smoke test, Workers runtime test, Release, Publish mirror to GitHub Packages, Attest SBOM and build provenance (npm), Attest SBOM and build provenance (GitHub Packages). Nothing was dropped. The turbo cache step moved into the composite action rather than disappearing, and dependabot was extended to cover packages/* (plus a new github-actions ecosystem entry, which main lacked).

The isomorphism guard still fires. packages/trilean/eslint.config.ts is byte-identical to main's root config, so the Node-import bans, barrel policy and per-file exemptions all survive. I checked this functionally rather than by reading: injecting import { readFileSync } from "node:fs" into src/json-value.ts produces 'node:fs' import is restricted ... This is an isomorphic library. The tsconfig split also reconstructs main's options exactly — 13 shared in tsconfig.base.json plus the package's own lib/types/resolveJsonModule give the same 16 main had, and tsconfig.node.json is byte-identical.

Worth knowing before merging

--affected on pull requests is a genuine behavioural change to CI, not just a reorganisation: a PR touching only root-level files may not run the package's test jobs. Pushes to main run the whole workspace and the release job gates on all of them, so nothing untested can publish — but main could go briefly red for something a PR run skipped.

The trilean@1.3.0 tag is load-bearing and lives outside the repository. It exists and is correct today; if it were ever deleted, the next release would restart at 1.0.0 and collide with a published version. The reasoning is recorded in release-workspace.config.ts rather than only in a commit message.

I have deliberately not merged this. Restructuring a live, published, depended-on package warrants human sign-off even though every check above is clean.

@Mearman
Mearman marked this pull request as ready for review September 3, 2026 10:37
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
🔒 Security Review Completed 2026-09-03T10:47:18.979316Z 3974747 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Every file belonging to the package itself -- src/, test/, scripts/,
its manifest, tsconfigs, tsdown/vitest/wrangler configs, its ESLint
config, README, and CHANGELOG -- moves under packages/trilean/, leaving
the repository root free to hold workspace-level configuration only.

LICENSE is copied rather than moved: the package needs its own copy in
the published tarball, and the repository still needs one at its root.

Pure relocation, byte for byte -- every path is a 100% rename and no
file's content changes. The root workspace configuration that makes
this directory a pnpm workspace member lands in the following commit.
…ipeline

pnpm-workspace.yaml gains the packages glob and linkWorkspacePackages, so
a sibling added later is symlinked from the workspace rather than fetched
from the registry, and keeps the install-time settings (build allowlist,
release-age gate) that can only take effect at a workspace root.

The root package.json is private and publishes nothing; it holds the task
wrappers, the shared tooling devDependencies, and the git hooks. Tooling
that is a property of the repository -- ESLint's root config, prettier,
commitlint, lint-staged -- stays here, because only one copy of each can
ever take effect; tooling that is genuinely per-package stays in the
package.

turbo.json grows the tasks each package's underscore-prefixed scripts
already carried, ordered by ^_build so a sibling's dist/ exists before
anything resolves an import through it. _typecheck, _lint and _test:smoke
additionally depend on the package's own _build: the smoke suite and the
JSON-schema generator both import ../dist/ by relative path. attw --pack
becomes a task of its own rather than a bare CI step, depending on the
build whose output it inspects.

tsconfig.base.json holds the compiler options every package extends, so
tightening a flag is one edit rather than one per package. The root
tsconfig.json covers the root's own config files, which are otherwise in
no program at all -- a file type-aware ESLint then refuses to lint.

lint-staged runs eslint once per package directory, because ESLint's flat
config resolves from the working directory: a root run over a staged file
under packages/ would apply the root config, which ignores packages/**,
and report the file as ignored rather than linting it.

The root README describes the workspace and points at the package's own
README for the library itself; AGENTS.md and CLAUDE.md symlink to it as
they already do inside the package.
Each job ran its command against the repository root when that root was
also the package; now it runs the same task across the workspace through
turbo, so a package added later is covered with no edit here. Pull-request
runs pass --affected, scoping every task to the packages the branch
actually changed.

The pnpm/Node/install/cache preamble each job repeated is extracted into a
composite action, which also carries the turbo cache restore that makes
one root job per task cheaper than re-running the work per package.

attw --pack was a bare step in the Typecheck job, run after pnpm typecheck
purely to reuse the dist/ that task had left behind; it is a turbo task now
and `pnpm typecheck` reaches it, so the step goes.

The release job runs the workspace orchestrator and reports what it
released by diffing the remote's tags across the run, which is what the
mirror and attestation jobs key off -- they check out that tag rather than
main, since a queued release run can push further commits in between, and
build and pack from packages/trilean.

Dependabot gains the package manifests under packages/*, which a
`directory: "/"` entry alone leaves unwatched, and a github-actions entry
covering both the workflows and the composite action's own uses: steps --
including the ones holding id-token: write to sign release attestations.
semantic-release ran once for the whole repository, which was the same
thing as the package while there was only one of it. It now runs through
@exadev/semantic-release-workspace, once per package, with each package's
commits path-filtered to its own directory and its tags in name@version
form -- so a package's version follows its own changes and a change to one
never bumps another.

commitStrategy "single" makes one commit for the whole run rather than one
per released package, which is why @semantic-release/git is absent from
the plugin list: its own prepare step would produce exactly the
per-package commit that mode replaces.

Release notes move from the angular preset to conventionalcommits, so the
changelog groups entries under a heading per commit type. That preset was
unusable before -- it renders only against conventional-changelog-writer 9
or newer, which @semantic-release/release-notes-generator does not depend
on -- and the pnpm override added with the workspace root supplies it, so
the workaround the angular preset represented is no longer needed.

commitTypes keeps its role as the single source of truth for which commit
types exist: commitlint's type-enum, the analyser's release levels, and
now each type's changelog heading all derive from the one list.
…ues from

The per-package tag format is what carries a version across from before
the workspace existed, and it differs from the one a single-package
release used -- v1.3.0 then, trilean@1.3.0 now. Nothing in the config
says so, and the consequence of not knowing is severe rather than
cosmetic: with no tag in the new format, semantic-release finds no
previous release, restarts the package at 1.0.0, and its first publish
collides with a version the registry already holds.
…real output

notify-hive still gated on needs.release.outputs.published, the flat
single-package workflow's output name before the release job was restructured
to emit released/version/tag. The condition silently evaluated to false on
every run -- GitHub Actions treats a reference to a non-existent output as an
empty string, which never equals 'true' -- so the job would never have fired.
@Mearman
Mearman force-pushed the refactor/monorepo-restructure branch from 3974747 to 2f2fc4a Compare September 3, 2026 13:29
@Mearman
Mearman merged commit c20627b into main Sep 3, 2026
13 checks passed
@Mearman
Mearman deleted the refactor/monorepo-restructure branch September 3, 2026 13:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant