From fb010cc0c77a5c93a302c339b022919097ca0065 Mon Sep 17 00:00:00 2001 From: Hannah Wolfe Date: Mon, 8 Jun 2026 11:57:10 +0100 Subject: [PATCH 01/16] =?UTF-8?q?=F0=9F=90=9B=20Fixed=20cut-off=20dropdown?= =?UTF-8?q?s=20in=20Membership=20settings=20during=20pre-launch=20mode=20(?= =?UTF-8?q?#28301)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the access dropdowns in **Settings → Membership** are clipped when a site is in pre-launch mode (free trial), so options below the fold are no longer cut off. Root cause: in pre-launch mode the Access card was given `overflow-hidden` to contain the gradient "Pre-launch mode" banner's glow shadow. That clip is geometric, so it also truncated any `Select` dropdown that opened past the card's edge. Fix: drop the card-level `overflow-hidden` and instead contain the glow on a small `overflow-hidden` wrapper around the gradient banner — so the card no longer clips anything and the dropdowns render normally. --- .../components/settings/membership/access.tsx | 19 ++++++----- .../test/acceptance/membership/access.test.ts | 33 +++++++++++++++++++ 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/apps/admin-x-settings/src/components/settings/membership/access.tsx b/apps/admin-x-settings/src/components/settings/membership/access.tsx index 111ecf8c434..50753961d4a 100644 --- a/apps/admin-x-settings/src/components/settings/membership/access.tsx +++ b/apps/admin-x-settings/src/components/settings/membership/access.tsx @@ -168,15 +168,17 @@ const Access: React.FC<{ keywords: string[] }> = ({keywords}) => { const form = ( {isTrialMode && ( - -
-
Pre-launch mode
-
- During your free trial, a private access code is required to browse your site. When you're ready to launch, pick a plan to upgrade your account and make everything public. +
+ +
+
Pre-launch mode
+
+ During your free trial, a private access code is required to browse your site. When you're ready to launch, pick a plan to upgrade your account and make everything public. +
-
- Upgrade now - + Upgrade now + +
)}
Who should be able to browse your site?
@@ -310,7 +312,6 @@ const Access: React.FC<{ keywords: string[] }> = ({keywords}) => { keywords={keywords} navid='members' saveState={saveState} - styles={isTrialMode ? 'overflow-hidden' : undefined} testId='access' title='Access' hideEditButton diff --git a/apps/admin-x-settings/test/acceptance/membership/access.test.ts b/apps/admin-x-settings/test/acceptance/membership/access.test.ts index 8495701e8b2..eed72a79da6 100644 --- a/apps/admin-x-settings/test/acceptance/membership/access.test.ts +++ b/apps/admin-x-settings/test/acceptance/membership/access.test.ts @@ -154,6 +154,39 @@ test.describe('Access settings', async () => { await expect(accessCode).toHaveValue('fake-456'); }); + test('Does not clip dropdown options off the Access card in pre-launch mode', async ({page}) => { + await mockApi({page, requests: { + ...globalDataRequests, + browseConfig: createConfigWithLimits({ + publicSiteAccess: { + disabled: true, + error: 'This plan does not include public site access' + } + }) + }}); + + await page.goto('/'); + + const section = page.getByTestId('access'); + + await expect(section.getByText('Pre-launch mode')).toBeVisible(); + + await section.getByTestId('commenting-select').click(); + + const lastOption = page.locator('[data-testid="select-option"]', {hasText: 'Nobody'}); + await expect(lastOption).toBeVisible(); + + const box = await lastOption.boundingBox(); + expect(box).not.toBeNull(); + + const optionIsActuallyPainted = await page.evaluate(({x, y, width, height}) => { + const el = document.elementFromPoint(x + width / 2, y + height / 2); + return Boolean(el?.closest('[data-testid="select-option"]')); + }, box!); + + expect(optionIsActuallyPainted).toBe(true); + }); + test('Disables other sections when signup is disabled', async ({page}) => { const {lastApiRequests} = await mockApi({page, requests: { ...globalDataRequests, From 00149cd84e66741979a9b6750a567cdf731f2fda Mon Sep 17 00:00:00 2001 From: Hannah Wolfe Date: Mon, 8 Jun 2026 12:53:34 +0100 Subject: [PATCH 02/16] Fixed IndexNow status-code detection (#28407) IndexNow failures were mis-detecting the HTTP status code, so rate-limits (429) and key-validation errors (422/403) were silently mislabelled as the generic `indexnow.ping_failed` (with `status_code: null`). The classification in the `ping()` catch block keyed off `err.statusCode`, but for a non-2xx response `got` throws an `HTTPError` whose status lives at `err.response.statusCode`, **not** `err.statusCode`. So in production those branches never matched and every 429/422/403 fell through to the generic failure branch - making rate-limiting and key problems invisible in structured logs. --- ghost/core/core/server/services/indexnow.js | 9 ++- .../unit/server/services/indexnow.test.js | 76 +++++++++++++++++++ 2 files changed, 81 insertions(+), 4 deletions(-) diff --git a/ghost/core/core/server/services/indexnow.js b/ghost/core/core/server/services/indexnow.js index 4f669ef5847..e6782525ba0 100644 --- a/ghost/core/core/server/services/indexnow.js +++ b/ghost/core/core/server/services/indexnow.js @@ -138,9 +138,11 @@ async function ping(post) { }, `${INDEXNOW_LOG_KEY} Successfully pinged ${url}`); } catch (err) { // Log errors but don't throw - IndexNow failures shouldn't disrupt publishing + const statusCode = err.statusCode ?? err.response?.statusCode ?? null; + let eventName; let error; - if (err.statusCode === 429) { + if (statusCode === 429) { // Rate limited by IndexNow - we have no retry/backoff, so the ping is dropped eventName = 'indexnow.rate_limited'; error = new errors.TooManyRequestsError({ @@ -149,8 +151,7 @@ async function ping(post) { context: tpl(messages.requestFailedError, {service: 'IndexNow'}), help: tpl(messages.requestFailedHelp, {url: 'https://ghost.org/docs/'}) }); - } else if (err.statusCode === 422) { - // 422 means the URL is invalid or key doesn't match + } else if (statusCode === 422 || statusCode === 403) { eventName = 'indexnow.key_validation_failed'; error = new errors.ValidationError({ err, @@ -171,7 +172,7 @@ async function ping(post) { logging.warn({ event: {name: eventName}, post: {id: post.id, slug: post.slug, url}, - http: {response: {status_code: err.statusCode ?? null}}, + http: {response: {status_code: statusCode}}, err: error }, `${INDEXNOW_LOG_KEY} ${error.message}`); } diff --git a/ghost/core/test/unit/server/services/indexnow.test.js b/ghost/core/test/unit/server/services/indexnow.test.js index df8f3f51548..7d67edce7d1 100644 --- a/ghost/core/test/unit/server/services/indexnow.test.js +++ b/ghost/core/test/unit/server/services/indexnow.test.js @@ -3,6 +3,7 @@ const sinon = require('sinon'); const _ = require('lodash'); const nock = require('nock'); const rewire = require('rewire'); +const errors = require('@tryghost/errors'); const testUtils = require('../../../utils'); const indexnow = rewire('../../../../core/server/services/indexnow'); const events = require('../../../../core/server/lib/common/events'); @@ -437,6 +438,81 @@ describe('IndexNow', function () { }); }); + describe('ping() error classification (got HTTPError shape)', function () { + const ping = indexnow.__get__('ping'); + let resetIndexNow; + + beforeEach(function () { + sinon.stub(urlService.facade, 'getUrlForResource').returns('https://example.com/my-post/'); + loggingStub = sinon.stub(logging, 'warn'); + }); + + afterEach(function () { + if (resetIndexNow) { + resetIndexNow(); + resetIndexNow = null; + } + }); + + function makeHttpError(statusCode) { + const err = new Error(`Response code ${statusCode}`); + err.name = 'HTTPError'; + err.code = 'ERR_NON_2XX_3XX_RESPONSE'; + err.response = {statusCode}; + return err; + } + + async function pingWithHttpError(statusCode) { + resetIndexNow = indexnow.__set__('request', sinon.stub().rejects(makeHttpError(statusCode))); + const testPost = _.clone(testUtils.DataGenerator.Content.posts[2]); + await ping(testPost); + } + + it('classifies a 429 (status on err.response.statusCode) as rate_limited', async function () { + await pingWithHttpError(429); + + sinon.assert.calledOnce(loggingStub); + assert.equal(loggingStub.args[0][0].event.name, 'indexnow.rate_limited'); + assert.equal(loggingStub.args[0][0].http.response.status_code, 429); + }); + + it('classifies a 422 (status on err.response.statusCode) as key_validation_failed', async function () { + await pingWithHttpError(422); + + sinon.assert.calledOnce(loggingStub); + assert.equal(loggingStub.args[0][0].event.name, 'indexnow.key_validation_failed'); + assert.equal(loggingStub.args[0][0].http.response.status_code, 422); + }); + + it('classifies a 403 (key not valid) as key_validation_failed', async function () { + await pingWithHttpError(403); + + sinon.assert.calledOnce(loggingStub); + assert.equal(loggingStub.args[0][0].event.name, 'indexnow.key_validation_failed'); + assert.equal(loggingStub.args[0][0].http.response.status_code, 403); + }); + + it('classifies other 5xx errors (status on err.response.statusCode) as ping_failed', async function () { + await pingWithHttpError(503); + + sinon.assert.calledOnce(loggingStub); + assert.equal(loggingStub.args[0][0].event.name, 'indexnow.ping_failed'); + assert.equal(loggingStub.args[0][0].http.response.status_code, 503); + }); + + it('still classifies a GhostError carrying err.statusCode (manual throw) correctly', async function () { + const err = new errors.TooManyRequestsError({message: 'manual', statusCode: 429}); + resetIndexNow = indexnow.__set__('request', sinon.stub().rejects(err)); + const testPost = _.clone(testUtils.DataGenerator.Content.posts[2]); + + await ping(testPost); + + sinon.assert.calledOnce(loggingStub); + assert.equal(loggingStub.args[0][0].event.name, 'indexnow.rate_limited'); + assert.equal(loggingStub.args[0][0].http.response.status_code, 429); + }); + }); + describe('getApiKey()', function () { it('should return the API key from settings', function () { const expectedKey = 'test-api-key-12345'; From 133a5f06b14e98e8a5c43953c7df7508ce37e8d6 Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Mon, 8 Jun 2026 12:54:54 +0100 Subject: [PATCH 03/16] =?UTF-8?q?=F0=9F=90=9B=20Fixed=20incoming=20recomme?= =?UTF-8?q?ndations=20disappearing=20on=20transient=20errors=20(#26673)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes https://linear.app/ghost/issue/BER-3490 During boot, incoming recommendation mentions are revalidated. If the metadata fetch failed for any reason (including transient errors like 429 rate limits or network timeouts), the mention was immediately soft-deleted, causing valid recommendations to disappear from Ghost Admin. Now transient errors are ignored entirely, and hard failures require 3 consecutive occurrences before deletion. A successful fetch resets the failure count. ## Changes - Transient fetch errors (429, 503) during incoming recommendation revalidation are now ignored instead of immediately deleting the mention - Hard failures now require 3 consecutive occurrences before a mention is soft-deleted, tracked via a new `revalidation_failure_count` column on the `mentions` table - A successful metadata fetch resets the failure counter to zero - All failures are logged with a `[Webmention Revalidation]` prefix for easy diagnosis --- Co-authored-by: Sag --- ghost/admin/package.json | 2 +- ...-revalidation-failure-count-to-mentions.js | 8 + ghost/core/core/server/data/schema/schema.js | 3 +- ghost/core/core/server/models/mention.js | 3 +- .../mentions/bookshelf-mention-repository.js | 6 +- .../core/server/services/mentions/mention.js | 20 ++- .../server/services/mentions/mentions-api.js | 17 +- .../services/mentions/webmention-metadata.js | 60 +++++--- .../server/services/oembed/oembed-service.js | 8 +- ghost/core/package.json | 2 +- .../unit/server/data/schema/integrity.test.js | 2 +- .../services/mentions/mentions-api.test.js | 145 ++++++++++++++++-- .../mentions/webmention-metadata.test.js | 85 ++++++++++ .../services/oembed/oembed-service.test.js | 81 ++++++++++ 14 files changed, 397 insertions(+), 45 deletions(-) create mode 100644 ghost/core/core/server/data/migrations/versions/6.45/2026-06-08-11-23-46-add-revalidation-failure-count-to-mentions.js create mode 100644 ghost/core/test/unit/server/services/mentions/webmention-metadata.test.js diff --git a/ghost/admin/package.json b/ghost/admin/package.json index 50148adcb4f..76dd2168d01 100644 --- a/ghost/admin/package.json +++ b/ghost/admin/package.json @@ -1,6 +1,6 @@ { "name": "ghost-admin", - "version": "6.44.2-rc.0", + "version": "6.45.0-rc.0", "description": "Ember.js admin client for Ghost", "author": "Ghost Foundation", "homepage": "http://ghost.org", diff --git a/ghost/core/core/server/data/migrations/versions/6.45/2026-06-08-11-23-46-add-revalidation-failure-count-to-mentions.js b/ghost/core/core/server/data/migrations/versions/6.45/2026-06-08-11-23-46-add-revalidation-failure-count-to-mentions.js new file mode 100644 index 00000000000..c22ff4702df --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/6.45/2026-06-08-11-23-46-add-revalidation-failure-count-to-mentions.js @@ -0,0 +1,8 @@ +const {createAddColumnMigration} = require('../../utils'); + +module.exports = createAddColumnMigration('mentions', 'revalidation_failure_count', { + type: 'integer', + nullable: false, + unsigned: true, + defaultTo: 0 +}); diff --git a/ghost/core/core/server/data/schema/schema.js b/ghost/core/core/server/data/schema/schema.js index 3d7c5037d0b..ae35ee23aa6 100644 --- a/ghost/core/core/server/data/schema/schema.js +++ b/ghost/core/core/server/data/schema/schema.js @@ -1080,7 +1080,8 @@ module.exports = { created_at: {type: 'dateTime', nullable: false}, payload: {type: 'text', maxlength: 65535, nullable: true}, deleted: {type: 'boolean', nullable: false, defaultTo: false}, - verified: {type: 'boolean', nullable: false, defaultTo: false} + verified: {type: 'boolean', nullable: false, defaultTo: false}, + revalidation_failure_count: {type: 'integer', nullable: false, unsigned: true, defaultTo: 0} }, milestones: { id: {type: 'string', maxlength: 24, nullable: false, primary: true}, diff --git a/ghost/core/core/server/models/mention.js b/ghost/core/core/server/models/mention.js index 269bf71f598..bd5b62d846d 100644 --- a/ghost/core/core/server/models/mention.js +++ b/ghost/core/core/server/models/mention.js @@ -4,7 +4,8 @@ const Mention = ghostBookshelf.Model.extend({ tableName: 'mentions', defaults: { deleted: false, - verified: false + verified: false, + revalidation_failure_count: 0 }, defaultFilters() { return 'deleted:false'; diff --git a/ghost/core/core/server/services/mentions/bookshelf-mention-repository.js b/ghost/core/core/server/services/mentions/bookshelf-mention-repository.js index 42310851e9f..cbd56d5e073 100644 --- a/ghost/core/core/server/services/mentions/bookshelf-mention-repository.js +++ b/ghost/core/core/server/services/mentions/bookshelf-mention-repository.js @@ -58,7 +58,8 @@ module.exports = class BookshelfMentionRepository { sourceFavicon: model.get('source_favicon'), sourceFeaturedImage: model.get('source_featured_image'), verified: model.get('verified'), - deleted: model.get('deleted') + deleted: model.get('deleted'), + revalidationFailureCount: model.get('revalidation_failure_count') }); } @@ -133,7 +134,8 @@ module.exports = class BookshelfMentionRepository { resource_type: mention.resourceType, payload: mention.payload ? JSON.stringify(mention.payload) : null, deleted: Mention.isDeleted(mention), - verified: mention.verified + verified: mention.verified, + revalidation_failure_count: mention.revalidationFailureCount }; const existing = await this.#MentionModel.findOne({id: data.id}, {require: false}); diff --git a/ghost/core/core/server/services/mentions/mention.js b/ghost/core/core/server/services/mentions/mention.js index d0ac79b167f..cf8c37bb047 100644 --- a/ghost/core/core/server/services/mentions/mention.js +++ b/ghost/core/core/server/services/mentions/mention.js @@ -29,6 +29,22 @@ module.exports = class Mention { this.#deleted = true; } + /** @type {number} */ + #revalidationFailureCount = 0; + + get revalidationFailureCount() { + return this.#revalidationFailureCount; + } + + recordRevalidationFailure() { + this.#revalidationFailureCount += 1; + return this.#revalidationFailureCount; + } + + clearRevalidationFailures() { + this.#revalidationFailureCount = 0; + } + #undelete() { // When an earlier mention is deleted, but then it gets verified again, we need to undelete it if (this.#deleted) { @@ -229,6 +245,7 @@ module.exports = class Mention { this.#resourceType = data.resourceType; this.#verified = data.verified; this.#deleted = data.deleted || false; + this.#revalidationFailureCount = data.revalidationFailureCount || 0; } /** @@ -315,7 +332,8 @@ module.exports = class Mention { resourceId, resourceType, verified, - deleted: isNew ? false : !!data.deleted + deleted: isNew ? false : !!data.deleted, + revalidationFailureCount: isNew ? 0 : (data.revalidationFailureCount || 0) }); mention.setSourceMetadata(data); diff --git a/ghost/core/core/server/services/mentions/mentions-api.js b/ghost/core/core/server/services/mentions/mentions-api.js index 9aa8e321b1f..643b0a2b9df 100644 --- a/ghost/core/core/server/services/mentions/mentions-api.js +++ b/ghost/core/core/server/services/mentions/mentions-api.js @@ -2,6 +2,8 @@ const errors = require('@tryghost/errors'); const logging = require('@tryghost/logging'); const Mention = require('./mention'); +const MAX_REVALIDATION_FAILURES = 3; + /** * @template Model * @typedef {object} Page @@ -210,12 +212,25 @@ module.exports = class MentionsAPI { sourceFavicon: metadata.favicon, sourceFeaturedImage: metadata.image }); + mention.clearRevalidationFailures(); } } catch (err) { if (!mention) { throw err; } - mention.delete(); + + if (err.transient) { + logging.warn(`[Webmention Revalidation] Transient error fetching ${webmention.source.href}: ${err.message} (status: ${err.statusCode || 'N/A'})`); + return mention; + } + + const count = mention.recordRevalidationFailure(); + logging.warn(`[Webmention Revalidation] Hard failure ${count}/${MAX_REVALIDATION_FAILURES} for ${webmention.source.href}: ${err.message} (status: ${err.statusCode || 'N/A'})`); + + if (count >= MAX_REVALIDATION_FAILURES) { + logging.warn(`[Webmention Revalidation] Deleting mention after ${count} consecutive failures: ${webmention.source.href}`); + mention.delete(); + } } if (!mention) { diff --git a/ghost/core/core/server/services/mentions/webmention-metadata.js b/ghost/core/core/server/services/mentions/webmention-metadata.js index ec90a1a20c0..2634e69f4ac 100644 --- a/ghost/core/core/server/services/mentions/webmention-metadata.js +++ b/ghost/core/core/server/services/mentions/webmention-metadata.js @@ -1,5 +1,15 @@ const oembedService = require('../oembed'); +function isTransientError(err) { + const statusCode = err.statusCode || err.response?.statusCode; + return statusCode === 429 || statusCode === 503 || err.name === 'TimeoutError' || err.code === 'ETIMEDOUT'; +} + +function tagFetchError(err) { + err.transient = isTransientError(err); + return err; +} + module.exports = class WebmentionMetadata { /** * Helpers that change the URL for which metadata for a given external resource is fetched. Return undefined to now handle the URL. @@ -34,15 +44,21 @@ module.exports = class WebmentionMetadata { */ async fetch(url) { const mappedUrl = this.#getMappedUrl(url); - const data = await oembedService.fetchOembedDataFromUrl(mappedUrl.href, 'mention', { - timeout: { - request: 15000 - }, - retry: { - // Only retry on network issues, or specific HTTP status codes - limit: 3 - } - }); + let data; + try { + data = await oembedService.fetchOembedDataFromUrl(mappedUrl.href, 'mention', { + timeout: { + request: 15000 + }, + retry: { + // Only retry on network issues, or specific HTTP status codes + limit: 3 + }, + shouldRethrowFetchError: isTransientError + }); + } catch (err) { + throw tagFetchError(err); + } const result = { siteTitle: data.metadata.publisher, @@ -58,17 +74,21 @@ module.exports = class WebmentionMetadata { if (mappedUrl.href !== url.href) { // Still need to fetch body and contentType separately now // For verification - const {body, contentType} = await oembedService.fetchPageHtml(url, { - timeout: { - request: 15000 - }, - retry: { - // Only retry on network issues, or specific HTTP status codes - limit: 3 - } - }); - result.body = body; - result.contentType = contentType; + try { + const {body, contentType} = await oembedService.fetchPageHtml(url, { + timeout: { + request: 15000 + }, + retry: { + // Only retry on network issues, or specific HTTP status codes + limit: 3 + } + }); + result.body = body; + result.contentType = contentType; + } catch (err) { + throw tagFetchError(err); + } } return result; } diff --git a/ghost/core/core/server/services/oembed/oembed-service.js b/ghost/core/core/server/services/oembed/oembed-service.js index 59da4af0d1a..0783ecb8dc9 100644 --- a/ghost/core/core/server/services/oembed/oembed-service.js +++ b/ghost/core/core/server/services/oembed/oembed-service.js @@ -464,6 +464,8 @@ class OEmbedService { * @returns {Promise} */ async fetchOembedDataFromUrl(url, type, options = {}) { + const {shouldRethrowFetchError, ...fetchOptions} = options; + try { const urlObject = new URL(url); @@ -502,7 +504,7 @@ class OEmbedService { } // Not in the list, we need to fetch the content - const {url: pageUrl, body, contentType} = await this.fetchPageHtml(url, options); + const {url: pageUrl, body, contentType} = await this.fetchPageHtml(url, fetchOptions); // fetch only bookmark when explicitly requested if (type === 'bookmark') { @@ -558,6 +560,10 @@ class OEmbedService { return data; } catch (err) { + if (shouldRethrowFetchError?.(err)) { + throw err; + } + // allow specific validation errors through for better error messages if (errors.utils.isGhostError(err) && err.errorType === 'ValidationError') { throw err; diff --git a/ghost/core/package.json b/ghost/core/package.json index f73280e9f9c..b6ded8a8433 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -1,6 +1,6 @@ { "name": "ghost", - "version": "6.44.2-rc.0", + "version": "6.45.0-rc.0", "description": "The professional publishing platform", "author": "Ghost Foundation", "homepage": "https://ghost.org", diff --git a/ghost/core/test/unit/server/data/schema/integrity.test.js b/ghost/core/test/unit/server/data/schema/integrity.test.js index 453c292b2a2..afb88112cd7 100644 --- a/ghost/core/test/unit/server/data/schema/integrity.test.js +++ b/ghost/core/test/unit/server/data/schema/integrity.test.js @@ -35,7 +35,7 @@ const validateRouteSettings = require('../../../../../core/server/services/route */ describe('DB version integrity', function () { // Only these variables should need updating - const currentSchemaHash = 'fafc9d5ad026f5eef11f9229a34994eb'; + const currentSchemaHash = '7f386a2e943ae71a246858e896ecf314'; const currentFixturesHash = '823aa0e8a8f083e80e271c47836a7e5d'; const currentSettingsHash = '397be8628c753b1959b8954d5610f83f'; const currentRoutesHash = '3d180d52c663d173a6be791ef411ed01'; diff --git a/ghost/core/test/unit/server/services/mentions/mentions-api.test.js b/ghost/core/test/unit/server/services/mentions/mentions-api.test.js index 18073ce8884..cdd543b70c2 100644 --- a/ghost/core/test/unit/server/services/mentions/mentions-api.test.js +++ b/ghost/core/test/unit/server/services/mentions/mentions-api.test.js @@ -227,17 +227,23 @@ describe('MentionsAPI', function () { it('Can update recommendations', async function () { const repository = new InMemoryMentionRepository(); + const fetchStub = sinon.stub(); + // First two calls succeed (processWebmention for source1 and source2) + fetchStub.onCall(0).resolves(mockWebmentionMetadata.fetch()); + fetchStub.onCall(1).resolves(mockWebmentionMetadata.fetch()); + // refreshMentions: source1 succeeds, source2 fails 3 times in a row + fetchStub.onCall(2).resolves(mockWebmentionMetadata.fetch()); // refresh source1 pass 1 + fetchStub.onCall(3).rejects(new Error('fail')); // refresh source2 fail 1 + fetchStub.onCall(4).resolves(mockWebmentionMetadata.fetch()); // refresh source1 pass 2 + fetchStub.onCall(5).rejects(new Error('fail')); // refresh source2 fail 2 + fetchStub.onCall(6).resolves(mockWebmentionMetadata.fetch()); // refresh source1 pass 3 + fetchStub.onCall(7).rejects(new Error('fail')); // refresh source2 fail 3 → deleted + const api = new MentionsAPI({ repository, routingService: mockRoutingService, resourceService: mockResourceService, - webmentionMetadata: { - fetch: sinon.stub() - .onFirstCall().resolves(mockWebmentionMetadata.fetch()) - .onSecondCall().resolves(mockWebmentionMetadata.fetch()) - .onThirdCall().resolves(mockWebmentionMetadata.fetch()) - .onCall(3).rejects() - } + webmentionMetadata: {fetch: fetchStub} }); await api.processWebmention({ @@ -259,16 +265,112 @@ describe('MentionsAPI', function () { }); assert(page.meta.pagination.total === 2); - // Now we invalidate the second mention + // First refresh: source2 gets failure 1 of 3 — not deleted yet + await api.refreshMentions({limit: 'all'}); + page = await api.listMentions({limit: 'all'}); + assert.equal(page.meta.pagination.total, 2, 'Mention should survive first hard failure'); - await api.refreshMentions({ - limit: 'all' + // Second refresh: source2 gets failure 2 of 3 — still not deleted + await api.refreshMentions({limit: 'all'}); + page = await api.listMentions({limit: 'all'}); + assert.equal(page.meta.pagination.total, 2, 'Mention should survive second hard failure'); + + // Third refresh: source2 gets failure 3 of 3 — now deleted + await api.refreshMentions({limit: 'all'}); + page = await api.listMentions({limit: 'all'}); + assert.equal(page.meta.pagination.total, 1, 'Mention should be deleted after 3rd consecutive hard failure'); + }); + + it('Does not delete mention on transient error during refresh', async function () { + const repository = new InMemoryMentionRepository(); + const transientErr = new Error('Too Many Requests'); + transientErr.transient = true; + transientErr.statusCode = 429; + + const api = new MentionsAPI({ + repository, + routingService: mockRoutingService, + resourceService: mockResourceService, + webmentionMetadata: { + fetch: sinon.stub() + .onFirstCall().resolves(mockWebmentionMetadata.fetch()) + .onSecondCall().rejects(transientErr) + } }); - page = await api.listMentions({ - limit: 'all' + await api.processWebmention({ + source: new URL('https://source.com'), + target: new URL('https://target.com'), + payload: {} }); - assert(page.meta.pagination.total === 1); + + await api.refreshMentions({limit: 'all'}); + + const page = await api.listMentions({limit: 'all'}); + assert.equal(page.meta.pagination.total, 1, 'Mention should not be deleted on transient error'); + }); + + it('Increments hard failure count but does not delete when count < 3', async function () { + const repository = new InMemoryMentionRepository(); + const api = new MentionsAPI({ + repository, + routingService: mockRoutingService, + resourceService: mockResourceService, + webmentionMetadata: { + fetch: sinon.stub() + .onFirstCall().resolves(mockWebmentionMetadata.fetch()) + .onSecondCall().rejects(new Error('404')) + } + }); + + await api.processWebmention({ + source: new URL('https://source.com'), + target: new URL('https://target.com'), + payload: {} + }); + + await api.refreshMentions({limit: 'all'}); + + const page = await api.listMentions({limit: 'all'}); + assert.equal(page.meta.pagination.total, 1, 'Mention should survive a single hard failure'); + }); + + it('Successful fetch after failures resets the failure count', async function () { + const repository = new InMemoryMentionRepository(); + const fetchStub = sinon.stub(); + fetchStub.onCall(0).resolves(mockWebmentionMetadata.fetch()); // processWebmention + fetchStub.onCall(1).rejects(new Error('fail')); // refresh 1: hard failure 1 + fetchStub.onCall(2).rejects(new Error('fail')); // refresh 2: hard failure 2 + fetchStub.onCall(3).resolves(mockWebmentionMetadata.fetch()); // refresh 3: success → resets count + fetchStub.onCall(4).rejects(new Error('fail')); // refresh 4: hard failure 1 again + fetchStub.onCall(5).rejects(new Error('fail')); // refresh 5: hard failure 2 + + const api = new MentionsAPI({ + repository, + routingService: mockRoutingService, + resourceService: mockResourceService, + webmentionMetadata: {fetch: fetchStub} + }); + + await api.processWebmention({ + source: new URL('https://source.com'), + target: new URL('https://target.com'), + payload: {} + }); + + // Two hard failures + await api.refreshMentions({limit: 'all'}); + await api.refreshMentions({limit: 'all'}); + + // One success — should reset count + await api.refreshMentions({limit: 'all'}); + + // Two more hard failures — count should be 2, not 4 + await api.refreshMentions({limit: 'all'}); + await api.refreshMentions({limit: 'all'}); + + const page = await api.listMentions({limit: 'all'}); + assert.equal(page.meta.pagination.total, 1, 'Mention should survive because success reset the failure count'); }); it('Can handle updating mentions', async function () { @@ -449,8 +551,10 @@ describe('MentionsAPI', function () { webmentionMetadata: { fetch: sinon.stub() .onFirstCall().resolves(mockWebmentionMetadata.fetch()) - .onSecondCall().rejects() - .onThirdCall().resolves(mockWebmentionMetadata.fetch()) + .onSecondCall().rejects(new Error('fail')) + .onThirdCall().rejects(new Error('fail')) + .onCall(3).rejects(new Error('fail')) + .onCall(4).resolves(mockWebmentionMetadata.fetch()) } }); @@ -470,6 +574,17 @@ describe('MentionsAPI', function () { } checkMentionDeleted: { + // Need 3 consecutive hard failures to delete + await api.processWebmention({ + source: new URL('https://source.com'), + target: new URL('https://target.com'), + payload: {} + }); + await api.processWebmention({ + source: new URL('https://source.com'), + target: new URL('https://target.com'), + payload: {} + }); await api.processWebmention({ source: new URL('https://source.com'), target: new URL('https://target.com'), diff --git a/ghost/core/test/unit/server/services/mentions/webmention-metadata.test.js b/ghost/core/test/unit/server/services/mentions/webmention-metadata.test.js new file mode 100644 index 00000000000..4540aca392d --- /dev/null +++ b/ghost/core/test/unit/server/services/mentions/webmention-metadata.test.js @@ -0,0 +1,85 @@ +const assert = require('node:assert/strict'); +const sinon = require('sinon'); + +const oembedService = require('../../../../../core/server/services/oembed'); +const WebmentionMetadata = require('../../../../../core/server/services/mentions/webmention-metadata'); + +describe('WebmentionMetadata', function () { + afterEach(function () { + sinon.restore(); + }); + + it('passes a transient fetch error classifier to the oEmbed service', async function () { + const error = new Error('Too Many Requests'); + error.response = { + statusCode: 429 + }; + + const fetchStub = sinon.stub(oembedService, 'fetchOembedDataFromUrl').rejects(error); + const webmentionMetadata = new WebmentionMetadata(); + + await assert.rejects( + () => webmentionMetadata.fetch(new URL('https://example.com')), + (err) => { + assert.equal(err, error); + assert.equal(err.transient, true); + return true; + } + ); + + const options = fetchStub.firstCall.args[2]; + assert.equal(options.shouldRethrowFetchError(error), true); + }); + + it('tags 503 fetch errors with got response shape as transient', async function () { + const error = new Error('Service Unavailable'); + error.response = { + statusCode: 503 + }; + + sinon.stub(oembedService, 'fetchOembedDataFromUrl').rejects(error); + const webmentionMetadata = new WebmentionMetadata(); + + await assert.rejects( + () => webmentionMetadata.fetch(new URL('https://example.com')), + (err) => { + assert.equal(err.transient, true); + return true; + } + ); + }); + + it('tags timeout fetch errors as transient', async function () { + const error = new Error('Timeout awaiting request'); + error.name = 'TimeoutError'; + + sinon.stub(oembedService, 'fetchOembedDataFromUrl').rejects(error); + const webmentionMetadata = new WebmentionMetadata(); + + await assert.rejects( + () => webmentionMetadata.fetch(new URL('https://example.com')), + (err) => { + assert.equal(err.transient, true); + return true; + } + ); + }); + + it('does not tag hard fetch errors as transient', async function () { + const error = new Error('Not Found'); + error.response = { + statusCode: 404 + }; + + sinon.stub(oembedService, 'fetchOembedDataFromUrl').rejects(error); + const webmentionMetadata = new WebmentionMetadata(); + + await assert.rejects( + () => webmentionMetadata.fetch(new URL('https://example.com')), + (err) => { + assert.equal(err.transient, false); + return true; + } + ); + }); +}); diff --git a/ghost/core/test/unit/server/services/oembed/oembed-service.test.js b/ghost/core/test/unit/server/services/oembed/oembed-service.test.js index be00f0a4831..89b3a535e50 100644 --- a/ghost/core/test/unit/server/services/oembed/oembed-service.test.js +++ b/ghost/core/test/unit/server/services/oembed/oembed-service.test.js @@ -259,6 +259,87 @@ describe('oembed-service', function () { await oembedService.fetchOembedDataFromUrl('https://youtube.com/live/1234?param=existing'); }); + + it('keeps unknown provider fallback by default when the page fetch fails', async function () { + const fetchError = new Error('Service Unavailable'); + fetchError.response = { + statusCode: 503 + }; + + const service = new OembedService({ + config: {get() { + return true; + }}, + externalRequest() { + throw fetchError; + } + }); + + await assert.rejects( + () => service.fetchOembedDataFromUrl('https://www.example.com', 'mention'), + { + name: 'ValidationError', + message: 'No provider found for supplied URL.' + } + ); + }); + + it('does not pass the rethrow callback to the external request', async function () { + const service = new OembedService({ + config: {get() { + return true; + }}, + externalRequest(url, options) { + assert.equal(url, 'https://www.example.com'); + assert.equal(options.shouldRethrowFetchError, undefined); + + return { + headers: { + 'content-type': 'text/html' + }, + body: Buffer.from('Example'), + url + }; + } + }); + + const response = await service.fetchOembedDataFromUrl('https://www.example.com', 'bookmark', { + shouldRethrowFetchError() { + return true; + } + }); + + assert.equal(response.metadata.title, 'Example'); + }); + + it('allows callers to rethrow selected page fetch errors', async function () { + const fetchError = new Error('Service Unavailable'); + fetchError.response = { + statusCode: 503 + }; + + const service = new OembedService({ + config: {get() { + return true; + }}, + externalRequest() { + throw fetchError; + } + }); + + await assert.rejects( + () => service.fetchOembedDataFromUrl('https://www.example.com', 'mention', { + shouldRethrowFetchError(err) { + return err.response?.statusCode === 503; + } + }), + (err) => { + assert.equal(err, fetchError); + assert.equal(err.response.statusCode, 503); + return true; + } + ); + }); }); describe('processImageFromUrl', function () { From 4a5630065039271bd25d4dedcc10cd48a04bbc8e Mon Sep 17 00:00:00 2001 From: Hannah Wolfe Date: Mon, 8 Jun 2026 13:36:59 +0100 Subject: [PATCH 04/16] Added useIndexNow privacy config (#28408) IndexNow notifies search engines when content is published or updated by pinging an external API. This gates the ping behind Ghost's existing privacy config. Meaning it can be disabled via `privacy: {useIndexNow: false}` or `privacy: {useTinfoil: true}`. This keeps it consistent with other features like gravatar, whilst also being useful for disabling pings in test mode. --- e2e/helpers/environment/constants.ts | 5 ++++- ghost/core/core/server/services/indexnow.js | 6 ++++++ .../unit/server/services/indexnow.test.js | 19 ++++++++++++++++++- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/e2e/helpers/environment/constants.ts b/e2e/helpers/environment/constants.ts index 6e42de6fa26..4d4c2fbe343 100644 --- a/e2e/helpers/environment/constants.ts +++ b/e2e/helpers/environment/constants.ts @@ -83,7 +83,10 @@ export const BASE_GHOST_ENV = [ // Email configuration 'mail__transport=SMTP', 'mail__options__host=ghost-dev-mailpit', - 'mail__options__port=1025' + 'mail__options__port=1025', + + // Disable IndexNow pings (tests run with real network access) + 'privacy__useIndexNow=false' ] as const; export const TEST_ENVIRONMENT = { diff --git a/ghost/core/core/server/services/indexnow.js b/ghost/core/core/server/services/indexnow.js index e6782525ba0..3758d29ad31 100644 --- a/ghost/core/core/server/services/indexnow.js +++ b/ghost/core/core/server/services/indexnow.js @@ -27,6 +27,7 @@ const tpl = require('@tryghost/tpl'); const logging = require('@tryghost/logging'); const request = require('@tryghost/request'); const settingsCache = require('../../shared/settings-cache'); +const config = require('../../shared/config'); const labs = require('../../shared/labs'); const events = require('../lib/common/events'); @@ -75,6 +76,11 @@ async function ping(post) { return; } + // Skip if IndexNow pings are disabled via privacy config + if (config.isPrivacyDisabled('useIndexNow')) { + return; + } + // Skip if IndexNow is not enabled in labs if (!labs.isSet('indexnow')) { return; diff --git a/ghost/core/test/unit/server/services/indexnow.test.js b/ghost/core/test/unit/server/services/indexnow.test.js index 7d67edce7d1..bc562cec383 100644 --- a/ghost/core/test/unit/server/services/indexnow.test.js +++ b/ghost/core/test/unit/server/services/indexnow.test.js @@ -8,6 +8,7 @@ const testUtils = require('../../../utils'); const indexnow = rewire('../../../../core/server/services/indexnow'); const events = require('../../../../core/server/lib/common/events'); const settingsCache = require('../../../../core/shared/settings-cache'); +const config = require('../../../../core/shared/config'); const labs = require('../../../../core/shared/labs'); const logging = require('@tryghost/logging'); const urlService = require('../../../../core/server/services/url'); @@ -17,16 +18,19 @@ describe('IndexNow', function () { let loggingStub; let settingsCacheStub; let labsStub; + let privacyDisabledStub; beforeEach(function () { eventStub = sinon.stub(events, 'on'); settingsCacheStub = sinon.stub(settingsCache, 'get'); labsStub = sinon.stub(labs, 'isSet'); + privacyDisabledStub = sinon.stub(config, 'isPrivacyDisabled'); - // Default: IndexNow enabled, site not private, API key set + // Default: IndexNow enabled, site not private, API key set, privacy not disabled labsStub.withArgs('indexnow').returns(true); settingsCacheStub.withArgs('is_private').returns(false); settingsCacheStub.withArgs('indexnow_api_key').returns('a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4'); + privacyDisabledStub.withArgs('useIndexNow').returns(false); }); afterEach(function () { @@ -328,6 +332,19 @@ describe('IndexNow', function () { assert.equal(pingRequest.isDone(), false); }); + it('when privacy.useIndexNow is disabled should not execute ping', async function () { + privacyDisabledStub.withArgs('useIndexNow').returns(true); + + const pingRequest = nock('https://api.indexnow.org') + .get(/\/indexnow/) + .reply(200); + const testPost = _.clone(testUtils.DataGenerator.Content.posts[2]); + + await ping(testPost); + + assert.equal(pingRequest.isDone(), false); + }); + it('when site is private should not execute ping', async function () { settingsCacheStub.withArgs('is_private').returns(true); From fe2b06eccbd10c3e7285ccb0a9f525a01396d94c Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Mon, 8 Jun 2026 13:32:08 +0000 Subject: [PATCH 05/16] Allowed more "ref" lines in commit messages (#28261) no ref This change should have no user impact. It just quiets a development-only warning. Before this change, our pre-commit hook showed a warning unless your commit's third line started with "ref", "fixes", or "closes". Now it supports a wide array of options. These are lifted from some our internal document, "How to write commit messages". --- .github/hooks/commit-msg.bash | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/hooks/commit-msg.bash b/.github/hooks/commit-msg.bash index 8719d116d25..332e66d2219 100755 --- a/.github/hooks/commit-msg.bash +++ b/.github/hooks/commit-msg.bash @@ -38,7 +38,7 @@ fi if [ ! -z "$third_line" ]; then if [[ "$third_line" =~ ^(refs|ref:) ]]; then echo -e "${red}Error: Third line should not start with 'refs' or 'ref:'${no_color}" >&2 - echo -e "Use 'ref ', 'fixes ', or 'closes ' instead" >&2 + echo -e "Use a supported keyword like 'ref', 'close', 'fixes', 'related to', or 'contributes to' followed by an issue link (or 'no ref')" >&2 echo -e "${yellow}Press Enter to edit the message...${no_color}" >&2 read < /dev/tty # Wait for Enter key press from the terminal @@ -67,8 +67,9 @@ if [ ! -z "$third_line" ]; then # If fixed, the script will continue to the next checks fi - if ! [[ "$third_line" =~ ^(ref|fixes|closes)\ .*$ ]]; then - echo -e "${yellow}Warning: Third line should start with 'ref', 'fixes', or 'closes' followed by an issue link${no_color}" >&2 + if ! [[ "$third_line" =~ ^(close|closes|closed|closing|fix|fixes|fixed|fixing|resolve|resolves|resolved|resolving|complete|completes|completed|completing|ref|references|part\ of|related\ to|contributes\ to|towards)\ .*$ ]] \ + && ! [[ "$third_line" == "no ref" ]]; then + echo -e "${yellow}Warning: Third line should start with a supported issue-link keyword (for example: 'ref', 'close', 'fixes', 'related to', or 'contributes to') or be 'no ref'${no_color}" >&2 fi fi From cfa1dea0fd3742a7f8d2a04b4073f614aa1d5e10 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Mon, 8 Jun 2026 13:52:54 +0000 Subject: [PATCH 06/16] Cleaned up automation edit state (#28381) closes https://linear.app/ghost/issue/NY-1302 This change should have no user impact. This refactor should (hopefully) make things a bit easier to read. --- apps/posts/src/views/Automations/editor.tsx | 223 +++++++++----------- apps/posts/src/views/Automations/types.ts | 17 +- 2 files changed, 106 insertions(+), 134 deletions(-) diff --git a/apps/posts/src/views/Automations/editor.tsx b/apps/posts/src/views/Automations/editor.tsx index 6129581991a..bec6da2db06 100644 --- a/apps/posts/src/views/Automations/editor.tsx +++ b/apps/posts/src/views/Automations/editor.tsx @@ -39,25 +39,7 @@ const editableSlice = (automation: AutomationDetail) => ({ }); const isFailedEditState = (editState: AutomationEditState): boolean => { - switch (editState) { - case 'failed to publish': - case 'failed to re-publish': - case 'failed to save': - case 'failed to unpublish': - return true; - case 'confirming re-publish': - case 'confirming unpublish': - case 'idle': - case 'publishing': - case 're-publishing': - case 'saving': - case 'unpublishing': - return false; - default: { - const _exhaustive: never = editState; - throw new Error(`Unhandled edit state: ${_exhaustive}`); - } - } + return editState.phase === 'failed'; }; const getActionErrors = (automation: AutomationDetail): Record => { @@ -92,7 +74,7 @@ const AutomationEditor: React.FC = () => { const automation = data?.automations[0]; const editMutation = useEditAutomation(); - const [editState, setEditState] = React.useState('idle'); + const [editState, setEditState] = React.useState({phase: 'idle'}); const [actionErrors, setActionErrors] = React.useState>({}); // Draft is the user-facing, locally mutable copy. The React Query cache stays as server truth; @@ -126,7 +108,7 @@ const AutomationEditor: React.FC = () => { ); }); setEditState(prev => ( - isFailedEditState(prev) ? 'idle' : prev + isFailedEditState(prev) ? {phase: 'idle'} : prev )); }; @@ -157,20 +139,20 @@ const AutomationEditor: React.FC = () => { const statusTransition: `${AutomationStatus} -> ${AutomationStatus}` = `${oldStatus} -> ${newStatus}`; switch (statusTransition) { case 'active -> active': - requestState = 're-publishing'; - errorState = 'failed to re-publish'; + requestState = {phase: 'submitting', action: 'republish'}; + errorState = {phase: 'failed', action: 'republish'}; break; case 'inactive -> inactive': - requestState = 'saving'; - errorState = 'failed to save'; + requestState = {phase: 'submitting', action: 'save'}; + errorState = {phase: 'failed', action: 'save'}; break; case 'inactive -> active': - requestState = 'publishing'; - errorState = 'failed to publish'; + requestState = {phase: 'submitting', action: 'publish'}; + errorState = {phase: 'failed', action: 'publish'}; break; case 'active -> inactive': - requestState = 'unpublishing'; - errorState = 'failed to unpublish'; + requestState = {phase: 'submitting', action: 'unpublish'}; + errorState = {phase: 'failed', action: 'unpublish'}; break; default: { const _exhaustive: never = statusTransition; @@ -195,7 +177,7 @@ const AutomationEditor: React.FC = () => { onSuccess: (response) => { setDraft(response.automations[0]); setActionErrors({}); - setEditState('idle'); + setEditState({phase: 'idle'}); }, onError: (error) => { void error; @@ -218,9 +200,9 @@ const AutomationEditor: React.FC = () => { ); }; - let isConfirmUnpublishAlertOpen = false; - let isConfirmRepublishAlertOpen = false; - let isEditRequestActive = false; + const isConfirmUnpublishAlertOpen = editState.action === 'unpublish'; + const isConfirmRepublishAlertOpen = editState.action === 'republish'; + const isEditRequestActive = editState.phase === 'submitting'; let isSaveButtonEnabled = !!draft && draft.actions.length > 0 && draft.status === 'inactive' && hasUnsavedChanges; let saveButtonVariant: ButtonProps['variant'] = 'secondary'; let saveButtonChildren: React.ReactNode = 'Save'; @@ -234,89 +216,84 @@ const AutomationEditor: React.FC = () => { let isRepublishButtonEnabled = true; let republishButtonVariant: ButtonProps['variant'] = 'default'; let republishButtonChildren: React.ReactNode = 'Publish changes'; - switch (editState) { + switch (editState.phase) { case 'idle': break; - case 'saving': - isEditRequestActive = true; - isSaveButtonEnabled = false; - isPublishButtonEnabled = false; - isTurnOffButtonEnabled = false; - saveButtonChildren = ( - <> - - Saving... - - ); - break; - case 'publishing': - isEditRequestActive = true; - isSaveButtonEnabled = false; - isPublishButtonEnabled = false; - isTurnOffButtonEnabled = false; - publishButtonChildren = ( - <> - - Publishing... - - ); - break; - case 're-publishing': - isEditRequestActive = true; - isConfirmRepublishAlertOpen = true; - isPublishButtonEnabled = false; - isTurnOffButtonEnabled = false; - isRepublishButtonEnabled = false; - republishButtonChildren = ( - <> - - Publishing... - - ); - break; - case 'unpublishing': - isEditRequestActive = true; - isConfirmUnpublishAlertOpen = true; - isSaveButtonEnabled = false; - isPublishButtonEnabled = false; - isTurnOffButtonEnabled = false; - turnOffButtonChildren = ( - <> - - Turning off... - - ); - break; - case 'confirming unpublish': - isConfirmUnpublishAlertOpen = true; + case 'submitting': isSaveButtonEnabled = false; isPublishButtonEnabled = false; isTurnOffButtonEnabled = false; + + switch (editState.action) { + case 'save': + saveButtonChildren = ( + <> + + Saving... + + ); + break; + case 'publish': + publishButtonChildren = ( + <> + + Publishing... + + ); + break; + case 'republish': + isRepublishButtonEnabled = false; + republishButtonChildren = ( + <> + + Publishing... + + ); + break; + case 'unpublish': + turnOffButtonChildren = ( + <> + + Turning off... + + ); + break; + } break; - case 'confirming re-publish': - isConfirmRepublishAlertOpen = true; - isPublishButtonEnabled = false; - isTurnOffButtonEnabled = false; - break; - case 'failed to save': - saveButtonVariant = 'destructive'; - saveButtonChildren = 'Retry'; - break; - case 'failed to publish': - publishButtonVariant = 'destructive'; - publishButtonChildren = 'Retry'; - break; - case 'failed to re-publish': - isConfirmRepublishAlertOpen = true; - isPublishButtonEnabled = false; - isTurnOffButtonEnabled = false; - republishButtonVariant = 'destructive'; - republishButtonChildren = 'Retry'; + case 'confirming': + switch (editState.action) { + case 'republish': + isPublishButtonEnabled = false; + isTurnOffButtonEnabled = false; + break; + case 'unpublish': + isSaveButtonEnabled = false; + isPublishButtonEnabled = false; + isTurnOffButtonEnabled = false; + break; + } break; - case 'failed to unpublish': - isConfirmUnpublishAlertOpen = true; - isTurnOffButtonEnabled = true; - turnOffButtonChildren = 'Retry'; + case 'failed': + switch (editState.action) { + case 'save': + saveButtonVariant = 'destructive'; + saveButtonChildren = 'Retry'; + break; + case 'publish': + publishButtonVariant = 'destructive'; + publishButtonChildren = 'Retry'; + break; + case 'republish': + isPublishButtonEnabled = false; + isTurnOffButtonEnabled = false; + republishButtonVariant = 'destructive'; + republishButtonChildren = 'Retry'; + break; + case 'unpublish': + isTurnOffButtonEnabled = true; + turnOffButtonChildren = 'Retry'; + break; + } break; default: { const _exhaustive: never = editState; @@ -326,12 +303,12 @@ const AutomationEditor: React.FC = () => { const onConfirmUnpublishOpenChange = (open: boolean): void => { setEditState((oldEditState) => { - switch (oldEditState) { - case 'confirming unpublish': - case 'failed to unpublish': - return open ? oldEditState : 'idle'; + switch (oldEditState.phase) { + case 'confirming': + case 'failed': + return oldEditState.action === 'unpublish' && !open ? {phase: 'idle'} : oldEditState; case 'idle': - return open ? 'confirming unpublish' : oldEditState; + return open ? {phase: 'confirming', action: 'unpublish'} : oldEditState; default: return oldEditState; } @@ -340,12 +317,12 @@ const AutomationEditor: React.FC = () => { const onConfirmRepublishOpenChange = (open: boolean): void => { setEditState((oldEditState) => { - switch (oldEditState) { - case 'confirming re-publish': - case 'failed to re-publish': - return open ? oldEditState : 'idle'; + switch (oldEditState.phase) { + case 'confirming': + case 'failed': + return oldEditState.action === 'republish' && !open ? {phase: 'idle'} : oldEditState; case 'idle': - return open ? 'confirming re-publish' : oldEditState; + return open ? {phase: 'confirming', action: 'republish'} : oldEditState; default: return oldEditState; } @@ -359,10 +336,10 @@ const AutomationEditor: React.FC = () => { switch (draft.status) { case 'active': - if (!validateActionErrors(draft, 'idle')) { + if (!validateActionErrors(draft, {phase: 'idle'})) { return; } - setEditState('confirming re-publish'); + setEditState({phase: 'confirming', action: 'republish'}); break; case 'inactive': save('active'); @@ -399,7 +376,7 @@ const AutomationEditor: React.FC = () => { saveButtonVariant={saveButtonVariant} onPublish={onPublish} onSave={() => save()} - onTurnOff={() => setEditState('confirming unpublish')} + onTurnOff={() => setEditState({phase: 'confirming', action: 'unpublish'})} /> { Cancel