Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/fix-codegen-dependency-version.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@shopify/hydrogen-codegen": patch
---

Fix codegen to use the correct version of the underlying GraphQL code generation library. Previously, custom codegen configurations (non-default output paths or formats) could fail with unexpected validation errors.
11 changes: 11 additions & 0 deletions .claude/skills/hydrogen-dev-workflow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,17 @@ The Hydrogen CLI source code lives at `packages/cli-hydrogen` in the Hydrogen re

**How to update**: A `shopify-cli-update` command exists in the Hydrogen repo at `.claude/commands/shopify-cli-update.md`. This is a Claude Code command — invoke it with `/shopify-cli-update` when working in the Hydrogen repo. It documents the full, nuanced, multi-step process. Always reference this command when performing the update — do not try to wing it from memory.

## GraphQL Codegen

The `--codegen` flag on `shopify hydrogen dev` and `shopify hydrogen build` triggers automatic TypeScript type generation from GraphQL queries. This involves two packages:

- **`@shopify/hydrogen-codegen`** (`packages/hydrogen-codegen/`) — Thin config wrapper providing Hydrogen-specific defaults (SFAPI/CAAPI namespaces, type import paths, `declare module` augmentation). See the package's own `CLAUDE.md` for architecture details.
- **`@shopify/graphql-codegen`** — Core codegen logic, lives in a **separate repo** ([`github.com/Shopify/graphql-codegen`](https://github.com/Shopify/graphql-codegen)). Low-activity; changes require a release in that repo first.

**How it works**: The CLI spawns `shopify hydrogen codegen --watch` as a child process. The orchestration logic lives in `packages/cli/src/lib/codegen.ts`, which dynamically imports `@shopify/hydrogen-codegen` from the merchant's project via `importLocal` (not a static dependency).

**Changeset rule**: Changes to `packages/hydrogen-codegen` (including dependency version bumps in `package.json`) require a changeset for `@shopify/hydrogen-codegen`. See Rule 3 in the root `CLAUDE.md`.

## Project Scaffolding

There are two ways to scaffold a new Hydrogen project:
Expand Down
8 changes: 8 additions & 0 deletions .claude/skills/hydrogen-release-process/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,14 @@ Hydrogen uses an automated release system built on Changesets, GitHub Actions (`
- Each gets appropriate version bump
- Published together when Version PR merged

### Note: @shopify/hydrogen-codegen

`@shopify/hydrogen-codegen` is an independently-versioned **SemVer** package (not CalVer). It releases through the same changeset/Version PR flow as other packages, but:

- It needs its own changeset when its source or dependency versions change (see Rule 3 in root `CLAUDE.md`)
- It does **not** require bumping `cli-hydrogen` or `create-hydrogen` (it is dynamically loaded, not bundled)
- Its dependency on `@shopify/graphql-codegen` (a separate repo) is invisible to CI workspace linking — always create a changeset when bumping it

## Other Release Types

### Snapshot Testing (`/snapit`)
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/changesets-linter.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Lint changesets
run: node scripts/lint-changesets.mjs
- name: Check dependency changes have changesets
run: node scripts/check-dependency-changesets.mjs
39 changes: 39 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,45 @@ jobs:
- name: 🔬 Check Formatting
run: pnpm run format:check

package-validation:
name: 📦 Package Validation
runs-on: ubuntu-latest
timeout-minutes: 10
concurrency:
group: ci-package-validation-${{ github.ref }}
cancel-in-progress: true
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: 📦 Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320
with:
run_install: false

- name: ⎔ Setup node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: '22'
cache: 'pnpm'
cache-dependency-path: 'pnpm-lock.yaml'

- name: 📥 Install dependencies
run: pnpm install --frozen-lockfile

- name: 🔨 Build packages
run: pnpm run build:pkg

- name: 🔍 Validate published packages with publint
run: |
npx publint packages/hydrogen-codegen
npx publint packages/hydrogen-react
npx publint packages/hydrogen
npx publint packages/mini-oxygen
npx publint packages/remix-oxygen
npx publint packages/cli
npx publint packages/create-hydrogen

typecheck:
name: Typescript
runs-on: ubuntu-latest
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/snapit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ jobs:

- name: Force snapshot changeset
run: |
printf -- "---\n'@shopify/hydrogen': patch\n'@shopify/remix-oxygen': patch\n'@shopify/cli-hydrogen': patch\n'@shopify/create-hydrogen': patch\n---\n\nForce snapshot build.\n" > .changeset/force-snapshot-build.md
printf -- "---\n'@shopify/hydrogen': patch\n'@shopify/remix-oxygen': patch\n'@shopify/cli-hydrogen': patch\n'@shopify/create-hydrogen': patch\n'@shopify/hydrogen-codegen': patch\n---\n\nForce snapshot build.\n" > .changeset/force-snapshot-build.md

- name: Create snapshot version
uses: Shopify/snapit@0c0d2dd62c9b0c94b7d03e1f54e72f18548e7752 # pin to a specific commit
Expand Down
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ The entire contents of `hydrogen-react` are re-exported in Hydrogen. Any changes

If forgotten: Hydrogen consumers will not get the `hydrogen-react` update until a separate Hydrogen release happens to include it.

### Rule 3: hydrogen-codegen Changes

Any change to `packages/hydrogen-codegen` (source code OR dependency versions in `package.json`) must include a changeset for `@shopify/hydrogen-codegen`. Unlike skeleton (Rule 1), this does **not** require bumping `cli-hydrogen` or `create-hydrogen` — the codegen package is dynamically loaded from the merchant's `node_modules` via `importLocal`, not bundled into the CLI.

**CI caveat**: Monorepo CI tests workspace-linked dependencies, not what npm actually resolves for merchants. A dependency version bump in `package.json` without a changeset will pass all CI checks but never reach merchants. Always create a changeset when modifying this package's dependencies.

If forgotten: merchants will be stuck on stale dependency versions with no way to get the fix until someone creates a changeset.

For the full release process (standard, back-fix, snapshot, failure recovery), see the `hydrogen-release-process` skill. For versioning semantics, see the `hydrogen-versioning` skill.

### CLI Dependency Graph
Expand Down
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ const localPlugin = {

const lintedTSPackages = [
'packages/hydrogen-react',
'packages/hydrogen-codegen',
'examples/express',
'templates/skeleton',
'docs/previews',
Expand Down
53 changes: 53 additions & 0 deletions packages/hydrogen-codegen/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# @shopify/hydrogen-codegen

Thin configuration wrapper (~130 lines) over [`@shopify/graphql-codegen`](https://github.com/Shopify/graphql-codegen) that provides Hydrogen-specific defaults for GraphQL type generation. Generates `.d.ts` files extending `StorefrontQueries`, `StorefrontMutations`, `CustomerAccountQueries`, and `CustomerAccountMutations` interfaces on the `@shopify/hydrogen` module.

## Source Structure

Only 4 source files:

- **`src/index.ts`** — Re-exports from `@shopify/graphql-codegen` + local modules
- **`src/preset.ts`** — Wraps the upstream preset with Hydrogen defaults (auto-detects SFAPI vs CAAPI based on output filename)
- **`src/defaults.ts`** — SFAPI and CAAPI default config: namespace names, type import paths, `declare module` augmentation
- **`src/schema.ts`** — Resolves JSON schema file paths bundled in `@shopify/hydrogen`

## Upstream Dependency

The core codegen logic lives in a **separate repository**: [`github.com/Shopify/graphql-codegen`](https://github.com/Shopify/graphql-codegen). This is the sole runtime dependency. As of April 2026, the repo has only 3 published versions (0.0.1, 0.0.2, 0.1.0) and appears low-activity (last release: May 2024). Changes to the core codegen logic require a release in that repo first, then a version bump here.

Other consumers of `@shopify/graphql-codegen` beyond this package include `@shopify/api-codegen-preset` in `shopify-app-js` and `Shopify/forge`.

## Build Quirks

The `tsup.config.ts` uses `dts-bundle-generator` instead of tsup's built-in DTS generation. It **inlines types** from `@shopify/graphql-codegen` and `type-fest` into the output `.d.ts` so consumers don't need them as direct dependencies. There is also a post-build CJS plugin that rewrites `.js` extensions to `.cjs` in `require()` calls for the CJS build.

## Known Issues

- **Undeclared runtime dependency on `@shopify/hydrogen`**: `schema.ts` calls `require.resolve('@shopify/hydrogen/...')` but `@shopify/hydrogen` is not declared in `dependencies` or `peerDependencies`. This works because every consumer of this package also has `@shopify/hydrogen` installed.

- **CI tests workspace-linked deps, not what npm resolves**: The monorepo uses pnpm workspace linking, so CI always tests against the local source version of dependencies — not the version range that merchants would resolve from npm. Always create a changeset when modifying dependency versions in `package.json` (see Changeset Rule 3 in the root `CLAUDE.md`).

## How It's Used

The CLI dynamically imports this package (not a static dependency):

```
shopify hydrogen dev --codegen
→ packages/cli/src/lib/codegen.ts calls importLocal('@shopify/hydrogen-codegen')
→ Uses preset, getSchema, pluckConfig to configure @graphql-codegen/cli
→ Generates storefrontapi.generated.d.ts / customer-accountapi.generated.d.ts
```

It is an **optional peer dependency** of `@shopify/cli-hydrogen` and a **devDependency** of the skeleton template.

## Versioning

This package uses **SemVer** (not CalVer like `@shopify/hydrogen`). Currently at 0.x, where caret ranges behave counterintuitively: `^0.0.2` means `>=0.0.2 <0.0.3`, not `>=0.0.2 <0.1.0`.

## Testing

```bash
pnpm run test # Unit tests + type-level tests (vitest with typecheck)
pnpm run build # Build with tsup + dts-bundle-generator
pnpm run typecheck # TypeScript type checking
```
4 changes: 1 addition & 3 deletions packages/hydrogen-codegen/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,7 @@ type Options<T extends boolean> = {throwIfMissing?: T};

/**
* Resolves a schema path for the provided API type. Only the API types currently
* bundled in Hydrogen are allowed: "storefront" and "customer".
* @param api
* @returns
* bundled in Hydrogen are allowed: "storefront" and "customer-account".
*/
export function getSchema(api: Api, options?: Options<true>): string;
export function getSchema(
Expand Down
150 changes: 150 additions & 0 deletions scripts/check-dependency-changesets.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/**
* CI check: dependency version changes in package.json require a changeset.
*
* Prevents the class of bug where a dependency is bumped in source but never
* released to npm because no changeset was created. Monorepo CI tests
* workspace-linked deps, so version range mismatches are invisible without this.
*
* Usage: node scripts/check-dependency-changesets.mjs
* Exits non-zero if any published package has dependency changes without a changeset.
*
* Note on pull_request checkout behavior: GitHub Actions checks out a merge
* commit (refs/pull/N/merge) for pull_request events. `HEAD` here is that
* merge commit, not the PR tip. `git merge-base origin/main HEAD` still
* produces the correct common ancestor for diffing.
*/

import {execSync} from 'child_process';
import fs from 'fs';
import path from 'path';

// Only check fields that affect what merchants resolve from npm.
// devDependencies are excluded because they are not installed by consumers
// and bumping them does not require a release.
const DEPENDENCY_FIELDS = [
'dependencies',
'peerDependencies',
'optionalDependencies',
];

function getMergeBase() {
return execSync('git merge-base origin/main HEAD', {
encoding: 'utf8',
}).trim();
}

function getChangedPackageJsonFiles(mergeBase) {
const diffOutput = execSync(
`git diff ${mergeBase} HEAD --name-only -- "packages/*/package.json"`,
{encoding: 'utf8'},
).trim();

if (!diffOutput) return [];
return diffOutput.split('\n').filter(Boolean);
}

function hasDependencyChanges(mergeBase, filePath) {
let oldContent;
try {
oldContent = JSON.parse(
execSync(`git show ${mergeBase}:${filePath}`, {encoding: 'utf8'}),
);
} catch {
// New package — no base version exists, so no dependency "change" to enforce
return false;
}

const newContent = JSON.parse(fs.readFileSync(filePath, 'utf8'));

for (const field of DEPENDENCY_FIELDS) {
const oldDeps = JSON.stringify(oldContent[field] || {});
const newDeps = JSON.stringify(newContent[field] || {});
if (oldDeps !== newDeps) return true;
}

return false;
}

function getPackageNameFromPath(filePath) {
const content = JSON.parse(fs.readFileSync(filePath, 'utf8'));
return content.name;
}

function getPackagesWithChangesets() {
const changesetDir = '.changeset';
const packages = new Set();

const files = fs.readdirSync(changesetDir).filter((f) => f.endsWith('.md'));

for (const file of files) {
const content = fs.readFileSync(path.join(changesetDir, file), 'utf8');
const frontmatter = content.match(/^---\n([\s\S]*?)\n---/m);
if (!frontmatter) continue;

// Extract package names from YAML frontmatter (format: "package-name": bump-type)
const lines = frontmatter[1].split('\n');
for (const line of lines) {
const match = line.match(/^['"]?([^'":\s]+)['"]?\s*:/);
if (match) packages.add(match[1]);
}
}

return packages;
}

function getIgnoredPackages() {
const config = JSON.parse(fs.readFileSync('.changeset/config.json', 'utf8'));
return new Set(config.ignore || []);
}

function main() {
const mergeBase = getMergeBase();
const changedFiles = getChangedPackageJsonFiles(mergeBase);

if (changedFiles.length === 0) {
console.log(
'✅ No package.json dependency changes detected in packages/.',
);
return;
}

const packagesWithChangesets = getPackagesWithChangesets();
const ignoredPackages = getIgnoredPackages();
const failures = [];

for (const filePath of changedFiles) {
if (!hasDependencyChanges(mergeBase, filePath)) continue;

const packageName = getPackageNameFromPath(filePath);
if (!packageName) continue;
if (ignoredPackages.has(packageName)) continue;

if (!packagesWithChangesets.has(packageName)) {
failures.push({filePath, packageName});
}
}

if (failures.length === 0) {
console.log('✅ All dependency changes have corresponding changesets.');
return;
}

console.error(
'❌ The following packages have dependency version changes but no changeset:\n',
);
for (const {filePath, packageName} of failures) {
console.error(` • ${packageName} (${filePath})`);
}
console.error(
'\nDependency changes require a changeset to be released to npm.',
);
console.error(
'Without a changeset, merchants will be stuck on stale versions.',
);
console.error(
'\nRun `npx changeset add` or manually create a changeset file.',
);
process.exit(1);
}

main();
Loading