Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ The `--autoPublish` flag lets you automatically publish synced content and/or pa
4. Publishing is done per-locale to match the workflow API requirements
5. After publishing, reference mappings are updated to reflect the new published versions

A page's components are shared by all of its locales, so a save that adds, removes or reorders components in one locale also puts the page in staging in its other locales. When the page was published in another locale of the run before the save, and is published in the source, `pages` and `both` republish it in that locale too. With `content` or no `--autoPublish`, the sync lists those pages under "Pages Left in Staging by Another Locale's Save" (and in `--jsonSummary` under `autoPublish.warnings`, type `staging`). Locales not included in the run are not checked.

#### Examples

```bash
Expand Down
7 changes: 5 additions & 2 deletions src/core/json-summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,9 +301,12 @@ export function buildJsonSummary(input: BuildJsonSummaryInput): JsonSummary {
);

// Same split the console summary uses (PROD-2311): "publish"/"fatal" block the run,
// "mapping"/"refresh" are post-publish bookkeeping and deliberately do not.
// "mapping"/"refresh" are post-publish bookkeeping and deliberately do not, nor does "staging"
// (PROD-2782: a page another locale's save left in staging, not republished).
const autoPublishErrors = input.autoPublishErrors.filter((e) => e.type === "publish" || e.type === "fatal");
const autoPublishWarnings = input.autoPublishErrors.filter((e) => e.type === "mapping" || e.type === "refresh");
const autoPublishWarnings = input.autoPublishErrors.filter(
(e) => e.type === "mapping" || e.type === "refresh" || e.type === "staging"
);

return {
schemaVersion: JSON_SUMMARY_SCHEMA_VERSION,
Expand Down
76 changes: 75 additions & 1 deletion src/core/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
clearFailedContentRegistry,
clearHeldBackTemplateRegistry,
clearEffectiveTargetModels,
clearOtherLocaleStagingRegistry,
getOtherLocaleStaging,
getPageCmsLink,
getContentCmsLink,
} from "./state";
Expand All @@ -21,6 +23,48 @@ import { buildJsonSummary, writeJsonSummary, JsonSummaryDetail, JsonSummaryPhase
import type { FailureDetail } from "../types/sourceData";
import { foreignCdnReport } from "../lib/content/foreign-cdn-report";
import { resetForeignCdnDetectorCache } from "../lib/pushers/content-pusher/util/report-foreign-cdn-references";
import type { OtherLocaleStagingEntry } from "./state";

/**
* PROD-2782: whether an --autoPublish mode publishes pages ("pages" or "both").
*/
export function autoPublishIncludesPages(autoPublishMode: string | undefined): boolean {
return autoPublishMode === "pages" || autoPublishMode === "both";
}

/**
* PROD-2782: add the pages a save in one locale put in staging in another locale to that locale's
* page publish list. Duplicates are left in; executeAutoPublish de-duplicates per locale.
*/
export function mergeOtherLocaleStaging(
pageIdsByLocale: Map<string, number[]>,
stagingByLocale: Map<string, OtherLocaleStagingEntry[]>
): void {
stagingByLocale.forEach((entries, locale) => {
const existing = pageIdsByLocale.get(locale) || [];
pageIdsByLocale.set(locale, [...existing, ...entries.map((e) => e.targetPageID)]);
});
}

/**
* PROD-2782: non-blocking "staging" notices for pages a save in one locale put in staging in
* another locale, used when auto-publish does not publish pages and so cannot republish them.
*/
export function otherLocaleStagingWarnings(
stagingByLocale: Map<string, OtherLocaleStagingEntry[]>
): Array<{ locale: string; type: string; error: string }> {
const warnings: Array<{ locale: string; type: string; error: string }> = [];
stagingByLocale.forEach((entries, locale) => {
for (const { targetPageID, pageName, savedLocale } of entries) {
warnings.push({
locale,
type: "staging",
error: `page "${pageName}" (target page ${targetPageID}) was put in staging by the ${savedLocale} save and was not republished (auto-publish does not include pages)`,
});
}
});
return warnings;
}

/**
* PROD-2310: Decide whether a set of auto-publish errors should fail the sync exit code.
Expand Down Expand Up @@ -61,6 +105,7 @@ export class Push {
clearHeldBackTemplateRegistry();
// PROD-2542: models saved by a previous run must not stand in for this run's target models
clearEffectiveTargetModels();
clearOtherLocaleStagingRegistry();

// Preflight (PROD-2203): start each run with a clean report of planned actions
preflightReport.reset();
Expand Down Expand Up @@ -210,6 +255,12 @@ export class Push {
autoPublishErrors = await this.executeAutoPublish(results, autoPublish);
}

// PROD-2782: pages a save in one locale put in staging in another locale are republished by
// auto-publish when it covers pages. Otherwise, say which ones were left in staging.
if (!autoPublishIncludesPages(autoPublish) || !isSync || state.preflight) {
autoPublishErrors.push(...otherLocaleStagingWarnings(getOtherLocaleStaging()));
}

// Split auto-publish outcomes: genuine publish failures (blocking) vs
// post-publish mapping/refresh bookkeeping notices (non-blocking). PROD-2311
const genuinePublishErrors = autoPublishErrors.filter(
Expand All @@ -218,6 +269,7 @@ export class Push {
const mappingWarnings = autoPublishErrors.filter(
({ type }) => type === "mapping" || type === "refresh"
);
const stagingWarnings = autoPublishErrors.filter(({ type }) => type === "staging");

// PROD-2310: Fold auto-publish failures into the returned success signal so the
// entry point can exit non-zero. "mapping"/"refresh" entries are post-publish
Expand Down Expand Up @@ -322,6 +374,16 @@ export class Push {
});
}

// PROD-2782: pages left in staging in a locale by another locale's save (no auto-publish of
// pages). Non-blocking: nothing was lost, the page needs publishing in that locale.
if (stagingWarnings.length > 0) {
console.log(ansiColors.yellow(`\n Pages Left in Staging by Another Locale's Save (non-blocking):`));
stagingWarnings.forEach(({ locale, error }) => {
const localeDisplay = locale ? `[${locale}]` : "";
console.log(ansiColors.yellow(` • ${localeDisplay} ${error}`));
});
}

// Show log file paths at the very end
if (logFilePaths.length > 0) {
console.log(ansiColors.cyan("\n📄 Log Files:"));
Expand Down Expand Up @@ -494,7 +556,14 @@ export class Push {

// Determine what to publish based on mode
const publishContent = autoPublishMode === "content" || autoPublishMode === "both";
const publishPages = autoPublishMode === "pages" || autoPublishMode === "both";
const publishPages = autoPublishIncludesPages(autoPublishMode);

// PROD-2782: a page saved in one locale may have been put in staging in another locale that
// this run did not save it in. Republish it there too.
const otherLocaleStaging = getOtherLocaleStaging();
if (publishPages && otherLocaleStaging.size > 0) {
mergeOtherLocaleStaging(pageIdsByLocale, otherLocaleStaging);
}

// Get all locales that have items to publish
const allLocales = new Set<string>();
Expand Down Expand Up @@ -527,6 +596,11 @@ export class Push {
console.log(ansiColors.gray(`Locales to publish: ${Array.from(allLocales).join(", ")}`));
console.log(ansiColors.gray(`Total content items: ${totalContent}`));
console.log(ansiColors.gray(`Total pages: ${totalPages}`));
if (publishPages && otherLocaleStaging.size > 0) {
let count = 0;
otherLocaleStaging.forEach((entries) => (count += entries.length));
console.log(ansiColors.gray(` incl. ${count} page(s) put in staging by a save in another locale`));
}

// Import workflow dependencies
const { processBatches } = await import("../lib/workflows/process-batches");
Expand Down
61 changes: 61 additions & 0 deletions src/core/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,20 @@ export interface State {
// Held-back template registry - source template names whose mapped target template is gone and
// was not recreated (no --overwrite). Used by page pusher to skip pages that depend on them.
heldBackTemplateRegistry: Map<string, string>;

// Other-locale staging registry (PROD-2782) - pages that a save in one locale put in staging in
// another locale, keyed by that other locale, then by target page ID. Auto-publish republishes
// them; without auto-publish they are reported as warnings.
otherLocaleStagingRegistry: Map<string, Map<number, OtherLocaleStagingEntry>>;
// "locale:targetPageID" for pages whose own save in that locale failed this run - never republished
otherLocaleStagingBlocked: Set<string>;
}

export interface OtherLocaleStagingEntry {
targetPageID: number;
pageName: string;
/** The locale whose save put this page in staging. */
savedLocale: string;
}

// Global state - populated from argv and referenced throughout the app
Expand Down Expand Up @@ -199,6 +213,10 @@ export const state: State = {

// Held-back template registry - templates whose mapped target is gone and was not recreated
heldBackTemplateRegistry: new Map(),

// Other-locale staging registry - pages a save in one locale put in staging in another (PROD-2782)
otherLocaleStagingRegistry: new Map(),
otherLocaleStagingBlocked: new Set(),
};

/**
Expand Down Expand Up @@ -848,6 +866,49 @@ export function clearHeldBackTemplateRegistry(): void {
state.heldBackTemplateRegistry.clear();
}

/**
* Record a page that a save in another locale put in staging in `locale` (PROD-2782)
* @param locale - The locale the page went to staging in
* @param entry - The target page and the locale whose save caused it
*/
export function registerOtherLocaleStaging(locale: string, entry: OtherLocaleStagingEntry): void {
if (state.otherLocaleStagingBlocked.has(`${locale}:${entry.targetPageID}`)) return;
let pages = state.otherLocaleStagingRegistry.get(locale);
if (!pages) {
pages = new Map();
state.otherLocaleStagingRegistry.set(locale, pages);
}
pages.set(entry.targetPageID, entry);
}

/**
* Never republish this page in `locale` this run - its own save in that locale failed. Drops it if
* it was already recorded, and keeps a later save in another locale from recording it again.
*/
export function blockOtherLocaleStaging(locale: string, targetPageID: number): void {
state.otherLocaleStagingRegistry.get(locale)?.delete(targetPageID);
state.otherLocaleStagingBlocked.add(`${locale}:${targetPageID}`);
}

/**
* The pages recorded for each locale, as arrays
*/
export function getOtherLocaleStaging(): Map<string, OtherLocaleStagingEntry[]> {
const result = new Map<string, OtherLocaleStagingEntry[]>();
state.otherLocaleStagingRegistry.forEach((pages, locale) => {
if (pages.size > 0) result.set(locale, Array.from(pages.values()));
});
return result;
}

/**
* Clear the other-locale staging registry (should be called at start of each sync)
*/
export function clearOtherLocaleStagingRegistry(): void {
state.otherLocaleStagingRegistry.clear();
state.otherLocaleStagingBlocked.clear();
}

/**
* Get the CMS app URL based on environment
* - dev/local/preprod: app-qa.publishwithagility.com
Expand Down
3 changes: 2 additions & 1 deletion src/core/tests/json-summary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,12 +125,13 @@ describe("buildJsonSummary — auto-publish classification", () => {
{ type: "fatal", error: "crashed" },
{ type: "mapping", error: "mapping refresh skipped" },
{ type: "refresh", error: "target refresh failed" },
{ type: "staging", error: "left in staging by another locale's save", locale: "pt-br" },
],
})
);

expect(summary.autoPublish.errors.map((e) => e.type)).toEqual(["publish", "fatal"]);
expect(summary.autoPublish.warnings.map((e) => e.type)).toEqual(["mapping", "refresh"]);
expect(summary.autoPublish.warnings.map((e) => e.type)).toEqual(["mapping", "refresh", "staging"]);
});

it("yields empty groups when auto-publish reported nothing", () => {
Expand Down
89 changes: 89 additions & 0 deletions src/core/tests/push-auto-publish.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* PROD-2782: executeAutoPublish republishes pages that a save in one locale put in staging in
* another locale, alongside the pages the run saved.
*/
import { Push } from "../push";
import { resetState, setState, registerOtherLocaleStaging, clearOtherLocaleStagingRegistry } from "../state";

jest.mock("../../lib/workflows/process-batches", () => ({
processBatches: jest.fn(async (ids: number[]) => ({ processedIds: ids, failed: 0 })),
}));
jest.mock("../../lib/mappers/mapping-version-updater", () => ({
updateMappingsAfterPublish: jest.fn(async () => ({ result: { errors: [] } })),
}));
jest.mock("../../lib/shared/get-fetch-api-status", () => ({
waitForFetchApiSync: jest.fn(async () => undefined),
}));
jest.mock("../pull", () => ({
Pull: jest.fn().mockImplementation(() => ({ pullInstances: jest.fn(async () => ({ success: true })) })),
}));

import { processBatches } from "../../lib/workflows/process-batches";
import { updateMappingsAfterPublish } from "../../lib/mappers/mapping-version-updater";

const mockProcessBatches = processBatches as jest.Mock;
const mockUpdateMappings = updateMappingsAfterPublish as jest.Mock;

beforeEach(() => {
resetState();
clearOtherLocaleStagingRegistry();
setState({ sourceGuid: "src", targetGuid: "tgt" });
mockProcessBatches.mockClear();
mockUpdateMappings.mockClear();
jest.spyOn(console, "log").mockImplementation(() => {});
jest.spyOn(console, "warn").mockImplementation(() => {});
jest.spyOn(console, "error").mockImplementation(() => {});
});

afterEach(() => {
jest.restoreAllMocks();
});

/** The run saved page 98 in pt-br only; the pt-br save put page 98 in staging in en-us. */
function results(): any[] {
return [
{
publishableContentIdsByLocale: new Map(),
publishablePageIdsByLocale: new Map([["pt-br", [98, 40]]]),
},
];
}

function pageCalls(): Array<[number[], string]> {
return mockProcessBatches.mock.calls
.filter((c: any[]) => c[1] === "pages")
.map((c: any[]) => [c[0], c[2]] as [number[], string]);
}

describe("executeAutoPublish — pages another locale's save put in staging (PROD-2782)", () => {
it("republishes the page in the locale it went to staging in, and updates its mapping", async () => {
registerOtherLocaleStaging("en-us", { targetPageID: 98, pageName: "how-it-works", savedLocale: "pt-br" });

const errors = await (new Push() as any).executeAutoPublish(results(), "both");

expect(errors).toEqual([]);
expect(pageCalls()).toEqual(
expect.arrayContaining([
[[98, 40], "pt-br"],
[[98], "en-us"],
])
);
expect(mockUpdateMappings).toHaveBeenCalledWith([], [98], "src", "tgt", "en-us");
});

it("publishes a page once when the run also saved it in that locale", async () => {
registerOtherLocaleStaging("pt-br", { targetPageID: 40, pageName: "promotions", savedLocale: "en-us" });

await (new Push() as any).executeAutoPublish(results(), "pages");

expect(pageCalls()).toEqual([[[98, 40], "pt-br"]]);
});

it("does not republish pages when auto-publish is content-only", async () => {
registerOtherLocaleStaging("en-us", { targetPageID: 98, pageName: "how-it-works", savedLocale: "pt-br" });

await (new Push() as any).executeAutoPublish(results(), "content");

expect(pageCalls()).toEqual([]);
});
});
Loading
Loading