Skip to content

Commit 77693e8

Browse files
naman-contentstackclaude
andcommitted
feat(bulk-assets): retry publishing assets whose scan was pending
Assets still being scanned for malware were skipped on every publish run with only a console warning, leaving no way to publish them once the scan finished. Record those skips to pending-scan.json in the bulk operation folder, and add --retry-pending to re-check their status and publish the ones that came back clean. The log is self-pruning: a retry drops the entries it published along with any that turned quarantined, keeping only those still scanning. It is also excluded from clearLogs(), since a scan can outlast the gap between two unrelated runs. Quarantined assets are never retried, and the flag is rejected for unpublish, delete and move — scan status only gates publish. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 4fb99ed commit 77693e8

12 files changed

Lines changed: 677 additions & 11 deletions

File tree

.talismanrc

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,17 @@ fileignoreconfig:
3232
- filename: packages/contentstack-audit/test/unit/mock/am-contents/stack/stack.json
3333
checksum: c4ac3ff80eb38b1c58eb0db21a232bd84f856d4647093542296199036945083c
3434
- filename: packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-assets.ts
35-
checksum: 60f33657cc6e2664c17397a2f6b4b54a3f7bf71618931d63cd2811822bd4837e
35+
checksum: 0b07ebebd0aaf3c1cb2dedd74801c93ed4a083864c96b8457d7e612c5736d0da
3636
- filename: packages/contentstack-bulk-operations/src/utils/data-dir-asset-fetcher.ts
3737
checksum: ddcf4601ac47be300eba0eb901e4d45d748c2c4eab676c56677cb9a802fe3db0
3838
- filename: packages/contentstack-migration/src/commands/cm/stacks/migration.ts
3939
checksum: a64a4d396eddd936a63b799eff58c5c6660b5dcaa3a310fd8b09a027932f1789
4040
- filename: packages/contentstack-migration/README.md
4141
checksum: e96006c1a948f766c88ae972b29582fa58eaf8184606bf011eebddc5a06cd7b6
42+
- filename: packages/contentstack-bulk-operations/src/utils/bulk-operation-log-handler.ts
43+
checksum: e67a7a16a395be315c7464c8a24b142cbd259c59d31b17e1cdd32f613060c2b4
44+
- filename: packages/contentstack-bulk-operations/test/unit/commands/bulk-assets.test.ts
45+
checksum: 92e0ba427283573dd629ca96e5d2c4286bfd90cf6f36af65b404c09bb32d856f
46+
- filename: packages/contentstack-bulk-operations/test/unit/utils/bulk-operation-log-handler.test.ts
47+
checksum: a660f8caac1418746ef6ec7ef9b69d04065ce0a8c0e7568bcbbd6e5287841304
4248
version: ""

packages/contentstack-bulk-operations/README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@ USAGE
4444
$ csdx cm:stacks:bulk-assets [-a <value>] [-k <value>] [--operation publish|unpublish|delete|move] [--environments
4545
<value>...] [--locales <value>...] [--source-env <value>] [--source-alias <value>] [--publish-mode bulk|single]
4646
[--branch <value>] [-c <value>] [-y] [--retry-failed <value>] [--revert <value>] [--bulk-operation-file <value>]
47-
[--folder-uid <value>] [-d <value>] [--dry-run] [--space-uid <value>] [--org-uid <value>] [--workspace <value>]
48-
[--asset-uids-file <value>] [--locale <value>] [--target-folder-uid <value>]
47+
[--folder-uid <value>] [-d <value>] [--dry-run] [--retry-pending <value>] [--space-uid <value>] [--org-uid <value>]
48+
[--workspace <value>] [--asset-uids-file <value>] [--locale <value>] [--target-folder-uid <value>]
4949
5050
FLAGS
5151
-a, --alias=<value> Uses the name of a saved Management Token to authenticate the command. The command
@@ -85,6 +85,10 @@ FLAGS
8585
--retry-failed=<value> (optional) Use this option to retry publishing the failed entries/assets from the
8686
logfile. Specify the name of the logfile that lists failed publish calls. If this
8787
option is used, it will override all other flags.
88+
--retry-pending=<value> (optional) Re-checks the malware scan status of assets a previous run skipped
89+
because scanning was still in progress, and publishes the ones that are now clean.
90+
Specify the bulk operation folder used by that run. Quarantined assets are never
91+
retried.
8892
--revert=<value> (optional) Revert publish operations from a log folder. Specify the folder path
8993
containing success logs. Works similar to retry-failed.
9094
--source-alias=<value> Alias name for source environment delivery token (required for cross-publish). Add
@@ -112,6 +116,8 @@ EXAMPLES
112116
113117
$ csdx cm:stacks:bulk-assets --retry-failed ./bulk-operation -a myAlias
114118
119+
$ csdx cm:stacks:bulk-assets --retry-pending ./bulk-operation -a myAlias
120+
115121
$ csdx cm:stacks:bulk-assets --revert ./bulk-operation -a myAlias
116122
117123
$ csdx cm:stacks:bulk-assets --data-dir ./content --operation publish -k blt123

packages/contentstack-bulk-operations/src/base-bulk-command.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,18 @@ export abstract class BaseBulkCommand extends Command {
156156
return false;
157157
}
158158

159+
/**
160+
* Hook for a resource-specific retry flow that loads its config from a log file
161+
* rather than the usual flags — like --retry-failed/--revert, but with its own
162+
* pre-publish checks (see BulkAssets and --retry-pending).
163+
*
164+
* Return true when the flow handled the run: init() then returns immediately,
165+
* skipping interactive prompts, the normal pipeline, and clearLogs().
166+
*/
167+
protected async handleResourceSpecificRetryFlow(_flags: any): Promise<boolean> {
168+
return false;
169+
}
170+
159171
/**
160172
* Initialize common components
161173
*/
@@ -182,6 +194,12 @@ export abstract class BaseBulkCommand extends Command {
182194
this.logger = log;
183195
this.loggerContext = { module: this.id };
184196

197+
// Resource-specific retry flows (e.g. asset scan-status retry) run their own
198+
// init and must bypass clearLogs() for the same reason revert/retry does.
199+
if (await this.handleResourceSpecificRetryFlow(flags)) {
200+
return;
201+
}
202+
185203
// Check for revert/retry EARLY - all config comes from log file
186204
const isRevertOrRetry = flags.revert || flags['retry-failed'];
187205

packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-assets.ts

Lines changed: 181 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,14 @@ import * as path from 'path';
33

44
import { flags, handleAndLogError, log, FlagInput } from '@contentstack/cli-utilities';
55

6-
import { AssetPublishData, BulkOperationResult, ResourceType, OperationType, CsAssetsFlags } from '../../../interfaces';
6+
import {
7+
AssetPublishData,
8+
BulkOperationResult,
9+
ResourceType,
10+
OperationType,
11+
CsAssetsFlags,
12+
PendingScanLogEntry,
13+
} from '../../../interfaces';
714
import { BaseBulkCommand } from '../../../base-bulk-command';
815
import {
916
$t,
@@ -21,6 +28,12 @@ import {
2128
RETRY_REVERT_CONTEXT,
2229
} from '../../../utils';
2330
import type { DataDirScanStats } from '../../../utils';
31+
import {
32+
appendPendingScanLog,
33+
getLogPaths,
34+
readPendingScanLog,
35+
writePendingScanLog,
36+
} from '../../../utils/bulk-operation-log-handler';
2437
import { AssetService } from '../../../services';
2538

2639
type RegionWithOptionalCsAssetsUrl = { csAssetsUrl?: string };
@@ -65,6 +78,9 @@ export default class BulkAssets extends BaseBulkCommand {
6578
// Retry failed assets from a log file
6679
'<%= config.bin %> <%= command.id %> --retry-failed ./bulk-operation -a myAlias',
6780

81+
// Re-check scan status and publish assets a previous run skipped as still scanning
82+
'<%= config.bin %> <%= command.id %> --retry-pending ./bulk-operation -a myAlias',
83+
6884
// Revert (unpublish) previously published assets using success log
6985
'<%= config.bin %> <%= command.id %> --revert ./bulk-operation -a myAlias',
7086

@@ -96,6 +112,9 @@ export default class BulkAssets extends BaseBulkCommand {
96112
description: messages.DRY_RUN_FLAG_DESC,
97113
default: false,
98114
}),
115+
'retry-pending': flags.string({
116+
description: messages.RETRY_PENDING,
117+
}),
99118

100119
// CS Assets delete/move flags
101120
'space-uid': flags.string({
@@ -140,7 +159,9 @@ export default class BulkAssets extends BaseBulkCommand {
140159
token === '--retry-failed' ||
141160
token.startsWith('--retry-failed=') ||
142161
token === '--revert' ||
143-
token.startsWith('--revert=')
162+
token.startsWith('--revert=') ||
163+
token === '--retry-pending' ||
164+
token.startsWith('--retry-pending=')
144165
);
145166

146167
if (!operation && !isRevertOrRetry) {
@@ -217,6 +238,20 @@ export default class BulkAssets extends BaseBulkCommand {
217238
pending.forEach((a) => this.logger.warn($t(messages.SCAN_STATUS_SKIPPED_PENDING, { uid: a.uid })));
218239
quarantined.forEach((a) => this.logger.warn($t(messages.SCAN_STATUS_SKIPPED_QUARANTINED, { uid: a.uid })));
219240

241+
// Persist the pending ones so --retry-pending can publish them once the scan clears.
242+
// fetchAssets() builds one row per uid x locale, so each carries its own locale/version.
243+
appendPendingScanLog(
244+
pending.map((a) =>
245+
this.buildPendingScanEntry({
246+
uid: a.uid,
247+
locale: a.locale,
248+
version: a.version ?? a._version,
249+
environments: (a.publish_details || []).map((pd: any) => pd.environment),
250+
})
251+
),
252+
this.bulkOperationConfig.bulkOperationFolder
253+
);
254+
220255
this.printScanningDashboard({
221256
total: assets.length,
222257
clean: clean.length,
@@ -361,6 +396,9 @@ export default class BulkAssets extends BaseBulkCommand {
361396
let workingBatch: AssetPublishData[] = [];
362397
let batchNumber = 0;
363398
let totalSubmitted = 0;
399+
// Collected across the whole stream and written once at the end — a per-asset
400+
// write would defeat the one-chunk-at-a-time design of this pass.
401+
const pendingScanEntries: PendingScanLogEntry[] = [];
364402

365403
this.batchResults.clear();
366404

@@ -394,15 +432,21 @@ export default class BulkAssets extends BaseBulkCommand {
394432
this.logger.warn($t(messages.SCAN_STATUS_SKIPPED_QUARANTINED, { uid: targetUid }));
395433
continue;
396434
}
435+
const assetLocales: string[] = useOverrideLocales
436+
? cliLocales
437+
: [...new Set<string>(asset.publish_details.map((pd: any) => pd.locale as string))];
438+
397439
if (scanStatus === 'pending') {
398440
this.logger.warn($t(messages.SCAN_STATUS_SKIPPED_PENDING, { uid: targetUid }));
441+
// Record per locale so --retry-pending republishes the same rows this run would have.
442+
for (const locale of assetLocales) {
443+
pendingScanEntries.push(
444+
this.buildPendingScanEntry({ uid: targetUid, locale, version: asset._version, environments })
445+
);
446+
}
399447
continue;
400448
}
401449

402-
const assetLocales: string[] = useOverrideLocales
403-
? cliLocales
404-
: [...new Set<string>(asset.publish_details.map((pd: any) => pd.locale as string))];
405-
406450
for (const locale of assetLocales) {
407451
workingBatch.push({ type: 'asset', uid: targetUid, locale, version: asset._version });
408452
if (workingBatch.length >= BATCH_SIZE) {
@@ -415,6 +459,9 @@ export default class BulkAssets extends BaseBulkCommand {
415459

416460
flushBatch();
417461

462+
// One write for the whole streamed run.
463+
appendPendingScanLog(pendingScanEntries, this.bulkOperationConfig.bulkOperationFolder);
464+
418465
// Invariant: pass 1 and pass 2 use identical counting logic (excluding scan status filtering).
419466
// If batchNumber < totalBatches, scan status filtering reduced the published count — expected.
420467
if (batchNumber !== totalBatches) {
@@ -464,4 +511,132 @@ export default class BulkAssets extends BaseBulkCommand {
464511
protected async fetchItems(): Promise<any[]> {
465512
return await fetchAssets(this.bulkOperationConfig, this.managementStack, this.deliveryStack, this.logger);
466513
}
514+
515+
protected async handleResourceSpecificRetryFlow(flags: any): Promise<boolean> {
516+
if (!flags['retry-pending']) {
517+
return false;
518+
}
519+
await this.initForRetryPendingScan(flags);
520+
return true;
521+
}
522+
523+
/**
524+
* Initialize the --retry-pending run. Mirrors initForRevertOrRetry: everything
525+
* needed comes from the pending-scan log, with any CLI flag overriding it.
526+
*/
527+
private async initForRetryPendingScan(flags: any): Promise<void> {
528+
const logPath = flags['retry-pending'];
529+
const pendingEntries = readPendingScanLog(logPath);
530+
531+
if (pendingEntries.length === 0) {
532+
log.warn($t(messages.NO_PENDING_SCAN_ITEMS_IN_LOG, { path: getLogPaths(logPath).pendingScan }));
533+
this.finalizeProgressSummary();
534+
process.exit(0);
535+
}
536+
537+
const [first] = pendingEntries;
538+
const mergedFlags = {
539+
...flags,
540+
'stack-api-key': flags['stack-api-key'] || first.apiKey,
541+
environments:
542+
flags.environments?.length > 0
543+
? flags.environments
544+
: [...new Set(pendingEntries.flatMap((entry) => entry.environments))],
545+
locales: flags.locales?.length > 0 ? flags.locales : [...new Set(pendingEntries.map((entry) => entry.locale))],
546+
branch: flags.branch !== 'main' ? flags.branch : first.branch || 'main',
547+
// Scan status only ever gates publish.
548+
operation: OperationType.PUBLISH,
549+
'publish-mode': flags['publish-mode'] || 'bulk',
550+
};
551+
552+
this.parsedFlags = mergedFlags;
553+
await this.buildConfiguration(mergedFlags);
554+
await this.setupStack();
555+
await this.initializeComponents();
556+
557+
await this.retryPendingScan(logPath, pendingEntries);
558+
559+
// Mirrors initForRevertOrRetry: this early exit bypasses finally().
560+
this.finalizeProgressSummary();
561+
process.exit(0);
562+
}
563+
564+
/**
565+
* Stamp a pending-scan skip with the run metadata --retry-pending needs to
566+
* rebuild the publish call later without re-fetching the asset.
567+
*/
568+
private buildPendingScanEntry(item: {
569+
uid: string;
570+
locale: string;
571+
version?: number;
572+
environments: string[];
573+
}): PendingScanLogEntry {
574+
return {
575+
...item,
576+
operation: 'publish',
577+
timestamp: new Date().toISOString(),
578+
apiKey: this.bulkOperationConfig.apiKey || this.bulkOperationConfig.stackApiKey || '',
579+
branch: this.bulkOperationConfig.branch,
580+
};
581+
}
582+
583+
/**
584+
* Re-check the scan status of assets a previous run skipped as still scanning,
585+
* publish the ones now clean, and prune the log down to those still pending.
586+
*/
587+
private async retryPendingScan(logPath: string, pendingEntries: PendingScanLogEntry[]): Promise<void> {
588+
const uids = [...new Set(pendingEntries.map((entry) => entry.uid))];
589+
const assetService = new AssetService(this.managementStack, this.deliveryStack, this.logger);
590+
const scanStatusMap = await assetService.fetchScanStatusByUIDs(uids);
591+
592+
const nowClean: PendingScanLogEntry[] = [];
593+
const stillPending: PendingScanLogEntry[] = [];
594+
const nowQuarantined: PendingScanLogEntry[] = [];
595+
596+
for (const entry of pendingEntries) {
597+
const status = scanStatusMap.get(entry.uid);
598+
if (status === 'pending') stillPending.push(entry);
599+
else if (status === 'quarantined') nowQuarantined.push(entry);
600+
else nowClean.push(entry); // clean, or undefined when scanning is disabled
601+
}
602+
603+
log.info(
604+
$t(messages.SCAN_RECHECK_SUMMARY, {
605+
total: pendingEntries.length,
606+
clean: nowClean.length,
607+
pending: stillPending.length,
608+
quarantined: nowQuarantined.length,
609+
})
610+
);
611+
nowQuarantined.forEach((entry) =>
612+
this.logger.warn($t(messages.SCAN_STATUS_SKIPPED_QUARANTINED, { uid: entry.uid }))
613+
);
614+
615+
if (nowClean.length === 0) {
616+
this.logger.warn($t(messages.NO_PUBLISHABLE_ASSETS));
617+
// Still prune: the quarantined ones will never become publishable.
618+
writePendingScanLog(stillPending, logPath);
619+
return;
620+
}
621+
622+
const items: AssetPublishData[] = nowClean.map((entry) => ({
623+
type: 'asset',
624+
uid: entry.uid,
625+
locale: entry.locale,
626+
version: entry.version,
627+
publish_details: entry.environments.map((environment) => ({ environment, locale: entry.locale })),
628+
}));
629+
630+
const confirmed = await this.confirmOperation(items);
631+
if (!confirmed) {
632+
this.logger.warn($t(messages.OPERATION_CANCELLED));
633+
// Leave the log untouched so the next run sees the same set.
634+
return;
635+
}
636+
637+
const result = await this.executeBulkOperation(items);
638+
this.printOperationSummary(result);
639+
640+
writePendingScanLog(stillPending, logPath);
641+
}
467642
}

packages/contentstack-bulk-operations/src/interfaces/index.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,25 @@ export interface SingleModeLogEntry {
400400
*/
401401
export type LogEntry = BulkModeLogEntry | SingleModeLogEntry;
402402

403+
/**
404+
* Log entry for an asset skipped because its malware scan was still pending.
405+
*
406+
* Flat (no bulk/single split) — the skip happens before the publish mode matters.
407+
* Self-sufficient by design: a later --retry-pending run rebuilds the publish
408+
* payload straight from this record without re-fetching the asset.
409+
*/
410+
export interface PendingScanLogEntry {
411+
uid: string;
412+
locale: string;
413+
version?: number;
414+
environments: string[];
415+
operation: 'publish'; // scan status only gates publish
416+
timestamp: string;
417+
// Metadata
418+
apiKey: string;
419+
branch?: string;
420+
}
421+
403422
export interface LogPaths {
404423
folder: string;
405424
// Bulk mode logs
@@ -408,6 +427,8 @@ export interface LogPaths {
408427
// Single mode logs
409428
singleSuccess: string;
410429
singleFailed: string;
430+
// Assets skipped for a pending scan, retried via --retry-pending
431+
pendingScan: string;
411432
}
412433

413434
/**

packages/contentstack-bulk-operations/src/messages/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,10 @@ const bulkAssetsMsg = {
231231
SCAN_STATUS_SKIPPED_QUARANTINED: 'Skipped (quarantined): {uid}',
232232
SCAN_STATUS_FETCHING: 'Checking asset scan status for {count} assets...',
233233
NO_PUBLISHABLE_ASSETS: 'No publishable assets — all assets are either still scanning or quarantined.',
234+
SCAN_RECHECK_SUMMARY:
235+
'Re-checked {total} pending asset(s): {clean} now clean (will publish), {pending} still scanning, {quarantined} now quarantined (skipped permanently).',
236+
NO_PENDING_SCAN_ITEMS_IN_LOG:
237+
'No pending-scan assets found in {path} — nothing to retry. Assets are recorded there when a publish run skips them for a scan still in progress.',
234238
};
235239

236240
/**
@@ -423,6 +427,8 @@ const flagDescriptions = {
423427
YES: 'Skips interactive confirmation prompts and runs the command immediately using the provided options. Useful for automation and scripts.',
424428
RETRY_FAILED:
425429
'(optional) Use this option to retry publishing the failed entries/assets from the logfile. Specify the name of the logfile that lists failed publish calls. If this option is used, it will override all other flags.',
430+
RETRY_PENDING:
431+
'(optional) Re-checks the malware scan status of assets a previous run skipped because scanning was still in progress, and publishes the ones that are now clean. Specify the bulk operation folder used by that run. Quarantined assets are never retried.',
426432

427433
// Entry-specific flags
428434
CONTENT_TYPES: 'Content type UIDs to perform operation on. If not provided, operates on all content types.',

0 commit comments

Comments
 (0)