Skip to content

fix: infer uidStrategy from backend typename#7005

Open
alfonso-noriega wants to merge 16 commits intomainfrom
fix_uid_strategy_matching
Open

fix: infer uidStrategy from backend typename#7005
alfonso-noriega wants to merge 16 commits intomainfrom
fix_uid_strategy_matching

Conversation

@alfonso-noriega
Copy link
Contributor

@alfonso-noriega alfonso-noriega commented Mar 13, 2026

Problem

Channel extensions (channel_config) were always shown as deleted + new on every deploy, even when no changes were made to them.

Root cause

The channel_config local spec is defined via createContractBasedModuleSpecification, which defaults to uidStrategy: 'uuid' (the default for non-configuration specs). During the merge of local and remote specs in mergeLocalAndRemoteSpecs, the local value wins because FlattenedRemoteSpecification has no uidStrategy field — only the binary options.uidIsClientProvided flag.

The backend knows channel_config should use 'single' strategy, but the CLI was unable to receive that signal. The uidIsClientProvided flag cannot distinguish between 'single' and 'dynamic' (both map to false), so the merge always kept the local default of 'uuid'.

With uidStrategy: 'uuid', the extension goes through UUID-based matching. The remote registration lives in the single/config group and can never match → it appears as deleted, and the local copy appears as new, on every deploy.

Solution

The backend uidStrategy GraphQL field is a union type whose __typename encodes the concrete strategy (single, dynamic, or client-provided/uuid). The GraphQL document already requests __typename — we just weren't using it.

This PR:

  1. specifications.graphql — explicitly adds __typename to the uidStrategy selection so it becomes part of the source-of-truth query (and will be properly typed after the next codegen run).

  2. extension_specifications.ts — adds uidStrategy?: 'single' | 'dynamic' | 'uuid' to RemoteSpecification.options to carry the backend-resolved value through the pipeline.

  3. app-management-client.ts — adds uidStrategyFromTypename() that maps __typename + isClientProvided → CLI UidStrategy, and populates options.uidStrategy when building RemoteSpecification objects.

  4. fetch-extension-specifications.ts — applies options.uidStrategy as an override to merged.uidStrategy for all specs when it is present (app-management API path). The Partners API path is unaffected since it never sets options.uidStrategy.

⚠️ One thing to verify

The dynamic typename used in uidStrategyFromTypename is currently 'AppModuleDynamicUidStrategy'. Please confirm this matches what the app-management backend actually returns and update if needed.

Testing steps

  1. Manual — channel deploy regression

    • Have an app with a channel_config extension already deployed.
    • Run shopify app deploy twice in a row with no local changes.
    • Before fix: second deploy shows the channel extension as removed + new.
    • After fix: second deploy shows the channel extension as unchanged (no prompt required).
  2. Manual — other extension types unaffected

    • Verify that UI extensions (uuid strategy), app config extensions (single strategy), and webhook subscriptions (dynamic strategy) continue to match correctly across deploys.
  3. Unit tests

    pnpm nx run app:vitest -- src/cli/services/generate/fetch-extension-specifications.test.ts
    pnpm nx run app:vitest -- src/cli/utilities/developer-platform-client/app-management-client.test.ts
    
  4. Confirm __typename value — after deploying to a test environment, add a temporary console.log in uidStrategyFromTypename to confirm the actual typename the backend returns for channel_config and for webhook subscriptions.

alfonso-noriega and others added 4 commits March 13, 2026 14:40
- shopify upgrade now prompts to opt in to automatic upgrades and runs the upgrade
- postrun hook auto-upgrades the CLI after a command when a newer version is cached
  and the user has opted in; shows a warning instead for major version bumps
- prerun hook replaced warnOnAvailableUpgrade with a non-blocking checkForNewVersion
- is-global: replaced npm-prefix execa call with getProjectDir (walks up for shopify.app*.toml
  / hydrogen.config.*); added Homebrew detection via symlink, /cellar/, SHOPIFY_HOMEBREW_FORMULA,
  HOMEBREW_PREFIX
- upgrade: full rewrite — cliInstallCommand returns string|undefined with homebrew support;
  new runCLIUpgrade, versionToAutoUpgrade, promptAutoUpgrade, local upgrade helpers
- conf-store: autoUpgradeEnabled flag + getAutoUpgradeEnabled / setAutoUpgradeEnabled
- node-package-manager: homebrew added to PackageManager type; guarded addNPMDependencies
- fs: added findPathUpSync (sync wrapper around find-up's findUpSync)
- version: added isMajorVersionChange

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Contributor Author

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

@alfonso-noriega alfonso-noriega changed the title feat: port auto-upgrade POC to main (Phase 1, issue #22363) fix: infer uidStrategy from backend typename to fix channel extension matching Mar 13, 2026
@alfonso-noriega alfonso-noriega force-pushed the fix_uid_strategy_matching branch from 40ba712 to aa8c5fd Compare March 13, 2026 14:33
… matching

Channel extensions were always showing as deleted + new on every deploy
because the local channel_config spec defaults to uidStrategy 'uuid' but
the backend defines it as 'single'. The binary uidIsClientProvided flag
cannot express 'dynamic', so the merge logic was unable to correctly set
uidStrategy for specs that have a local definition.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@alfonso-noriega alfonso-noriega force-pushed the fix_uid_strategy_matching branch from aa8c5fd to 3a29152 Compare March 13, 2026 15:16
@github-actions
Copy link
Contributor

Differences in type declarations

We detected differences in the type declarations generated by Typescript for this branch compared to the baseline ('main' branch). Please, review them to ensure they are backward-compatible. Here are some important things to keep in mind:

  • Some seemingly private modules might be re-exported through public modules.
  • If the branch is behind main you might see odd diffs, rebase main into this branch.

New type declarations

We found no new type declarations in this PR

Existing type declarations

packages/cli-kit/dist/private/node/conf-store.d.ts
@@ -24,6 +24,7 @@ export interface ConfSchema {
     devSessionStore?: string;
     currentDevSessionId?: string;
     cache?: Cache;
+    autoUpgradeEnabled?: boolean;
 }
 /**
  * Get session.
@@ -125,6 +126,18 @@ interface RunWithRateLimitOptions {
  * @returns true, or undefined if the task was not run.
  */
 export declare function runWithRateLimit(options: RunWithRateLimitOptions, config?: LocalStorage<ConfSchema>): Promise<boolean>;
+/**
+ * Get auto-upgrade preference.
+ *
+ * @returns Whether auto-upgrade is enabled, or undefined if not set.
+ */
+export declare function getAutoUpgradeEnabled(config?: LocalStorage<ConfSchema>): boolean | undefined;
+/**
+ * Set auto-upgrade preference.
+ *
+ * @param enabled - Whether auto-upgrade should be enabled.
+ */
+export declare function setAutoUpgradeEnabled(enabled: boolean, config?: LocalStorage<ConfSchema>): void;
 export declare function getConfigStoreForPartnerStatus(): LocalStorage<Record<string, {
     status: true;
     checkedAt: string;
packages/cli-kit/dist/public/node/fs.d.ts
@@ -1,6 +1,6 @@
 import { OverloadParameters } from '../../private/common/ts/overloaded-parameters.js';
 import { RandomNameFamily } from '../common/string.js';
-import { findUp as internalFindUp } from 'find-up';
+import { findUp as internalFindUp, findUpSync as internalFindUpSync } from 'find-up';
 import { ReadStream, WriteStream } from 'fs';
 import type { Pattern, Options as GlobOptions } from 'fast-glob';
 /**
@@ -335,6 +335,7 @@ export declare function defaultEOL(): EOL;
  * @returns The first path found that matches or  if none could be found.
  */
 export declare function findPathUp(matcher: OverloadParameters<typeof internalFindUp>[0], options: OverloadParameters<typeof internalFindUp>[1]): ReturnType<typeof internalFindUp>;
+export declare function findPathUpSync(matcher: OverloadParameters<typeof internalFindUp>[0], options: OverloadParameters<typeof internalFindUp>[1]): ReturnType<typeof internalFindUpSync>;
 export interface MatchGlobOptions {
     matchBase: boolean;
     noglobstar: boolean;
packages/cli-kit/dist/public/node/is-global.d.ts
@@ -26,6 +26,14 @@ export declare function installGlobalCLIPrompt(): Promise<InstallGlobalCLIPrompt
  * Infers the package manager used by the global CLI.
  *
  * @param argv - The arguments passed to the process.
+ * @param env - The environment variables of the process.
  * @returns The package manager used by the global CLI.
  */
-export declare function inferPackageManagerForGlobalCLI(argv?: string[]): PackageManager;
\ No newline at end of file
+export declare function inferPackageManagerForGlobalCLI(argv?: string[], env?: NodeJS.ProcessEnv): PackageManager;
+/**
+ * Returns the project directory for the given path.
+ *
+ * @param directory - The path to the directory to get the project directory for.
+ * @returns The project directory for the given path.
+ */
+export declare function getProjectDir(directory: string): string | undefined;
\ No newline at end of file
packages/cli-kit/dist/public/node/node-package-manager.d.ts
@@ -25,7 +25,7 @@ export type DependencyType = 'dev' | 'prod' | 'peer';
 /**
  * A union that represents the package managers available.
  */
-export declare const packageManager: readonly ["yarn", "npm", "pnpm", "bun", "unknown"];
+export declare const packageManager: readonly ["yarn", "npm", "pnpm", "bun", "unknown", "homebrew"];
 export type PackageManager = (typeof packageManager)[number];
 /**
  * Returns an abort error that's thrown when the package manager can't be determined.
packages/cli-kit/dist/public/node/upgrade.d.ts
@@ -2,13 +2,34 @@
  * Utility function for generating an install command for the user to run
  * to install an updated version of Shopify CLI.
  *
- * @returns A string with the command to run.
+ * @returns A string with the command to run, or undefined if the package manager cannot be determined.
  */
-export declare function cliInstallCommand(): string;
+export declare function cliInstallCommand(): string | undefined;
+/**
+ * Runs the CLI upgrade using the appropriate package manager.
+ * Determines the install command and executes it.
+ *
+ * @throws Error if the package manager or command cannot be determined.
+ */
+export declare function runCLIUpgrade(): Promise<void>;
+/**
+ * Returns the version to auto-upgrade to, or undefined if auto-upgrade should be skipped.
+ * Auto-upgrade is disabled by default and must be enabled via .
+ * Also skips for CI, pre-release versions, or when no newer version is available.
+ *
+ * @returns The version string to upgrade to, or undefined if no upgrade should happen.
+ */
+export declare function versionToAutoUpgrade(): string | undefined;
 /**
  * Generates a message to remind the user to update the CLI.
  *
  * @param version - The version to update to.
  * @returns The message to remind the user to update the CLI.
  */
-export declare function getOutputUpdateCLIReminder(version: string): string;
\ No newline at end of file
+export declare function getOutputUpdateCLIReminder(version: string): string;
+/**
+ * Prompts the user to enable or disable automatic upgrades, then persists their choice.
+ *
+ * @returns Whether the user chose to enable auto-upgrade.
+ */
+export declare function promptAutoUpgrade(): Promise<boolean>;
\ No newline at end of file
packages/cli-kit/dist/public/node/version.d.ts
@@ -18,4 +18,13 @@ export declare function globalCLIVersion(): Promise<string | undefined>;
  * @param version - The version to check.
  * @returns True if the version is a pre-release version.
  */
-export declare function isPreReleaseVersion(version: string): boolean;
\ No newline at end of file
+export declare function isPreReleaseVersion(version: string): boolean;
+/**
+ * Checks if there is a major version change between two versions.
+ * Pre-release versions (0.0.0-*) are treated as not having a major version change.
+ *
+ * @param currentVersion - The current version.
+ * @param newerVersion - The newer version to compare against.
+ * @returns True if there is a major version change.
+ */
+export declare function isMajorVersionChange(currentVersion: string, newerVersion: string): boolean;
\ No newline at end of file
packages/cli-kit/dist/public/node/hooks/postrun.d.ts
@@ -5,4 +5,10 @@ import { Hook } from '@oclif/core';
  * @returns Whether post run hook has completed.
  */
 export declare function postRunHookHasCompleted(): boolean;
-export declare const hook: Hook.Postrun;
\ No newline at end of file
+export declare const hook: Hook.Postrun;
+/**
+ * Auto-upgrades the CLI after a command completes, if a newer version is available.
+ *
+ * @returns Resolves when the upgrade attempt (or fallback warning) is complete.
+ */
+export declare function autoUpgradeIfNeeded(): Promise<void>;
\ No newline at end of file
packages/cli-kit/dist/public/node/hooks/prerun.d.ts
@@ -11,6 +11,7 @@ export declare function parseCommandContent(cmdInfo: {
     pluginAlias?: string;
 }): CommandContent;
 /**
- * Warns the user if there is a new version of the CLI available
+ * Triggers a background check for a newer CLI version (non-blocking).
+ * The result is cached and consumed by the postrun hook for auto-upgrade.
  */
-export declare function warnOnAvailableUpgrade(): Promise<void>;
\ No newline at end of file
+export declare function checkForNewVersionInBackground(): void;
\ No newline at end of file

alfonso-noriega and others added 2 commits March 13, 2026 16:18
These files belong to the auto-upgrade Phase 1 work and were accidentally
included in this branch. Reverting to main state to keep the PR focused
on the uid strategy matching fix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@alfonso-noriega alfonso-noriega changed the title fix: infer uidStrategy from backend typename to fix channel extension matching fix: infer uidStrategy from backend typename Mar 13, 2026
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@alfonso-noriega alfonso-noriega marked this pull request as ready for review March 13, 2026 15:50
@alfonso-noriega alfonso-noriega requested a review from a team as a code owner March 13, 2026 15:50
@github-actions
Copy link
Contributor

We detected some changes at packages/*/src and there are no updates in the .changeset.
If the changes are user-facing, run pnpm changeset add to track your changes and include them in the next release CHANGELOG.

Caution

DO NOT create changesets for features which you do not wish to be included in the public changelog of the next CLI release.

@github-actions
Copy link
Contributor

github-actions bot commented Mar 13, 2026

Coverage report

St.
Category Percentage Covered / Total
🟡 Statements 77.32% 14608/18892
🟡 Branches 70.91% 7249/10223
🟡 Functions 76.32% 3710/4861
🟡 Lines 78.81% 13813/17526

Test suite run success

3815 tests passing in 1474 suites.

Report generated by 🧪jest coverage report action from 508bfae

@alfonso-noriega alfonso-noriega requested a review from dmerand March 13, 2026 16:10
isaacroldan and others added 7 commits March 17, 2026 11:40
…ion/isAppConfigExtension

Centralizes the experience === 'configuration' and experience === 'extension'
checks behind isAppConfigSpecification helper and the existing
isAppConfigExtension getter on ExtensionInstance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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.

3 participants