Skip to content

Commit f782f13

Browse files
committed
feat(theme): mirror theme-scoped translations on blue/green deploy
Translations registered via Translate & Adapt (and any caller of the Translations API) for resources of type ONLINE_STORE_THEME_* are keyed to a specific theme GID. Blue and Green are distinct permanent themes, so translations written against the currently-live color do not follow when the on-deck color is promoted. The previously-live color's translations are silently stranded, and the storefront reverts to source-language strings on the next deploy. This PR adds: - `shopkeeper theme translations mirror` — a standalone command that copies theme-scoped translations from one theme to another. * `--from / --to` explicit override (recovery scenarios where the authoritative-translations theme is no longer [live]) * `--blue / --green` derive source = live, target = on-deck via the same getLiveTheme + getOnDeckThemeId helpers `theme deploy` uses * `--locale` restrict to a single locale (default: every non-primary published locale) * `--resource-type` restrict to a single theme-scoped resource type (useful for targeted ops + testing) * `--dry-run` build the write plan, do not register - Optional auto-step inside `blueGreenDeploy()`, gated on the new `--mirror-translations` flag (env: SKR_FLAG_MIRROR_TRANSLATIONS). Default-off because most client stores don't use Translate & Adapt and paying the pre-flight cost on every deploy isn't justified for them. Stores that need it (e.g. Hiya) opt in via env. Performance: - Pre-flight short-circuit: before doing the full inventory, compare aggregate translation maps for ONLINE_STORE_THEME, ONLINE_STORE_THEME_LOCALE_CONTENT, and ONLINE_STORE_THEME_SETTINGS_DATA_SECTIONS between source and target. If all match per-locale, skip the mirror entirely (~6 queries vs the full ~250). - Source-is-live fast path: when source = live, the type-scoped list query returns content + translations directly, so we avoid a per-resource source-fetch round-trip (~50% fewer reads). - Bounded-concurrency parallelism on per-resource target fetches (concurrency 8, hand-rolled in src/utilities/concurrency.ts). Together these bring a typical mirror on Hiya's catalog (~124 theme- scoped resources) from ~5 minutes to well under a minute. Auth: - Requires a Shopify Theme Access password (--password or SHOPIFY_CLI_THEME_TOKEN). The token's Theme Access app must include read_translations + write_translations scopes; ensureAuthenticatedThemes uses the password directly without falling back to OAuth. Resource scope: ONLINE_STORE_THEME ONLINE_STORE_THEME_APP_EMBED ONLINE_STORE_THEME_JSON_TEMPLATE ONLINE_STORE_THEME_LOCALE_CONTENT ONLINE_STORE_THEME_SECTION_GROUP ONLINE_STORE_THEME_SETTINGS_CATEGORY ONLINE_STORE_THEME_SETTINGS_DATA_SECTIONS Resource-scoped translations (PRODUCT, COLLECTION, METAOBJECT, PAGE, etc.) are not theme-keyed and survive blue/green without intervention, so they are intentionally excluded. Idempotent + safe: - Skips writes when the target value already matches the source. - Skips keys whose source content has been removed on the target, or whose digest no longer matches between deploys. - Refuses to mirror a theme onto itself. - Anchoring on explicit `--from` / `--to` is intentional: in recovery scenarios the most-up-to-date translations may live on a no-longer- live theme. Deriving direction from [live] would invert the mirror and overwrite the freshly-edited theme with stale data. Tests: 164 passing. New coverage in services/theme/translations.test.ts covers resolveThemes flag matrix, password requirement, sourceIsLive detection, digest-match registration, idempotent skip, dry-run, target-resource-absent, locale/resource-type filters, and the pre-flight skip + full-mirror-on-mismatch behavior. Deploy tests updated to assert default-off and explicit opt-in.
1 parent a1c53f5 commit f782f13

10 files changed

Lines changed: 1728 additions & 375 deletions

File tree

docs/commands/readme.md

Lines changed: 142 additions & 79 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

oclif.manifest.json

Lines changed: 419 additions & 278 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/commands/theme/deploy.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ export default class Deploy extends ThemeCommand {
4444
},
4545
],
4646
}),
47+
'mirror-translations': Flags.boolean({
48+
description:
49+
'Opt-in. Before deploying code, mirror theme-scoped translations (Translate & Adapt template / locale-content / settings translations) from the currently-live theme onto the on-deck theme. Default off; most stores do not register theme-scoped translations, and the pre-flight cost is not justified for them. Stores that use Translate & Adapt should enable this via SKR_FLAG_MIRROR_TRANSLATIONS=true.',
50+
default: false,
51+
env: 'SKR_FLAG_MIRROR_TRANSLATIONS',
52+
}),
4753
}
4854

4955
async run(): Promise<void> {
@@ -60,6 +66,7 @@ export default class Deploy extends ThemeCommand {
6066
strategy: flags.strategy,
6167
green: flags.green,
6268
blue: flags.blue,
69+
mirrorTranslations: flags['mirror-translations'],
6370
}
6471
await deploy(deployFlags)
6572
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import {Flags} from '@oclif/core'
2+
import {globalFlags} from '@shopify/cli-kit/node/cli'
3+
import BaseCommand from '@shopify/cli-kit/node/base-command'
4+
import {themeFlags} from '../../../utilities/shopify/flags.js'
5+
import {mirrorTranslations, MirrorTranslationsFlags} from '../../../services/theme/translations.js'
6+
7+
export default class Mirror extends BaseCommand {
8+
static description =
9+
'Mirror theme-scoped translations from one theme to another. Translations registered via the Translate & Adapt app (or any caller of translationsRegister) are keyed to a specific theme GID and do NOT migrate between themes — so a blue/green deploy strands them on the previously-live color unless something explicitly carries them across. This command does that.'
10+
11+
static examples = [
12+
{
13+
description: 'Mirror live -> on-deck before a blue/green deploy (standard use):',
14+
command: '<%= config.bin %> <%= command.id %> --blue 134599540817 --green 134599737425',
15+
},
16+
{
17+
description: 'Recover translations stranded on a no-longer-live theme:',
18+
command: '<%= config.bin %> <%= command.id %> --from 134599540817 --to 134599737425',
19+
},
20+
{
21+
description: 'Dry-run (print plan without writing):',
22+
command: '<%= config.bin %> <%= command.id %> --blue 1 --green 2 --dry-run',
23+
},
24+
]
25+
26+
static flags = {
27+
...globalFlags,
28+
store: themeFlags.store,
29+
password: themeFlags.password,
30+
environment: themeFlags.environment,
31+
from: Flags.integer({
32+
description:
33+
'Source theme ID. When provided, must be combined with --to. Use this to anchor the mirror direction explicitly (e.g. for recovery scenarios where the most-up-to-date translation source is no longer [live]).',
34+
env: 'SKR_FLAG_FROM_THEME_ID',
35+
}),
36+
to: Flags.integer({
37+
description: 'Target theme ID. When provided, must be combined with --from.',
38+
env: 'SKR_FLAG_TO_THEME_ID',
39+
}),
40+
blue: Flags.integer({
41+
description:
42+
'Blue theme ID. Used when --from/--to are not provided: live theme becomes source, on-deck becomes target.',
43+
env: 'SKR_FLAG_BLUE_THEME_ID',
44+
}),
45+
green: Flags.integer({
46+
description: 'Green theme ID. Pairs with --blue.',
47+
env: 'SKR_FLAG_GREEN_THEME_ID',
48+
}),
49+
locale: Flags.string({
50+
description: 'Mirror only this locale (e.g. "fr"). Defaults to every published non-primary locale on the store.',
51+
env: 'SKR_FLAG_LOCALE',
52+
}),
53+
'resource-type': Flags.string({
54+
description:
55+
'Restrict the mirror to a single theme-scoped resource type. Useful for targeted ops or testing. Allowed: ONLINE_STORE_THEME, ONLINE_STORE_THEME_APP_EMBED, ONLINE_STORE_THEME_JSON_TEMPLATE, ONLINE_STORE_THEME_LOCALE_CONTENT, ONLINE_STORE_THEME_SECTION_GROUP, ONLINE_STORE_THEME_SETTINGS_CATEGORY, ONLINE_STORE_THEME_SETTINGS_DATA_SECTIONS.',
56+
env: 'SKR_FLAG_RESOURCE_TYPE',
57+
}),
58+
'dry-run': Flags.boolean({
59+
description: 'Build the write plan and print a summary, but do not invoke translationsRegister.',
60+
env: 'SKR_FLAG_DRY_RUN',
61+
default: false,
62+
}),
63+
}
64+
65+
async run(): Promise<void> {
66+
const {flags} = await this.parse(Mirror)
67+
const serviceFlags: MirrorTranslationsFlags = {
68+
store: flags.store,
69+
password: flags.password,
70+
from: flags.from,
71+
to: flags.to,
72+
blue: flags.blue,
73+
green: flags.green,
74+
locale: flags.locale,
75+
resourceType: flags['resource-type'],
76+
dryRun: flags['dry-run'],
77+
noColor: flags['no-color'],
78+
verbose: flags.verbose,
79+
}
80+
await mirrorTranslations(serviceFlags)
81+
}
82+
}

src/services/theme/deploy.test.ts

Lines changed: 107 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { beforeEach, describe, expect, test, vi } from 'vitest'
1+
import {beforeEach, describe, expect, test, vi} from 'vitest'
22
import {
33
basicDeploy,
44
blueGreenDeploy,
@@ -8,16 +8,17 @@ import {
88
getOnDeckThemeId,
99
gitHeadHash,
1010
} from './deploy.js'
11-
import { findPathUp } from '@shopify/cli-kit/node/fs'
12-
import { getLatestGitCommit } from '@shopify/cli-kit/node/git'
13-
import { BLUE_GREEN_STRATEGY } from '../../utilities/constants.js'
14-
import { Theme } from '@shopify/cli-kit/node/themes/types'
15-
import { outputInfo } from '@shopify/cli-kit/node/output'
16-
import { themeUpdate } from '@shopify/cli-kit/node/themes/api'
17-
import { deployToLive, deployTheme, pullLiveThemeSettings } from '../../utilities/theme.js'
18-
import { findThemes } from '../../utilities/shopify/theme-selector.js'
19-
import { ensureAuthenticatedThemes } from '@shopify/cli-kit/node/session'
20-
import { getThemeStore } from '../../utilities/shopify/services/local-storage.js'
11+
import {findPathUp} from '@shopify/cli-kit/node/fs'
12+
import {getLatestGitCommit} from '@shopify/cli-kit/node/git'
13+
import {BLUE_GREEN_STRATEGY} from '../../utilities/constants.js'
14+
import {Theme} from '@shopify/cli-kit/node/themes/types'
15+
import {outputInfo} from '@shopify/cli-kit/node/output'
16+
import {themeUpdate} from '@shopify/cli-kit/node/themes/api'
17+
import {deployToLive, deployTheme, pullLiveThemeSettings} from '../../utilities/theme.js'
18+
import {findThemes} from '../../utilities/shopify/theme-selector.js'
19+
import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session'
20+
import {getThemeStore} from '../../utilities/shopify/services/local-storage.js'
21+
import {mirrorTranslations} from './translations.js'
2122

2223
vi.mock('@shopify/cli-kit/node/fs')
2324
vi.mock('@shopify/cli-kit/node/git')
@@ -27,13 +28,14 @@ vi.mock('@shopify/cli-kit/node/session')
2728
vi.mock('../../utilities/shopify/theme-selector.js')
2829
vi.mock('../../utilities/theme.js')
2930
vi.mock('../../utilities/shopify/services/local-storage.js')
31+
vi.mock('./translations.js')
3032

3133
describe('deploy', () => {
32-
const adminSession = { token: 'ABC', storeFqdn: 'example.myshopify.com' }
34+
const adminSession = {token: 'ABC', storeFqdn: 'example.myshopify.com'}
3335
const path = '/my-theme'
3436

3537
function theme(id: number, role: string) {
36-
return { id, role, name: `theme (${id})` } as Theme
38+
return {id, role, name: `theme (${id})`} as Theme
3739
}
3840

3941
beforeEach(async () => {
@@ -42,7 +44,7 @@ describe('deploy', () => {
4244

4345
describe('deploy', () => {
4446
describe('when blue/green strategy', () => {
45-
test('makes blue/green deploy', async () => {
47+
test('makes blue/green deploy without mirroring translations by default', async () => {
4648
// Given
4749
const green = 1
4850
const blue = 2
@@ -74,12 +76,90 @@ describe('deploy', () => {
7476
await deploy(flags)
7577

7678
// Then
77-
expect(outputInfo).toHaveBeenCalledTimes(2)
7879
expect(outputInfo).toHaveBeenCalledWith('Pulling theme settings')
7980
expect(pullLiveThemeSettings).toBeCalledWith(flags)
81+
expect(mirrorTranslations).not.toHaveBeenCalled()
8082
expect(deployTheme).toBeCalledWith(green, flags)
8183
expect(outputInfo).toHaveBeenCalledWith('Green renamed to [BABCD123] Production - Green')
8284
})
85+
86+
test('mirrors translations when mirrorTranslations is set', async () => {
87+
// Given
88+
const green = 1
89+
const blue = 2
90+
const liveTheme = theme(blue, 'main')
91+
const themes = [theme(green, 'unpublished'), liveTheme]
92+
vi.mocked(getThemeStore).mockReturnValue('')
93+
vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(adminSession)
94+
vi.mocked(findThemes).mockResolvedValue([liveTheme])
95+
vi.mocked(themeUpdate).mockResolvedValue(themes[1])
96+
vi.mocked(findPathUp).mockResolvedValue('path')
97+
vi.mocked(getLatestGitCommit).mockResolvedValue({
98+
hash: 'BABCD1234AB',
99+
date: '',
100+
message: '',
101+
refs: '',
102+
body: '',
103+
author_name: '',
104+
author_email: '',
105+
})
106+
const flags: DeployFlags = {
107+
store: 'test',
108+
green: green,
109+
blue: blue,
110+
strategy: BLUE_GREEN_STRATEGY,
111+
publish: false,
112+
mirrorTranslations: true,
113+
}
114+
115+
// When
116+
await deploy(flags)
117+
118+
// Then
119+
expect(mirrorTranslations).toBeCalledWith({
120+
store: 'test',
121+
password: undefined,
122+
from: blue,
123+
to: green,
124+
})
125+
expect(deployTheme).toBeCalledWith(green, flags)
126+
})
127+
128+
test('continues deploy when translation mirror throws', async () => {
129+
// Given
130+
const green = 1
131+
const blue = 2
132+
const liveTheme = theme(blue, 'main')
133+
vi.mocked(getThemeStore).mockReturnValue('')
134+
vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(adminSession)
135+
vi.mocked(findThemes).mockResolvedValue([liveTheme])
136+
vi.mocked(themeUpdate).mockResolvedValue(theme(green, 'unpublished'))
137+
vi.mocked(findPathUp).mockResolvedValue('path')
138+
vi.mocked(getLatestGitCommit).mockResolvedValue({
139+
hash: 'BABCD1234AB',
140+
date: '',
141+
message: '',
142+
refs: '',
143+
body: '',
144+
author_name: '',
145+
author_email: '',
146+
})
147+
vi.mocked(mirrorTranslations).mockRejectedValue(new Error('admin API down'))
148+
const flags: DeployFlags = {
149+
store: 'test',
150+
green: green,
151+
blue: blue,
152+
strategy: BLUE_GREEN_STRATEGY,
153+
publish: false,
154+
mirrorTranslations: true,
155+
}
156+
157+
// When
158+
await deploy(flags)
159+
160+
// Then — the code deploy should still happen even if translation mirror failed
161+
expect(deployTheme).toBeCalledWith(green, flags)
162+
})
83163
})
84164

85165
describe('when no strategy is passsed', () => {
@@ -144,15 +224,23 @@ describe('deploy', () => {
144224
blue: blue,
145225
strategy: BLUE_GREEN_STRATEGY,
146226
publish: false,
227+
mirrorTranslations: true,
147228
}
148229

149230
// When
150231
await blueGreenDeploy(flags)
151232

152233
// Then
153-
expect(outputInfo).toHaveBeenCalledTimes(2)
234+
expect(outputInfo).toHaveBeenCalledTimes(3)
154235
expect(outputInfo).toHaveBeenCalledWith('Pulling theme settings')
236+
expect(outputInfo).toHaveBeenCalledWith('Mirroring theme-scoped translations from live to on-deck')
155237
expect(pullLiveThemeSettings).toBeCalledWith(flags)
238+
expect(mirrorTranslations).toBeCalledWith({
239+
store: undefined,
240+
password: undefined,
241+
from: blue,
242+
to: green,
243+
})
156244
expect(deployTheme).toBeCalledWith(green, flags)
157245
expect(outputInfo).toHaveBeenCalledWith('Green renamed to [BABCD123] Production - Green')
158246
})
@@ -220,7 +308,9 @@ describe('deploy', () => {
220308
}
221309

222310
// Then
223-
await expect(errorFunc).rejects.toThrowError(/Something very bad has happened. The store doesn't have a live theme./)
311+
await expect(errorFunc).rejects.toThrowError(
312+
/Something very bad has happened. The store doesn't have a live theme./,
313+
)
224314
})
225315
})
226316
})

src/services/theme/deploy.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ import {getLatestGitCommit} from '@shopify/cli-kit/node/git'
55
import {deployToLive, deployTheme as deployTheme, pullLiveThemeSettings} from '../../utilities/theme.js'
66
import {findPathUp} from '@shopify/cli-kit/node/fs'
77
import {BLUE_GREEN_STRATEGY} from '../../utilities/constants.js'
8-
import {outputInfo} from '@shopify/cli-kit/node/output'
8+
import {outputInfo, outputWarn} from '@shopify/cli-kit/node/output'
99
import {findThemes} from '../../utilities/shopify/theme-selector.js'
1010
import {ensureThemeStore} from '../../utilities/shopify/theme-store.js'
11+
import {mirrorTranslations as runMirrorTranslations} from './translations.js'
1112

1213
type OnDeckTheme = {
1314
id: number
@@ -48,6 +49,21 @@ export interface DeployFlags {
4849
strategy: string
4950
blue?: number
5051
green?: number
52+
53+
/**
54+
* Opt-in. When true, mirror theme-scoped translations (Translate & Adapt
55+
* template / locale-content / settings translations) from the currently-
56+
* live theme onto the on-deck theme before deploying code. Translations
57+
* registered via the Translations API are keyed to a theme GID, so without
58+
* this step they are stranded on the previously-live color and disappear
59+
* from the storefront on promotion.
60+
*
61+
* Default-off because most client stores don't use Translate & Adapt and
62+
* paying the (cheap, but non-zero) pre-flight cost on every deploy isn't
63+
* justified for them. Stores that do use it (Hiya) should set
64+
* SKR_FLAG_MIRROR_TRANSLATIONS=true in their deploy environment.
65+
*/
66+
mirrorTranslations?: boolean
5167
}
5268

5369
export async function deploy(flags: DeployFlags) {
@@ -72,6 +88,24 @@ export async function blueGreenDeploy(flags: DeployFlags) {
7288

7389
const liveThemeId = await getLiveTheme(adminSession)
7490
const onDeckTheme = getOnDeckThemeId(liveThemeId, blue!, green!)
91+
92+
if (flags.mirrorTranslations) {
93+
try {
94+
outputInfo('Mirroring theme-scoped translations from live to on-deck')
95+
await runMirrorTranslations({
96+
store: flags.store,
97+
password: flags.password,
98+
from: liveThemeId,
99+
to: onDeckTheme.id,
100+
})
101+
} catch (error) {
102+
outputWarn(
103+
`Failed to mirror theme translations before deploy. Theme-scoped translations registered via Translate & Adapt on the previously-live theme may not appear after this deploy promotes ${onDeckTheme.name}.`,
104+
)
105+
outputWarn(error instanceof Error ? error.message : String(error))
106+
}
107+
}
108+
75109
await deployTheme(onDeckTheme.id, flags)
76110

77111
const headSHA = await gitHeadHash()

0 commit comments

Comments
 (0)