From 4207f2b8090ebd7f84c4ade417fd52ab91f9d197 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 17 Sep 2026 11:48:57 -0500 Subject: [PATCH 01/13] Rendered server-sent automation descriptions if available (#30860) towards https://linear.app/ghost/issue/NY-1621 This change should have no user impact. This renders automations' descriptions from the server, if applicable. They're not sent today, but they will be in a future change! In addition to automated tests, I also manually verified this by hacking the backend API endpoint to send a description: ![Screenshot of database-backed description](https://github.com/user-attachments/assets/cc1a1c33-06c4-4f68-b1b3-d78ebb38352d) --- apps/admin-x-framework/src/api/automations.ts | 1 + .../src/automations/automations.acceptance.test.tsx | 3 ++- .../components/automations-list.test.tsx | 8 ++++++-- .../src/automations/components/automations-list.tsx | 13 +++++++++---- .../testing/test-data/src/builders/automation.ts | 2 ++ 5 files changed, 20 insertions(+), 7 deletions(-) diff --git a/apps/admin-x-framework/src/api/automations.ts b/apps/admin-x-framework/src/api/automations.ts index c2d4d42f149..7c455bf2020 100644 --- a/apps/admin-x-framework/src/api/automations.ts +++ b/apps/admin-x-framework/src/api/automations.ts @@ -8,6 +8,7 @@ export const MAX_AUTOMATION_ACTIONS = 20; export type Automation = { id: string; name: string; + description?: string; /** @deprecated `slug` will be removed in the future. */ slug?: null | string; status: AutomationStatus; diff --git a/apps/admin/src/automations/automations.acceptance.test.tsx b/apps/admin/src/automations/automations.acceptance.test.tsx index 80cd034e474..aa96f37783f 100644 --- a/apps/admin/src/automations/automations.acceptance.test.tsx +++ b/apps/admin/src/automations/automations.acceptance.test.tsx @@ -19,6 +19,7 @@ describe('Automations list', () => { fakeAutomations([ automation({ name: 'Free member welcome flow', + description: 'Greet new free members.', slug: 'member-welcome-email-free', status: 'active', stats: { @@ -45,7 +46,7 @@ describe('Automations list', () => { await expect.element(automationsScreen.columnHeader('Total entries')).toBeVisible(); await expect.element(automationsScreen.columnHeader('In progress')).toBeVisible(); const row = automationsScreen.rows(); - await expect.element(row).toHaveTextContent('Welcome new free members after they sign up.'); + await expect.element(row).toHaveTextContent('Greet new free members.'); await expect.element(row).toHaveTextContent('1,432'); await expect.element(row).toHaveTextContent('118'); await expect.element(row).toHaveTextContent('Live'); diff --git a/apps/admin/src/automations/components/automations-list.test.tsx b/apps/admin/src/automations/components/automations-list.test.tsx index f5cd5125723..085d20e32d2 100644 --- a/apps/admin/src/automations/components/automations-list.test.tsx +++ b/apps/admin/src/automations/components/automations-list.test.tsx @@ -8,6 +8,7 @@ const automations = [ { id: 'automation-id-1', name: 'Free member welcome flow', + description: 'Greet new free members.', slug: 'member-welcome-email-free', status: 'active' as const, stats: { @@ -67,12 +68,15 @@ describe('AutomationsList', () => { vi.useRealTimers(); }); - it('renders fetched automations with private beta copy and status labels', () => { + it('renders fetched automations with API and fallback descriptions and status labels', () => { renderWithRouter(); expect(screen.getAllByTestId('automation-list-row')).toHaveLength(3); expect(screen.getByText('Free member welcome flow')).toBeInTheDocument(); - expect(screen.getByText('Welcome new free members after they sign up.')).toBeInTheDocument(); + expect(screen.getByText('Greet new free members.')).toBeInTheDocument(); + expect( + screen.queryByText('Welcome new free members after they sign up.'), + ).not.toBeInTheDocument(); expect(screen.getByText('Paid member welcome flow')).toBeInTheDocument(); expect( screen.getByText('Welcome new paid members after they start their subscription.'), diff --git a/apps/admin/src/automations/components/automations-list.tsx b/apps/admin/src/automations/components/automations-list.tsx index 54c44335224..4eaeefe1364 100644 --- a/apps/admin/src/automations/components/automations-list.tsx +++ b/apps/admin/src/automations/components/automations-list.tsx @@ -18,6 +18,14 @@ const AUTOMATION_DESCRIPTIONS: Record = { 'member-welcome-email-paid': 'Welcome new paid members after they start their subscription.', }; +const getAutomationDescription = (automation: AutomationBrowseItem): string | undefined => { + if (automation.description !== undefined) { + return automation.description; + } + + return automation.slug ? AUTOMATION_DESCRIPTIONS[automation.slug] : undefined; +}; + // Widths are scoped to `lg` because below that the stats lay out on the row's // grid rather than in table cells, where a fixed width would fight the columns. const AUTOMATION_STAT_COLUMNS = [ @@ -137,10 +145,7 @@ const AutomationsList: React.FC = ({ )} {automations.map((automation) => { - // NOTE: We will soon start reading this description from the API and stop using hard-coded values. - const description = automation.slug - ? AUTOMATION_DESCRIPTIONS[automation.slug] - : undefined; + const description = getAutomationDescription(automation); const lastEntry = automation.stats?.last_run_created_at; const totalEntries = automation.stats?.total_run_count ?? 0; const inProgressEntries = automation.stats?.in_progress_run_count ?? 0; diff --git a/packages/testing/test-data/src/builders/automation.ts b/packages/testing/test-data/src/builders/automation.ts index 9bead91d0e8..791ed8e9195 100644 --- a/packages/testing/test-data/src/builders/automation.ts +++ b/packages/testing/test-data/src/builders/automation.ts @@ -6,6 +6,7 @@ import { generateId, generateSlug } from '../utils'; export interface Automation { id: string; name: string; + description: string; slug: string; status: 'active' | 'inactive'; stats?: { @@ -21,6 +22,7 @@ export const automation = createBuilder(() => { return { id: generateId(), name, + description: faker.lorem.sentence(), slug: `${generateSlug(name)}-${faker.string.alphanumeric(6).toLowerCase()}`, status: 'inactive', stats: { From 0d336213eafb9138164385a5a799e4befd4ba6d8 Mon Sep 17 00:00:00 2001 From: Arthur da Paz Date: Thu, 17 Sep 2026 14:23:23 -0300 Subject: [PATCH 02/13] =?UTF-8?q?=F0=9F=8C=90=20Added=20Brazilian=20Portug?= =?UTF-8?q?uese=20translations=20for=20gift=20memberships=20(#30844)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no ref --- packages/i18n/locales/pt-BR/ghost.json | 72 ++++++------- packages/i18n/locales/pt-BR/portal.json | 128 ++++++++++++------------ 2 files changed, 100 insertions(+), 100 deletions(-) diff --git a/packages/i18n/locales/pt-BR/ghost.json b/packages/i18n/locales/pt-BR/ghost.json index 20a0b33dfdf..d33fba67cb7 100644 --- a/packages/i18n/locales/pt-BR/ghost.json +++ b/packages/i18n/locales/pt-BR/ghost.json @@ -1,9 +1,9 @@ { - "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", - "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", - "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", - "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", - "{buyerName} sent you a gift": "", + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "{buyerName} presenteou você com uma assinatura {tierName} para {siteTitle}, válida por {duration} mês(es)", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "{buyerName} presenteou você com uma assinatura {tierName} para {siteTitle}, válida por {duration} ano", + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "{buyerName} presenteou você com uma assinatura {tierName} para {siteTitle}, válida por {duration} mês(es)", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "{buyerName} presenteou você com uma assinatura {tierName} para {siteTitle}, válida por {duration} ano", + "{buyerName} sent you a gift": "{buyerName} enviou um presente para você", "{count} month_one": "{count} mês", "{count} month_many": "{count} meses", "{count} month_other": "{count} meses", @@ -12,7 +12,7 @@ "{count} year_other": "{count} anos", "{date}": "{date}", "{tierName} membership": "assinatura {tierName}", - "A gift, just for you": "", + "A gift, just for you": "Um presente, só para você", "All the best!": "Até breve!", "Become a paid member of {site} to get access to all premium content.": "Torne-se um membro pago do {site} para ter acesso a todo o conteúdo premium.", "By {authors}": "Por {authors}", @@ -37,7 +37,7 @@ "Hey there,": "Olá,", "Hey there!": "Olá!", "Hi {firstName},": "Olá, {firstName},", - "Hi {recipientName},": "", + "Hi {recipientName},": "Olá, {recipientName},", "If you did not make this request, you can safely ignore this email.": "Se você não fez esta solicitação, pode ignorar este e-mail.", "If you did not make this request, you can simply delete this message.": "Se você não fez esta solicitação, pode apagar esta mensagem.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "Se você não tentou entrar recentemente, ignore este e-mail. Essa medida impedirá o acesso não autorizado.", @@ -55,12 +55,12 @@ "Or, skip the code and sign in directly": "Ou, ignore o código e entre diretamente", "paid": "pago", "Please confirm your email address with this link:": "Por favor, confirme seu endereço de e-mail usando este link:", - "Redeem your gift": "", + "Redeem your gift": "Resgatar seu presente", "Redeem your gift subscription": "Resgatar sua assinatura de presente", - "Redeem your gift:": "", + "Redeem your gift:": "Resgate seu presente:", "Secure sign in link for {siteTitle}": "Link seguro para acessar o site {siteTitle}", "See you soon!": "Até logo!", - "Send the gift link manually so it can still be redeemed.": "", + "Send the gift link manually so it can still be redeemed.": "Envie o link do presente manualmente para que ele ainda possa ser resgatado.", "Sent to {email}": "Enviado para {email}", "Share": "Compartilhar", "Sign in": "Entrar", @@ -78,23 +78,23 @@ "Thank you for subscribing to {siteTitle}.": "Obrigado por se inscrever no site {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Obrigado por se inscrever no site {siteTitle}. Clique no link abaixo para entrar automaticamente:", "Thank you for supporting {siteTitle}.": "Obrigado por apoiar {siteTitle}.", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", - "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", - "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. You can also share the link below yourself.": "", - "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", - "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. You can also share the link below yourself.": "", - "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", - "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. You can also share the link below yourself.": "", - "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", - "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. You can also share the link below yourself.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "Obrigado pelo seu apoio. Compartilhe o link abaixo com quem você quiser presentear com uma assinatura {tierName} para {siteTitle}, válida por {duration} mês(es).", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "Obrigado pelo seu apoio. Compartilhe o link abaixo com quem você quiser presentear com uma assinatura {tierName} para {siteTitle}, válida por {duration} ano.", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "Obrigado pelo seu apoio. Compartilhe o link abaixo com quem você quiser presentear com uma assinatura {tierName} para {siteTitle}, válida por {duration} mês(es).", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "Obrigado pelo seu apoio. Compartilhe o link abaixo com quem você quiser presentear com uma assinatura {tierName} para {siteTitle}, válida por {duration} ano.", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "Obrigado pelo seu apoio. Seu presente — uma assinatura {tierName} para {siteTitle}, válida por {duration} mês(es) — está a caminho de {recipientEmail}. Você também pode compartilhar o link abaixo.", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. You can also share the link below yourself.": "Obrigado pelo seu apoio. Seu presente — uma assinatura {tierName} para {siteTitle}, válida por {duration} mês(es) — será enviado para {recipientEmail} em {deliveryDate}. Você também pode compartilhar o link abaixo.", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "Obrigado pelo seu apoio. Seu presente — uma assinatura {tierName} para {siteTitle}, válida por {duration} ano — está a caminho de {recipientEmail}. Você também pode compartilhar o link abaixo.", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. You can also share the link below yourself.": "Obrigado pelo seu apoio. Seu presente — uma assinatura {tierName} para {siteTitle}, válida por {duration} ano — será enviado para {recipientEmail} em {deliveryDate}. Você também pode compartilhar o link abaixo.", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "Obrigado pelo seu apoio. Seu presente — uma assinatura {tierName} para {siteTitle}, válida por {duration} mês(es) — está a caminho de {recipientEmail}. Você também pode compartilhar o link abaixo.", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. You can also share the link below yourself.": "Obrigado pelo seu apoio. Seu presente — uma assinatura {tierName} para {siteTitle}, válida por {duration} mês(es) — será enviado para {recipientEmail} em {deliveryDate}. Você também pode compartilhar o link abaixo.", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "Obrigado pelo seu apoio. Seu presente — uma assinatura {tierName} para {siteTitle}, válida por {duration} ano — está a caminho de {recipientEmail}. Você também pode compartilhar o link abaixo.", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. You can also share the link below yourself.": "Obrigado pelo seu apoio. Seu presente — uma assinatura {tierName} para {siteTitle}, válida por {duration} ano — será enviado para {recipientEmail} em {deliveryDate}. Você também pode compartilhar o link abaixo.", "Thanks for reading {siteTitle}.": "Obrigado por ler {siteTitle}.", "The link expires on {expiresAt} and can only be redeemed once.": "O link expira em {expiresAt} e só pode ser resgatado uma vez.", "This email address will not be used.": "Este endereço de e-mail não será usado.", - "This gift can only be redeemed once and expires on {expiresAt}.": "", - "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", + "This gift can only be redeemed once and expires on {expiresAt}.": "Este presente só pode ser resgatado uma vez e expira em {expiresAt}.", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "Esta mensagem foi enviada de {siteDomain} para {email} em nome de {buyerName} ({buyerEmail}).", "This message was sent from {siteDomain} to {email}.": "Esta mensagem foi enviada de {siteDomain} para {email}.", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "Para manter sua assinatura {tierName}, continue hoje com uma assinatura paga e adicionaremos automaticamente o restante do período do presente como teste grátis.", "trialing": "em período de teste", @@ -104,14 +104,14 @@ "Upgrade to continue reading.": "Faça upgrade para continuar lendo.", "View comments": "Ver comentários", "View in browser": "Ver no navegador", - "We couldn't deliver your gift": "", - "We couldn't deliver your gift to {recipientEmail}. Send them the gift link below so they can redeem it.": "", + "We couldn't deliver your gift": "Não conseguimos entregar seu presente", + "We couldn't deliver your gift to {recipientEmail}. Send them the gift link below so they can redeem it.": "Não conseguimos entregar seu presente para {recipientEmail}. Envie o link do presente abaixo para que a pessoa possa resgatá-lo.", "Welcome back to {siteTitle}!": "Bem-vindo de volta ao site {siteTitle}!", "Welcome back to {siteTitle}! Your verification code is {otc}.": "Bem-vindo de volta ao site {siteTitle}! Seu código de verificação é {otc}.", "Welcome back! Here's your code to sign in to {siteTitle}": "Bem-vindo de volta! Aqui está seu código para entrar no site {siteTitle}", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Bem-vindo de volta! Use este link para entrar com segurança na sua conta do site {siteTitle}:", - "What's included": "", - "What's included:": "", + "What's included": "O que está incluído", + "What's included:": "O que está incluído:", "When:": "Quando:", "Where:": "Onde:", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "Você está recebendo isto porque é um assinante %%{status}%% de {site}.", @@ -122,19 +122,19 @@ "You will not be subscribed.": "Você não será inscrito.", "You're one tap away from subscribing to {siteTitle} — please confirm your email address with this link:": "Você está a um clique de se inscrever no site {siteTitle} — por favor, confirme seu endereço de e-mail com este link:", "You're one tap away from subscribing to {siteTitle}!": "Você está a um clique de se inscrever no site {siteTitle}!", - "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "Você recebeu uma assinatura {tierName} para {siteTitle}, válida por {duration} mês.", - "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "Você recebeu uma assinatura {tierName} para {siteTitle}, válida por {duration} ano.", - "You've been gifted a membership to {siteTitle}": "", + "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "Você recebeu uma assinatura {tierName} para {siteTitle}, válida por {duration} mês(es)", + "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "Você recebeu uma assinatura {tierName} para {siteTitle}, válida por {duration} ano", + "You've been gifted a membership to {siteTitle}": "Você recebeu uma assinatura de presente para {siteTitle}", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "Seu período de teste gratuito termina em {date}, quando será cobrado o preço normal. Você pode cancelar antes disso.", - "Your gift has been sent": "", - "Your gift is on its way": "", + "Your gift has been sent": "Seu presente foi enviado", + "Your gift is on its way": "Seu presente está a caminho", "Your gift is ready": "Seu presente está pronto", - "Your gift is scheduled": "", + "Your gift is scheduled": "Seu presente está agendado", "Your gift subscription confirmation": "Confirmação da sua assinatura de presente", "Your gift subscription is ending soon": "Sua assinatura de presente vai terminar em breve", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "Sua assinatura de presente para {siteTitle} termina em {consumesAt}.", - "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", - "Your gift will be sent on {deliveryDate}": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "Seu presente foi enviado para {recipientEmail}. Você também pode compartilhar o link abaixo.", + "Your gift will be sent on {deliveryDate}": "Seu presente será enviado em {deliveryDate}", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "Sua assinatura foi cancelada e expirará em {date}. Você pode retomar sua assinatura nas configurações de sua conta.", "Your subscription has expired.": "Sua assinatura expirou.", "Your subscription will expire on {date}.": "Sua assinatura expirará em {date}.", diff --git a/packages/i18n/locales/pt-BR/portal.json b/packages/i18n/locales/pt-BR/portal.json index a298717f720..07b70d60590 100644 --- a/packages/i18n/locales/pt-BR/portal.json +++ b/packages/i18n/locales/pt-BR/portal.json @@ -1,23 +1,23 @@ { - "(None)": "", + "(None)": "(Nenhum)", "(save {highestYearlyDiscount}%)": "(poupe {highestYearlyDiscount}%)", "{amount} days free": "{amount} dias grátis", "{amount} off": "{amount} de desconto", "{amount} off for first {number} months.": "{amount} de desconto nos primeiros {number} meses.", "{amount} off for first {period}.": "{amount} de desconto nos primeiros {period}.", "{amount} off forever.": "{amount} de desconto para sempre.", - "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}_one": "", - "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}_many": "", - "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}_other": "", - "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}_one": "{buyerName} presenteou você com uma assinatura {tierName} para {siteTitle}, válida por {duration} mês", + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}_many": "{buyerName} presenteou você com uma assinatura {tierName} para {siteTitle}, válida por {duration} meses", + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}_other": "{buyerName} presenteou você com uma assinatura {tierName} para {siteTitle}, válida por {duration} meses", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "{buyerName} presenteou você com uma assinatura {tierName} para {siteTitle}, válida por {duration} ano", "{discount}% discount": "{discount}% de desconto", - "{duration} membership": "", - "{field}: {message}": "", + "{duration} membership": "Assinatura de {duration}", + "{field}: {message}": "{field}: {message}", "{memberEmail} will no longer receive {newsletterName} newsletter.": "{memberEmail} não receberá mais a newsletter {newsletterName}.", "{memberEmail} will no longer receive emails when someone replies to your comments.": "{memberEmail} não receberá mais e-mails quando alguém responder seus comentários.", "{memberEmail} will no longer receive this newsletter.": "{memberEmail} não receberá mais esta newsletter.", - "{memberEmail} will no longer receive updates & announcements.": "", - "{months} month": "", + "{memberEmail} will no longer receive updates & announcements.": "{memberEmail} não receberá mais novidades e comunicados.", + "{months} month": "{months} meses", "{months} months": "{months} meses", "{months} months free": "{months} meses grátis", "{tierName} membership": "assinatura {tierName}", @@ -33,9 +33,9 @@ "Account details updated successfully": "Detalhes da conta atualizados com sucesso", "Account settings": "Configurações de conta", "Add a personal note": "Deixar comentário", - "Add a short note to go with your gift": "", - "Address line 1": "", - "Address line 2": "", + "Add a short note to go with your gift": "Adicione uma mensagem curta para acompanhar seu presente", + "Address line 1": "Endereço (linha 1)", + "Address line 2": "Endereço (linha 2)", "After a free trial ends, you will be charged the regular price for the tier you've chosen. You can always cancel before then.": "Quando o teste grátis acabar, será cobrado o preço normal do plano que você escolheu. Você sempre pode cancelar antes.", "Already a member?": "Já é membro?", "An error occurred": "Ocorreu um erro", @@ -46,8 +46,8 @@ "Billing info & receipts": "Informações de cobrança e recibos", "Black Friday": "Black Friday", "Bluesky": "Bluesky", - "Buy": "", - "Buying for someone else?": "", + "Buy": "Comprar", + "Buying for someone else?": "Comprando para outra pessoa?", "Cancel anytime.": "Cancele quando quiser.", "Cancel subscription": "Cancelar assinatura", "Canceled": "Cancelado", @@ -58,14 +58,14 @@ "Check with your mail provider": "Verificar com seu provedor de e-mail", "Check your inbox to verify email update": "Verifique sua caixa de entrada para confirmar a atualização de e-mail", "Choose": "Escolher", - "Choose a date from today onwards": "", - "Choose a date within the next year": "", - "Choose a delivery date": "", + "Choose a date from today onwards": "Escolha uma data a partir de hoje", + "Choose a date within the next year": "Escolha uma data dentro do próximo ano", + "Choose a delivery date": "Escolha uma data de entrega", "Choose a different plan": "Escolher um plano diferente", "Choose a plan": "Escolher um plano", - "Choose a tier": "", + "Choose a tier": "Escolha um plano", "Choose your newsletters": "Escolha suas newsletters", - "City": "", + "City": "Cidade", "Click here to retry": "Clique aqui para tentar novamente", "Click the confirmation link in your inbox to finish redeeming your membership. If it doesn't arrive within 3 minutes, check your spam folder.": "Clique no link de confirmação na sua caixa de entrada para concluir o resgate da sua assinatura. Se ele não chegar em 3 minutos, verifique sua pasta de spam.", "Close": "Fechar", @@ -79,8 +79,8 @@ "Contact support": "Contatar suporte", "Continue": "Continuar", "Continue subscription": "Continuar assinatura", - "Continue to delivery details": "", - "Continue to payment": "", + "Continue to delivery details": "Continuar para os dados de entrega", + "Continue to payment": "Continuar para o pagamento", "Continue with a paid subscription anytime. Your remaining gift period will be added as a free trial.": "Continue com uma assinatura paga quando quiser. O período restante do seu presente será adicionado como teste grátis.", "Copied": "Copiado", "Copy": "Copiar", @@ -89,18 +89,18 @@ "Could not create Stripe checkout session": "Não foi possível criar sessão de pagamento no Stripe", "Could not sign in. Login link expired.": "Não foi possível fazer login. O link de acesso expirou.", "Could not update email! Invalid link.": "Não foi possível atualizar o e-mail! Link inválido.", - "Country": "", + "Country": "País", "Create a new contact": "Criar um novo contato", "Current plan": "Plano atual", "Delete account": "Excluir conta", - "Delivery date": "", - "Delivery method": "", + "Delivery date": "Data de entrega", + "Delivery method": "Forma de entrega", "Didn't mean to do this? Manage your preferences .": "Não era isso que você queria? Gerencie suas preferências .", "Don't have an account?": "Não tem uma conta?", "Edit": "Editar", "Email": "E-mail", "Email confirmation link expired.": "O link de confirmação de e-mail expirou.", - "Email it to them": "", + "Email it to them": "Enviar por e-mail", "Email newsletter": "Newsletter por e-mail", "Email newsletter settings updated": "Configurações de newsletter por e-mail atualizadas com sucesso", "Email preferences": "Preferências de e-mail", @@ -114,7 +114,7 @@ "Enjoy a free month on us.": "Ganhe 1 mês grátis por nossa conta.", "Enjoy a free month on us. You won't be charged until {newBillingDate}.": "Ganhe 1 mês grátis por nossa conta. Você não será cobrado até {newBillingDate}.", "Enter code above": "Insira o código acima", - "Enter the recipient's email address": "", + "Enter the recipient's email address": "Informe o e-mail do destinatário", "Enter your email address": "Insira seu endereço de e-mail", "Enter your name": "Insira seu nome", "Error": "Erro", @@ -134,19 +134,19 @@ "Failed to update newsletter settings": "Falha ao atualizar as configurações da newsletter", "Failed to update subscription, please try again": "Falha ao atualizar a assinatura, por favor, tente novamente", "Failed to verify code, please try again": "Falha ao verificar o código, por favor, tente novamente", - "For a friend or colleague": "", + "For a friend or colleague": "Para um amigo ou colega", "Forever": "Para sempre", "Free Trial – Ends {trialEnd}": "Teste grátis – Termina em {trialEnd}", - "From": "", + "From": "De", "Get help": "Obter ajuda", "Get in touch for help": "Entre em contato para obter ajuda", "Get notified when someone replies to your comment": "Receber notificação quando alguém responder seu comentário", "Gift a membership": "Presentear uma assinatura", "Gift could not be redeemed": "O presente não pôde ser resgatado", "Gift details": "Detalhes do presente", - "Gift duration": "", - "Gift link copied to clipboard": "", - "Gift membership": "", + "Gift duration": "Duração do presente", + "Gift link copied to clipboard": "Link do presente copiado para a área de transferência", + "Gift membership": "Presentear uma assinatura", "Gift redeemed! You're all set.": "Presente resgatado! Está tudo certo.", "Gift subscription": "Assinatura de presente", "Gift subscriptions are not available right now.": "As assinaturas de presente não estão disponíveis no momento.", @@ -154,10 +154,10 @@ "Give feedback on this post": "Enviar feedback sobre esta postagem", "Help! I'm not receiving emails": "Ajuda! Não estou recebendo e-mails", "Here are a few other sites you may enjoy.": "Aqui estão alguns outros sites que você pode gostar.", - "Hi {recipientName},": "", + "Hi {recipientName},": "Olá, {recipientName},", "Hide details": "Ocultar detalhes", - "How would you like to share this gift?": "", - "I'll share it myself": "", + "How would you like to share this gift?": "Como você quer compartilhar este presente?", + "I'll share it myself": "Prefiro compartilhar", "If a newsletter is flagged as spam, emails are automatically disabled for that address to make sure you no longer receive any unwanted messages.": "Se uma newsletter for marcada como spam, os e-mails são automaticamente desativados para esse endereço para garantir que você não receba mais mensagens indesejadas.", "If the spam complaint was accidental, or you would like to begin receiving emails again, you can resubscribe to emails by clicking the button on the previous screen.": "Se a reclamação de spam foi acidental ou se você deseja começar a receber e-mails novamente, pode se inscrever novamente para receber e-mails clicando no botão na tela anterior.", "If you cancel your subscription now, you will continue to have access until {periodEnd}.": "Se você cancelar sua assinatura agora, continuará tendo acesso até {periodEnd}.", @@ -192,9 +192,9 @@ "No thanks, I want to cancel": "Não, obrigado, quero cancelar", "Not ready to share? We've also emailed a copy to your inbox.": "Ainda não quer compartilhar? Também enviamos uma cópia para sua caixa de entrada.", "Not receiving emails?": "Não está recebendo e-mails?", - "Now": "", + "Now": "Agora", "Now check your email!": "Agora confira seu e-mail!", - "Occasional updates from {siteTitle}": "", + "Occasional updates from {siteTitle}": "Novidades ocasionais de {siteTitle}", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Depois de se inscrever novamente, se você ainda não vir e-mails na sua caixa de entrada, verifique sua pasta de spam. Alguns provedores de caixa de entrada mantêm um registro de reclamações anteriores de spam e continuarão a sinalizar e-mails. Se isso acontecer, marque a newsletter mais recente como 'Não é spam' para movê-la de volta para sua caixa de entrada principal.", "Open AOL Mail": "Abrir AOL Mail", "Open email": "Abrir e-mail", @@ -206,7 +206,7 @@ "Open Outlook": "Abrir Outlook", "Open Proton Mail": "Abrir Proton Mail", "Open Yahoo Mail": "Abrir Yahoo Mail", - "Optional message": "", + "Optional message": "Mensagem (opcional)", "Permanent failure (bounce)": "Falha permanente (bounce)", "Phone number": "Número de telefone", "Plan": "Plano", @@ -216,12 +216,12 @@ "Please enter {fieldName}": "Por favor, insira {fieldName}", "Please fill in required fields": "Por favor, preencha os campos obrigatórios", "Podcasts": "Podcasts", - "Postal code": "", + "Postal code": "Código postal", "Re-enable emails": "Reativar e-mails", - "Recipient's email": "", - "Recipient's name": "", + "Recipient's email": "E-mail do destinatário", + "Recipient's name": "Nome do destinatário", "Recommendations": "Recomendações", - "Redeem your gift": "", + "Redeem your gift": "Resgatar seu presente", "Redeeming...": "Resgatando...", "Renews at {price}.": "Renova por {price}", "Resume subscription": "Retomar assinatura", @@ -234,7 +234,7 @@ "Sending...": "Enviando...", "Share": "Compartilhar", "Share a full membership to {siteTitle} with a friend or colleague": "Compartilhe uma assinatura completa de {siteTitle} com um amigo ou colega", - "Share it yourself": "", + "Share it yourself": "Compartilhe o link", "Show all": "Mostre tudo", "Sign in": "Entrar", "Sign out": "Sair", @@ -242,14 +242,14 @@ "Signup error: Invalid link": "Erro de inscrição: link inválido", "Signups from this email domain are currently restricted.": "As inscrições utilizando este domínio de e-mail estão restritas no momento.", "Something went wrong, please try again later.": "Algo deu errado, tente novamente mais tarde.", - "Sorry, no paid plans are available.": "", + "Sorry, no paid plans are available.": "Desculpe, não há planos pagos disponíveis.", "Sorry, no recommendations are available right now.": "Desculpe, não há recomendações disponíveis no momento.", "Sorry, that didn’t work.": "Desculpe, isso não funcionou.", "Spam complaints": "Reclamações de spam", "Start {amount}-day free trial": "Começar teste grátis de {amount} dias", "Starting {startDate}": "Começando {startDate}", "Starting today": "Começando hoje", - "State": "", + "State": "Estado", "Submit feedback": "Enviar avaliação", "Subscribe": "Inscrever-se", "Subscribed": "Inscrito", @@ -259,8 +259,8 @@ "Success! Your account is fully activated, you now have access to all content.": "Sucesso! Sua conta está totalmente ativada, agora você tem acesso a todo o conteúdo.", "Success! Your email is updated.": "Sucesso! Seu e-mail foi atualizado.", "Successfully unsubscribed": "Inscrição cancelada com sucesso", - "Taylor Reid": "", - "taylor@example.com": "", + "Taylor Reid": "Ana Souza", + "taylor@example.com": "ana@example.com", "Thank you for subscribing. Before you start reading, below are a few other sites you may enjoy.": "Obrigado por se inscrever. Antes de começar a ler, abaixo estão alguns outros sites que você pode gostar.", "Thank you for your support": "Obrigado pelo seu apoio", "Thank you for your support!": "Obrigado pelo seu apoio!", @@ -272,20 +272,20 @@ "There was an error continuing your subscription, please try again.": "Houve um erro ao continuar sua assinatura, por favor, tente novamente.", "There was an error processing your payment. Please try again.": "Houve um erro ao processar seu pagamento. Por favor, tente novamente.", "There was an error sending the email, please try again": "Houve um erro ao enviar o e-mail, por favor, tente novamente.", - "This gift can only be redeemed once and expires on {expiryDate}.": "", + "This gift can only be redeemed once and expires on {expiryDate}.": "Este presente só pode ser resgatado uma vez e expira em {expiryDate}.", "This gift has already been consumed.": "Este presente já foi consumido.", "This gift has already been redeemed.": "Este presente já foi resgatado.", "This gift has been refunded.": "Este presente foi reembolsado.", "This gift has expired.": "Este presente expirou.", - "This gift link is invalid.": "", + "This gift link is invalid.": "Este link de presente é inválido.", "This site is invite-only, contact the owner for access.": "Este site é apenas para convidados. Entre em contato com o proprietário para obter acesso.", "This site is not accepting donations at the moment.": "Este site não está aceitando doações no momento.", "This site is not accepting payments at the moment.": "Este site não está aceitando pagamentos no momento.", "This site only accepts paid members.": "Este site aceita apenas membros pagos.", - "This unsubscribe link belongs to a different email address than the one you are signed in with.": "", + "This unsubscribe link belongs to a different email address than the one you are signed in with.": "Este link de cancelamento pertence a um endereço de e-mail diferente daquele com o qual você está conectado.", "Threads": "Threads", "Tier": "Plano", - "To": "", + "To": "Para", "To complete signup, click the confirmation link in your inbox. If it doesn't arrive within 3 minutes, check your spam folder!": "Para completar o cadastro, clique no link de confirmação enviado para sua caixa de entrada. Se o link não chegar dentro de 3 minutos, confira a pasta de spam!", "To continue to stay up to date, subscribe to {publication} below.": "Para continuar atualizado, inscreva-se em {publication} abaixo.", "Too many attempts try again in {number} days.": "Muitas tentativas. Tente novamente em {number} dias.", @@ -304,19 +304,19 @@ "Unsubscribing from emails will not cancel your paid subscription to {title}": "Cancelar o recebimento de e-mails não cancelará sua assinatura paga em {title}", "Update": "Atualizar", "Update your preferences": "Atualizar suas preferências", - "Updates & announcements": "", + "Updates & announcements": "Novidades e comunicados", "Verification link sent, check your inbox": "Link de verificação enviado, verifique sua caixa de entrada", "Verify your email address is correct": "Verifique se o endereço de e-mail está correto", "Verifying...": "Verificando...", "View plans": "Ver planos", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Não foi possível cancelar sua inscrição, pois o e-mail não foi encontrado. Por favor, entre em contato com o proprietário do site.", "We'd hate to see you leave. How about a special offer to stay?": "Não queremos ver você partir. Que tal uma oferta especial para ficar?", - "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", - "We'll email it to the recipient. A copy will be in your inbox too.": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "Vamos enviar por e-mail ao destinatário em {deliveryDate}. Uma cópia também está na sua caixa de entrada.", + "We'll email it to the recipient. A copy will be in your inbox too.": "Vamos enviar por e-mail ao destinatário. Uma cópia também chegará à sua caixa de entrada.", "Welcome back, {name}!": "Bem-vindo de volta, {name}!", "Welcome back!": "Bem-vindo de volta!", "Welcome to {siteTitle}": "Bem-vindo ao site {siteTitle}", - "What's included": "", + "What's included": "O que está incluído", "When an inbox fails to accept an email it is commonly called a bounce. In many cases, this can be temporary. However, in some cases, a bounced email can be returned as a permanent failure when an email address is invalid or non-existent.": "Quando uma caixa de entrada falha ao aceitar um e-mail, isso é comumente chamado de bounce. Em muitos casos, isso pode ser temporário. No entanto, em alguns casos, um e-mail com falha pode ser retornado como uma falha permanente quando um endereço de e-mail é inválido ou não existe.", "Why has my email been disabled?": "Por que meu e-mail foi desativado?", "X (Twitter)": "X (Twitter)", @@ -329,22 +329,22 @@ "You're currently not receiving emails": "Você não está recebendo e-mails no momento", "You're not receiving emails": "Você não está recebendo e-mails", "You're not receiving emails because you either marked a recent message as spam, or because messages could not be delivered to your provided email address.": "Você não está recebendo e-mails porque classificou uma mensagem recente como spam ou porque as mensagens não puderam ser entregues no endereço de e-mail que você forneceu.", - "You've been gifted a {duration}-month {tierName} membership_one": "", - "You've been gifted a {duration}-month {tierName} membership_many": "", - "You've been gifted a {duration}-month {tierName} membership_other": "", - "You've been gifted a {duration}-month {tierName} membership to {siteTitle}_one": "", - "You've been gifted a {duration}-month {tierName} membership to {siteTitle}_many": "", - "You've been gifted a {duration}-month {tierName} membership to {siteTitle}_other": "", - "You've been gifted a {duration}-year {tierName} membership": "", - "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a {duration}-month {tierName} membership_one": "Você recebeu uma assinatura {tierName} válida por {duration} mês", + "You've been gifted a {duration}-month {tierName} membership_many": "Você recebeu uma assinatura {tierName} válida por {duration} meses", + "You've been gifted a {duration}-month {tierName} membership_other": "Você recebeu uma assinatura {tierName} válida por {duration} meses", + "You've been gifted a {duration}-month {tierName} membership to {siteTitle}_one": "Você recebeu uma assinatura {tierName} para {siteTitle}, válida por {duration} mês", + "You've been gifted a {duration}-month {tierName} membership to {siteTitle}_many": "Você recebeu uma assinatura {tierName} para {siteTitle}, válida por {duration} meses", + "You've been gifted a {duration}-month {tierName} membership to {siteTitle}_other": "Você recebeu uma assinatura {tierName} para {siteTitle}, válida por {duration} meses", + "You've been gifted a {duration}-year {tierName} membership": "Você recebeu uma assinatura {tierName} válida por {duration} ano", + "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "Você recebeu uma assinatura {tierName} para {siteTitle}, válida por {duration} ano", "You've successfully signed in.": "Você entrou com sucesso.", "You've successfully subscribed to {siteTitle}": "Você se inscreveu com sucesso {siteTitle}", "Your account": "Sua conta", "Your email": "Seu e-mail", "Your email has failed to resubscribe, please try again": "Não foi possível reinscrever seu e-mail, por favor, tente novamente.", - "Your gift is on its way": "", + "Your gift is on its way": "Seu presente está a caminho", "Your gift is ready": "Seu presente está pronto", - "Your gift is scheduled": "", + "Your gift is scheduled": "Seu presente está agendado", "Your gift subscription will expire on {expiryDate}": "Sua assinatura de presente expirará em {expiryDate}", "your inbox": "sua caixa de entrada", "Your input helps shape what gets published.": "Sua resposta ajuda a definir o que será publicado.", From a7099a423bc9288f1d51a8c7d0b46c2b7f9ae1e8 Mon Sep 17 00:00:00 2001 From: Rob Lester Date: Thu, 17 Sep 2026 18:27:11 +0100 Subject: [PATCH 03/13] Fixed Portal lint running before the metafield types it checks against are built Portal's type check reads @tryghost/metafield-types from that package's build output, but nothing told Nx to build it first. CI only passed while another project in the same run happened to build it, so a change touching Portal alone failed to find the module. Portal's lint now declares the build it needs, the same way Admin's does. --- apps/portal/package.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/portal/package.json b/apps/portal/package.json index dd47e7e792e..4045363538f 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -76,6 +76,16 @@ "cwd": "apps/portal", "command": "vite build --watch --mode development" } + }, + "lint": { + "dependsOn": [ + { + "projects": [ + "@tryghost/metafield-types" + ], + "target": "build" + } + ] } }, "tags": [ From a009a9b9de26a4568a5993efaf40b3467e68d562 Mon Sep 17 00:00:00 2001 From: Rob Lester Date: Thu, 17 Sep 2026 18:22:37 +0100 Subject: [PATCH 04/13] Fixed Portal address fields losing focus on every keystroke no ref Each row of an address was a component declared inside the field, so every render made a new component type and React replaced the row's inputs. Typing a key re-renders the field, so the input a member was typing in was torn down after the first character and the rest of what they typed went nowhere. Declaring the row once, outside the field, keeps its inputs mounted across renders. The new browser test types an address line a key at a time and checks the input keeps focus and the whole value. --- .../common/member-custom-fields.tsx | 56 ++++++++++++------- e2e/tests/portal/member-custom-fields.test.ts | 27 +++++++++ 2 files changed, 63 insertions(+), 20 deletions(-) diff --git a/apps/portal/src/components/common/member-custom-fields.tsx b/apps/portal/src/components/common/member-custom-fields.tsx index 13ae801e652..10ac66dc587 100644 --- a/apps/portal/src/components/common/member-custom-fields.tsx +++ b/apps/portal/src/components/common/member-custom-fields.tsx @@ -35,10 +35,6 @@ const addressLabels = (): Record => ({ country: t('Country'), }); -interface RowProps { - parts: (keyof Address)[]; -} - interface FieldProps { field: DrawableCustomField; value: CustomFieldValue; @@ -69,6 +65,32 @@ function ScalarField({ field, value, errors, onChange, onKeyDown }: FieldProps) ); } +interface AddressRowProps { + parts: (keyof Address)[]; + input: (part: keyof Address) => Record; + onChange: (part: string | null, value: string) => void; + onKeyDown: (event: React.KeyboardEvent) => void; +} + +/** + * One row of an address. Declared here rather than inside the field: a component made + * afresh on every render is a new type each time, so React would replace the row's + * inputs on every keystroke and take the member's focus with them. + */ +function AddressRow({ parts, input, onChange, onKeyDown }: AddressRowProps) { + return ( +
+ + onChange(changed.part ?? null, event.target.value) + } + onKeyDown={onKeyDown} + /> +
+ ); +} + /** * An address: several inputs drawn as one field, their borders merged so the group reads * as a single thing. @@ -106,26 +128,20 @@ function AddressField({ field, value, errors, onChange, onKeyDown }: FieldProps) }; }; - const Row = ({ parts }: RowProps) => ( -
- - onChange(field, changed.part ?? null, event.target.value) - } - onKeyDown={onKeyDown} - /> -
- ); + const rowProps = { + input, + onChange: (part: string | null, changed: string) => onChange(field, part, changed), + onKeyDown, + }; // The form, written out. Nothing else states how an address is laid out, and the // reasons below read their order from these same rows, so the order a member sees and // the order they are listed in cannot come apart. - const rows: React.ReactElement[] = [ - , - , - , - , + const rows: React.ReactElement[] = [ + , + , + , + , ]; const refused = rows diff --git a/e2e/tests/portal/member-custom-fields.test.ts b/e2e/tests/portal/member-custom-fields.test.ts index 6d45d232cff..d64ef324a47 100644 --- a/e2e/tests/portal/member-custom-fields.test.ts +++ b/e2e/tests/portal/member-custom-fields.test.ts @@ -75,6 +75,33 @@ test.describe('Portal - member custom fields', () => { } }); + // Typed a key at a time, the way a member fills it in: each keystroke redraws the page, + // and an input rebuilt by that redraw loses focus, swallowing the rest of what is typed. + test('keeps an address part focused while the member types', async ({ + page, + browser, + baseURL, + }) => { + const fieldName = `Shipping address ${Date.now()}`; + const member = await createMemberFactory(page.request).create({ + name: 'Ada Lovelace', + email: `ada-types-${Date.now()}@ghost.org`, + }); + await anAddressFieldMembersMayEdit(page, fieldName); + + const { context, profile, key } = await accountSettingsAs(browser, baseURL!, member, fieldName); + try { + const line1 = profile.partInput(key, 'line1'); + await line1.click(); + await line1.page().keyboard.type('221B Baker Street'); + + await expect(line1).toBeFocused(); + await expect(line1).toHaveValue('221B Baker Street'); + } finally { + await context.close(); + } + }); + // Leaving the page discards what was typed, so a refusal of it has to go too. The // refusal lives in app state, which outlives the page unless it is cleared, and a // value the member never sees again would otherwise come back marked as wrong. From 41cbd50276729e137584b421875a121c54ee81d4 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 17 Sep 2026 13:08:43 -0500 Subject: [PATCH 05/13] TypeScriptified model schema validation (#30868) towards https://linear.app/ghost/issue/NY-1622 Should have no user impact. I think this is a useful change on its own, but will also make an upcoming change easier. --- ghost/core/core/server/data/schema/index.js | 2 +- .../schema/{validator.js => validator.ts} | 30 +++++++++++++------ .../{validator.test.js => validator.test.ts} | 25 ++++++++-------- 3 files changed, 35 insertions(+), 22 deletions(-) rename ghost/core/core/server/data/schema/{validator.js => validator.ts} (87%) rename ghost/core/test/unit/server/data/schema/{validator.test.js => validator.test.ts} (88%) diff --git a/ghost/core/core/server/data/schema/index.js b/ghost/core/core/server/data/schema/index.js index 6aa50784e76..aebd061f0e3 100644 --- a/ghost/core/core/server/data/schema/index.js +++ b/ghost/core/core/server/data/schema/index.js @@ -2,4 +2,4 @@ module.exports.tables = require('./schema'); module.exports.views = require('./views'); module.exports.commands = require('./commands'); module.exports.defaultSettings = require('./default-settings'); -module.exports.validate = require('./validator'); +module.exports.validate = require('./validator').validateSchema; diff --git a/ghost/core/core/server/data/schema/validator.js b/ghost/core/core/server/data/schema/validator.ts similarity index 87% rename from ghost/core/core/server/data/schema/validator.js rename to ghost/core/core/server/data/schema/validator.ts index 92ce48009c9..7e198769607 100644 --- a/ghost/core/core/server/data/schema/validator.js +++ b/ghost/core/core/server/data/schema/validator.ts @@ -1,9 +1,11 @@ -const _ = require('lodash'); -const tpl = require('@tryghost/tpl'); -const errors = require('@tryghost/errors'); -const validator = require('@tryghost/validator'); - -const schema = require('./schema'); +import errors from '@tryghost/errors'; +import tpl from '@tryghost/tpl'; +import _ from 'lodash'; +import type { ReadonlyDeep } from 'type-fest'; +// @ts-expect-error This module lacks type definitions. +import validator from '@tryghost/validator'; +// @ts-expect-error This module lacks type definitions. +import schema from './schema'; const messages = { valueCannotBeBlank: 'Value in [{tableName}.{columnKey}] cannot be blank.', @@ -12,6 +14,17 @@ const messages = { 'Value in [{tableName}.{columnKey}] exceeds maximum length of {maxlength} characters.', valueIsNotInteger: 'Value in [{tableName}.{columnKey}] is not an integer.', }; + +type Model = { + get(key: string): unknown; + set(key: string, value: unknown): unknown; + changed: Record; +}; + +type Options = ReadonlyDeep<{ + method?: 'insert' | 'update'; +}>; + /** * Validate model against schema. * @@ -27,11 +40,11 @@ const messages = { * ## on model add * - validate everything to catch required fields */ -function validateSchema(tableName, model, options) { +export function validateSchema(tableName: string, model: Model, options?: Options) { options = options || {}; const columns = _.keys(schema[tableName]); - let validationErrors = []; + let validationErrors: errors.ValidationError[] = []; _.each(columns, function each(columnKey) { let message = ''; // KEEP: Validator.js only validates strings. @@ -142,4 +155,3 @@ function validateSchema(tableName, model, options) { return Promise.resolve(); } -module.exports = validateSchema; diff --git a/ghost/core/test/unit/server/data/schema/validator.test.js b/ghost/core/test/unit/server/data/schema/validator.test.ts similarity index 88% rename from ghost/core/test/unit/server/data/schema/validator.test.js rename to ghost/core/test/unit/server/data/schema/validator.test.ts index faba62854dd..e48500fe5ea 100644 --- a/ghost/core/test/unit/server/data/schema/validator.test.js +++ b/ghost/core/test/unit/server/data/schema/validator.test.ts @@ -1,10 +1,11 @@ -const assert = require('node:assert/strict'); -const _ = require('lodash'); -const ObjectId = require('bson-objectid').default; -const testUtils = require('../../../../utils'); -const models = require('../../../../../core/server/models'); - -const validateSchema = require('../../../../../core/server/data/schema/validator'); +import assert from 'node:assert/strict'; +import _ from 'lodash'; +import ObjectId from 'bson-objectid'; +// @ts-expect-error This module lacks type definitions. +import testUtils from '../../../../utils'; +// @ts-expect-error This module lacks type definitions. +import models from '../../../../../core/server/models'; +import { validateSchema } from '../../../../../core/server/data/schema/validator'; describe('Validate Schema', function () { describe('models.add', function () { @@ -14,7 +15,7 @@ describe('Validate Schema', function () { .then(function () { throw new Error('Expected ValidationError.'); }) - .catch(function (err) { + .catch(function (err: unknown) { if (!_.isArray(err)) { throw err; } @@ -44,7 +45,7 @@ describe('Validate Schema', function () { .then(function () { throw new Error('Expected ValidationError.'); }) - .catch(function (err) { + .catch(function (err: unknown) { if (!_.isArray(err)) { throw err; } @@ -101,7 +102,7 @@ describe('Validate Schema', function () { .then(function () { throw new Error('Expected ValidationError.'); }) - .catch(function (err) { + .catch(function (err: unknown) { if (!_.isArray(err)) { throw err; } @@ -123,7 +124,7 @@ describe('Validate Schema', function () { .then(function () { throw new Error('Expected ValidationError.'); }) - .catch(function (err) { + .catch(function (err: unknown) { if (!_.isArray(err)) { throw err; } @@ -142,7 +143,7 @@ describe('Validate Schema', function () { .then(function () { throw new Error('Expected ValidationError.'); }) - .catch(function (err) { + .catch(function (err: unknown) { if (!_.isArray(err)) { throw err; } From 7f3e6f5b5503ffde3122e98e969e1e5be26bf6cc Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 17 Sep 2026 13:08:44 -0500 Subject: [PATCH 06/13] Made model schema validation function synchronous (#30869) towards https://linear.app/ghost/issue/NY-1622 There was no reason for this to return a promise. Should have no user impact. I think this is a useful change on its own, but will also make an upcoming change easier. --- .../core/core/server/data/schema/validator.ts | 4 +- .../core/server/models/base/plugins/events.js | 6 +- .../unit/server/data/schema/validator.test.ts | 86 +++++++++---------- 3 files changed, 44 insertions(+), 52 deletions(-) diff --git a/ghost/core/core/server/data/schema/validator.ts b/ghost/core/core/server/data/schema/validator.ts index 7e198769607..02cd7ad04c1 100644 --- a/ghost/core/core/server/data/schema/validator.ts +++ b/ghost/core/core/server/data/schema/validator.ts @@ -150,8 +150,6 @@ export function validateSchema(tableName: string, model: Model, options?: Option }); if (validationErrors.length !== 0) { - return Promise.reject(validationErrors); + throw validationErrors; } - - return Promise.resolve(); } diff --git a/ghost/core/core/server/models/base/plugins/events.js b/ghost/core/core/server/models/base/plugins/events.js index de66e285473..68474cc2272 100644 --- a/ghost/core/core/server/models/base/plugins/events.js +++ b/ghost/core/core/server/models/base/plugins/events.js @@ -103,11 +103,11 @@ module.exports = function (Bookshelf) { /** * Do not call `toJSON`. This can remove properties e.g. password. - * @returns {*} + * @returns {Promise} */ - onValidate: function onValidate(model, columns, options) { + onValidate: async function onValidate(model, columns, options) { this.setEmptyValuesToNull(); - return schema.validate(this.tableName, this, options); + schema.validate(this.tableName, this, options); }, onFetched() {}, diff --git a/ghost/core/test/unit/server/data/schema/validator.test.ts b/ghost/core/test/unit/server/data/schema/validator.test.ts index e48500fe5ea..b71a25a6cc0 100644 --- a/ghost/core/test/unit/server/data/schema/validator.test.ts +++ b/ghost/core/test/unit/server/data/schema/validator.test.ts @@ -11,15 +11,12 @@ describe('Validate Schema', function () { describe('models.add', function () { it('blank model', function () { // NOTE: Fields with `defaultTo` are getting ignored. This is handled on the DB level. - return validateSchema('posts', models.Post.forge(), { method: 'insert' }) - .then(function () { - throw new Error('Expected ValidationError.'); - }) - .catch(function (err: unknown) { + assert.throws( + () => validateSchema('posts', models.Post.forge(), { method: 'insert' }), + (err: unknown) => { if (!_.isArray(err)) { - throw err; + return false; } - assert.equal(err.length, 5); const errorMessages = _.map(err, function (object) { @@ -30,7 +27,9 @@ describe('Validate Schema', function () { ['id', 'uuid', 'slug', 'title', 'created_at'].forEach(function (attr) { assert.match(errorMessages, RegExp('posts.' + attr)); }); - }); + return true; + }, + ); }); it('blank id', function () { @@ -41,29 +40,28 @@ describe('Validate Schema', function () { }), ); - return validateSchema('posts', postModel, { method: 'insert' }) - .then(function () { - throw new Error('Expected ValidationError.'); - }) - .catch(function (err: unknown) { + assert.throws( + () => validateSchema('posts', postModel, { method: 'insert' }), + (err: unknown) => { if (!_.isArray(err)) { - throw err; + return false; } - assert.equal(err.length, 1); assert.match(err[0].message, /posts\.id/); - }); + return true; + }, + ); }); it('should pass', function () { - return validateSchema( + validateSchema( 'posts', models.Post.forge(testUtils.DataGenerator.forKnex.createPost({ slug: 'title' })), { method: 'insert' }, ); }); - it('transforms 0 and 1 (boolean)', async function () { + it('transforms 0 and 1 (boolean)', function () { const user = models.User.forge( testUtils.DataGenerator.forKnex.createUser({ email: 'test@example.com', @@ -72,7 +70,7 @@ describe('Validate Schema', function () { ); assert.equal(user.get('comment_notifications'), 0); - await validateSchema('users', user, { method: 'insert' }); + validateSchema('users', user, { method: 'insert' }); assert.equal(user.get('comment_notifications'), false); }); @@ -82,9 +80,8 @@ describe('Validate Schema', function () { ); assert.equal(post.get('featured'), true); - return validateSchema('posts', post, { method: 'insert' }).then(function () { - assert.equal(post.get('featured'), true); - }); + validateSchema('posts', post, { method: 'insert' }); + assert.equal(post.get('featured'), true); }); }); @@ -98,19 +95,18 @@ describe('Validate Schema', function () { ); // NOTE: Fields with `defaultTo` are getting ignored. This is handled on the DB level. - return validateSchema('webhooks', webhook, { method: 'insert' }) - .then(function () { - throw new Error('Expected ValidationError.'); - }) - .catch(function (err: unknown) { + assert.throws( + () => validateSchema('webhooks', webhook, { method: 'insert' }), + (err: unknown) => { if (!_.isArray(err)) { - throw err; + return false; } - assert.equal(err.length, 1); assert.equal(err[0].errorType, 'ValidationError'); assert.match(err[0].message, /isLowercase/); - }); + return true; + }, + ); }); }); @@ -120,18 +116,17 @@ describe('Validate Schema', function () { postModel.changed = { uuid: postModel.get('uuid') }; - return validateSchema('posts', postModel) - .then(function () { - throw new Error('Expected ValidationError.'); - }) - .catch(function (err: unknown) { + assert.throws( + () => validateSchema('posts', postModel), + (err: unknown) => { if (!_.isArray(err)) { - throw err; + return false; } - assert.equal(err.length, 1); assert.match(err[0].message, /isUUID/); - }); + return true; + }, + ); }); it('date is null', function () { @@ -139,18 +134,17 @@ describe('Validate Schema', function () { postModel.changed = { created_at: postModel.get('updated_at') }; - return validateSchema('posts', postModel) - .then(function () { - throw new Error('Expected ValidationError.'); - }) - .catch(function (err: unknown) { + assert.throws( + () => validateSchema('posts', postModel), + (err: unknown) => { if (!_.isArray(err)) { - throw err; + return false; } - assert.equal(err.length, 1); assert.match(err[0].message, /posts\.created_at/); - }); + return true; + }, + ); }); }); }); From fa392235e4c916b7013bd664cbba40d5a821374c Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 17 Sep 2026 13:38:43 -0500 Subject: [PATCH 07/13] Replaced Lodash array check with native helper (#30871) no ref Replaces `_.isArray` with `Array.isArray`. `_.isArray` is [just an alias for `Array.isArray`][0], so this should have no effect. [0]: https://github.com/lodash/lodash/blob/cb0b9b9212521c08e3eafe7c8cb0af1b42b6649e/lodash.js#L11385 --- ghost/core/core/frontend/helpers/foreach.js | 8 ++++---- .../core/core/frontend/services/rendering/templates.js | 2 +- .../api/endpoints/utils/validators/input/settings.js | 6 +++--- ghost/core/core/server/data/exporter/exporter.js | 3 +-- .../core/server/data/importer/importers/data/base.js | 2 +- .../data/importer/importers/data/posts-importer.js | 4 ++-- .../data/importer/importers/data/users-importer.js | 2 +- .../server/data/schema/fixtures/fixture-manager.js | 6 +++--- ghost/core/core/server/models/base/plugins/actions.js | 3 +-- ghost/core/core/server/models/base/plugins/sanitize.js | 2 +- ghost/core/core/server/models/settings.js | 2 +- .../server/services/custom-redirects/validation.js | 2 +- .../server/services/notifications/notifications.js | 2 +- .../services/update-check/update-check-service.js | 4 ++-- ghost/core/test/legacy/api/admin/posts.test.js | 3 +-- .../test/unit/server/data/schema/validator.test.ts | 10 +++++----- ghost/core/test/utils/mock-knex.js | 6 +++--- packages/api-framework/lib/utils/options.js | 2 +- 18 files changed, 33 insertions(+), 36 deletions(-) diff --git a/ghost/core/core/frontend/helpers/foreach.js b/ghost/core/core/frontend/helpers/foreach.js index 3350be916ab..30a9b2618a0 100644 --- a/ghost/core/core/frontend/helpers/foreach.js +++ b/ghost/core/core/frontend/helpers/foreach.js @@ -28,17 +28,17 @@ module.exports = function foreach(items, options) { items = items.call(this); } let visibility = options.hash.visibility; - if (_.isArray(items) && items.length > 0 && checks.isPost(items[0])) { + if (Array.isArray(items) && items.length > 0 && checks.isPost(items[0])) { visibility = visibility || 'all'; - } else if (_.isObject(items) && _.isArray(Object.values(items))) { + } else if (_.isObject(items) && Array.isArray(Object.values(items))) { if (Object.values(items).length > 0 && checks.isPost(Object.values(items)[0])) { visibility = visibility || 'all'; } } - if (_.isArray(items) && items.length > 0 && checks.isNewsletter(items[0])) { + if (Array.isArray(items) && items.length > 0 && checks.isNewsletter(items[0])) { visibility = visibility || 'all'; - } else if (_.isObject(items) && _.isArray(Object.values(items))) { + } else if (_.isObject(items) && Array.isArray(Object.values(items))) { if (Object.values(items).length > 0 && checks.isNewsletter(Object.values(items)[0])) { visibility = visibility || 'all'; } diff --git a/ghost/core/core/frontend/services/rendering/templates.js b/ghost/core/core/frontend/services/rendering/templates.js index 010182fe9f7..367f9be2db8 100644 --- a/ghost/core/core/frontend/services/rendering/templates.js +++ b/ghost/core/core/frontend/services/rendering/templates.js @@ -118,7 +118,7 @@ templates.getEntryTemplateHierarchy = function getEntryTemplateHierarchy(postObj templates.pickTemplate = function pickTemplate(templateList, fallback) { let template; - if (!_.isArray(templateList)) { + if (!Array.isArray(templateList)) { templateList = [templateList]; } diff --git a/ghost/core/core/server/api/endpoints/utils/validators/input/settings.js b/ghost/core/core/server/api/endpoints/utils/validators/input/settings.js index 9fc877acacb..22122cc1932 100644 --- a/ghost/core/core/server/api/endpoints/utils/validators/input/settings.js +++ b/ghost/core/core/server/api/endpoints/utils/validators/input/settings.js @@ -28,7 +28,7 @@ const iconUrlRegex = new RegExp(/^(\/|__GHOST_URL__\/)/); const iconUrlOptions = { require_protocol: true, protocols: ['http', 'https'] }; function parseArraySettingValue(value) { - if (_.isArray(value)) { + if (Array.isArray(value)) { return value; } @@ -163,10 +163,10 @@ module.exports = { // settings API use raw unstringified objects (e.g. when adding notifications) // The conditional can be removed once internals are changed to do the calls properly // and the JSON.parse should be left as the only valid way to check the value. - if (!_.isArray(setting.value)) { + if (!Array.isArray(setting.value)) { try { const value = JSON.parse(setting.value); - if (!_.isArray(value)) { + if (!Array.isArray(value)) { errors.push(typeError); } } catch (err) { diff --git a/ghost/core/core/server/data/exporter/exporter.js b/ghost/core/core/server/data/exporter/exporter.js index 7be7e6f2d5f..2ae44487f46 100644 --- a/ghost/core/core/server/data/exporter/exporter.js +++ b/ghost/core/core/server/data/exporter/exporter.js @@ -1,4 +1,3 @@ -const _ = require('lodash'); const db = require('../../data/db'); const commands = require('../schema').commands; const ghostVersion = require('@tryghost/version'); @@ -14,7 +13,7 @@ const { TABLES_ALLOWLIST, SETTING_KEYS_BLOCKLIST } = require('./table-lists'); const exportTable = function exportTable(tableName, options) { if ( TABLES_ALLOWLIST.includes(tableName) || - (options.include && _.isArray(options.include) && options.include.indexOf(tableName) !== -1) + (options.include && Array.isArray(options.include) && options.include.indexOf(tableName) !== -1) ) { const query = (options.transacting || db.knex)(tableName); diff --git a/ghost/core/core/server/data/importer/importers/data/base.js b/ghost/core/core/server/data/importer/importers/data/base.js index e54f80038a2..56ec6315d4e 100644 --- a/ghost/core/core/server/data/importer/importers/data/base.js +++ b/ghost/core/core/server/data/importer/importers/data/base.js @@ -113,7 +113,7 @@ class Base { let problems = []; // CASE: validation errors, see models/base/events.js onValidate - if (!_.isArray(errs)) { + if (!Array.isArray(errs)) { errs = [errs]; } diff --git a/ghost/core/core/server/data/importer/importers/data/posts-importer.js b/ghost/core/core/server/data/importer/importers/data/posts-importer.js index 81b9521171c..a7162855459 100644 --- a/ghost/core/core/server/data/importer/importers/data/posts-importer.js +++ b/ghost/core/core/server/data/importer/importers/data/posts-importer.js @@ -110,7 +110,7 @@ class PostsImporter extends BaseImporter { return; } - if (!postToImport[target] || !_.isArray(postToImport[target])) { + if (!postToImport[target] || !Array.isArray(postToImport[target])) { postToImport[target] = []; } @@ -274,7 +274,7 @@ class PostsImporter extends BaseImporter { try { mobiledoc = JSON.parse(model.mobiledoc); - if (!mobiledoc.cards || !_.isArray(mobiledoc.cards)) { + if (!mobiledoc.cards || !Array.isArray(mobiledoc.cards)) { mobiledoc = mobiledocLib.blankDocument; } } catch (err) { diff --git a/ghost/core/core/server/data/importer/importers/data/users-importer.js b/ghost/core/core/server/data/importer/importers/data/users-importer.js index e687af8cc7b..e339deae952 100644 --- a/ghost/core/core/server/data/importer/importers/data/users-importer.js +++ b/ghost/core/core/server/data/importer/importers/data/users-importer.js @@ -70,7 +70,7 @@ class UsersImporter extends BaseImporter { _.each(this.dataToImport, (obj) => { if (attachedRole.user_id === obj.id) { - if (!_.isArray(obj.roles)) { + if (!Array.isArray(obj.roles)) { obj.roles = []; } diff --git a/ghost/core/core/server/data/schema/fixtures/fixture-manager.js b/ghost/core/core/server/data/schema/fixtures/fixture-manager.js index 26653398800..7de6fa622a2 100644 --- a/ghost/core/core/server/data/schema/fixtures/fixture-manager.js +++ b/ghost/core/core/server/data/schema/fixtures/fixture-manager.js @@ -55,11 +55,11 @@ class FixtureManager { * @returns {Function} matching function */ static matchFunc(match, key, value) { - if (_.isArray(match)) { + if (Array.isArray(match)) { return function (item) { let valueTest = true; - if (_.isArray(value)) { + if (Array.isArray(value)) { valueTest = value.indexOf(item.get(match[1])) > -1; } else if (value !== 'all') { valueTest = item.get(match[1]) === value; @@ -78,7 +78,7 @@ class FixtureManager { static matchObj(match, item) { const matchedObj = {}; - if (_.isArray(match)) { + if (Array.isArray(match)) { _.each(match, (matchProp) => { matchedObj[matchProp] = item.get(matchProp); }); diff --git a/ghost/core/core/server/models/base/plugins/actions.js b/ghost/core/core/server/models/base/plugins/actions.js index 59c5d9a21c9..816a65c5ec8 100644 --- a/ghost/core/core/server/models/base/plugins/actions.js +++ b/ghost/core/core/server/models/base/plugins/actions.js @@ -1,4 +1,3 @@ -const _ = require('lodash'); const errors = require('@tryghost/errors'); const logging = require('@tryghost/logging'); @@ -27,7 +26,7 @@ module.exports = function (Bookshelf) { return Bookshelf.model('Action') .add(action, { autoRefresh: false }) .catch((err) => { - if (_.isArray(err)) { + if (Array.isArray(err)) { err = err[0]; } diff --git a/ghost/core/core/server/models/base/plugins/sanitize.js b/ghost/core/core/server/models/base/plugins/sanitize.js index 8ebd2d46b80..11df26f9dcc 100644 --- a/ghost/core/core/server/models/base/plugins/sanitize.js +++ b/ghost/core/core/server/models/base/plugins/sanitize.js @@ -134,7 +134,7 @@ module.exports = function (Bookshelf) { let relations = data[property]; // CASE: 1:1 relation will have single data point - if (!_.isArray(data[property])) { + if (!Array.isArray(data[property])) { relations = [data[property]]; } _.each(relations, (relation, indexInArr) => { diff --git a/ghost/core/core/server/models/settings.js b/ghost/core/core/server/models/settings.js index 5fad12b8f0e..5269f13bb2c 100644 --- a/ghost/core/core/server/models/settings.js +++ b/ghost/core/core/server/models/settings.js @@ -126,7 +126,7 @@ function transformNavigationIconUrls(value, transformUrl) { return value; } - if (!_.isArray(navigationItems)) { + if (!Array.isArray(navigationItems)) { return value; } diff --git a/ghost/core/core/server/services/custom-redirects/validation.js b/ghost/core/core/server/services/custom-redirects/validation.js index 08f947c2d78..1a9aba6851c 100644 --- a/ghost/core/core/server/services/custom-redirects/validation.js +++ b/ghost/core/core/server/services/custom-redirects/validation.js @@ -16,7 +16,7 @@ const messages = { * @param {import('@tryghost/adapter-base-redirects').RedirectConfig[]} redirects */ const validate = (redirects) => { - if (!_.isArray(redirects)) { + if (!Array.isArray(redirects)) { throw new errors.ValidationError({ message: tpl(messages.redirectsWrongFormat), help: tpl(messages.redirectsHelp), diff --git a/ghost/core/core/server/services/notifications/notifications.js b/ghost/core/core/server/services/notifications/notifications.js index b31276800fe..e12ffc51c6e 100644 --- a/ghost/core/core/server/services/notifications/notifications.js +++ b/ghost/core/core/server/services/notifications/notifications.js @@ -67,7 +67,7 @@ class Notifications { * @returns {boolean} */ areNotificationsValid(notifications) { - if (!_.isArray(notifications)) { + if (!Array.isArray(notifications)) { return false; } diff --git a/ghost/core/core/server/services/update-check/update-check-service.js b/ghost/core/core/server/services/update-check/update-check-service.js index 003b19ff5a7..f5a045872a5 100644 --- a/ghost/core/core/server/services/update-check/update-check-service.js +++ b/ghost/core/core/server/services/update-check/update-check-service.js @@ -19,10 +19,10 @@ function normalizeNotifications(response) { if (!response) { return []; } - if (_.isArray(response.notifications)) { + if (Array.isArray(response.notifications)) { return response.notifications; } - if (_.isArray(response)) { + if (Array.isArray(response)) { return response; } if (response.messages) { diff --git a/ghost/core/test/legacy/api/admin/posts.test.js b/ghost/core/test/legacy/api/admin/posts.test.js index 320a736b75c..6d07bce6a55 100644 --- a/ghost/core/test/legacy/api/admin/posts.test.js +++ b/ghost/core/test/legacy/api/admin/posts.test.js @@ -1,7 +1,6 @@ const assert = require('node:assert/strict'); const { assertExists } = require('../../../utils/assertions'); const { waitForEmailStatus } = require('../../../utils/batch-email-utils'); -const _ = require('lodash'); const supertest = require('supertest'); const ObjectId = require('bson-objectid').default; const moment = require('moment-timezone'); @@ -210,7 +209,7 @@ describe('Posts API', function () { }, }); } catch (err) { - if (_.isArray(err)) { + if (Array.isArray(err)) { throw err[0]; } throw err; diff --git a/ghost/core/test/unit/server/data/schema/validator.test.ts b/ghost/core/test/unit/server/data/schema/validator.test.ts index b71a25a6cc0..407ef7829a1 100644 --- a/ghost/core/test/unit/server/data/schema/validator.test.ts +++ b/ghost/core/test/unit/server/data/schema/validator.test.ts @@ -14,7 +14,7 @@ describe('Validate Schema', function () { assert.throws( () => validateSchema('posts', models.Post.forge(), { method: 'insert' }), (err: unknown) => { - if (!_.isArray(err)) { + if (!Array.isArray(err)) { return false; } assert.equal(err.length, 5); @@ -43,7 +43,7 @@ describe('Validate Schema', function () { assert.throws( () => validateSchema('posts', postModel, { method: 'insert' }), (err: unknown) => { - if (!_.isArray(err)) { + if (!Array.isArray(err)) { return false; } assert.equal(err.length, 1); @@ -98,7 +98,7 @@ describe('Validate Schema', function () { assert.throws( () => validateSchema('webhooks', webhook, { method: 'insert' }), (err: unknown) => { - if (!_.isArray(err)) { + if (!Array.isArray(err)) { return false; } assert.equal(err.length, 1); @@ -119,7 +119,7 @@ describe('Validate Schema', function () { assert.throws( () => validateSchema('posts', postModel), (err: unknown) => { - if (!_.isArray(err)) { + if (!Array.isArray(err)) { return false; } assert.equal(err.length, 1); @@ -137,7 +137,7 @@ describe('Validate Schema', function () { assert.throws( () => validateSchema('posts', postModel), (err: unknown) => { - if (!_.isArray(err)) { + if (!Array.isArray(err)) { return false; } assert.equal(err.length, 1); diff --git a/ghost/core/test/utils/mock-knex.js b/ghost/core/test/utils/mock-knex.js index a10f010bae1..6600bd22180 100644 --- a/ghost/core/test/utils/mock-knex.js +++ b/ghost/core/test/utils/mock-knex.js @@ -78,7 +78,7 @@ class Mocker { replace(obj, specs) { const replaced = {}; - specs = _.isArray(specs) ? specs : [specs]; + specs = Array.isArray(specs) ? specs : [specs]; _.forEach(specs, (spec) => { const paths = _.partition(this.paths(spec), (path) => { @@ -95,7 +95,7 @@ class Mocker { } undefine(obj, specs) { - specs = _.isArray(specs) ? specs : [specs]; + specs = Array.isArray(specs) ? specs : [specs]; _.forEach(specs, (spec) => { const paths = this.paths(spec); @@ -119,7 +119,7 @@ class Mocker { define(obj, specs) { const defined = {}; - specs = _.isArray(specs) ? specs : [specs]; + specs = Array.isArray(specs) ? specs : [specs]; _.forEach(specs, (spec) => { _.forEach(spec, (descriptors, path) => { diff --git a/packages/api-framework/lib/utils/options.js b/packages/api-framework/lib/utils/options.js index 7964fcaa411..02bf7ce49c6 100644 --- a/packages/api-framework/lib/utils/options.js +++ b/packages/api-framework/lib/utils/options.js @@ -18,7 +18,7 @@ const trimAndLowerCase = (params) => { // If we don't have an array at this point, something is wrong, so we should throw an // error to avoid trying to .map over something else - if (!_.isArray(params)) { + if (!Array.isArray(params)) { throw new IncorrectUsageError({ message: 'Params must be a string or array', }); From b34728c94094096382f1b6c19fccb49cb3d65a77 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 17 Sep 2026 14:09:04 -0500 Subject: [PATCH 08/13] TypeScriptified schema commands test (#30872) no ref --- .../{commands.test.js => commands.test.ts} | 71 +++++++++++-------- 1 file changed, 40 insertions(+), 31 deletions(-) rename ghost/core/test/unit/server/data/schema/{commands.test.js => commands.test.ts} (81%) diff --git a/ghost/core/test/unit/server/data/schema/commands.test.js b/ghost/core/test/unit/server/data/schema/commands.test.ts similarity index 81% rename from ghost/core/test/unit/server/data/schema/commands.test.js rename to ghost/core/test/unit/server/data/schema/commands.test.ts index d103ed84b81..9bd861612a8 100644 --- a/ghost/core/test/unit/server/data/schema/commands.test.js +++ b/ghost/core/test/unit/server/data/schema/commands.test.ts @@ -1,7 +1,8 @@ -const assert = require('node:assert/strict'); -const errors = require('@tryghost/errors'); - -const commands = require('../../../../../core/server/data/schema/commands'); +import assert from 'node:assert/strict'; +import { utils as errorUtils } from '@tryghost/errors'; +import createKnex from 'knex'; +// @ts-expect-error This module lacks type definitions. +import commands from '../../../../../core/server/data/schema/commands'; describe('schema commands', function () { describe('getTables', function () { @@ -20,8 +21,7 @@ describe('schema commands', function () { }); it('_hasForeignSQLite throws when knex is nox configured to use sqlite3', async function () { - const Knex = require('knex'); - const knex = Knex({ + const knex = createKnex({ client: 'mysql', }); @@ -29,14 +29,14 @@ describe('schema commands', function () { await commands._hasForeignSQLite({ transaction: knex }); assert.fail('addForeign did not throw'); } catch (err) { - assert.equal(errors.utils.isGhostError(err), true); + assert(err instanceof Error); + assert.equal(errorUtils.isGhostError(err), true); assert.equal(err.message, 'Must use hasForeignSQLite3 on an SQLite3 database'); } }); it('_hasPrimaryKeySQLite throws when knex is configured to use sqlite', async function () { - const Knex = require('knex'); - const knex = Knex({ + const knex = createKnex({ client: 'mysql', }); @@ -44,7 +44,8 @@ describe('schema commands', function () { await commands._hasPrimaryKeySQLite(null, knex); assert.fail('hasPrimaryKeySQLite did not throw'); } catch (err) { - assert.equal(errors.utils.isGhostError(err), true); + assert(err instanceof Error); + assert.equal(errorUtils.isGhostError(err), true); assert.equal(err.message, 'Must use hasPrimaryKeySQLite on an SQLite3 database'); } }); @@ -52,9 +53,14 @@ describe('schema commands', function () { describe('addTableColumn', function () { // addTableColumn isn't exported, so we exercise it through createTable // and stringify the builder rather than running it against a database. - function ddlFor(client, tableSpec) { - const Knex = require('knex'); - const knex = Knex({ client, useNullAsDefault: true }); + function ddlFor( + client: string, + tableSpec: Record< + string, + { type: string; maxlength?: number; nullable?: boolean } | string[][] + >, + ) { + const knex = createKnex({ client, useNullAsDefault: true }); try { return commands.createTable('test_table', knex, tableSpec).toString(); @@ -119,10 +125,10 @@ describe('schema commands', function () { // default DEFINER security, which binds them to the migrating account // and breaks when a backup is restored under a different MySQL user. it('creates the view with SQL SECURITY INVOKER on MySQL', async function () { - const rawStatements = []; + const rawStatements: string[] = []; const fakeKnex = { client: { config: { client: 'mysql2' } }, - raw: (sql) => { + raw: (sql: string) => { rawStatements.push(sql); return Promise.resolve(); }, @@ -137,12 +143,12 @@ describe('schema commands', function () { }); it('uses the plain builder (no security clause) on SQLite', async function () { - const builderViews = []; + const builderViews: string[] = []; const fakeKnex = { client: { config: { client: 'sqlite3' } }, - raw: (sql) => sql, + raw: (sql: string) => sql, schema: { - createViewOrReplace: (name) => { + createViewOrReplace: (name: string) => { builderViews.push(name); return Promise.resolve(); }, @@ -157,10 +163,10 @@ describe('schema commands', function () { describe('renameColumn', function () { it('uses requested algorithm on MySQL', async function () { - const rawStatements = []; + const rawStatements: string[] = []; const fakeKnex = { client: { config: { client: 'mysql2' } }, - raw: (sql) => { + raw: (sql: string) => { rawStatements.push(sql); return Promise.resolve(); }, @@ -176,10 +182,10 @@ describe('schema commands', function () { }); it('does not force an algorithm when none is requested', async function () { - const rawStatements = []; + const rawStatements: string[] = []; const fakeKnex = { client: { config: { client: 'mysql2' } }, - raw: (sql) => { + raw: (sql: string) => { rawStatements.push(sql); return Promise.resolve(); }, @@ -193,17 +199,19 @@ describe('schema commands', function () { }); it('retries without the algorithm when the server does not support it', async function () { - const rawStatements = []; + const rawStatements: string[] = []; const fakeKnex = { client: { config: { client: 'mysql2' } }, - raw: (sql) => { + raw: (sql: string) => { rawStatements.push(sql); if (sql.includes('algorithm=')) { - const error = new Error( - 'ALGORITHM=INSTANT is not supported for this operation. Try ALGORITHM=COPY/INPLACE.', + const error = Object.assign( + new Error( + 'ALGORITHM=INSTANT is not supported for this operation. Try ALGORITHM=COPY/INPLACE.', + ), + { code: 'ER_ALTER_OPERATION_NOT_SUPPORTED' }, ); - error.code = 'ER_ALTER_OPERATION_NOT_SUPPORTED'; return Promise.reject(error); } @@ -222,13 +230,14 @@ describe('schema commands', function () { }); it('does not retry on unrelated errors', async function () { - const rawStatements = []; + const rawStatements: string[] = []; const fakeKnex = { client: { config: { client: 'mysql2' } }, - raw: (sql) => { + raw: (sql: string) => { rawStatements.push(sql); - const error = new Error("Table 'email_batches' doesn't exist"); - error.code = 'ER_NO_SUCH_TABLE'; + const error = Object.assign(new Error("Table 'email_batches' doesn't exist"), { + code: 'ER_NO_SUCH_TABLE', + }); return Promise.reject(error); }, }; From 28e8b086031b52d03d3837b7b1f875b5c82de8e3 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 17 Sep 2026 14:35:12 -0500 Subject: [PATCH 09/13] TypeScriptified migration utils test (#30875) no ref --- .../{utils.test.js => utils.test.ts} | 115 +++++++++++------- 1 file changed, 74 insertions(+), 41 deletions(-) rename ghost/core/test/unit/server/data/migrations/{utils.test.js => utils.test.ts} (92%) diff --git a/ghost/core/test/unit/server/data/migrations/utils.test.js b/ghost/core/test/unit/server/data/migrations/utils.test.ts similarity index 92% rename from ghost/core/test/unit/server/data/migrations/utils.test.js rename to ghost/core/test/unit/server/data/migrations/utils.test.ts index 7594ab3ded5..783280ca502 100644 --- a/ghost/core/test/unit/server/data/migrations/utils.test.js +++ b/ghost/core/test/unit/server/data/migrations/utils.test.ts @@ -1,12 +1,14 @@ -const assert = require('node:assert/strict'); -const sinon = require('sinon'); -const errors = require('@tryghost/errors'); -const logging = require('@tryghost/logging'); - -const DatabaseInfo = require('@tryghost/database-info'); -const db = require('../../../../../core/server/data/db'); - -const utils = require('../../../../../core/server/data/migrations/utils'); +import { utils as errorUtils } from '@tryghost/errors'; +import logging from '@tryghost/logging'; +import ObjectId from 'bson-objectid'; +import Knex, { type Knex as KnexInstance } from 'knex'; +import assert from 'node:assert/strict'; +import sinon from 'sinon'; +// @ts-expect-error database-info has no TypeScript declarations +import DatabaseInfo from '@tryghost/database-info'; +import * as db from '../../../../../core/server/data/db'; +// @ts-expect-error migration utils has no TypeScript declarations +import * as utils from '../../../../../core/server/data/migrations/utils'; // Nullable migrations run in a transaction on MySQL but not on SQLite (SQLite // cannot toggle foreign_keys inside a transaction). Both expectations are derived @@ -14,14 +16,22 @@ const utils = require('../../../../../core/server/data/migrations/utils'); const expectsTransaction = !DatabaseInfo.isSQLite(db.knex); class Deferred { + promise: Promise; + resolve: () => Promise = async () => { + throw new Error('Deferred not initialized'); + }; + reject: (reason: Error) => Promise = async () => { + throw new Error('Deferred not initialized'); + }; + constructor() { - this.promise = new Promise((resolve, reject) => { - this.resolve = async (val) => { - resolve(val); + this.promise = new Promise((resolve, reject) => { + this.resolve = async () => { + resolve(); return this.promise; }; - this.reject = async (val) => { - reject(val); + this.reject = async (reason: Error) => { + reject(reason); return this.promise; }; }); @@ -212,9 +222,6 @@ describe('migrations/utils', function () { }); }); -const Knex = require('knex'); -const ObjectId = require('bson-objectid').default; - async function setupPermissionsDb() { const knex = Knex({ client: 'better-sqlite3', @@ -317,7 +324,19 @@ async function setupSettingsDb() { return knex; } -async function runUpMigration(knex, migration) { +type Migration = { + config?: { transaction?: boolean }; + up: (config: { + connection?: KnexInstance; + transacting?: KnexInstance.Transaction; + }) => Promise; + down: (config: { + connection?: KnexInstance; + transacting?: KnexInstance.Transaction; + }) => Promise; +}; + +async function runUpMigration(knex: KnexInstance, migration: Migration) { // Non-transactional migrations receive a plain connection rather than a // transaction. Wrapping them in knex.transaction() here would also deadlock, // since the better-sqlite3 pool only has a single connection. @@ -406,9 +425,11 @@ describe('migrations/utils/permissions', function () { r.name = 'Role Name'; `); - const attachedPermissionAfterUp = allPermissionsForRoleAfterUp.find((row) => { - return row.name === 'Permission Name'; - }); + const attachedPermissionAfterUp = allPermissionsForRoleAfterUp.find( + (row: { name: string }) => { + return row.name === 'Permission Name'; + }, + ); assert(attachedPermissionAfterUp, 'The permission was attached to the role.'); @@ -431,9 +452,11 @@ describe('migrations/utils/permissions', function () { r.name = 'Role Name'; `); - const attachedPermissionAfterDown = allPermissionsForRoleAfterDown.find((row) => { - return row.name === 'Permission Name'; - }); + const attachedPermissionAfterDown = allPermissionsForRoleAfterDown.find( + (row: { name: string }) => { + return row.name === 'Permission Name'; + }, + ); assert(!attachedPermissionAfterDown, 'The permission was removed from the role.'); }); @@ -451,7 +474,8 @@ describe('migrations/utils/permissions', function () { await runUpMigration(knex, migration); assert.fail('addPermissionToRole up migration did not throw'); } catch (err) { - assert.equal(errors.utils.isGhostError(err), true); + assert(err instanceof Error); + assert.equal(errorUtils.isGhostError(err), true); assert.equal( err.message, 'Cannot add permission(Unimaginable) with role(Not there) - permission does not exist', @@ -470,7 +494,7 @@ describe('migrations/utils/permissions', function () { const runDownMigration = await runUpMigration(knex, migration); await knex('permissions').where('name', '=', 'Permission Name').del(); - await runDownMigration(knex, migration); + await runDownMigration(); }); it('Throws when role cannot be found', async function () { @@ -485,7 +509,8 @@ describe('migrations/utils/permissions', function () { await runUpMigration(knex, migration); assert.fail('addPermissionToRole did not throw'); } catch (err) { - assert.equal(errors.utils.isGhostError(err), true); + assert(err instanceof Error); + assert.equal(errorUtils.isGhostError(err), true); assert.equal( err.message, 'Cannot add permission(Permission Name) with role(Not there) - role does not exist', @@ -504,7 +529,7 @@ describe('migrations/utils/permissions', function () { const runDownMigration = await runUpMigration(knex, migration); await knex('roles').where('name', '=', 'Role Name').del(); - await runDownMigration(knex, migration); + await runDownMigration(); }); }); }); @@ -551,9 +576,11 @@ describe('migrations/utils/permissions', function () { r.name = 'Role Name'; `); - const permissionAttachedToRoleAfterUp = allPermissionsForRoleAfterUp.find((row) => { - return row.name === 'scarface'; - }); + const permissionAttachedToRoleAfterUp = allPermissionsForRoleAfterUp.find( + (row: { name: string }) => { + return row.name === 'scarface'; + }, + ); assert(permissionAttachedToRoleAfterUp, 'The permission was attached to the role.'); @@ -574,9 +601,11 @@ describe('migrations/utils/permissions', function () { r.name = 'Other Role Name'; `); - const permissionAttachedToOtherRoleAfterUp = allPermissionsForRoleAfterUp.find((row) => { - return row.name === 'scarface'; - }); + const permissionAttachedToOtherRoleAfterUp = allPermissionsForRoleAfterUp.find( + (row: { name: string }) => { + return row.name === 'scarface'; + }, + ); assert( permissionAttachedToOtherRoleAfterUp, @@ -612,9 +641,11 @@ describe('migrations/utils/permissions', function () { r.name = 'Role Name'; `); - const permissionAttachedToRoleAfterDown = allPermissionsForRoleAfterDown.find((row) => { - return row.name === 'scarface'; - }); + const permissionAttachedToRoleAfterDown = allPermissionsForRoleAfterDown.find( + (row: { name: string }) => { + return row.name === 'scarface'; + }, + ); assert(!permissionAttachedToRoleAfterDown, 'The permission was removed from the role.'); @@ -636,7 +667,7 @@ describe('migrations/utils/permissions', function () { `); const permissionAttachedToOtherRoleAfterDown = allPermissionsForOtherRoleAfterDown.find( - (row) => { + (row: { name: string }) => { return row.name === 'scarface'; }, ); @@ -809,8 +840,10 @@ async function setupNullableTestDb() { } // Helper function to check column nullable status for SQLite -async function checkColumnNullable(table, column, knex) { - const response = await knex.raw(`PRAGMA table_info(??)`, [table]); +async function checkColumnNullable(table: string, column: string, knex: KnexInstance) { + const response: { name: string; notnull: number }[] = await knex.raw(`PRAGMA table_info(??)`, [ + table, + ]); const columnInfo = response.find((col) => col.name === column); return columnInfo ? columnInfo.notnull === 0 : null; } @@ -965,7 +998,7 @@ describe('migrations/utils/schema nullable functions', function () { assert.equal(isNotNullableInitial, false, 'Column should initially be not nullable'); // Spy on logging to verify skip message - const logSpy = sinon.spy(require('@tryghost/logging'), 'warn'); + const logSpy = sinon.spy(logging, 'warn'); try { const runDownMigration = await runUpMigration(knex, migration); From c8b58be83a37c544030cacd5cf7ea303f5a44670 Mon Sep 17 00:00:00 2001 From: Austin Burdine Date: Thu, 17 Sep 2026 16:35:04 -0400 Subject: [PATCH 10/13] Fixed Renovate deadlock from stale env var and PR cap counting (#30876) no ref Renovate has not created or merged a dependency PR in weeks. Two independent faults combined into a deadlock. `allowedPostUpgradeCommands` was renamed to `allowedCommands` in Renovate 41, so `RENOVATE_ALLOWED_POST_UPGRADE_COMMANDS` has been silently ignored since the runner moved to 44. Renovate refused both changeset postUpgradeTask commands, set `renovate/artifacts` to failure on every npm branch, and will not automerge a PR carrying an artifact error. The open-PR cap counted every PR without a `needs:review` label, but that label marks only one of the config's automerge exclusions. Docker digest, pin, and monorepo PRs that Renovate can never merge itself counted against the cap of 6, pinning the count at 7 and holding the runner in maintenance-only mode indefinitely. Counting Renovate's own per-PR automerge marker instead drops the live count to 3 and reopens PR creation. A body missing the marker still counts, so an unparseable PR tightens the cap rather than loosening it. --- .github/renovate.json5 | 2 +- .github/workflows/renovate.yml | 32 ++++++++++++++++++++++++-------- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index c657935a7e9..a6fa357cab4 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -198,7 +198,7 @@ // changeset. The first command installs the script's deps — Renovate's // own install can be lockfile-only, so @internal/scripts' node_modules // isn't guaranteed present. Both commands must be allowlisted in the - // runner via RENOVATE_ALLOWED_POST_UPGRADE_COMMANDS (see + // runner via RENOVATE_ALLOWED_COMMANDS (see // .github/workflows/renovate.yml). { description: 'Auto-generate changesets for publishable package updates', diff --git a/.github/workflows/renovate.yml b/.github/workflows/renovate.yml index cede723baec..458afdbba8f 100644 --- a/.github/workflows/renovate.yml +++ b/.github/workflows/renovate.yml @@ -111,19 +111,32 @@ jobs: PR_CAP=10 fi - # Exclude `needs:review` PRs: Renovate cannot merge them itself, so - # counting them lets a handful of parked updates hold the cap shut - # forever — the cap is only released by Renovate merging something. - open_count=$(gh pr list \ + # Count only the PRs Renovate can merge itself. Renovate states that + # per PR in the body ("Automerge: Enabled" / "Disabled by config"), + # which is the only live signal for it — `needs:review` was a proxy + # that missed every other automerge exclusion, letting parked PRs + # hold the cap shut forever. The cap is only released by Renovate + # merging something, so a PR it will never merge must not count. + # A body without the marker counts, so an unparseable PR tightens + # the cap rather than loosening it. `--limit` is well above any real + # backlog for the same reason: a truncated list undercounts, which + # would loosen the cap. + prs=$(gh pr list \ --repo "${{ github.repository }}" \ --author "app/tryghost-renovate" \ --state open \ - --limit 100 \ - --json number,labels \ - --jq '[.[] | select(any(.labels[].name; . == "needs:review") | not)] | length') + --limit 1000 \ + --json number,body) + + open_count=$(jq '[.[] | select((.body // "") | test("\\*\\*Automerge\\*\\*: Disabled") | not)] | length' <<<"$prs") + parked=$(jq -r '[.[] | select((.body // "") | test("\\*\\*Automerge\\*\\*: Disabled")) | .number | tostring] | join(", ")' <<<"$prs") echo "Renovate has $open_count open PRs it can merge itself (cap: $PR_CAP)" + if [ -n "$parked" ]; then + echo "Parked PRs excluded from the cap, each needs a human merge: $parked" + fi + if [ "$MAINTENANCE_ONLY" = "true" ]; then force='{"dependencyDashboardApproval":true,"prCreation":"approval","vulnerabilityAlerts":{"dependencyDashboardApproval":false}}' echo "::notice::RENOVATE_MAINTENANCE_ONLY=true. Running in maintenance-only mode: existing PRs may update/automerge, new PRs require dashboard approval." @@ -165,4 +178,7 @@ jobs: # Renovate refuses to run any post-upgrade command that isn't matched # by one of these regexes — arbitrary command execution is opt-in. Both # the dep install and the generator command must be matched. - RENOVATE_ALLOWED_POST_UPGRADE_COMMANDS: '["^pnpm install --no-frozen-lockfile --filter @internal/scripts --prod --ignore-scripts$", "^node scripts/generate-changeset\\.js$"]' + # `allowedCommands` is the current name; the old + # `allowedPostUpgradeCommands` is silently ignored on Renovate >= 41, + # which left every npm PR with a failed `renovate/artifacts` status. + RENOVATE_ALLOWED_COMMANDS: '["^pnpm install --no-frozen-lockfile --filter @internal/scripts --prod --ignore-scripts$", "^node scripts/generate-changeset\\.js$"]' From 0fabf7efeabdc01df7aed0c601b89a3fb65fe12b Mon Sep 17 00:00:00 2001 From: "tryghost-renovate[bot]" <269725441+tryghost-renovate[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 01:39:50 +0000 Subject: [PATCH 11/13] Update mysql:8.4.11 Docker digest to 85b9bf2 --- compose.dev.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose.dev.yaml b/compose.dev.yaml index 386bd354731..64f6732bdf7 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -2,7 +2,7 @@ name: ghost-dev services: mysql: - image: mysql:8.4.11@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb + image: mysql:8.4.11@sha256:85b9bf2e29cf836ecb8c2a15a935d4ba0c606631dff1dd79531a11983c638f2a container_name: ghost-dev-mysql command: --innodb-buffer-pool-size=1G --innodb-log-buffer-size=500M --innodb-change-buffer-max-size=50 --innodb-flush-log-at-trx_commit=0 --innodb-flush-method=O_DIRECT --log-error-verbosity=1 ports: From 4f6ee2c1a11c917353eb20b477ac40124d492e7c Mon Sep 17 00:00:00 2001 From: "tryghost-renovate[bot]" <269725441+tryghost-renovate[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 01:39:41 +0000 Subject: [PATCH 12/13] Update docker/login-action digest to dbcb813 --- .github/workflows/publish-tinybird-local-slim.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-tinybird-local-slim.yml b/.github/workflows/publish-tinybird-local-slim.yml index d777fdfb6d5..a97df7ce39f 100644 --- a/.github/workflows/publish-tinybird-local-slim.yml +++ b/.github/workflows/publish-tinybird-local-slim.yml @@ -56,7 +56,7 @@ jobs: uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4 - name: Login to GHCR - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 with: registry: ghcr.io username: ${{ github.actor }} From 821033bcc8272ba54a775db8090a071cd098d164 Mon Sep 17 00:00:00 2001 From: "tryghost-renovate[bot]" <269725441+tryghost-renovate[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:28:15 +0000 Subject: [PATCH 13/13] Update redis:7.4 Docker digest to 71da927 --- .github/workflows/ci.yml | 2 +- compose.dev.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67d81ed3059..34ca64b4da2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -804,7 +804,7 @@ jobs: --health-timeout=5s --health-retries=60 redis: - image: redis:7.4@sha256:e9b2e45ecd47fbb69b877cf8d045d5cccaaaed52524b6e098b4abe8212994f73 + image: redis:7.4@sha256:71da9275c5f3fcb97d0fa0c8c5b36cc995327265420f17a04bfd544f458059f7 ports: - 6379:6379 options: >- diff --git a/compose.dev.yaml b/compose.dev.yaml index 64f6732bdf7..7f7009890fe 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -32,7 +32,7 @@ services: start_period: 10s redis: - image: redis:7.4@sha256:e9b2e45ecd47fbb69b877cf8d045d5cccaaaed52524b6e098b4abe8212994f73 + image: redis:7.4@sha256:71da9275c5f3fcb97d0fa0c8c5b36cc995327265420f17a04bfd544f458059f7 container_name: ghost-dev-redis command: ['redis-server', '--loglevel', 'warning'] ports: