Skip to content

fix(ci): auto-normalize exports in pre-commit hook [OS-608]#811

Merged
galligan merged 2 commits into
mainfrom
fix/ci/auto-normalize-exports
Mar 26, 2026
Merged

fix(ci): auto-normalize exports in pre-commit hook [OS-608]#811
galligan merged 2 commits into
mainfrom
fix/ci/auto-normalize-exports

Conversation

@galligan

@galligan galligan commented Mar 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Export drift in package.json has been the most recurring DX issue in the repo (OS-120, OS-374, OS-403, OS-523, OS-530, and this session's packages/tooling/package.json mutation). The root cause: adding/renaming source files without running bun run build leaves exports stale.

This PR adds an unconditional normalize-exports step to the pre-commit hook with scoped staging, so exports are always correct and the working tree is always clean — without pulling unrelated changes into commits.

Fixes https://linear.app/outfitter/issue/OS-608/auto-regenerate-and-auto-stage-exports-in-pre-commit-hook-to-eliminate

How it works

The pre-commit plan now includes:

  1. Ultracite fix (format)
  2. Typecheck (if TS files staged)
  3. Tooling sync:exports (conditional — if tooling files staged)
  4. Normalize exports (unconditional — all packages, every commit)
  5. Exports check (validation)

Key design: normalize all, stage selectively. The normalize step rewrites exports in ALL packages (so the working tree is always clean — no confusing dirty files for agents or developers). But it only git adds the package.json files for packages that have staged changes in the commit. This means:

  • No dirty working tree from export drift (agents don't see stale package.json diffs)
  • Only relevant package.json files enter the commit
  • Drift in unrelated packages is fixed silently without polluting the commit
  • Pre-push and CI still catch anything that slips through

The --stage <pkg...> flag on normalize-exports.ts handles the selective staging internally via execFileSync("git", ["add", ...]), so the orchestrator doesn't need to manage it.

What changed

  • scripts/normalize-exports.ts: Added --stage <pkg...> flag — normalizes all packages, but only git adds listed ones
  • apps/outfitter/src/commands/check-orchestrator.ts: Pre-commit plan computes affected packages from staged files and passes them to --stage
  • Tests updated for new step ordering (3 tests modified)

Test plan

  • 26 check-orchestrator tests pass
  • normalize-exports.ts works with no flags (backward compatible)
  • normalize-exports.ts --check works (backward compatible)
  • normalize-exports.ts --stage packages/docs normalizes all, stages only docs
  • Pre-push hooks green

🤘🏻 In-collaboration-with: Claude Code

@linear

linear Bot commented Mar 25, 2026

Copy link
Copy Markdown

@github-actions github-actions Bot added docs Documentation improvement testing Test-related release:minor Contains new features (minor version bump) labels Mar 25, 2026

Copy link
Copy Markdown
Contributor Author

This stack of pull requests is managed by Graphite. Learn more about stacking.

@galligan galligan marked this pull request as ready for review March 25, 2026 17:56
@greptile-apps

greptile-apps Bot commented Mar 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR eliminates export drift as a recurring DX pain point by adding an unconditional normalize-exports step to the pre-commit hook, so every commit leaves all workspace package.json exports clean regardless of which files were staged.\n\nKey changes:\n- scripts/normalize-exports.ts: New --stage <pkg...> flag — normalizes ALL workspace packages on disk (cleaning the working tree), but only git adds package.json files for the explicitly listed packages. Bun.spawnSync is used (Bun-native, consistent with the rest of the codebase) and the exit code is checked.\n- apps/outfitter/src/commands/check-orchestrator.ts: Pre-commit plan restructured to a natural push-order: ultracite-fixtypechecktooling-sync-exports (conditional) → normalize-exports (unconditional, with --stage <affectedPkgs>) → exports. The old splice-before-exports pattern and its guard throw are removed now that ordering is explicit.\n- apps/outfitter/src/__tests__/check-orchestrator.test.ts: Three tests updated to assert the full three-step tooling-sync-exports → normalize-exports → exports ordering chain.\n- The design correctly uses an exact-match filter (pkg === sp) in normalize-exports.ts rather than startsWith, avoiding accidental over-staging of sibling packages if nested workspaces are ever added.

Confidence Score: 4/5

Safe to merge — core logic is sound, Bun-native APIs are used consistently, and the selective-staging design correctly avoids polluting commits with unrelated package changes.

The approach is well-designed and addresses a real recurring problem. The git add exit code is checked, the --check/--stage interaction is handled with a warning, and the exact-match filter avoids the startsWith sibling-staging footgun. The one new minor gap — --stage with no packages is a silent no-op with no warning — is a developer-ergonomics issue for direct script invocation only; the orchestrator always guards against it.

scripts/normalize-exports.ts warrants a quick look for the silent empty---stage no-op; all other files are straightforward.

Important Files Changed

Filename Overview
scripts/normalize-exports.ts Adds --stage <pkg...> flag — normalizes all workspace packages on disk, then selectively git adds only listed packages. Exit-code check on Bun.spawnSync is present; --check + --stage combination emits a warning and safely ignores staging. Minor UX gap: empty --stage list is a silent no-op.
apps/outfitter/src/commands/check-orchestrator.ts Restructures pre-commit plan to push steps in natural order (tooling-sync-exports → normalize-exports → exports). affectedPackages computed via depth-2 path slicing and passed to --stage; exact-match filter in normalize-exports avoids the startsWith sibling-staging footgun noted in earlier reviews.
apps/outfitter/src/tests/check-orchestrator.test.ts Three tests updated to assert the full tooling-sync-exports → normalize-exports → exports ordering chain. One test (JS tooling file scenario, line 362) still omits the normalize-exports < exports assertion — but this was flagged in a prior review thread.
.changeset/auto-normalize-exports.md Correct patch bump for the outfitter package with an accurate description of the DX improvement.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A([pre-commit triggered]) --> B{JS/TS files staged?}
    B -- Yes --> C[ultracite-fix\nscoped to staged files]
    B -- No staged files --> D[ultracite-fix .]
    B -- Non-JS/TS only --> E{TS files staged?}
    C --> E
    D --> F[typecheck --only]
    E -- Yes --> G[typecheck\nscoped to staged .ts files]
    E -- No --> SKIP_TC[ ]:::invisible
    G --> H{packages/tooling/\nfiles staged?}
    SKIP_TC --> H
    F --> H
    H -- Yes --> I[tooling sync:exports]
    H -- No --> J
    I --> J[normalize-exports\nall packages written to disk]
    J --> K{affectedPackages\nnon-empty?}
    K -- Yes --> L["git add pkg/package.json\nfor each affected package"]
    K -- No --> M
    L --> M[check-exports\nvalidation]
    M --> N{All steps passed?}
    N -- Yes --> O([commit proceeds])
    N -- No --> P([commit aborted])

    classDef invisible fill:none,stroke:none
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: scripts/normalize-exports.ts
Line: 161-169

Comment:
**`--stage` with no packages silently no-ops**

When `--stage` is provided with no package arguments (e.g. `bun scripts/normalize-exports.ts --stage`), `stagePackages` resolves to an empty array `[]`. Because the staging block filters with `stagePackages.some(...)`, an empty `stagePackages` guarantees `toStage` is always empty — all packages are normalized on disk but nothing is staged, and the caller receives no feedback that the flag had no effect.

The orchestrator guards against this at the call site (via the `affectedPackages.length > 0` ternary), but a direct invocation during development or testing has no such safeguard. Adding an early warning when `--stage` is present but resolves to an empty list would make the CLI easier to reason about:

```typescript
const stagePackages =
  stageIdx !== -1
    ? args.slice(stageIdx + 1).filter((a) => !a.startsWith("--"))
    : undefined;

if (stagePackages !== undefined && stagePackages.length === 0) {
  console.warn(
    "[normalize-exports] --stage was provided with no packages; nothing will be staged"
  );
}
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (8): Last reviewed commit: "chore: add changeset" | Re-trigger Greptile

Comment thread apps/outfitter/src/__tests__/check-orchestrator.test.ts

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fa26d2ce58

ℹ️ 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".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/outfitter/src/commands/check-orchestrator.ts
@galligan galligan force-pushed the fix/ci/auto-normalize-exports branch from fa26d2c to 3f0d040 Compare March 25, 2026 18:28
@github-actions github-actions Bot added the area/infra Infrastructure label Mar 25, 2026
@galligan

Copy link
Copy Markdown
Contributor Author

@greptileai review — addressed the scoping concern: normalize-exports now uses --stage to selectively git-add only package.json files for packages with staged changes

Comment thread scripts/normalize-exports.ts Outdated
@galligan

Copy link
Copy Markdown
Contributor Author

@greptileai review

Comment thread scripts/normalize-exports.ts
@galligan galligan force-pushed the fix/ci/auto-normalize-exports branch from 9b52227 to b1026a9 Compare March 26, 2026 02:29
Comment thread apps/outfitter/src/commands/check-orchestrator.ts
Comment thread scripts/normalize-exports.ts
@galligan galligan force-pushed the fix/ci/auto-normalize-exports branch from b1026a9 to d407523 Compare March 26, 2026 15:40
Comment thread scripts/normalize-exports.ts
Comment thread apps/outfitter/src/commands/check-orchestrator.ts

galligan commented Mar 26, 2026

Copy link
Copy Markdown
Contributor Author

Merge activity

  • Mar 26, 11:48 PM UTC: A user started a stack merge that includes this pull request via Graphite.
  • Mar 26, 11:49 PM UTC: Graphite rebased this pull request as part of a merge.
  • Mar 26, 11:50 PM UTC: @galligan merged this pull request with Graphite.

@galligan galligan force-pushed the fix/ci/auto-normalize-exports branch from d407523 to 87e6c6e Compare March 26, 2026 23:49
@galligan galligan merged commit 40a3076 into main Mar 26, 2026
12 checks passed
@galligan galligan deleted the fix/ci/auto-normalize-exports branch March 26, 2026 23:50
Comment on lines +161 to +169
const stageIdx = args.indexOf("--stage");

// --stage <pkg...>: normalize ALL packages (clean working tree) but only
// git-add the package.json files for listed packages. This keeps commits
// scoped while preventing drift from showing up as a dirty working tree.
const stagePackages =
stageIdx !== -1
? args.slice(stageIdx + 1).filter((a) => !a.startsWith("--"))
: undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 --stage with no packages silently no-ops

When --stage is provided with no package arguments (e.g. bun scripts/normalize-exports.ts --stage), stagePackages resolves to an empty array []. Because the staging block filters with stagePackages.some(...), an empty stagePackages guarantees toStage is always empty — all packages are normalized on disk but nothing is staged, and the caller receives no feedback that the flag had no effect.

The orchestrator guards against this at the call site (via the affectedPackages.length > 0 ternary), but a direct invocation during development or testing has no such safeguard. Adding an early warning when --stage is present but resolves to an empty list would make the CLI easier to reason about:

const stagePackages =
  stageIdx !== -1
    ? args.slice(stageIdx + 1).filter((a) => !a.startsWith("--"))
    : undefined;

if (stagePackages !== undefined && stagePackages.length === 0) {
  console.warn(
    "[normalize-exports] --stage was provided with no packages; nothing will be staged"
  );
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: scripts/normalize-exports.ts
Line: 161-169

Comment:
**`--stage` with no packages silently no-ops**

When `--stage` is provided with no package arguments (e.g. `bun scripts/normalize-exports.ts --stage`), `stagePackages` resolves to an empty array `[]`. Because the staging block filters with `stagePackages.some(...)`, an empty `stagePackages` guarantees `toStage` is always empty — all packages are normalized on disk but nothing is staged, and the caller receives no feedback that the flag had no effect.

The orchestrator guards against this at the call site (via the `affectedPackages.length > 0` ternary), but a direct invocation during development or testing has no such safeguard. Adding an early warning when `--stage` is present but resolves to an empty list would make the CLI easier to reason about:

```typescript
const stagePackages =
  stageIdx !== -1
    ? args.slice(stageIdx + 1).filter((a) => !a.startsWith("--"))
    : undefined;

if (stagePackages !== undefined && stagePackages.length === 0) {
  console.warn(
    "[normalize-exports] --stage was provided with no packages; nothing will be staged"
  );
}
```

How can I resolve this? If you propose a fix, please make it concise.

galligan added a commit that referenced this pull request Mar 26, 2026
…6] (#813)

## Summary

CI Summary job fails intermittently even when all individual jobs pass. Observed 3-4 times in a single session across PRs #806, #810, #811, #812.

**Root cause:** When `gt submit` force-pushes a branch, GitHub Actions cancels the in-flight run. The `ci-summary` job checks `contains(needs.*.result, 'cancelled')`, which catches cancelled jobs from the superseded run — not actual failures.

Fixes https://linear.app/outfitter/issue/OS-616/ci-summary-job-fails-spuriously-after-force-push-due-to-cancelled-job

## What changed

`.github/workflows/ci.yml`:

1. **Dropped `cancelled` from the failure check** — only `failure` triggers the exit. Cancelled jobs from superseded runs aren't real failures.

2. **Added `concurrency` group** — `ci-${{ github.ref }}` with `cancel-in-progress: true` ensures only one CI run per branch exists at a time, preventing the race structurally.

## Test plan

- [x] Workflow YAML is syntactically valid
- [x] The concurrency group scopes to the branch ref, so main and PR branches don't cancel each other
- [x] `cancel-in-progress: true` only cancels runs on the same ref (safe for stacked PRs on different branches)

🤘🏻 In-collaboration-with: [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/infra Infrastructure docs Documentation improvement release:minor Contains new features (minor version bump) testing Test-related

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant