From 771dc505ee24d5ea37883749ae298c27f1d6b337 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Tue, 3 Nov 2026 14:25:19 +0100 Subject: [PATCH] bugfix-844-branch-sync-hides-missing-workflow-write-capability-and-aborts-after-committing-its-merge: reconcile committed publication failures --- build/api/index.js | 67 ++++ .../api/src/data/model/application_error.d.ts | 5 +- .../src/domain/branch_sync_publication.d.ts | 18 + build/cli/index.js | 298 +++++++++++++- build/deployment_recovery/index.js | 124 ++++++ build/github_action/index.js | 298 +++++++++++++- docs/issues/branch-synchronization.mdx | 31 +- .../operations/troubleshooting.mdx | 40 ++ quality/branch-sync/evidence/issue-844.json | 167 ++++++++ scripts/coverage-budgets.json | 17 + specs/CATALOG.md | 10 +- specs/branch-sync-publication-recovery.md | 363 ++++++++++++++++++ ...h-synchronization-and-conflict-recovery.md | 2 +- specs/catalog.json | 17 +- ...lication_error_presentation_policy.test.ts | 6 + ...anch_sync_publication_presentation.test.ts | 41 ++ .../application_error_message_catalog.ts | 6 + .../application_error_presentation_policy.ts | 10 + .../branch_sync_publication_messages.ts | 39 ++ src/application/ports/branch_sync_ports.ts | 2 + .../__tests__/sync_branch_use_case.test.ts | 43 +++ .../branch_sync_execution_policy.ts | 17 +- .../branch_sync/sync_branch_use_case.ts | 28 +- src/data/model/application_error.ts | 13 + .../__tests__/branch_sync_publication.test.ts | 57 +++ src/domain/branch_sync_publication.ts | 43 +++ src/domain/git_push_failure.ts | 13 + .../branch_sync_workspace_adapter.test.ts | 78 +++- .../__tests__/git_push_diagnostic.test.ts | 51 +++ .../branch_sync_workspace_adapter.ts | 36 +- src/infrastructure/git_commit_adapter.ts | 29 +- src/infrastructure/git_push_diagnostic.ts | 18 + ...ranch_sync_publication_integration.test.ts | 23 ++ .../branch_sync_publication_fixture.ts | 67 ++++ 34 files changed, 1999 insertions(+), 78 deletions(-) create mode 100644 build/api/src/domain/branch_sync_publication.d.ts create mode 100644 quality/branch-sync/evidence/issue-844.json create mode 100644 specs/branch-sync-publication-recovery.md create mode 100644 src/application/policies/__tests__/branch_sync_publication_presentation.test.ts create mode 100644 src/application/policies/branch_sync_publication_messages.ts create mode 100644 src/domain/__tests__/branch_sync_publication.test.ts create mode 100644 src/domain/branch_sync_publication.ts create mode 100644 src/domain/git_push_failure.ts create mode 100644 src/infrastructure/__tests__/git_push_diagnostic.test.ts create mode 100644 src/infrastructure/git_push_diagnostic.ts create mode 100644 src/tooling/__tests__/branch_sync_publication_integration.test.ts create mode 100644 src/tooling/__tests__/fixtures/branch_sync_publication_fixture.ts diff --git a/build/api/index.js b/build/api/index.js index af9613e35..7eb5b7ee9 100644 --- a/build/api/index.js +++ b/build/api/index.js @@ -7038,6 +7038,7 @@ var _ApplicationError_cause; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.ApplicationError = exports.APPLICATION_ERROR_METADATA = exports.APPLICATION_ERROR_RECOVERY_IDS = void 0; exports.isApplicationErrorCorrelationId = isApplicationErrorCorrelationId; +const branch_sync_publication_1 = __nccwpck_require__(7857); const framed_review_1 = __nccwpck_require__(3750); const progress_response_diagnostic_1 = __nccwpck_require__(6289); const progress_completion_diagnostic_1 = __nccwpck_require__(5095); @@ -7068,6 +7069,8 @@ exports.APPLICATION_ERROR_RECOVERY_IDS = Object.freeze([ 'branch-sync-resolution-invalid', 'branch-sync-verification-failed', 'branch-sync-catalog-incompatible', + 'branch-sync-workflow-write-required', + 'branch-sync-publication-failed', 'sdd-draft-size-exceeded', 'sdd-catalog-unsupported-profile', 'sdd-catalog-format-invalid', @@ -7322,6 +7325,8 @@ const RECOVERY_VARIABLE_KEYS = Object.freeze({ 'branch-sync-resolution-invalid': Object.freeze(['validationCode', 'command']), 'branch-sync-verification-failed': Object.freeze(['stage', 'reason', 'command', 'commandIndex', 'childSha', 'restoration']), 'branch-sync-catalog-incompatible': Object.freeze(['phase', 'profile', 'field', 'capabilityNumber', 'abort']), + 'branch-sync-workflow-write-required': Object.freeze(['reason', 'remoteState', 'originalSha', 'candidateSha', 'observedSha', 'pushAttempted']), + 'branch-sync-publication-failed': Object.freeze(['reason', 'remoteState', 'originalSha', 'candidateSha', 'observedSha', 'pushAttempted']), 'sdd-draft-size-exceeded': Object.freeze([]), 'sdd-catalog-unsupported-profile': Object.freeze([]), 'sdd-catalog-format-invalid': Object.freeze([]), @@ -7401,6 +7406,12 @@ function normalizeApplicationErrorRecovery(recovery) { if (recovery.id === 'branch-sync-verification-failed' && !(0, verification_failure_1.isBranchSyncVerificationFailure)(variables)) { throw new TypeError('Branch synchronization verification diagnostic is invalid.'); } + if (recovery.id === 'branch-sync-workflow-write-required' || recovery.id === 'branch-sync-publication-failed') { + if (!(0, branch_sync_publication_1.isBranchSyncPublicationDiagnostic)(variables) || variables.remoteState === 'published' + || (recovery.id === 'branch-sync-workflow-write-required') !== (variables.reason === 'workflow-write-required')) { + throw new TypeError('Branch synchronization publication diagnostic is invalid.'); + } + } if (recovery.id === 'branch-sync-catalog-incompatible') { const { abort, ...diagnostic } = variables; if (!(0, branch_sync_catalog_diagnostic_1.isBranchSyncCatalogDiagnostic)(diagnostic) || !['completed', 'unverified'].includes(String(abort))) { @@ -7726,6 +7737,62 @@ function classifyBranchSyncCatalog(value) { } +/***/ }), + +/***/ 7857: +/***/ ((__unused_webpack_module, exports) => { + + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.BranchSyncPublicationFailure = exports.BRANCH_SYNC_REMOTE_STATES = exports.BRANCH_SYNC_PUBLICATION_REASONS = void 0; +exports.isBranchSyncObjectId = isBranchSyncObjectId; +exports.admittedBranchSyncObjectId = admittedBranchSyncObjectId; +exports.isBranchSyncPublicationDiagnostic = isBranchSyncPublicationDiagnostic; +exports.BRANCH_SYNC_PUBLICATION_REASONS = Object.freeze(['workflow-write-required', 'push-failed', 'candidate-unverified']); +exports.BRANCH_SYNC_REMOTE_STATES = Object.freeze(['unchanged', 'published', 'changed', 'unverified']); +function isBranchSyncObjectId(value) { + return typeof value === 'string' && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u.test(value) && !/^0+$/u.test(value); +} +function admittedBranchSyncObjectId(value) { + return isBranchSyncObjectId(value) ? value : 'unavailable'; +} +function isBranchSyncPublicationDiagnostic(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) + return false; + const d = value; + if (Object.keys(d).sort().join(',') !== 'candidateSha,observedSha,originalSha,pushAttempted,reason,remoteState' + || !exports.BRANCH_SYNC_PUBLICATION_REASONS.includes(d.reason) || !exports.BRANCH_SYNC_REMOTE_STATES.includes(d.remoteState) + || typeof d.pushAttempted !== 'boolean' + || ![d.originalSha, d.candidateSha, d.observedSha].every(x => x === 'unavailable' || isBranchSyncObjectId(x))) + return false; + if (d.reason === 'candidate-unverified' && (d.pushAttempted || d.candidateSha !== 'unavailable')) + return false; + if (d.reason !== 'candidate-unverified' && (!d.pushAttempted || !isBranchSyncObjectId(d.candidateSha) || d.candidateSha === d.originalSha)) + return false; + if (d.remoteState === 'published') + return isBranchSyncObjectId(d.candidateSha) && d.observedSha === d.candidateSha; + if (d.remoteState === 'unchanged') + return isBranchSyncObjectId(d.originalSha) && d.observedSha === d.originalSha && d.observedSha !== d.candidateSha; + if (d.remoteState === 'changed') + return isBranchSyncObjectId(d.originalSha) && isBranchSyncObjectId(d.observedSha) + && d.observedSha !== d.originalSha && d.observedSha !== d.candidateSha; + return (d.observedSha === 'unavailable' || d.originalSha === 'unavailable') + && !(isBranchSyncObjectId(d.candidateSha) && d.observedSha === d.candidateSha); +} +/** A committed candidate is no longer an in-progress Git merge. */ +class BranchSyncPublicationFailure extends Error { + constructor(diagnostic) { + super('Branch synchronization candidate publication requires reconciliation.'); + if (!isBranchSyncPublicationDiagnostic(diagnostic)) + throw new TypeError('Invalid branch synchronization publication facts.'); + this.name = 'BranchSyncPublicationFailure'; + this.diagnostic = Object.freeze({ ...diagnostic }); + Object.freeze(this); + } +} +exports.BranchSyncPublicationFailure = BranchSyncPublicationFailure; + + /***/ }), /***/ 981: diff --git a/build/api/src/data/model/application_error.d.ts b/build/api/src/data/model/application_error.d.ts index 01864794a..3ce47d45c 100644 --- a/build/api/src/data/model/application_error.d.ts +++ b/build/api/src/data/model/application_error.d.ts @@ -1,3 +1,4 @@ +import { type BranchSyncPublicationDiagnostic } from '../../domain/branch_sync_publication'; import { type BugbotReviewBlockFacts } from '../../domain/bugbot/framed_review'; import { type ProgressScopeCapacityFacts } from '../../domain/progress_response_diagnostic'; import { type ProgressCompletionDiagnostic } from '../../domain/progress_completion_diagnostic'; @@ -17,7 +18,7 @@ import { type BranchSyncCatalogDiagnostic } from '../../domain/branch_sync_catal import { type SddSourceContextDiagnostic } from '../../domain/agent_source_snapshot'; export type ApplicationErrorKind = 'configuration' | 'authorization' | 'provider' | 'agent' | 'validation' | 'workflow' | 'unknown'; export type ApplicationErrorCode = 'configuration.invalid' | 'configuration.unsupported' | 'authorization.denied' | 'authorization.credential-invalid' | 'provider.not-found' | 'provider.conflict' | 'provider.rate-limited' | 'provider.unavailable' | 'provider.contract-invalid' | 'agent.policy-rejected' | 'agent.failed' | 'locale.output-invalid' | 'locale.translation-failed' | 'validation.invalid-input' | 'validation.evidence-too-large' | 'validation.evidence-unavailable' | 'workflow.invalid-event' | 'workflow.stale' | 'workflow.cancelled' | 'workflow.failed' | 'workflow.presentation-pending' | 'timeout' | 'unexpected'; -export declare const APPLICATION_ERROR_RECOVERY_IDS: readonly ["comment-command-length", "comment-command-name-missing", "comment-command-name-unknown", "comment-command-argument-count", "comment-command-arguments-unexpected", "comment-command-arguments-missing", "comment-command-finding-quoting", "comment-command-review-format", "comment-command-review-effort", "comment-command-review-boolean", "comment-command-review-option", "comment-command-sync-option", "comment-command-sync-parent", "pull-request-link-restored", "pull-request-link-base-retained", "pull-request-link-reference-retained", "pull-request-link-base-and-reference-retained", "managed-branch-enrichment-failed", "inactivity-explanation-failed", "bugbot-review-blocks-pending", "branch-sync-agent-disabled", "branch-sync-fixer-unavailable", "branch-sync-resolution-invalid", "branch-sync-verification-failed", "branch-sync-catalog-incompatible", "sdd-draft-size-exceeded", "sdd-catalog-unsupported-profile", "sdd-catalog-format-invalid", "sdd-catalog-missing-trusted-source", "sdd-source-context-unavailable", "sdd-draft-format-invalid", "sdd-draft-case-identity-invalid", "sdd-draft-case-budget-invalid", "sdd-draft-requirement-mapping-invalid", "sdd-draft-repository-path-invalid", "deployment-publication-context-missing", "workspace-changes-rejected", "closed-issue-reconciliation-failed", "approval-evidence-read-failed", "change-size-reconciliation-failed", "issue-workflow-admission-blocked", "progress-binary-evidence-unavailable", "progress-inventory-incomplete", "bugbot-review-incomplete", "progress-result-contract-invalid", "progress-partition-length-invalid", "progress-assessment-capacity-exceeded", "progress-scope-clause-limit", "progress-completion-recovery-exhausted", "stored-configuration-schema-missing", "stored-configuration-schema-unsupported", "stored-configuration-invalid"]; +export declare const APPLICATION_ERROR_RECOVERY_IDS: readonly ["comment-command-length", "comment-command-name-missing", "comment-command-name-unknown", "comment-command-argument-count", "comment-command-arguments-unexpected", "comment-command-arguments-missing", "comment-command-finding-quoting", "comment-command-review-format", "comment-command-review-effort", "comment-command-review-boolean", "comment-command-review-option", "comment-command-sync-option", "comment-command-sync-parent", "pull-request-link-restored", "pull-request-link-base-retained", "pull-request-link-reference-retained", "pull-request-link-base-and-reference-retained", "managed-branch-enrichment-failed", "inactivity-explanation-failed", "bugbot-review-blocks-pending", "branch-sync-agent-disabled", "branch-sync-fixer-unavailable", "branch-sync-resolution-invalid", "branch-sync-verification-failed", "branch-sync-catalog-incompatible", "branch-sync-workflow-write-required", "branch-sync-publication-failed", "sdd-draft-size-exceeded", "sdd-catalog-unsupported-profile", "sdd-catalog-format-invalid", "sdd-catalog-missing-trusted-source", "sdd-source-context-unavailable", "sdd-draft-format-invalid", "sdd-draft-case-identity-invalid", "sdd-draft-case-budget-invalid", "sdd-draft-requirement-mapping-invalid", "sdd-draft-repository-path-invalid", "deployment-publication-context-missing", "workspace-changes-rejected", "closed-issue-reconciliation-failed", "approval-evidence-read-failed", "change-size-reconciliation-failed", "issue-workflow-admission-blocked", "progress-binary-evidence-unavailable", "progress-inventory-incomplete", "bugbot-review-incomplete", "progress-result-contract-invalid", "progress-partition-length-invalid", "progress-assessment-capacity-exceeded", "progress-scope-clause-limit", "progress-completion-recovery-exhausted", "stored-configuration-schema-missing", "stored-configuration-schema-unsupported", "stored-configuration-invalid"]; export type ApplicationErrorRecoveryId = typeof APPLICATION_ERROR_RECOVERY_IDS[number]; interface ApplicationErrorRecoveryVariables extends Readonly; + readonly 'branch-sync-workflow-write-required': BranchSyncPublicationDiagnostic; + readonly 'branch-sync-publication-failed': BranchSyncPublicationDiagnostic; readonly 'sdd-draft-size-exceeded': Readonly>; readonly 'sdd-catalog-unsupported-profile': Readonly>; readonly 'sdd-catalog-format-invalid': Readonly>; diff --git a/build/api/src/domain/branch_sync_publication.d.ts b/build/api/src/domain/branch_sync_publication.d.ts new file mode 100644 index 000000000..086a65cdf --- /dev/null +++ b/build/api/src/domain/branch_sync_publication.d.ts @@ -0,0 +1,18 @@ +export declare const BRANCH_SYNC_PUBLICATION_REASONS: readonly ["workflow-write-required", "push-failed", "candidate-unverified"]; +export declare const BRANCH_SYNC_REMOTE_STATES: readonly ["unchanged", "published", "changed", "unverified"]; +export interface BranchSyncPublicationDiagnostic { + readonly reason: typeof BRANCH_SYNC_PUBLICATION_REASONS[number]; + readonly remoteState: typeof BRANCH_SYNC_REMOTE_STATES[number]; + readonly originalSha: string; + readonly candidateSha: string; + readonly observedSha: string; + readonly pushAttempted: boolean; +} +export declare function isBranchSyncObjectId(value: unknown): value is string; +export declare function admittedBranchSyncObjectId(value: unknown): string; +export declare function isBranchSyncPublicationDiagnostic(value: unknown): value is BranchSyncPublicationDiagnostic; +/** A committed candidate is no longer an in-progress Git merge. */ +export declare class BranchSyncPublicationFailure extends Error { + readonly diagnostic: BranchSyncPublicationDiagnostic; + constructor(diagnostic: BranchSyncPublicationDiagnostic); +} diff --git a/build/cli/index.js b/build/cli/index.js index 4c7bda080..834b725c5 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -39650,6 +39650,7 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.readEnglishApplicationErrorMessage = exports.APPLICATION_ERROR_CATALOG_DEFINITIONS = exports.SPANISH_APPLICATION_ERROR_DEFINITION = exports.ENGLISH_APPLICATION_ERROR_DEFINITION = exports.SPANISH_APPLICATION_ERROR_MESSAGES = exports.ENGLISH_APPLICATION_ERROR_MESSAGES = exports.APPLICATION_ERROR_MESSAGE_IDS = exports.APPLICATION_ERROR_CODES = void 0; exports.resolveStaticApplicationErrorCatalog = resolveStaticApplicationErrorCatalog; exports.resolveApplicationErrorCatalog = resolveApplicationErrorCatalog; +const branch_sync_publication_messages_1 = __nccwpck_require__(3948); const bugbot_review_diagnostic_labels_1 = __nccwpck_require__(20351); const comment_command_validation_message_catalog_1 = __nccwpck_require__(62002); const application_error_1 = __nccwpck_require__(97790); @@ -39657,6 +39658,7 @@ const message_catalog_1 = __nccwpck_require__(27097); const resolved_message_catalog_policy_1 = __nccwpck_require__(55069); const LABEL_KEYS = Object.freeze([ ...bugbot_review_diagnostic_labels_1.BUGBOT_REVIEW_DIAGNOSTIC_LABEL_KEYS, + ...branch_sync_publication_messages_1.BRANCH_SYNC_PUBLICATION_LABEL_KEYS, 'impact', 'errorCode', 'action', 'retainedState', 'retryable', 'yes', 'no', 'reference', ]); const CONTENT_FIELDS = Object.freeze(['impact', 'action', 'retainedState']); @@ -39669,6 +39671,7 @@ exports.APPLICATION_ERROR_MESSAGE_IDS = Object.freeze([ ]); const ENGLISH_LABELS = Object.freeze({ ...bugbot_review_diagnostic_labels_1.ENGLISH_REVIEW_DIAGNOSTIC_LABELS, + ...branch_sync_publication_messages_1.ENGLISH_BRANCH_SYNC_PUBLICATION_LABELS, impact: 'Impact', errorCode: 'Error code', action: 'Action', @@ -39680,6 +39683,7 @@ const ENGLISH_LABELS = Object.freeze({ }); const SPANISH_LABELS = Object.freeze({ ...bugbot_review_diagnostic_labels_1.SPANISH_REVIEW_DIAGNOSTIC_LABELS, + ...branch_sync_publication_messages_1.SPANISH_BRANCH_SYNC_PUBLICATION_LABELS, impact: 'Impacto', errorCode: 'Código de error', action: 'Acción', @@ -39819,6 +39823,7 @@ const STORED_CONFIGURATION_RETAINED_EN = 'The issue/PR description and stored co const STORED_CONFIGURATION_RETAINED_ES = 'Esta lectura no modificó la descripción de la issue/PR ni su configuración almacenada.'; const ENGLISH_RECOVERY_CONTENT = Object.freeze({ ...comment_command_validation_message_catalog_1.ENGLISH_COMMAND_VALIDATION_CONTENT, + ...branch_sync_publication_messages_1.ENGLISH_BRANCH_SYNC_PUBLICATION_CONTENT, 'stored-configuration-schema-missing': Object.freeze({ impact: 'Automation stopped for issue/PR #{issueNumber}: the stored configuration has no schema version; version {expectedVersion} is required.', action: STORED_CONFIGURATION_ACTION_EN, @@ -40017,6 +40022,7 @@ const ENGLISH_RECOVERY_CONTENT = Object.freeze({ }); const SPANISH_RECOVERY_CONTENT = Object.freeze({ ...comment_command_validation_message_catalog_1.SPANISH_COMMAND_VALIDATION_CONTENT, + ...branch_sync_publication_messages_1.SPANISH_BRANCH_SYNC_PUBLICATION_CONTENT, 'stored-configuration-schema-missing': Object.freeze({ impact: 'La automatización se detuvo para la issue/PR #{issueNumber}: la configuración almacenada no tiene versión de esquema; se requiere la versión {expectedVersion}.', action: STORED_CONFIGURATION_ACTION_ES, @@ -40276,6 +40282,7 @@ const application_error_message_catalog_1 = __nccwpck_require__(64809); // This trusted destination is inserted after localization, never supplied by a body or translation. const STORED_CONFIGURATION_RECOVERY_URL = 'https://docs.page/vypdev/copilot/security-operations/operations/troubleshooting#unsupported-stored-configuration'; const BRANCH_SYNC_CATALOG_RECOVERY_URL = 'https://docs.page/vypdev/copilot/security-operations/operations/troubleshooting#branch-sync-catalog-compatibility'; +const BRANCH_SYNC_PUBLICATION_RECOVERY_URL = 'https://docs.page/vypdev/copilot/security-operations/operations/troubleshooting#branch-sync-workflow-write-recovery'; const agent_source_snapshot_1 = __nccwpck_require__(59205); /** Shared semantic view model for terminal, GitHub, and API presentation. */ function buildApplicationErrorPresentation(error, message = application_error_message_catalog_1.readEnglishApplicationErrorMessage) { @@ -40300,11 +40307,20 @@ function buildApplicationErrorPresentation(error, message = application_error_me ? (0, comment_command_validation_presentation_policy_1.commandValidationPresentationVariables)(error.recovery.id, error.recovery.variables.command) : error.recovery?.id === 'branch-sync-verification-failed' ? { ...error.recovery.variables, syncCommand: '/copilot sync-branch' } - : error.recovery?.id === 'branch-sync-catalog-incompatible' - ? { ...error.recovery.variables, syncCommand: '/copilot sync-branch', catalogGuide: BRANCH_SYNC_CATALOG_RECOVERY_URL } - : error.recovery?.id.startsWith('stored-configuration-') - ? { ...error.recovery.variables, recoveryGuide: STORED_CONFIGURATION_RECOVERY_URL } - : error.recovery?.variables; + : error.recovery?.id === 'branch-sync-workflow-write-required' || error.recovery?.id === 'branch-sync-publication-failed' + ? { + reason: error.recovery.variables.reason, originalSha: error.recovery.variables.originalSha, + candidateSha: error.recovery.variables.candidateSha, observedSha: error.recovery.variables.observedSha, + pushStatus: message(error.recovery.variables.pushAttempted ? 'error.label.syncPushAttempted' : 'error.label.syncPushNotAttempted'), + remoteStatus: message(error.recovery.variables.remoteState === 'unchanged' ? 'error.label.syncRemoteUnchanged' + : error.recovery.variables.remoteState === 'changed' ? 'error.label.syncRemoteChanged' : 'error.label.syncRemoteUnverified'), + publicationGuide: BRANCH_SYNC_PUBLICATION_RECOVERY_URL, syncCommand: '/copilot sync-branch', + } + : error.recovery?.id === 'branch-sync-catalog-incompatible' + ? { ...error.recovery.variables, syncCommand: '/copilot sync-branch', catalogGuide: BRANCH_SYNC_CATALOG_RECOVERY_URL } + : error.recovery?.id.startsWith('stored-configuration-') + ? { ...error.recovery.variables, recoveryGuide: STORED_CONFIGURATION_RECOVERY_URL } + : error.recovery?.variables; return { impact: message(`${descriptor}.impact`, variables), code: error.code, @@ -40867,6 +40883,56 @@ ${messages.message('branchSync.relationship.history')}`; } +/***/ }), + +/***/ 3948: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.SPANISH_BRANCH_SYNC_PUBLICATION_CONTENT = exports.ENGLISH_BRANCH_SYNC_PUBLICATION_CONTENT = exports.SPANISH_BRANCH_SYNC_PUBLICATION_LABELS = exports.ENGLISH_BRANCH_SYNC_PUBLICATION_LABELS = exports.BRANCH_SYNC_PUBLICATION_LABEL_KEYS = void 0; +exports.BRANCH_SYNC_PUBLICATION_LABEL_KEYS = ['syncRemoteUnchanged', 'syncRemoteChanged', 'syncRemoteUnverified', 'syncPushAttempted', 'syncPushNotAttempted']; +exports.ENGLISH_BRANCH_SYNC_PUBLICATION_LABELS = { + syncRemoteUnchanged: 'The observed remote still points to the original head; the candidate was not observed as published.', + syncRemoteChanged: 'The remote has a different head; this observation does not establish whether it includes the candidate.', + syncRemoteUnverified: 'The remote head could not be established; publication remains unknown.', + syncPushAttempted: 'A normal push was attempted.', syncPushNotAttempted: 'No push was attempted.', +}; +exports.SPANISH_BRANCH_SYNC_PUBLICATION_LABELS = { + syncRemoteUnchanged: 'El remoto observado conserva el head original; no se observó la publicación del candidato.', + syncRemoteChanged: 'El remoto tiene otro head; esta observación no determina si incluye el candidato.', + syncRemoteUnverified: 'No se pudo establecer el head remoto; la publicación sigue sin verificarse.', + syncPushAttempted: 'Se intentó un push normal.', syncPushNotAttempted: 'No se intentó ningún push.', +}; +const retained = 'The local merge commit step finished; candidate identity: {candidateSha}. {pushStatus} {remoteStatus} Original head: {originalSha}; observed head: {observedSha}. Recovery did not reset or abort the committed workspace; runner-local storage is temporary.'; +const retainedSpanish = 'Terminó el paso de commit del merge local; identidad del candidato: {candidateSha}. {pushStatus} {remoteStatus} Head original: {originalSha}; head observado: {observedSha}. La recuperación no restableció ni abortó el espacio de trabajo confirmado; el almacenamiento del runner es temporal.'; +exports.ENGLISH_BRANCH_SYNC_PUBLICATION_CONTENT = { + 'branch-sync-workflow-write-required': { + impact: 'The remote rejected workflow-file publication by the configured publisher.', + action: 'A maintainer must review the publisher credential and grant the appropriate workflow-write capability through its normal configuration, then rerun {syncCommand} from the current managed PR. Do not retry unchanged credentials or bypass the managed branch. Recovery guide: {publicationGuide}.', + retainedState: retained, + }, + 'branch-sync-publication-failed': { + impact: 'Branch synchronization did not reach a verified remote publication ({reason}).', + action: 'Inspect the current native remote head and the retained candidate before a fresh Action-managed synchronization. An unavailable observation is not proof that nothing was published. Recovery guide: {publicationGuide}.', + retainedState: retained, + }, +}; +exports.SPANISH_BRANCH_SYNC_PUBLICATION_CONTENT = { + 'branch-sync-workflow-write-required': { + impact: 'El remoto rechazó la publicación de archivos de workflow mediante la credencial configurada.', + action: 'Un mantenedor debe revisar la credencial y conceder la capacidad de escritura de workflows adecuada mediante su configuración habitual; después, debe ejecutar {syncCommand} desde la PR gestionada actual. No repitas la misma credencial ni eludas la rama gestionada. Guía: {publicationGuide}.', + retainedState: retainedSpanish, + }, + 'branch-sync-publication-failed': { + impact: 'La sincronización no alcanzó una publicación remota verificada ({reason}).', + action: 'Inspecciona el head remoto nativo actual y el candidato conservado antes de una nueva sincronización gestionada por la Action. No disponer de una observación no demuestra que no se publicara nada. Guía: {publicationGuide}.', + retainedState: retainedSpanish, + }, +}; + + /***/ }), /***/ 14791: @@ -61410,6 +61476,7 @@ exports.branchSyncConflictEligibilityFailure = branchSyncConflictEligibilityFail exports.completedBranchSyncResult = completedBranchSyncResult; exports.unavailableBranchSyncResult = unavailableBranchSyncResult; exports.failedBranchSyncResult = failedBranchSyncResult; +exports.failedBranchSyncPublicationResult = failedBranchSyncPublicationResult; const agent_1 = __nccwpck_require__(79937); const result_1 = __nccwpck_require__(73817); const workspace_changes_1 = __nccwpck_require__(93370); @@ -61444,6 +61511,7 @@ function completedBranchSyncResult(input) { "dry-run-conflicted": `Dry run complete: the merge has ${preparation.kind === "conflicted" ? preparation.conflictPaths.length : 0} conflict(s). Nothing was pushed and no agent was invoked.`, "merged-cleanly": `Merged \`${parentBranch}\` into \`${workingBranch}\` cleanly and pushed the result.`, "merged-with-agent": `Merged \`${parentBranch}\` into \`${workingBranch}\`, used the fixer agent to resolve conflicts, verified the workspace, and pushed the result.`, + "merged-verified-remotely": `The merge candidate \`${input.commitSha}\` was independently confirmed on remote branch \`${workingBranch}\` after a failed push response. No second push was attempted.`, }; return new result_1.Result({ id: exports.BRANCH_SYNC_TASK_ID, @@ -61481,6 +61549,16 @@ function failedBranchSyncResult(reason, cause, recovery) { : (0, application_error_1.toApplicationError)(cause, 'workflow.failed', reason, { recovery })], }); } +/** Publication facts come only from the trusted workspace's bounded remote observation. */ +function failedBranchSyncPublicationResult(diagnostic) { + const workflow = diagnostic.reason === 'workflow-write-required'; + const error = new application_error_1.ApplicationError(workflow ? 'authorization.denied' : 'provider.unavailable', 'Branch synchronization candidate publication requires maintainer review.', { + retryable: false, + recovery: { id: workflow ? 'branch-sync-workflow-write-required' : 'branch-sync-publication-failed', variables: diagnostic }, + }); + return new result_1.Result({ id: exports.BRANCH_SYNC_TASK_ID, success: false, executed: true, + steps: ['The local merge commit step finished; inspect the publication facts before retrying.'], errors: [error] }); +} /***/ }), @@ -61492,12 +61570,14 @@ function failedBranchSyncResult(reason, cause, recovery) { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.SyncBranchUseCase = void 0; +const branch_sync_publication_1 = __nccwpck_require__(67857); const branch_sync_validation_1 = __nccwpck_require__(90981); const branch_sync_conflicts_1 = __nccwpck_require__(84434); const logging_ports_1 = __nccwpck_require__(6152); const verify_command_policy_1 = __nccwpck_require__(96031); const verify_command_runner_1 = __nccwpck_require__(57742); const branch_sync_execution_policy_1 = __nccwpck_require__(82113); +const application_error_1 = __nccwpck_require__(75999); const branch_sync_catalog_diagnostic_1 = __nccwpck_require__(15677); /** Performs a race-safe parent-to-child merge and invokes the fixer only for eligible conflicts. */ class SyncBranchUseCase { @@ -61531,7 +61611,7 @@ class SyncBranchUseCase { id: 'branch-sync-catalog-incompatible', variables: { ...catalogFailure, abort: aborted ? 'completed' : 'unverified' }, })]; } - return [(0, branch_sync_execution_policy_1.failedBranchSyncResult)("Branch synchronization failed safely; no push was completed.", cause)]; + return [(0, branch_sync_execution_policy_1.failedBranchSyncResult)("Branch synchronization completion could not be verified. Inspect the current remote state before retrying.", cause)]; } } async synchronize(context, options, target, parentBranch) { @@ -61563,7 +61643,22 @@ class SyncBranchUseCase { if (!remoteValidation.valid) { return [await this.abortFailure(remoteValidation.reason ?? "A branch changed while synchronization was running; retry from the latest heads.", validationRecovery(remoteValidation.code))]; } - const commitSha = await this.workspace.commitAndPush(target.workingBranch, `Merge ${parentBranch} into ${target.workingBranch}`, author); + let commitSha; + try { + commitSha = await this.workspace.commitAndPush(target.workingBranch, `Merge ${parentBranch} into ${target.workingBranch}`, author); + } + catch (cause) { + if (cause instanceof branch_sync_publication_1.BranchSyncPublicationFailure && (0, branch_sync_publication_1.isBranchSyncPublicationDiagnostic)(cause.diagnostic)) { + if (cause.diagnostic.originalSha !== preparation.childSha) { + throw new application_error_1.ApplicationError('provider.contract-invalid', 'Publication facts do not match the prepared branch head.'); + } + if (cause.diagnostic.remoteState === 'published') { + return [this.completed(preparation, parentBranch, target, 'merged-verified-remotely', verification.commandCount, cause.diagnostic.candidateSha)]; + } + return [(0, branch_sync_execution_policy_1.failedBranchSyncPublicationResult)(cause.diagnostic)]; + } + throw cause; + } const outcome = conflictResolution.agentUsed ? "merged-with-agent" : "merged-cleanly"; return [this.completed(preparation, parentBranch, target, outcome, verification.commandCount, commitSha)]; } @@ -83272,6 +83367,7 @@ var _ApplicationError_cause; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.ApplicationError = exports.APPLICATION_ERROR_METADATA = exports.APPLICATION_ERROR_RECOVERY_IDS = void 0; exports.isApplicationErrorCorrelationId = isApplicationErrorCorrelationId; +const branch_sync_publication_1 = __nccwpck_require__(67857); const framed_review_1 = __nccwpck_require__(43750); const progress_response_diagnostic_1 = __nccwpck_require__(56289); const progress_completion_diagnostic_1 = __nccwpck_require__(75095); @@ -83302,6 +83398,8 @@ exports.APPLICATION_ERROR_RECOVERY_IDS = Object.freeze([ 'branch-sync-resolution-invalid', 'branch-sync-verification-failed', 'branch-sync-catalog-incompatible', + 'branch-sync-workflow-write-required', + 'branch-sync-publication-failed', 'sdd-draft-size-exceeded', 'sdd-catalog-unsupported-profile', 'sdd-catalog-format-invalid', @@ -83556,6 +83654,8 @@ const RECOVERY_VARIABLE_KEYS = Object.freeze({ 'branch-sync-resolution-invalid': Object.freeze(['validationCode', 'command']), 'branch-sync-verification-failed': Object.freeze(['stage', 'reason', 'command', 'commandIndex', 'childSha', 'restoration']), 'branch-sync-catalog-incompatible': Object.freeze(['phase', 'profile', 'field', 'capabilityNumber', 'abort']), + 'branch-sync-workflow-write-required': Object.freeze(['reason', 'remoteState', 'originalSha', 'candidateSha', 'observedSha', 'pushAttempted']), + 'branch-sync-publication-failed': Object.freeze(['reason', 'remoteState', 'originalSha', 'candidateSha', 'observedSha', 'pushAttempted']), 'sdd-draft-size-exceeded': Object.freeze([]), 'sdd-catalog-unsupported-profile': Object.freeze([]), 'sdd-catalog-format-invalid': Object.freeze([]), @@ -83635,6 +83735,12 @@ function normalizeApplicationErrorRecovery(recovery) { if (recovery.id === 'branch-sync-verification-failed' && !(0, verification_failure_1.isBranchSyncVerificationFailure)(variables)) { throw new TypeError('Branch synchronization verification diagnostic is invalid.'); } + if (recovery.id === 'branch-sync-workflow-write-required' || recovery.id === 'branch-sync-publication-failed') { + if (!(0, branch_sync_publication_1.isBranchSyncPublicationDiagnostic)(variables) || variables.remoteState === 'published' + || (recovery.id === 'branch-sync-workflow-write-required') !== (variables.reason === 'workflow-write-required')) { + throw new TypeError('Branch synchronization publication diagnostic is invalid.'); + } + } if (recovery.id === 'branch-sync-catalog-incompatible') { const { abort, ...diagnostic } = variables; if (!(0, branch_sync_catalog_diagnostic_1.isBranchSyncCatalogDiagnostic)(diagnostic) || !['completed', 'unverified'].includes(String(abort))) { @@ -95828,6 +95934,63 @@ function escapeRegExp(value) { } +/***/ }), + +/***/ 67857: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.BranchSyncPublicationFailure = exports.BRANCH_SYNC_REMOTE_STATES = exports.BRANCH_SYNC_PUBLICATION_REASONS = void 0; +exports.isBranchSyncObjectId = isBranchSyncObjectId; +exports.admittedBranchSyncObjectId = admittedBranchSyncObjectId; +exports.isBranchSyncPublicationDiagnostic = isBranchSyncPublicationDiagnostic; +exports.BRANCH_SYNC_PUBLICATION_REASONS = Object.freeze(['workflow-write-required', 'push-failed', 'candidate-unverified']); +exports.BRANCH_SYNC_REMOTE_STATES = Object.freeze(['unchanged', 'published', 'changed', 'unverified']); +function isBranchSyncObjectId(value) { + return typeof value === 'string' && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u.test(value) && !/^0+$/u.test(value); +} +function admittedBranchSyncObjectId(value) { + return isBranchSyncObjectId(value) ? value : 'unavailable'; +} +function isBranchSyncPublicationDiagnostic(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) + return false; + const d = value; + if (Object.keys(d).sort().join(',') !== 'candidateSha,observedSha,originalSha,pushAttempted,reason,remoteState' + || !exports.BRANCH_SYNC_PUBLICATION_REASONS.includes(d.reason) || !exports.BRANCH_SYNC_REMOTE_STATES.includes(d.remoteState) + || typeof d.pushAttempted !== 'boolean' + || ![d.originalSha, d.candidateSha, d.observedSha].every(x => x === 'unavailable' || isBranchSyncObjectId(x))) + return false; + if (d.reason === 'candidate-unverified' && (d.pushAttempted || d.candidateSha !== 'unavailable')) + return false; + if (d.reason !== 'candidate-unverified' && (!d.pushAttempted || !isBranchSyncObjectId(d.candidateSha) || d.candidateSha === d.originalSha)) + return false; + if (d.remoteState === 'published') + return isBranchSyncObjectId(d.candidateSha) && d.observedSha === d.candidateSha; + if (d.remoteState === 'unchanged') + return isBranchSyncObjectId(d.originalSha) && d.observedSha === d.originalSha && d.observedSha !== d.candidateSha; + if (d.remoteState === 'changed') + return isBranchSyncObjectId(d.originalSha) && isBranchSyncObjectId(d.observedSha) + && d.observedSha !== d.originalSha && d.observedSha !== d.candidateSha; + return (d.observedSha === 'unavailable' || d.originalSha === 'unavailable') + && !(isBranchSyncObjectId(d.candidateSha) && d.observedSha === d.candidateSha); +} +/** A committed candidate is no longer an in-progress Git merge. */ +class BranchSyncPublicationFailure extends Error { + constructor(diagnostic) { + super('Branch synchronization candidate publication requires reconciliation.'); + if (!isBranchSyncPublicationDiagnostic(diagnostic)) + throw new TypeError('Invalid branch synchronization publication facts.'); + this.name = 'BranchSyncPublicationFailure'; + this.diagnostic = Object.freeze({ ...diagnostic }); + Object.freeze(this); + } +} +exports.BranchSyncPublicationFailure = BranchSyncPublicationFailure; + + /***/ }), /***/ 90981: @@ -97833,6 +97996,31 @@ function canonicalGitObjectId(value) { } +/***/ }), + +/***/ 60353: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.GitPushFailure = void 0; +exports.admittedGitPushDenial = admittedGitPushDenial; +class GitPushFailure extends Error { + constructor(reason) { + super('Git push did not return a successful publication response.'); + this.reason = reason; + this.name = 'GitPushFailure'; + Object.freeze(this); + } +} +exports.GitPushFailure = GitPushFailure; +function admittedGitPushDenial(error) { + return error instanceof GitPushFailure && error.reason === 'workflow-write-required' + ? 'workflow-write-required' : 'unclassified'; +} + + /***/ }), /***/ 21486: @@ -103228,6 +103416,8 @@ function rejectCatalog(diagnostic) { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.BranchSyncWorkspaceAdapter = void 0; +const git_push_failure_1 = __nccwpck_require__(60353); +const branch_sync_publication_1 = __nccwpck_require__(67857); const branch_sync_communication_budget_1 = __nccwpck_require__(99786); const logging_ports_1 = __nccwpck_require__(6152); const workspace_changes_1 = __nccwpck_require__(93370); @@ -103412,11 +103602,37 @@ class BranchSyncWorkspaceAdapter { await this.git.configureAuthor(author.name, author.email); await this.git.stageAll(); await this.git.commit(message); - const sha = await this.read("git", ["rev-parse", "HEAD"]); - await this.git.push(workingBranch, token); + const originalSha = (0, branch_sync_publication_1.admittedBranchSyncObjectId)(this.snapshot.childSha); + // Commit succeeded: never abort/reset this candidate, even if identity/read/push now fails. this.snapshot = undefined; this.mergeInProgress = false; - return sha; + this.resolutionValidated = false; + let candidateSha = 'unavailable'; + let pushAttempted = false; + try { + candidateSha = (0, branch_sync_publication_1.admittedBranchSyncObjectId)(await this.read('git', ['rev-parse', 'HEAD'])); + if (!(0, branch_sync_publication_1.isBranchSyncObjectId)(candidateSha) || !(0, branch_sync_publication_1.isBranchSyncObjectId)(originalSha) || candidateSha === originalSha) { + candidateSha = 'unavailable'; + throw new Error('Candidate identity is unavailable.'); + } + pushAttempted = true; + await this.git.push(workingBranch, token); + return candidateSha; + } + catch (error) { + let observedSha = 'unavailable'; + try { + await this.git.fetch(workingBranch, token); + observedSha = (0, branch_sync_publication_1.admittedBranchSyncObjectId)(await this.read('git', ['rev-parse', 'FETCH_HEAD'])); + } + catch { /* Missing observation never proves non-publication. */ } + const remoteState = (0, branch_sync_publication_1.isBranchSyncObjectId)(candidateSha) && observedSha === candidateSha ? 'published' + : (0, branch_sync_publication_1.isBranchSyncObjectId)(originalSha) && observedSha === originalSha ? 'unchanged' + : (0, branch_sync_publication_1.isBranchSyncObjectId)(originalSha) && (0, branch_sync_publication_1.isBranchSyncObjectId)(observedSha) ? 'changed' : 'unverified'; + const reason = !pushAttempted ? 'candidate-unverified' + : (0, git_push_failure_1.admittedGitPushDenial)(error) === 'workflow-write-required' ? 'workflow-write-required' : 'push-failed'; + throw new branch_sync_publication_1.BranchSyncPublicationFailure({ reason, remoteState, originalSha, candidateSha, observedSha, pushAttempted }); + } } async abort() { if (!this.mergeInProgress) @@ -103506,7 +103722,9 @@ class BranchSyncWorkspaceAdapter { } async readRaw(program, args) { const chunks = []; - await this.git.execute(program, args, { stdout: (data) => chunks.push(data) }); + if (await this.git.execute(program, args, { stdout: (data) => chunks.push(data) }) !== 0) { + throw new Error('Git workspace observation did not complete successfully.'); + } return Buffer.concat(chunks).toString("utf8"); } } @@ -106565,6 +106783,8 @@ var __importStar = (this && this.__importStar) || (function () { })(); Object.defineProperty(exports, "__esModule", ({ value: true })); exports.GitCommitAdapter = void 0; +const git_push_failure_1 = __nccwpck_require__(60353); +const git_push_diagnostic_1 = __nccwpck_require__(67062); const verification_executable_1 = __nccwpck_require__(20878); const native_verification_profiles_1 = __nccwpck_require__(22290); const exec = __importStar(__nccwpck_require__(18538)); @@ -106581,8 +106801,9 @@ class GitCommitAdapter { ? exec.exec(options.resolveFromEnvironment && options.env ? (0, verification_executable_1.verificationExecutable)(program, options.env) : program, args, { // Captured Git reads can contain NUL-delimited records that // hide subsequent lines in GitHub's Actions log viewer. - ...(options.stdout ? { silent: true } : {}), - ...(options.stdout ? { listeners: { stdout: options.stdout } } : {}), + ...(options.stdout || options.stderr ? { silent: true } : {}), + ...(options.stdout || options.stderr ? { listeners: { ...(options.stdout ? { stdout: options.stdout } : {}), + ...(options.stderr ? { stderr: options.stderr } : {}) } } : {}), ...(options.env ? { env: options.env } : {}), }) : exec.exec(program, args), checkPrerequisites = verification_runtime_preflight_1.assertVerificationRuntimePrerequisites, verificationSetup = verification_setup_ports_1.DISABLED_VERIFICATION_SETUP, prepareSourceDependencies = (0, source_verification_dependencies_1.captureSourceDependencyPreparation)()) { @@ -106623,7 +106844,9 @@ class GitCommitAdapter { await this.execute('git', ['config', 'user.email', email]); } async fetch(branch, token) { - await this.executeAuthenticated(['fetch', 'origin', branch], token); + if (await this.executeAuthenticated(['fetch', 'origin', branch], token) !== 0) { + throw new Error('Git fetch did not complete successfully.'); + } } async stageAll() { await this.execute('git', ['add', '-A']); @@ -106636,15 +106859,23 @@ class GitCommitAdapter { await (0, git_hook_isolation_1.withoutGitHooks)(emptyHooks => this.execute('git', ['-c', `core.hooksPath=${emptyHooks}`, 'commit', '-m', message], { untrusted: true })); } async push(branch, token) { - await this.executeAuthenticated(['push', 'origin', branch], token); + const diagnostic = (0, git_push_diagnostic_1.captureGitPushDiagnostic)(); + try { + const code = await this.executeAuthenticated(['push', 'origin', branch], token, diagnostic.stderr); + if (code !== 0) + throw new git_push_failure_1.GitPushFailure(diagnostic.classify()); + } + catch { + throw new git_push_failure_1.GitPushFailure(diagnostic.classify()); + } } - async executeAuthenticated(args, token) { + async executeAuthenticated(args, token, stderr) { if (!token?.trim()) { - await this.execute('git', args); - return; + return stderr ? this.execute('git', args, { stderr }) : this.execute('git', args); } const environment = (0, git_authentication_environment_1.buildGitAuthenticationEnvironment)(token); - await this.execute('git', args, { + return this.execute('git', args, { + ...(stderr ? { stderr } : {}), // Supply authentication only to this trusted git subprocess. The // agent process never receives this value and nothing is persisted // in the repository's git configuration or remote URL. @@ -106679,6 +106910,35 @@ async function withoutGitHooks(commit) { } +/***/ }), + +/***/ 67062: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.GIT_PUSH_DIAGNOSTIC_BYTES = void 0; +exports.captureGitPushDiagnostic = captureGitPushDiagnostic; +exports.GIT_PUSH_DIAGNOSTIC_BYTES = 8192; +/** Bounded suffix keeps the terminal remote rejection after progress output. */ +function captureGitPushDiagnostic() { + let tail = Buffer.alloc(0); + return { + stderr: data => { + const part = data.subarray(Math.max(0, data.length - exports.GIT_PUSH_DIAGNOSTIC_BYTES)); + tail = Buffer.concat([tail.subarray(Math.max(0, tail.length - (exports.GIT_PUSH_DIAGNOSTIC_BYTES - part.length))), part]); + }, + classify: () => { + const text = tail.toString('utf8').replace(/[`'"]/gu, ''); + // Path presence, arbitrary exception text and generic authorization errors are not scope proof. + return /refusing to allow (?:a Personal Access Token|an OAuth App|a GitHub App) to (?:create or update|create|update) workflow\b[^\r\n]*\bwithout (?:workflow scope|workflows permission)\b/iu.test(text) + ? 'workflow-write-required' : 'unclassified'; + }, + }; +} + + /***/ }), /***/ 19008: diff --git a/build/deployment_recovery/index.js b/build/deployment_recovery/index.js index 3266c951f..4deab592c 100644 --- a/build/deployment_recovery/index.js +++ b/build/deployment_recovery/index.js @@ -32361,6 +32361,7 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.readEnglishApplicationErrorMessage = exports.APPLICATION_ERROR_CATALOG_DEFINITIONS = exports.SPANISH_APPLICATION_ERROR_DEFINITION = exports.ENGLISH_APPLICATION_ERROR_DEFINITION = exports.SPANISH_APPLICATION_ERROR_MESSAGES = exports.ENGLISH_APPLICATION_ERROR_MESSAGES = exports.APPLICATION_ERROR_MESSAGE_IDS = exports.APPLICATION_ERROR_CODES = void 0; exports.resolveStaticApplicationErrorCatalog = resolveStaticApplicationErrorCatalog; exports.resolveApplicationErrorCatalog = resolveApplicationErrorCatalog; +const branch_sync_publication_messages_1 = __nccwpck_require__(3948); const bugbot_review_diagnostic_labels_1 = __nccwpck_require__(351); const comment_command_validation_message_catalog_1 = __nccwpck_require__(2002); const application_error_1 = __nccwpck_require__(7790); @@ -32368,6 +32369,7 @@ const message_catalog_1 = __nccwpck_require__(7097); const resolved_message_catalog_policy_1 = __nccwpck_require__(5069); const LABEL_KEYS = Object.freeze([ ...bugbot_review_diagnostic_labels_1.BUGBOT_REVIEW_DIAGNOSTIC_LABEL_KEYS, + ...branch_sync_publication_messages_1.BRANCH_SYNC_PUBLICATION_LABEL_KEYS, 'impact', 'errorCode', 'action', 'retainedState', 'retryable', 'yes', 'no', 'reference', ]); const CONTENT_FIELDS = Object.freeze(['impact', 'action', 'retainedState']); @@ -32380,6 +32382,7 @@ exports.APPLICATION_ERROR_MESSAGE_IDS = Object.freeze([ ]); const ENGLISH_LABELS = Object.freeze({ ...bugbot_review_diagnostic_labels_1.ENGLISH_REVIEW_DIAGNOSTIC_LABELS, + ...branch_sync_publication_messages_1.ENGLISH_BRANCH_SYNC_PUBLICATION_LABELS, impact: 'Impact', errorCode: 'Error code', action: 'Action', @@ -32391,6 +32394,7 @@ const ENGLISH_LABELS = Object.freeze({ }); const SPANISH_LABELS = Object.freeze({ ...bugbot_review_diagnostic_labels_1.SPANISH_REVIEW_DIAGNOSTIC_LABELS, + ...branch_sync_publication_messages_1.SPANISH_BRANCH_SYNC_PUBLICATION_LABELS, impact: 'Impacto', errorCode: 'Código de error', action: 'Acción', @@ -32530,6 +32534,7 @@ const STORED_CONFIGURATION_RETAINED_EN = 'The issue/PR description and stored co const STORED_CONFIGURATION_RETAINED_ES = 'Esta lectura no modificó la descripción de la issue/PR ni su configuración almacenada.'; const ENGLISH_RECOVERY_CONTENT = Object.freeze({ ...comment_command_validation_message_catalog_1.ENGLISH_COMMAND_VALIDATION_CONTENT, + ...branch_sync_publication_messages_1.ENGLISH_BRANCH_SYNC_PUBLICATION_CONTENT, 'stored-configuration-schema-missing': Object.freeze({ impact: 'Automation stopped for issue/PR #{issueNumber}: the stored configuration has no schema version; version {expectedVersion} is required.', action: STORED_CONFIGURATION_ACTION_EN, @@ -32728,6 +32733,7 @@ const ENGLISH_RECOVERY_CONTENT = Object.freeze({ }); const SPANISH_RECOVERY_CONTENT = Object.freeze({ ...comment_command_validation_message_catalog_1.SPANISH_COMMAND_VALIDATION_CONTENT, + ...branch_sync_publication_messages_1.SPANISH_BRANCH_SYNC_PUBLICATION_CONTENT, 'stored-configuration-schema-missing': Object.freeze({ impact: 'La automatización se detuvo para la issue/PR #{issueNumber}: la configuración almacenada no tiene versión de esquema; se requiere la versión {expectedVersion}.', action: STORED_CONFIGURATION_ACTION_ES, @@ -32965,6 +32971,56 @@ const readEnglishApplicationErrorMessage = (id, variables = {}) => (0, message_c exports.readEnglishApplicationErrorMessage = readEnglishApplicationErrorMessage; +/***/ }), + +/***/ 3948: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.SPANISH_BRANCH_SYNC_PUBLICATION_CONTENT = exports.ENGLISH_BRANCH_SYNC_PUBLICATION_CONTENT = exports.SPANISH_BRANCH_SYNC_PUBLICATION_LABELS = exports.ENGLISH_BRANCH_SYNC_PUBLICATION_LABELS = exports.BRANCH_SYNC_PUBLICATION_LABEL_KEYS = void 0; +exports.BRANCH_SYNC_PUBLICATION_LABEL_KEYS = ['syncRemoteUnchanged', 'syncRemoteChanged', 'syncRemoteUnverified', 'syncPushAttempted', 'syncPushNotAttempted']; +exports.ENGLISH_BRANCH_SYNC_PUBLICATION_LABELS = { + syncRemoteUnchanged: 'The observed remote still points to the original head; the candidate was not observed as published.', + syncRemoteChanged: 'The remote has a different head; this observation does not establish whether it includes the candidate.', + syncRemoteUnverified: 'The remote head could not be established; publication remains unknown.', + syncPushAttempted: 'A normal push was attempted.', syncPushNotAttempted: 'No push was attempted.', +}; +exports.SPANISH_BRANCH_SYNC_PUBLICATION_LABELS = { + syncRemoteUnchanged: 'El remoto observado conserva el head original; no se observó la publicación del candidato.', + syncRemoteChanged: 'El remoto tiene otro head; esta observación no determina si incluye el candidato.', + syncRemoteUnverified: 'No se pudo establecer el head remoto; la publicación sigue sin verificarse.', + syncPushAttempted: 'Se intentó un push normal.', syncPushNotAttempted: 'No se intentó ningún push.', +}; +const retained = 'The local merge commit step finished; candidate identity: {candidateSha}. {pushStatus} {remoteStatus} Original head: {originalSha}; observed head: {observedSha}. Recovery did not reset or abort the committed workspace; runner-local storage is temporary.'; +const retainedSpanish = 'Terminó el paso de commit del merge local; identidad del candidato: {candidateSha}. {pushStatus} {remoteStatus} Head original: {originalSha}; head observado: {observedSha}. La recuperación no restableció ni abortó el espacio de trabajo confirmado; el almacenamiento del runner es temporal.'; +exports.ENGLISH_BRANCH_SYNC_PUBLICATION_CONTENT = { + 'branch-sync-workflow-write-required': { + impact: 'The remote rejected workflow-file publication by the configured publisher.', + action: 'A maintainer must review the publisher credential and grant the appropriate workflow-write capability through its normal configuration, then rerun {syncCommand} from the current managed PR. Do not retry unchanged credentials or bypass the managed branch. Recovery guide: {publicationGuide}.', + retainedState: retained, + }, + 'branch-sync-publication-failed': { + impact: 'Branch synchronization did not reach a verified remote publication ({reason}).', + action: 'Inspect the current native remote head and the retained candidate before a fresh Action-managed synchronization. An unavailable observation is not proof that nothing was published. Recovery guide: {publicationGuide}.', + retainedState: retained, + }, +}; +exports.SPANISH_BRANCH_SYNC_PUBLICATION_CONTENT = { + 'branch-sync-workflow-write-required': { + impact: 'El remoto rechazó la publicación de archivos de workflow mediante la credencial configurada.', + action: 'Un mantenedor debe revisar la credencial y conceder la capacidad de escritura de workflows adecuada mediante su configuración habitual; después, debe ejecutar {syncCommand} desde la PR gestionada actual. No repitas la misma credencial ni eludas la rama gestionada. Guía: {publicationGuide}.', + retainedState: retainedSpanish, + }, + 'branch-sync-publication-failed': { + impact: 'La sincronización no alcanzó una publicación remota verificada ({reason}).', + action: 'Inspecciona el head remoto nativo actual y el candidato conservado antes de una nueva sincronización gestionada por la Action. No disponer de una observación no demuestra que no se publicara nada. Guía: {publicationGuide}.', + retainedState: retainedSpanish, + }, +}; + + /***/ }), /***/ 351: @@ -33807,6 +33863,7 @@ var _ApplicationError_cause; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.ApplicationError = exports.APPLICATION_ERROR_METADATA = exports.APPLICATION_ERROR_RECOVERY_IDS = void 0; exports.isApplicationErrorCorrelationId = isApplicationErrorCorrelationId; +const branch_sync_publication_1 = __nccwpck_require__(7857); const framed_review_1 = __nccwpck_require__(3750); const progress_response_diagnostic_1 = __nccwpck_require__(6289); const progress_completion_diagnostic_1 = __nccwpck_require__(5095); @@ -33837,6 +33894,8 @@ exports.APPLICATION_ERROR_RECOVERY_IDS = Object.freeze([ 'branch-sync-resolution-invalid', 'branch-sync-verification-failed', 'branch-sync-catalog-incompatible', + 'branch-sync-workflow-write-required', + 'branch-sync-publication-failed', 'sdd-draft-size-exceeded', 'sdd-catalog-unsupported-profile', 'sdd-catalog-format-invalid', @@ -34091,6 +34150,8 @@ const RECOVERY_VARIABLE_KEYS = Object.freeze({ 'branch-sync-resolution-invalid': Object.freeze(['validationCode', 'command']), 'branch-sync-verification-failed': Object.freeze(['stage', 'reason', 'command', 'commandIndex', 'childSha', 'restoration']), 'branch-sync-catalog-incompatible': Object.freeze(['phase', 'profile', 'field', 'capabilityNumber', 'abort']), + 'branch-sync-workflow-write-required': Object.freeze(['reason', 'remoteState', 'originalSha', 'candidateSha', 'observedSha', 'pushAttempted']), + 'branch-sync-publication-failed': Object.freeze(['reason', 'remoteState', 'originalSha', 'candidateSha', 'observedSha', 'pushAttempted']), 'sdd-draft-size-exceeded': Object.freeze([]), 'sdd-catalog-unsupported-profile': Object.freeze([]), 'sdd-catalog-format-invalid': Object.freeze([]), @@ -34170,6 +34231,12 @@ function normalizeApplicationErrorRecovery(recovery) { if (recovery.id === 'branch-sync-verification-failed' && !(0, verification_failure_1.isBranchSyncVerificationFailure)(variables)) { throw new TypeError('Branch synchronization verification diagnostic is invalid.'); } + if (recovery.id === 'branch-sync-workflow-write-required' || recovery.id === 'branch-sync-publication-failed') { + if (!(0, branch_sync_publication_1.isBranchSyncPublicationDiagnostic)(variables) || variables.remoteState === 'published' + || (recovery.id === 'branch-sync-workflow-write-required') !== (variables.reason === 'workflow-write-required')) { + throw new TypeError('Branch synchronization publication diagnostic is invalid.'); + } + } if (recovery.id === 'branch-sync-catalog-incompatible') { const { abort, ...diagnostic } = variables; if (!(0, branch_sync_catalog_diagnostic_1.isBranchSyncCatalogDiagnostic)(diagnostic) || !['completed', 'unverified'].includes(String(abort))) { @@ -35079,6 +35146,63 @@ function classifyBranchSyncCatalog(value) { } +/***/ }), + +/***/ 7857: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.BranchSyncPublicationFailure = exports.BRANCH_SYNC_REMOTE_STATES = exports.BRANCH_SYNC_PUBLICATION_REASONS = void 0; +exports.isBranchSyncObjectId = isBranchSyncObjectId; +exports.admittedBranchSyncObjectId = admittedBranchSyncObjectId; +exports.isBranchSyncPublicationDiagnostic = isBranchSyncPublicationDiagnostic; +exports.BRANCH_SYNC_PUBLICATION_REASONS = Object.freeze(['workflow-write-required', 'push-failed', 'candidate-unverified']); +exports.BRANCH_SYNC_REMOTE_STATES = Object.freeze(['unchanged', 'published', 'changed', 'unverified']); +function isBranchSyncObjectId(value) { + return typeof value === 'string' && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u.test(value) && !/^0+$/u.test(value); +} +function admittedBranchSyncObjectId(value) { + return isBranchSyncObjectId(value) ? value : 'unavailable'; +} +function isBranchSyncPublicationDiagnostic(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) + return false; + const d = value; + if (Object.keys(d).sort().join(',') !== 'candidateSha,observedSha,originalSha,pushAttempted,reason,remoteState' + || !exports.BRANCH_SYNC_PUBLICATION_REASONS.includes(d.reason) || !exports.BRANCH_SYNC_REMOTE_STATES.includes(d.remoteState) + || typeof d.pushAttempted !== 'boolean' + || ![d.originalSha, d.candidateSha, d.observedSha].every(x => x === 'unavailable' || isBranchSyncObjectId(x))) + return false; + if (d.reason === 'candidate-unverified' && (d.pushAttempted || d.candidateSha !== 'unavailable')) + return false; + if (d.reason !== 'candidate-unverified' && (!d.pushAttempted || !isBranchSyncObjectId(d.candidateSha) || d.candidateSha === d.originalSha)) + return false; + if (d.remoteState === 'published') + return isBranchSyncObjectId(d.candidateSha) && d.observedSha === d.candidateSha; + if (d.remoteState === 'unchanged') + return isBranchSyncObjectId(d.originalSha) && d.observedSha === d.originalSha && d.observedSha !== d.candidateSha; + if (d.remoteState === 'changed') + return isBranchSyncObjectId(d.originalSha) && isBranchSyncObjectId(d.observedSha) + && d.observedSha !== d.originalSha && d.observedSha !== d.candidateSha; + return (d.observedSha === 'unavailable' || d.originalSha === 'unavailable') + && !(isBranchSyncObjectId(d.candidateSha) && d.observedSha === d.candidateSha); +} +/** A committed candidate is no longer an in-progress Git merge. */ +class BranchSyncPublicationFailure extends Error { + constructor(diagnostic) { + super('Branch synchronization candidate publication requires reconciliation.'); + if (!isBranchSyncPublicationDiagnostic(diagnostic)) + throw new TypeError('Invalid branch synchronization publication facts.'); + this.name = 'BranchSyncPublicationFailure'; + this.diagnostic = Object.freeze({ ...diagnostic }); + Object.freeze(this); + } +} +exports.BranchSyncPublicationFailure = BranchSyncPublicationFailure; + + /***/ }), /***/ 981: diff --git a/build/github_action/index.js b/build/github_action/index.js index e46f51ecd..4355caeac 100644 --- a/build/github_action/index.js +++ b/build/github_action/index.js @@ -41227,6 +41227,7 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.readEnglishApplicationErrorMessage = exports.APPLICATION_ERROR_CATALOG_DEFINITIONS = exports.SPANISH_APPLICATION_ERROR_DEFINITION = exports.ENGLISH_APPLICATION_ERROR_DEFINITION = exports.SPANISH_APPLICATION_ERROR_MESSAGES = exports.ENGLISH_APPLICATION_ERROR_MESSAGES = exports.APPLICATION_ERROR_MESSAGE_IDS = exports.APPLICATION_ERROR_CODES = void 0; exports.resolveStaticApplicationErrorCatalog = resolveStaticApplicationErrorCatalog; exports.resolveApplicationErrorCatalog = resolveApplicationErrorCatalog; +const branch_sync_publication_messages_1 = __nccwpck_require__(3948); const bugbot_review_diagnostic_labels_1 = __nccwpck_require__(20351); const comment_command_validation_message_catalog_1 = __nccwpck_require__(62002); const application_error_1 = __nccwpck_require__(97790); @@ -41234,6 +41235,7 @@ const message_catalog_1 = __nccwpck_require__(27097); const resolved_message_catalog_policy_1 = __nccwpck_require__(55069); const LABEL_KEYS = Object.freeze([ ...bugbot_review_diagnostic_labels_1.BUGBOT_REVIEW_DIAGNOSTIC_LABEL_KEYS, + ...branch_sync_publication_messages_1.BRANCH_SYNC_PUBLICATION_LABEL_KEYS, 'impact', 'errorCode', 'action', 'retainedState', 'retryable', 'yes', 'no', 'reference', ]); const CONTENT_FIELDS = Object.freeze(['impact', 'action', 'retainedState']); @@ -41246,6 +41248,7 @@ exports.APPLICATION_ERROR_MESSAGE_IDS = Object.freeze([ ]); const ENGLISH_LABELS = Object.freeze({ ...bugbot_review_diagnostic_labels_1.ENGLISH_REVIEW_DIAGNOSTIC_LABELS, + ...branch_sync_publication_messages_1.ENGLISH_BRANCH_SYNC_PUBLICATION_LABELS, impact: 'Impact', errorCode: 'Error code', action: 'Action', @@ -41257,6 +41260,7 @@ const ENGLISH_LABELS = Object.freeze({ }); const SPANISH_LABELS = Object.freeze({ ...bugbot_review_diagnostic_labels_1.SPANISH_REVIEW_DIAGNOSTIC_LABELS, + ...branch_sync_publication_messages_1.SPANISH_BRANCH_SYNC_PUBLICATION_LABELS, impact: 'Impacto', errorCode: 'Código de error', action: 'Acción', @@ -41396,6 +41400,7 @@ const STORED_CONFIGURATION_RETAINED_EN = 'The issue/PR description and stored co const STORED_CONFIGURATION_RETAINED_ES = 'Esta lectura no modificó la descripción de la issue/PR ni su configuración almacenada.'; const ENGLISH_RECOVERY_CONTENT = Object.freeze({ ...comment_command_validation_message_catalog_1.ENGLISH_COMMAND_VALIDATION_CONTENT, + ...branch_sync_publication_messages_1.ENGLISH_BRANCH_SYNC_PUBLICATION_CONTENT, 'stored-configuration-schema-missing': Object.freeze({ impact: 'Automation stopped for issue/PR #{issueNumber}: the stored configuration has no schema version; version {expectedVersion} is required.', action: STORED_CONFIGURATION_ACTION_EN, @@ -41594,6 +41599,7 @@ const ENGLISH_RECOVERY_CONTENT = Object.freeze({ }); const SPANISH_RECOVERY_CONTENT = Object.freeze({ ...comment_command_validation_message_catalog_1.SPANISH_COMMAND_VALIDATION_CONTENT, + ...branch_sync_publication_messages_1.SPANISH_BRANCH_SYNC_PUBLICATION_CONTENT, 'stored-configuration-schema-missing': Object.freeze({ impact: 'La automatización se detuvo para la issue/PR #{issueNumber}: la configuración almacenada no tiene versión de esquema; se requiere la versión {expectedVersion}.', action: STORED_CONFIGURATION_ACTION_ES, @@ -41853,6 +41859,7 @@ const application_error_message_catalog_1 = __nccwpck_require__(64809); // This trusted destination is inserted after localization, never supplied by a body or translation. const STORED_CONFIGURATION_RECOVERY_URL = 'https://docs.page/vypdev/copilot/security-operations/operations/troubleshooting#unsupported-stored-configuration'; const BRANCH_SYNC_CATALOG_RECOVERY_URL = 'https://docs.page/vypdev/copilot/security-operations/operations/troubleshooting#branch-sync-catalog-compatibility'; +const BRANCH_SYNC_PUBLICATION_RECOVERY_URL = 'https://docs.page/vypdev/copilot/security-operations/operations/troubleshooting#branch-sync-workflow-write-recovery'; const agent_source_snapshot_1 = __nccwpck_require__(59205); /** Shared semantic view model for terminal, GitHub, and API presentation. */ function buildApplicationErrorPresentation(error, message = application_error_message_catalog_1.readEnglishApplicationErrorMessage) { @@ -41877,11 +41884,20 @@ function buildApplicationErrorPresentation(error, message = application_error_me ? (0, comment_command_validation_presentation_policy_1.commandValidationPresentationVariables)(error.recovery.id, error.recovery.variables.command) : error.recovery?.id === 'branch-sync-verification-failed' ? { ...error.recovery.variables, syncCommand: '/copilot sync-branch' } - : error.recovery?.id === 'branch-sync-catalog-incompatible' - ? { ...error.recovery.variables, syncCommand: '/copilot sync-branch', catalogGuide: BRANCH_SYNC_CATALOG_RECOVERY_URL } - : error.recovery?.id.startsWith('stored-configuration-') - ? { ...error.recovery.variables, recoveryGuide: STORED_CONFIGURATION_RECOVERY_URL } - : error.recovery?.variables; + : error.recovery?.id === 'branch-sync-workflow-write-required' || error.recovery?.id === 'branch-sync-publication-failed' + ? { + reason: error.recovery.variables.reason, originalSha: error.recovery.variables.originalSha, + candidateSha: error.recovery.variables.candidateSha, observedSha: error.recovery.variables.observedSha, + pushStatus: message(error.recovery.variables.pushAttempted ? 'error.label.syncPushAttempted' : 'error.label.syncPushNotAttempted'), + remoteStatus: message(error.recovery.variables.remoteState === 'unchanged' ? 'error.label.syncRemoteUnchanged' + : error.recovery.variables.remoteState === 'changed' ? 'error.label.syncRemoteChanged' : 'error.label.syncRemoteUnverified'), + publicationGuide: BRANCH_SYNC_PUBLICATION_RECOVERY_URL, syncCommand: '/copilot sync-branch', + } + : error.recovery?.id === 'branch-sync-catalog-incompatible' + ? { ...error.recovery.variables, syncCommand: '/copilot sync-branch', catalogGuide: BRANCH_SYNC_CATALOG_RECOVERY_URL } + : error.recovery?.id.startsWith('stored-configuration-') + ? { ...error.recovery.variables, recoveryGuide: STORED_CONFIGURATION_RECOVERY_URL } + : error.recovery?.variables; return { impact: message(`${descriptor}.impact`, variables), code: error.code, @@ -42370,6 +42386,56 @@ ${messages.message('branchSync.relationship.history')}`; } +/***/ }), + +/***/ 3948: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.SPANISH_BRANCH_SYNC_PUBLICATION_CONTENT = exports.ENGLISH_BRANCH_SYNC_PUBLICATION_CONTENT = exports.SPANISH_BRANCH_SYNC_PUBLICATION_LABELS = exports.ENGLISH_BRANCH_SYNC_PUBLICATION_LABELS = exports.BRANCH_SYNC_PUBLICATION_LABEL_KEYS = void 0; +exports.BRANCH_SYNC_PUBLICATION_LABEL_KEYS = ['syncRemoteUnchanged', 'syncRemoteChanged', 'syncRemoteUnverified', 'syncPushAttempted', 'syncPushNotAttempted']; +exports.ENGLISH_BRANCH_SYNC_PUBLICATION_LABELS = { + syncRemoteUnchanged: 'The observed remote still points to the original head; the candidate was not observed as published.', + syncRemoteChanged: 'The remote has a different head; this observation does not establish whether it includes the candidate.', + syncRemoteUnverified: 'The remote head could not be established; publication remains unknown.', + syncPushAttempted: 'A normal push was attempted.', syncPushNotAttempted: 'No push was attempted.', +}; +exports.SPANISH_BRANCH_SYNC_PUBLICATION_LABELS = { + syncRemoteUnchanged: 'El remoto observado conserva el head original; no se observó la publicación del candidato.', + syncRemoteChanged: 'El remoto tiene otro head; esta observación no determina si incluye el candidato.', + syncRemoteUnverified: 'No se pudo establecer el head remoto; la publicación sigue sin verificarse.', + syncPushAttempted: 'Se intentó un push normal.', syncPushNotAttempted: 'No se intentó ningún push.', +}; +const retained = 'The local merge commit step finished; candidate identity: {candidateSha}. {pushStatus} {remoteStatus} Original head: {originalSha}; observed head: {observedSha}. Recovery did not reset or abort the committed workspace; runner-local storage is temporary.'; +const retainedSpanish = 'Terminó el paso de commit del merge local; identidad del candidato: {candidateSha}. {pushStatus} {remoteStatus} Head original: {originalSha}; head observado: {observedSha}. La recuperación no restableció ni abortó el espacio de trabajo confirmado; el almacenamiento del runner es temporal.'; +exports.ENGLISH_BRANCH_SYNC_PUBLICATION_CONTENT = { + 'branch-sync-workflow-write-required': { + impact: 'The remote rejected workflow-file publication by the configured publisher.', + action: 'A maintainer must review the publisher credential and grant the appropriate workflow-write capability through its normal configuration, then rerun {syncCommand} from the current managed PR. Do not retry unchanged credentials or bypass the managed branch. Recovery guide: {publicationGuide}.', + retainedState: retained, + }, + 'branch-sync-publication-failed': { + impact: 'Branch synchronization did not reach a verified remote publication ({reason}).', + action: 'Inspect the current native remote head and the retained candidate before a fresh Action-managed synchronization. An unavailable observation is not proof that nothing was published. Recovery guide: {publicationGuide}.', + retainedState: retained, + }, +}; +exports.SPANISH_BRANCH_SYNC_PUBLICATION_CONTENT = { + 'branch-sync-workflow-write-required': { + impact: 'El remoto rechazó la publicación de archivos de workflow mediante la credencial configurada.', + action: 'Un mantenedor debe revisar la credencial y conceder la capacidad de escritura de workflows adecuada mediante su configuración habitual; después, debe ejecutar {syncCommand} desde la PR gestionada actual. No repitas la misma credencial ni eludas la rama gestionada. Guía: {publicationGuide}.', + retainedState: retainedSpanish, + }, + 'branch-sync-publication-failed': { + impact: 'La sincronización no alcanzó una publicación remota verificada ({reason}).', + action: 'Inspecciona el head remoto nativo actual y el candidato conservado antes de una nueva sincronización gestionada por la Action. No disponer de una observación no demuestra que no se publicara nada. Guía: {publicationGuide}.', + retainedState: retainedSpanish, + }, +}; + + /***/ }), /***/ 14791: @@ -59591,6 +59657,7 @@ exports.branchSyncConflictEligibilityFailure = branchSyncConflictEligibilityFail exports.completedBranchSyncResult = completedBranchSyncResult; exports.unavailableBranchSyncResult = unavailableBranchSyncResult; exports.failedBranchSyncResult = failedBranchSyncResult; +exports.failedBranchSyncPublicationResult = failedBranchSyncPublicationResult; const agent_1 = __nccwpck_require__(79937); const result_1 = __nccwpck_require__(73817); const workspace_changes_1 = __nccwpck_require__(93370); @@ -59625,6 +59692,7 @@ function completedBranchSyncResult(input) { "dry-run-conflicted": `Dry run complete: the merge has ${preparation.kind === "conflicted" ? preparation.conflictPaths.length : 0} conflict(s). Nothing was pushed and no agent was invoked.`, "merged-cleanly": `Merged \`${parentBranch}\` into \`${workingBranch}\` cleanly and pushed the result.`, "merged-with-agent": `Merged \`${parentBranch}\` into \`${workingBranch}\`, used the fixer agent to resolve conflicts, verified the workspace, and pushed the result.`, + "merged-verified-remotely": `The merge candidate \`${input.commitSha}\` was independently confirmed on remote branch \`${workingBranch}\` after a failed push response. No second push was attempted.`, }; return new result_1.Result({ id: exports.BRANCH_SYNC_TASK_ID, @@ -59662,6 +59730,16 @@ function failedBranchSyncResult(reason, cause, recovery) { : (0, application_error_1.toApplicationError)(cause, 'workflow.failed', reason, { recovery })], }); } +/** Publication facts come only from the trusted workspace's bounded remote observation. */ +function failedBranchSyncPublicationResult(diagnostic) { + const workflow = diagnostic.reason === 'workflow-write-required'; + const error = new application_error_1.ApplicationError(workflow ? 'authorization.denied' : 'provider.unavailable', 'Branch synchronization candidate publication requires maintainer review.', { + retryable: false, + recovery: { id: workflow ? 'branch-sync-workflow-write-required' : 'branch-sync-publication-failed', variables: diagnostic }, + }); + return new result_1.Result({ id: exports.BRANCH_SYNC_TASK_ID, success: false, executed: true, + steps: ['The local merge commit step finished; inspect the publication facts before retrying.'], errors: [error] }); +} /***/ }), @@ -59673,12 +59751,14 @@ function failedBranchSyncResult(reason, cause, recovery) { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.SyncBranchUseCase = void 0; +const branch_sync_publication_1 = __nccwpck_require__(67857); const branch_sync_validation_1 = __nccwpck_require__(90981); const branch_sync_conflicts_1 = __nccwpck_require__(84434); const logging_ports_1 = __nccwpck_require__(6152); const verify_command_policy_1 = __nccwpck_require__(96031); const verify_command_runner_1 = __nccwpck_require__(57742); const branch_sync_execution_policy_1 = __nccwpck_require__(82113); +const application_error_1 = __nccwpck_require__(75999); const branch_sync_catalog_diagnostic_1 = __nccwpck_require__(15677); /** Performs a race-safe parent-to-child merge and invokes the fixer only for eligible conflicts. */ class SyncBranchUseCase { @@ -59712,7 +59792,7 @@ class SyncBranchUseCase { id: 'branch-sync-catalog-incompatible', variables: { ...catalogFailure, abort: aborted ? 'completed' : 'unverified' }, })]; } - return [(0, branch_sync_execution_policy_1.failedBranchSyncResult)("Branch synchronization failed safely; no push was completed.", cause)]; + return [(0, branch_sync_execution_policy_1.failedBranchSyncResult)("Branch synchronization completion could not be verified. Inspect the current remote state before retrying.", cause)]; } } async synchronize(context, options, target, parentBranch) { @@ -59744,7 +59824,22 @@ class SyncBranchUseCase { if (!remoteValidation.valid) { return [await this.abortFailure(remoteValidation.reason ?? "A branch changed while synchronization was running; retry from the latest heads.", validationRecovery(remoteValidation.code))]; } - const commitSha = await this.workspace.commitAndPush(target.workingBranch, `Merge ${parentBranch} into ${target.workingBranch}`, author); + let commitSha; + try { + commitSha = await this.workspace.commitAndPush(target.workingBranch, `Merge ${parentBranch} into ${target.workingBranch}`, author); + } + catch (cause) { + if (cause instanceof branch_sync_publication_1.BranchSyncPublicationFailure && (0, branch_sync_publication_1.isBranchSyncPublicationDiagnostic)(cause.diagnostic)) { + if (cause.diagnostic.originalSha !== preparation.childSha) { + throw new application_error_1.ApplicationError('provider.contract-invalid', 'Publication facts do not match the prepared branch head.'); + } + if (cause.diagnostic.remoteState === 'published') { + return [this.completed(preparation, parentBranch, target, 'merged-verified-remotely', verification.commandCount, cause.diagnostic.candidateSha)]; + } + return [(0, branch_sync_execution_policy_1.failedBranchSyncPublicationResult)(cause.diagnostic)]; + } + throw cause; + } const outcome = conflictResolution.agentUsed ? "merged-with-agent" : "merged-cleanly"; return [this.completed(preparation, parentBranch, target, outcome, verification.commandCount, commitSha)]; } @@ -74053,6 +74148,7 @@ var _ApplicationError_cause; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.ApplicationError = exports.APPLICATION_ERROR_METADATA = exports.APPLICATION_ERROR_RECOVERY_IDS = void 0; exports.isApplicationErrorCorrelationId = isApplicationErrorCorrelationId; +const branch_sync_publication_1 = __nccwpck_require__(67857); const framed_review_1 = __nccwpck_require__(43750); const progress_response_diagnostic_1 = __nccwpck_require__(56289); const progress_completion_diagnostic_1 = __nccwpck_require__(75095); @@ -74083,6 +74179,8 @@ exports.APPLICATION_ERROR_RECOVERY_IDS = Object.freeze([ 'branch-sync-resolution-invalid', 'branch-sync-verification-failed', 'branch-sync-catalog-incompatible', + 'branch-sync-workflow-write-required', + 'branch-sync-publication-failed', 'sdd-draft-size-exceeded', 'sdd-catalog-unsupported-profile', 'sdd-catalog-format-invalid', @@ -74337,6 +74435,8 @@ const RECOVERY_VARIABLE_KEYS = Object.freeze({ 'branch-sync-resolution-invalid': Object.freeze(['validationCode', 'command']), 'branch-sync-verification-failed': Object.freeze(['stage', 'reason', 'command', 'commandIndex', 'childSha', 'restoration']), 'branch-sync-catalog-incompatible': Object.freeze(['phase', 'profile', 'field', 'capabilityNumber', 'abort']), + 'branch-sync-workflow-write-required': Object.freeze(['reason', 'remoteState', 'originalSha', 'candidateSha', 'observedSha', 'pushAttempted']), + 'branch-sync-publication-failed': Object.freeze(['reason', 'remoteState', 'originalSha', 'candidateSha', 'observedSha', 'pushAttempted']), 'sdd-draft-size-exceeded': Object.freeze([]), 'sdd-catalog-unsupported-profile': Object.freeze([]), 'sdd-catalog-format-invalid': Object.freeze([]), @@ -74416,6 +74516,12 @@ function normalizeApplicationErrorRecovery(recovery) { if (recovery.id === 'branch-sync-verification-failed' && !(0, verification_failure_1.isBranchSyncVerificationFailure)(variables)) { throw new TypeError('Branch synchronization verification diagnostic is invalid.'); } + if (recovery.id === 'branch-sync-workflow-write-required' || recovery.id === 'branch-sync-publication-failed') { + if (!(0, branch_sync_publication_1.isBranchSyncPublicationDiagnostic)(variables) || variables.remoteState === 'published' + || (recovery.id === 'branch-sync-workflow-write-required') !== (variables.reason === 'workflow-write-required')) { + throw new TypeError('Branch synchronization publication diagnostic is invalid.'); + } + } if (recovery.id === 'branch-sync-catalog-incompatible') { const { abort, ...diagnostic } = variables; if (!(0, branch_sync_catalog_diagnostic_1.isBranchSyncCatalogDiagnostic)(diagnostic) || !['completed', 'unverified'].includes(String(abort))) { @@ -86896,6 +87002,63 @@ function escapeRegExp(value) { } +/***/ }), + +/***/ 67857: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.BranchSyncPublicationFailure = exports.BRANCH_SYNC_REMOTE_STATES = exports.BRANCH_SYNC_PUBLICATION_REASONS = void 0; +exports.isBranchSyncObjectId = isBranchSyncObjectId; +exports.admittedBranchSyncObjectId = admittedBranchSyncObjectId; +exports.isBranchSyncPublicationDiagnostic = isBranchSyncPublicationDiagnostic; +exports.BRANCH_SYNC_PUBLICATION_REASONS = Object.freeze(['workflow-write-required', 'push-failed', 'candidate-unverified']); +exports.BRANCH_SYNC_REMOTE_STATES = Object.freeze(['unchanged', 'published', 'changed', 'unverified']); +function isBranchSyncObjectId(value) { + return typeof value === 'string' && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u.test(value) && !/^0+$/u.test(value); +} +function admittedBranchSyncObjectId(value) { + return isBranchSyncObjectId(value) ? value : 'unavailable'; +} +function isBranchSyncPublicationDiagnostic(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) + return false; + const d = value; + if (Object.keys(d).sort().join(',') !== 'candidateSha,observedSha,originalSha,pushAttempted,reason,remoteState' + || !exports.BRANCH_SYNC_PUBLICATION_REASONS.includes(d.reason) || !exports.BRANCH_SYNC_REMOTE_STATES.includes(d.remoteState) + || typeof d.pushAttempted !== 'boolean' + || ![d.originalSha, d.candidateSha, d.observedSha].every(x => x === 'unavailable' || isBranchSyncObjectId(x))) + return false; + if (d.reason === 'candidate-unverified' && (d.pushAttempted || d.candidateSha !== 'unavailable')) + return false; + if (d.reason !== 'candidate-unverified' && (!d.pushAttempted || !isBranchSyncObjectId(d.candidateSha) || d.candidateSha === d.originalSha)) + return false; + if (d.remoteState === 'published') + return isBranchSyncObjectId(d.candidateSha) && d.observedSha === d.candidateSha; + if (d.remoteState === 'unchanged') + return isBranchSyncObjectId(d.originalSha) && d.observedSha === d.originalSha && d.observedSha !== d.candidateSha; + if (d.remoteState === 'changed') + return isBranchSyncObjectId(d.originalSha) && isBranchSyncObjectId(d.observedSha) + && d.observedSha !== d.originalSha && d.observedSha !== d.candidateSha; + return (d.observedSha === 'unavailable' || d.originalSha === 'unavailable') + && !(isBranchSyncObjectId(d.candidateSha) && d.observedSha === d.candidateSha); +} +/** A committed candidate is no longer an in-progress Git merge. */ +class BranchSyncPublicationFailure extends Error { + constructor(diagnostic) { + super('Branch synchronization candidate publication requires reconciliation.'); + if (!isBranchSyncPublicationDiagnostic(diagnostic)) + throw new TypeError('Invalid branch synchronization publication facts.'); + this.name = 'BranchSyncPublicationFailure'; + this.diagnostic = Object.freeze({ ...diagnostic }); + Object.freeze(this); + } +} +exports.BranchSyncPublicationFailure = BranchSyncPublicationFailure; + + /***/ }), /***/ 90981: @@ -88833,6 +88996,31 @@ function canonicalGitObjectId(value) { } +/***/ }), + +/***/ 60353: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.GitPushFailure = void 0; +exports.admittedGitPushDenial = admittedGitPushDenial; +class GitPushFailure extends Error { + constructor(reason) { + super('Git push did not return a successful publication response.'); + this.reason = reason; + this.name = 'GitPushFailure'; + Object.freeze(this); + } +} +exports.GitPushFailure = GitPushFailure; +function admittedGitPushDenial(error) { + return error instanceof GitPushFailure && error.reason === 'workflow-write-required' + ? 'workflow-write-required' : 'unclassified'; +} + + /***/ }), /***/ 21486: @@ -94640,6 +94828,8 @@ function rejectCatalog(diagnostic) { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.BranchSyncWorkspaceAdapter = void 0; +const git_push_failure_1 = __nccwpck_require__(60353); +const branch_sync_publication_1 = __nccwpck_require__(67857); const branch_sync_communication_budget_1 = __nccwpck_require__(99786); const logging_ports_1 = __nccwpck_require__(6152); const workspace_changes_1 = __nccwpck_require__(93370); @@ -94824,11 +95014,37 @@ class BranchSyncWorkspaceAdapter { await this.git.configureAuthor(author.name, author.email); await this.git.stageAll(); await this.git.commit(message); - const sha = await this.read("git", ["rev-parse", "HEAD"]); - await this.git.push(workingBranch, token); + const originalSha = (0, branch_sync_publication_1.admittedBranchSyncObjectId)(this.snapshot.childSha); + // Commit succeeded: never abort/reset this candidate, even if identity/read/push now fails. this.snapshot = undefined; this.mergeInProgress = false; - return sha; + this.resolutionValidated = false; + let candidateSha = 'unavailable'; + let pushAttempted = false; + try { + candidateSha = (0, branch_sync_publication_1.admittedBranchSyncObjectId)(await this.read('git', ['rev-parse', 'HEAD'])); + if (!(0, branch_sync_publication_1.isBranchSyncObjectId)(candidateSha) || !(0, branch_sync_publication_1.isBranchSyncObjectId)(originalSha) || candidateSha === originalSha) { + candidateSha = 'unavailable'; + throw new Error('Candidate identity is unavailable.'); + } + pushAttempted = true; + await this.git.push(workingBranch, token); + return candidateSha; + } + catch (error) { + let observedSha = 'unavailable'; + try { + await this.git.fetch(workingBranch, token); + observedSha = (0, branch_sync_publication_1.admittedBranchSyncObjectId)(await this.read('git', ['rev-parse', 'FETCH_HEAD'])); + } + catch { /* Missing observation never proves non-publication. */ } + const remoteState = (0, branch_sync_publication_1.isBranchSyncObjectId)(candidateSha) && observedSha === candidateSha ? 'published' + : (0, branch_sync_publication_1.isBranchSyncObjectId)(originalSha) && observedSha === originalSha ? 'unchanged' + : (0, branch_sync_publication_1.isBranchSyncObjectId)(originalSha) && (0, branch_sync_publication_1.isBranchSyncObjectId)(observedSha) ? 'changed' : 'unverified'; + const reason = !pushAttempted ? 'candidate-unverified' + : (0, git_push_failure_1.admittedGitPushDenial)(error) === 'workflow-write-required' ? 'workflow-write-required' : 'push-failed'; + throw new branch_sync_publication_1.BranchSyncPublicationFailure({ reason, remoteState, originalSha, candidateSha, observedSha, pushAttempted }); + } } async abort() { if (!this.mergeInProgress) @@ -94918,7 +95134,9 @@ class BranchSyncWorkspaceAdapter { } async readRaw(program, args) { const chunks = []; - await this.git.execute(program, args, { stdout: (data) => chunks.push(data) }); + if (await this.git.execute(program, args, { stdout: (data) => chunks.push(data) }) !== 0) { + throw new Error('Git workspace observation did not complete successfully.'); + } return Buffer.concat(chunks).toString("utf8"); } } @@ -97504,6 +97722,8 @@ var __importStar = (this && this.__importStar) || (function () { })(); Object.defineProperty(exports, "__esModule", ({ value: true })); exports.GitCommitAdapter = void 0; +const git_push_failure_1 = __nccwpck_require__(60353); +const git_push_diagnostic_1 = __nccwpck_require__(67062); const verification_executable_1 = __nccwpck_require__(20878); const native_verification_profiles_1 = __nccwpck_require__(22290); const exec = __importStar(__nccwpck_require__(18538)); @@ -97520,8 +97740,9 @@ class GitCommitAdapter { ? exec.exec(options.resolveFromEnvironment && options.env ? (0, verification_executable_1.verificationExecutable)(program, options.env) : program, args, { // Captured Git reads can contain NUL-delimited records that // hide subsequent lines in GitHub's Actions log viewer. - ...(options.stdout ? { silent: true } : {}), - ...(options.stdout ? { listeners: { stdout: options.stdout } } : {}), + ...(options.stdout || options.stderr ? { silent: true } : {}), + ...(options.stdout || options.stderr ? { listeners: { ...(options.stdout ? { stdout: options.stdout } : {}), + ...(options.stderr ? { stderr: options.stderr } : {}) } } : {}), ...(options.env ? { env: options.env } : {}), }) : exec.exec(program, args), checkPrerequisites = verification_runtime_preflight_1.assertVerificationRuntimePrerequisites, verificationSetup = verification_setup_ports_1.DISABLED_VERIFICATION_SETUP, prepareSourceDependencies = (0, source_verification_dependencies_1.captureSourceDependencyPreparation)()) { @@ -97562,7 +97783,9 @@ class GitCommitAdapter { await this.execute('git', ['config', 'user.email', email]); } async fetch(branch, token) { - await this.executeAuthenticated(['fetch', 'origin', branch], token); + if (await this.executeAuthenticated(['fetch', 'origin', branch], token) !== 0) { + throw new Error('Git fetch did not complete successfully.'); + } } async stageAll() { await this.execute('git', ['add', '-A']); @@ -97575,15 +97798,23 @@ class GitCommitAdapter { await (0, git_hook_isolation_1.withoutGitHooks)(emptyHooks => this.execute('git', ['-c', `core.hooksPath=${emptyHooks}`, 'commit', '-m', message], { untrusted: true })); } async push(branch, token) { - await this.executeAuthenticated(['push', 'origin', branch], token); + const diagnostic = (0, git_push_diagnostic_1.captureGitPushDiagnostic)(); + try { + const code = await this.executeAuthenticated(['push', 'origin', branch], token, diagnostic.stderr); + if (code !== 0) + throw new git_push_failure_1.GitPushFailure(diagnostic.classify()); + } + catch { + throw new git_push_failure_1.GitPushFailure(diagnostic.classify()); + } } - async executeAuthenticated(args, token) { + async executeAuthenticated(args, token, stderr) { if (!token?.trim()) { - await this.execute('git', args); - return; + return stderr ? this.execute('git', args, { stderr }) : this.execute('git', args); } const environment = (0, git_authentication_environment_1.buildGitAuthenticationEnvironment)(token); - await this.execute('git', args, { + return this.execute('git', args, { + ...(stderr ? { stderr } : {}), // Supply authentication only to this trusted git subprocess. The // agent process never receives this value and nothing is persisted // in the repository's git configuration or remote URL. @@ -97618,6 +97849,35 @@ async function withoutGitHooks(commit) { } +/***/ }), + +/***/ 67062: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.GIT_PUSH_DIAGNOSTIC_BYTES = void 0; +exports.captureGitPushDiagnostic = captureGitPushDiagnostic; +exports.GIT_PUSH_DIAGNOSTIC_BYTES = 8192; +/** Bounded suffix keeps the terminal remote rejection after progress output. */ +function captureGitPushDiagnostic() { + let tail = Buffer.alloc(0); + return { + stderr: data => { + const part = data.subarray(Math.max(0, data.length - exports.GIT_PUSH_DIAGNOSTIC_BYTES)); + tail = Buffer.concat([tail.subarray(Math.max(0, tail.length - (exports.GIT_PUSH_DIAGNOSTIC_BYTES - part.length))), part]); + }, + classify: () => { + const text = tail.toString('utf8').replace(/[`'"]/gu, ''); + // Path presence, arbitrary exception text and generic authorization errors are not scope proof. + return /refusing to allow (?:a Personal Access Token|an OAuth App|a GitHub App) to (?:create or update|create|update) workflow\b[^\r\n]*\bwithout (?:workflow scope|workflows permission)\b/iu.test(text) + ? 'workflow-write-required' : 'unclassified'; + }, + }; +} + + /***/ }), /***/ 60029: diff --git a/docs/issues/branch-synchronization.mdx b/docs/issues/branch-synchronization.mdx index 4a581b768..d315727a4 100644 --- a/docs/issues/branch-synchronization.mdx +++ b/docs/issues/branch-synchronization.mdx @@ -232,7 +232,7 @@ If verification prevents a normal Git abort, Copilot restores the captured child only while both prepared merge identities remain unchanged. A moved HEAD or parent is retained and requires inspection of the run. -If any boundary fails, Copilot aborts the merge and does not push. Immediately before committing, it fetches both branches again and rejects stale work. Git credentials are scoped only to trusted fetch/push subprocesses and are never exposed to the agent or verification commands. +If a precommit boundary fails, Copilot attempts to abort the prepared merge and does not push. A completed commit is preserved when later publication fails; see the recovery states below. Immediately before committing, it fetches both branches again and rejects stale work. Git credentials are scoped only to trusted fetch/push subprocesses and are never exposed to the agent or verification commands. ## Result report @@ -242,7 +242,9 @@ Every command finishes with a comment in the conversation. The report distinguis - dry-run clean or conflicted; - merged cleanly without an agent; - merged after fixer-agent conflict resolution; or -- safely aborted, with a publishable reason and no provider-secret details. +- safely aborted before commit, with a publishable reason and no provider-secret details; +- committed locally but publication rejected or unverified; or +- the exact committed candidate independently confirmed on the remote after a failed push response, without a second push. When workspace validation rejects a resolution, the reply and workflow log include a closed diagnostic code such as `sdd-catalog-invalid`, @@ -271,6 +273,31 @@ adds a generic action summary to the conversation. Successful reports include the parent and working branches, verification count, and merge commit SHA in the structured run result. The subsequent push observer resolves the prior stale-branch card. +### Recover a committed candidate after publication failure + +A successful commit ends the abortable merge phase. If the push fails, Copilot +keeps that workspace and makes one authenticated observation of the existing +working branch. It reports the original, candidate and observed commit identities, +whether push was attempted, and one of these results: + +| Observation | Meaning | Next action | +| --- | --- | --- | +| Original head | Candidate not observed as published | Inspect the rejection before a fresh managed synchronization. | +| Exact candidate | Publication independently confirmed | Command completes; no second push is sent. | +| Other head | Another current head exists; ancestry is not established | Inspect native branch history before retrying. | +| Unavailable | Publication remains unknown | Establish the current remote state before retrying. | + +An unavailable candidate identity stops the push. Runner-local candidates are +temporary, not durable backups. Recovery does not abort/reset a committed +workspace, force-push, create backup branches or modify credentials. + +An explicit remote workflow-write rejection becomes nonretryable +`authorization.denied`, with localized instructions to review the actual +publisher credential. A workflow path alone does not prove insufficient scope. +See [workflow-write recovery](/security-operations/operations/troubleshooting#branch-sync-workflow-write-recovery) +for credential-specific guidance. Existing branch ownership, verification, +security and coverage gates still apply. + ### Validate the synchronized pull request The synchronization report confirms its configured verification commands and diff --git a/docs/security-operations/operations/troubleshooting.mdx b/docs/security-operations/operations/troubleshooting.mdx index 8b9152510..735b30dbe 100644 --- a/docs/security-operations/operations/troubleshooting.mdx +++ b/docs/security-operations/operations/troubleshooting.mdx @@ -651,6 +651,46 @@ No new configuration, automatic conversion, date repair, evidence omission or verification bypass is introduced. An incompatible catalog stays unchanged; only an already-authorized generated index can be refreshed after SDK validation. +## Branch sync workflow write recovery + +A parent merge may include `.github/workflows/` files. If GitHub explicitly +rejects publication because the configured publisher lacks workflow-write +capability, Copilot reports nonretryable `authorization.denied`, not a generic +retryable synchronization failure. The public result includes only closed +reason/state codes and commit identities; raw provider stderr, credentials and +private paths are withheld. + +1. Inspect the current native working-branch head and the reported original, + candidate and observed identities. A known original head means the candidate + was not observed as published; a different head requires history inspection. + An unavailable observation establishes no publication outcome. An exact + candidate observed remotely completes the command without a duplicate push. +2. Have the credential owner review the credential actually used by Git push. + Classic PAT/OAuth publishers need the applicable `workflow` scope. GitHub's + documented same-path/same-content-on-another-branch exception means that a + workflow path alone is not evidence of missing capability. Fine-grained PATs + and GitHub Apps have their own Workflows write permission. Review the + credential's normal configuration and organization policy; changing a job's + `contents: write` does not expand the scopes of a separate PAT. +3. Adopt the corrected runtime through the existing installation route on the + workflow's runtime branch. After the owner corrects a proven capability + problem, rerun `/copilot sync-branch` from the current managed PR. Repeating + the same rejected credential does not repair authorization. The fresh Action + must still verify current branch heads, conflicts and all configured checks. + +Copilot does not broaden credentials, refresh tokens, force-push or ask the +operator to reset a user checkout. Once commit finishes, recovery preserves +that workspace and never invokes merge abort for the publication failure. +Runner-local storage is temporary; the reported candidate is not a promised +persistent backup. If candidate identity cannot be verified, no push is attempted. +Other push failures use nonretryable `provider.unavailable` with explicit +publication uncertainty and the same observation/inspection contract. + +Provider references: [OAuth/classic scopes](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps) +and [fine-grained permission requirements](https://docs.github.com/en/rest/authentication/permissions-required-for-fine-grained-personal-access-tokens). +These explain capabilities; a local rejection fixture is not real GitHub +credential validation or evidence of execution in a consumer repository. + ## Repository setup blocks verification `Repository setup blocked verification` means preparation did not establish a safe diff --git a/quality/branch-sync/evidence/issue-844.json b/quality/branch-sync/evidence/issue-844.json new file mode 100644 index 000000000..bc0192656 --- /dev/null +++ b/quality/branch-sync/evidence/issue-844.json @@ -0,0 +1,167 @@ +{ + "schemaVersion": 1, + "issue": 844, + "gitBase": "c1d593dc72637e31e3dba0785e846b5548b9a4b0", + "kind": "Controlled real local Git regression using actual SDK source and use case", + "limitations": [ + "A local bare Git hook models the reported denial text; it does not validate real GitHub credential capability.", + "No real credential, provider write, consumer execution or historical consumer branch state is claimed.", + "The after result is bound to the listed working-tree source hashes; its Git base is not presented as an immutable after commit." + ], + "before": { + "remoteBefore": "38e159a7c7261a4acf3c5c2b0c11b84636c77d5f", + "remoteAfter": "38e159a7c7261a4acf3c5c2b0c11b84636c77d5f", + "candidate": "c541b4d9d879d387edaee1b2caaec2ec368c38a7", + "parent": "68a652ae52c0cf101f0a818ea5c13d70fd425af7", + "candidateParents": [ + "38e159a7c7261a4acf3c5c2b0c11b84636c77d5f", + "68a652ae52c0cf101f0a818ea5c13d70fd425af7" + ], + "abortCalls": 1, + "controls": { + "remoteUnchanged": true, + "userWorktreeUnchanged": true, + "candidateCommitted": true, + "candidateParentsMatch": true, + "noForcePush": true, + "fixerUnused": true + }, + "expected": { + "actionableWorkflowAuthorization": false, + "nonretryable": false, + "structuredRecovery": false, + "noPostCommitMergeAbort": false + }, + "result": { + "success": false, + "steps": [ + "Branch synchronization failed safely; no push was completed." + ], + "errors": [ + { + "code": "workflow.failed", + "retryable": true, + "message": "Branch synchronization failed safely; no push was completed." + } + ] + } + }, + "after": { + "remoteBefore": "38e159a7c7261a4acf3c5c2b0c11b84636c77d5f", + "remoteAfter": "38e159a7c7261a4acf3c5c2b0c11b84636c77d5f", + "candidate": "c541b4d9d879d387edaee1b2caaec2ec368c38a7", + "parent": "68a652ae52c0cf101f0a818ea5c13d70fd425af7", + "candidateParents": [ + "38e159a7c7261a4acf3c5c2b0c11b84636c77d5f", + "68a652ae52c0cf101f0a818ea5c13d70fd425af7" + ], + "abortCalls": 0, + "controls": { + "remoteUnchanged": true, + "userWorktreeUnchanged": true, + "candidateCommitted": true, + "candidateParentsMatch": true, + "noForcePush": true, + "fixerUnused": true + }, + "expected": { + "actionableWorkflowAuthorization": true, + "nonretryable": true, + "structuredRecovery": true, + "noPostCommitMergeAbort": true + }, + "result": { + "success": false, + "steps": [ + "The local merge commit step finished; inspect the publication facts before retrying." + ], + "errors": [ + { + "code": "authorization.denied", + "retryable": false, + "recovery": { + "id": "branch-sync-workflow-write-required", + "variables": { + "reason": "workflow-write-required", + "remoteState": "unchanged", + "originalSha": "38e159a7c7261a4acf3c5c2b0c11b84636c77d5f", + "candidateSha": "c541b4d9d879d387edaee1b2caaec2ec368c38a7", + "observedSha": "38e159a7c7261a4acf3c5c2b0c11b84636c77d5f", + "pushAttempted": true + } + }, + "message": "Branch synchronization candidate publication requires maintainer review." + } + ] + } + }, + "afterRuntimeSourceSha256": { + "src/infrastructure/git_commit_adapter.ts": "a4b7822e061763fbeb869b08b5f2dce53f2f4e8147b3d968cf12d44f3aa2824b", + "src/domain/git_push_failure.ts": "1866cae812fb0b8c2ceb9c90ce451a267d5e1219cf41ebdab32fa7d9233ab06b", + "src/infrastructure/git_push_diagnostic.ts": "4f344291f5bc28f4114d2e2c5b1daeeac46749676b12241adfad7fbe3563ca8c", + "src/infrastructure/verification_executable.ts": "8aebfe8ea68733006502b330fb0442725b68d020834505d025ba766c5314d2b2", + "src/infrastructure/native_verification_profiles.ts": "0f738257a6761f681c9746c2fc38f423a8694e14dbb96f288c060034056abfa8", + "src/application/policies/verification_setup_policy.ts": "007b9e14ba01bb336774c122f68ac153e259f762b987741ff42c78768d329fbe", + "src/infrastructure/native_verification_protocol.ts": "fae03f50993c75c1aaa8e55fd7e2c9264207a93ca6ecfae0ada9037d0c35c990", + "src/infrastructure/git_authentication_environment.ts": "a27b333ff8fe5a8e1fdb648a82f93ea06b09c1e0f62064494d90a28e30f25b06", + "src/infrastructure/untrusted_command_environment.ts": "a061ce69f191419313d9909206579e94ad02631187df8ca46ac7185d4beda343", + "src/infrastructure/agents/agent_private_install_root.ts": "2286574605d0664994209d1dac6a4f0a2922d4febd9dd87dd4b6d8b6defe8dee", + "src/infrastructure/agents/windows_runtime_acl.ts": "b904b8b40e6d953dd1b55be2eef1ac36cd6a280e3604ec0ac2bdbbf040be465c", + "src/infrastructure/private_jvm_environment.ts": "9d109d2fbf51c4f5dfe99cd71cb445707a3072c449af78c2e6a79c79f178fa38", + "src/infrastructure/git_hook_isolation.ts": "bafe75ce72f6ff4f2cc25bc04b3f615f1aef837808f8125374a35b33ad884bd6", + "src/infrastructure/verification_runtime_preflight.ts": "fffc8dd2cdb09aba1068a385912e7976cb16d0efa4e0df2edda3804fe10c3406", + "src/infrastructure/verification_runtime_manifest.ts": "aa1c6dcdf2d4a641afbd0d82db1a11757a3357886cd7051871acdb80881d465a", + "src/application/policies/verification_runtime_policy.ts": "e404d7f4e80565765fc268dee0d7e21af4c9bde87588d9a4f05f296f9be7005a", + "src/application/ports/verification_setup_ports.ts": "789ead2f66cccaf859a5214c5acc87d267bccea10df8fa3bd6f939b265d7858c", + "src/infrastructure/verification_setup_runner.ts": "42dc18b085cebcae4f7dc59de589fd1619a784291d80ca1825611b41bbd86f7f", + "src/infrastructure/verification_setup_snapshot.ts": "f0bef2064e008b644e27b54929ad2e17d15a9fc86f6bae9c8edda14e5480c69a", + "src/infrastructure/verification_setup_result.ts": "e5ed349fed4f190c0b7ffbef76c498a986ba0efac4e56993270010f293d0f96a", + "src/application/ports/logging_ports.ts": "0882fe9b4f118d27d0f6a6fb4a2f2d8ea60fd442e1d219fab1f7f1765ccbff86", + "src/infrastructure/native_verification_staging.ts": "fc980657994249456546b189f7b2b735d64997eafebd22a7535b98e4030b0a34", + "src/infrastructure/native_verification_candidate.ts": "5de354e229df9fd0f9eb38e45e87c45d0a1301e8dee58e789812c1f4ed6a83cc", + "src/infrastructure/source_verification_dependencies.ts": "c146de0e2f05970aa3d1f3c40effb21d2049e4e97bed42bc12c53441ef37623b", + "src/application/policies/source_verification_dependency_policy.ts": "71c53dd24a67b5e405e15cad4c27c4e75dfc514e277c42256085dc82cb55508b", + "src/infrastructure/verification_python_library_environment.ts": "bdd54af57fea1eaef531099f2a632f15c68ce42d9a86af13ed294e74a634e251", + "src/infrastructure/branch_sync_workspace_adapter.ts": "4453b9389252a0efc52bfa379266011b2f6805c5b5d7b075cbb53cef069e6bdd", + "src/domain/branch_sync_publication.ts": "0688ba7783dac5b1f9c2ed5365bf73d1354b8c883d61fa39eca141d4bc18ae4c", + "src/infrastructure/branch_sync_communication_budget.ts": "dd1473a176e61a8cf459438e5549d715a882d972400450cba386ea8f5fba8bb4", + "src/application/usecases/steps/commit/bugbot/workspace_changes.ts": "cefe549380bcf5b1b97d69dc3122ef21a2dacd5d42303bce4ed727bd97caa683", + "src/infrastructure/branch_sync_sdd_catalog.ts": "f31f06cfe57db02f9dcd4c00142d35b546c1226661fbccadeae1d76f5cfd15a3", + "src/domain/sdd_catalog_compatibility.ts": "150d56f78b12133d61c60d3321abbde254b23be8d55144f865bddae2b53b5d11", + "src/domain/branch_sync_catalog_diagnostic.ts": "128594113af2b19bd986b672fbe3f75bef06c14e783b96199e27efd3c544ccfb", + "src/application/usecases/branch_sync/sync_branch_use_case.ts": "223426b53fd3b82a93f6dbaa51ed65a8778fb13e06a395f78ab1378630c39cea", + "src/domain/branch_sync_validation.ts": "946db4814e7a3ac6a7b9aca650a68d7deb524388ace03b45874767cad76e2f96", + "src/prompts/branch_sync_conflicts.ts": "bc3ee190d4c14c5dde1664bd417ff37638639ae82b61dfdaa8c80148eee2aba3", + "src/prompts/fill.ts": "89b15cac94d39076b0fd61d795d844071e05458b2389e06eddccf41c45c29661", + "src/domain/security/untrusted_content.ts": "e3fdf13bb529c24501530c906c1210869e59caa9831544af8044d8c2a3ee3432", + "src/domain/bugbot/framed_review.ts": "dae4c15635e32b64a214fbe86473238c677fe4448afef888e55582352657e622", + "src/application/usecases/steps/commit/bugbot/verify_command_policy.ts": "1381b3578e760db92bc7f00bc02bac4b9d430f4b49792e6747da75179bb79207", + "src/application/usecases/steps/commit/bugbot/verify_command_runner.ts": "ef757e2fb434f1b9ff60270431845b47735764d72e34fcbe1a738ab538eb1258", + "src/domain/verification_failure.ts": "d54b2ab90c3ae10ebda1f963e2a7ac16f46b6c3c52dd8f2ddd669391039df0b8", + "src/application/usecases/branch_sync/branch_sync_execution_policy.ts": "00a6caaa4ed7f1d5347fb7f52a6f8ac6b22bea80cced37a274e6fdee5e0b1c28", + "src/data/model/agent.ts": "ddcd59e03470b294f2cab96c3e94f98de5fdee4b1d1e4fafb2e282aa38369d98", + "src/domain/agent.ts": "da7db9ecfa47be01d8d5967434fcd83d368ec47db1d709477bbafdd1b0bc4c46", + "src/data/model/result.ts": "42c86823bf0feb2f75d2a258a2a08e208d6aa6435e7e5d5c48b87ed8e4505148", + "src/application/errors/application_error.ts": "3354b677a12fb53219acc10982520eebcbde13101579dba5e4b36ec9c93ebedf", + "src/data/model/application_error.ts": "f2dd4c5bcf67ddaec7b0ffb1af1c9d1a32336776e6f6a61018d6593ca14d0070", + "src/domain/progress_response_diagnostic.ts": "0bec7679f13fb285d6bdbdff334aa29a3639e33d24015c4b79ce3ee8aeece1e6", + "src/domain/agent_execution_plan.ts": "3054a0ab72a0ec21b02497c106a3b6bc39fe603ff4d5e71911c25a12c0875db8", + "src/domain/progress_completion_diagnostic.ts": "ffede5fe9246d29c64c303380851980e002955bd517249bee932742bf17050dd", + "src/domain/progress_inventory_diagnostic.ts": "366677dcea6f39b4459213083081a7271aa5b3d08eeeed7b039b3bacf0b2f2f3", + "src/domain/progress_evidence_path.ts": "ed26c13a1f533fecbff2dcf6ba0d4fe4d6a895498e2e32396fe2532e3f326731", + "src/domain/progress_partition_length_failure.ts": "750927b2dca1505521f9a76291b4efbd8182524ab16f35adcf152dfee81c3c11", + "src/domain/progress_binary_evidence_diagnostic.ts": "67edba4c77c99407a27899efe4b825072f8bd2d8fbce60cd8698ee4881f20523", + "src/domain/issue_workflow_diagnostic.ts": "222678371aa820b2233a64988c3ce08699cf602825df1f7b4c37bc2c6edc7a7d", + "src/domain/issue_workflow_profile.ts": "d9d91f05c27357471e1be4be3c701b753638d21f7ca302fd6a830186dc91703d", + "src/domain/issue_workflow_body.ts": "c2ca92310b50b5d2ca9c523a7805f61258534c4dcef42c6a3a10038807cd6dbb", + "src/domain/issue_workflow_field_mapping.ts": "b73d69454eaacde0f8d317fbdea543897e5ae7874d0c1ba4fb5a7e02992a789a", + "src/domain/strict_json_keys.ts": "74508d6153f5a5daf6edfd61c698c878a65b43b637c0eedec9273c5cb77ef92a", + "src/domain/change_size_reconciliation.ts": "dc7e0e328c48900a55a252b75faa5297caba9a1ab35feaef3528f1a6687cffc7", + "src/domain/copilot_command_validation.ts": "4ce738fb6b0546ab22fd8a4934402e677164ddd50a79a5a2fe81f1f2332c838e", + "src/domain/closed_issue_reconciliation.ts": "500facde07d6d9b5d6689f5834403972915b89cecc10bae1cb22056d94f66420", + "src/domain/approval_evidence_diagnostic.ts": "0202e5fade44191ef5341d987d2f3d95bace9f7ffdc2ef5d9d5331088d07a0e8", + "src/domain/agent_source_snapshot.ts": "0ae2e7f6b445f67ade3cedf755f7652e04b95b5611161f49225db7b25fb81e86", + "src/application/errors/application_error_context.ts": "1deff1c70f8913c378347a3e65075d615204d7932ee0c372a42ec546a4f32a73" + }, + "executableRegressions": "src/tooling/__tests__/branch_sync_publication_integration.test.ts" +} diff --git a/scripts/coverage-budgets.json b/scripts/coverage-budgets.json index 4fb9dbe51..3e4665c6f 100644 --- a/scripts/coverage-budgets.json +++ b/scripts/coverage-budgets.json @@ -1407,6 +1407,23 @@ } ], "successMessage": "immutable SDD source context coverage: PASS (100% lines/statements/branches/functions)" + }, + { + "name": "Branch sync publication recovery", + "missingEntryLabel": "branch sync publication recovery", + "rules": [ + { + "files": [ + "src/domain/branch_sync_publication.ts", + "src/domain/git_push_failure.ts", + "src/infrastructure/git_push_diagnostic.ts", + "src/application/policies/branch_sync_publication_messages.ts" + ], + "mode": "each", + "thresholdProfile": "exhaustive" + } + ], + "successMessage": "branch sync publication recovery: PASS (closed facts, bounded classification and locale content100%)" } ] } diff --git a/specs/CATALOG.md b/specs/CATALOG.md index 43cd5c154..dff457e3c 100644 --- a/specs/CATALOG.md +++ b/specs/CATALOG.md @@ -24,7 +24,7 @@ debt or convert unknown historic intent into a design decision. | `managed-issue-lifecycle` | As-built baseline | Convert typed issues into traceable work branches, project state, and lifecycle state | [Managed issue and branch lifecycle](./managed-issue-and-branch-lifecycle.md) | 75 paths · 2026-10-30 | | `comment-automation` | Implemented | Admit only explicit commands or exact mentions, then route them while protecting repository mutations | [Comment automation and authorization](./comment-automation-and-authorization.md) | 88 paths · 2026-10-26 | | `bugbot-analysis-and-autofix` | Implemented | Select one canonical PR, exhaustively analyze bounded diff partitions with version-grounded dependency, policy-to-request transformation and source-bound assertion and generated-source coordinates with independent candidate verification, configuration-selected initial-session credential ownership, delegated test-path and external Action input-contract, host-aware self-repository references and Kotlin/Wasm lexical-binding and renderer-grounded Markdown path evidence, publish stable findings atomically, and apply authorized verified fixes with regression coverage for cwd/pattern-grounded indirect unittest discovery and source-bound module execution evidence, with active-instance Kotlin Json nullable-input policy grounding; verify complete large diffs through a source-bound closed framed profile with immutable local Git proof, live-base freshness and atomic global publication; ground Python exception syntax in declared interpreter versions and PEP 758 while preserving older-target and independent runtime defects; ground filesystem recovery findings in retention/no-overwrite, ownership and publication contracts while preserving required disposable cleanup and data-loss defects; independently adjudicate candidates for supplied Python source, retaining existing bounded source, identity and history controls; trace concrete helper defaults/overrides, database-file parent containment and stored query values before judging assertions, preserving exact fragment coordinates and genuine assertion failures; co-deliver complete long discussion to every source-bound partition within closed native prompt and invocation traffic limits, preserving provenance and truthful provider partial coverage | [Bugbot analysis, finding publication, and autofix](./bugbot-analysis-publication-and-autofix.md) + 7 companion | 198 paths · 2026-11-03 | -| `branch-synchronization` | Implemented | Observe parent drift with one localized status card and transition-only notifications, then safely merge a parent branch into a linked working branch | [Branch synchronization and conflict recovery](./branch-synchronization-and-conflict-recovery.md) + 2 companion | 151 paths · 2026-10-30 | +| `branch-synchronization` | Implemented | Observe parent drift with one localized status card and transition-only notifications, then safely merge a parent branch into a linked working branch | [Branch synchronization and conflict recovery](./branch-synchronization-and-conflict-recovery.md) + 3 companion | 159 paths · 2026-11-03 | | `pull-request-lifecycle` | Implemented | Enrich linked and unlinked pull requests with safe issue linkage, metadata, concise descriptions preserving validated closing intent, and distinct workflow evidence | [Pull request lifecycle and enrichment](./pull-request-lifecycle-and-enrichment.md) | 98 paths · 2026-10-30 | | `agent-runtime` | Implemented | Resolve, provision, authenticate, authorize, and execute only the agent roles reachable by a run | [Agent runtime, provider, model, and role routing](./agent-runtime-provider-and-model-routing.md) + 1 companion | 116 paths · 2026-11-02 | | `cli-and-single-actions` | As-built baseline | Expose bounded local commands and workflow-dispatched operations through the shared application core | [CLI and single-action execution](./cli-and-single-action-execution.md) | 51 paths · 2026-10-28 | @@ -192,12 +192,12 @@ debt or convert unknown historic intent into a design decision. ### `branch-synchronization` — Branch synchronization and conflict recovery - Owner: Copilot maintainers -- Last verified: 2026-10-30 -- Specifications: [`specs/branch-synchronization-and-conflict-recovery.md`](./branch-synchronization-and-conflict-recovery.md) · [`specs/repository-verification-runtime-prerequisites.md`](./repository-verification-runtime-prerequisites.md) · [`specs/repository-verification-setup-hooks.md`](./repository-verification-setup-hooks.md) +- Last verified: 2026-11-03 +- Specifications: [`specs/branch-synchronization-and-conflict-recovery.md`](./branch-synchronization-and-conflict-recovery.md) · [`specs/repository-verification-runtime-prerequisites.md`](./repository-verification-runtime-prerequisites.md) · [`specs/repository-verification-setup-hooks.md`](./repository-verification-setup-hooks.md) · [`specs/branch-sync-publication-recovery.md`](./branch-sync-publication-recovery.md) - Workflows: [`.github/workflows/copilot_branch_sync.yml`](../.github/workflows/copilot_branch_sync.yml) · [`setup/workflows/copilot_branch_sync.yml`](../setup/workflows/copilot_branch_sync.yml) · [`.github/workflows/copilot_issue.yml`](../.github/workflows/copilot_issue.yml) · [`setup/workflows/copilot_issue.yml`](../setup/workflows/copilot_issue.yml) · [`setup/workflows/copilot_issue_comment.yml`](../setup/workflows/copilot_issue_comment.yml) · [`setup/workflows/copilot_pull_request_comment.yml`](../setup/workflows/copilot_pull_request_comment.yml) · [`.github/workflows/copilot_comment_runtime.yml`](../.github/workflows/copilot_comment_runtime.yml) · [`.github/workflows/copilot_issue_comment.yml`](../.github/workflows/copilot_issue_comment.yml) · [`.github/workflows/copilot_pull_request_comment.yml`](../.github/workflows/copilot_pull_request_comment.yml) - Entrypoints: [`src/application/usecases/branch_sync/sync_branch_use_case.ts`](../src/application/usecases/branch_sync/sync_branch_use_case.ts) · [`src/application/usecases/actions/observe_branch_sync_use_case.ts`](../src/application/usecases/actions/observe_branch_sync_use_case.ts) -- Core code: [`src/domain/branch_sync_command.ts`](../src/domain/branch_sync_command.ts) · [`src/application/ports/branch_sync_ports.ts`](../src/application/ports/branch_sync_ports.ts) · [`src/application/policies/branch_sync_message_catalog.ts`](../src/application/policies/branch_sync_message_catalog.ts) · [`src/application/policies/branch_sync_notification_policy.ts`](../src/application/policies/branch_sync_notification_policy.ts) · [`src/application/usecases/branch_sync/branch_sync_execution_policy.ts`](../src/application/usecases/branch_sync/branch_sync_execution_policy.ts) · [`src/application/usecases/actions/observe_branch_sync_use_case.ts`](../src/application/usecases/actions/observe_branch_sync_use_case.ts) · [`src/application/usecases/push_single_action_contexts.ts`](../src/application/usecases/push_single_action_contexts.ts) · [`src/application/usecases/steps/common/transition_notification_workflow.ts`](../src/application/usecases/steps/common/transition_notification_workflow.ts) · [`src/data/repository/branch_sync/branch_dependency_policy.ts`](../src/data/repository/branch_sync/branch_dependency_policy.ts) · [`src/data/repository/branch_sync/branch_dependency_repository.ts`](../src/data/repository/branch_sync/branch_dependency_repository.ts) · [`src/infrastructure/composition/main_run_route_composition_root.ts`](../src/infrastructure/composition/main_run_route_composition_root.ts) · [`src/infrastructure/composition/push_single_action_capability_port_binding.ts`](../src/infrastructure/composition/push_single_action_capability_port_binding.ts) · [`src/infrastructure/branch_sync_workspace_adapter.ts`](../src/infrastructure/branch_sync_workspace_adapter.ts) · [`src/prompts/branch_sync_conflicts.ts`](../src/prompts/branch_sync_conflicts.ts) · [`src/data/model/application_error.ts`](../src/data/model/application_error.ts) · [`src/application/policies/application_error_message_catalog.ts`](../src/application/policies/application_error_message_catalog.ts) · [`src/infrastructure/git_commit_adapter.ts`](../src/infrastructure/git_commit_adapter.ts) · [`src/infrastructure/branch_sync_sdd_catalog.ts`](../src/infrastructure/branch_sync_sdd_catalog.ts) · [`src/domain/branch_sync_validation.ts`](../src/domain/branch_sync_validation.ts) · [`src/application/policies/lifecycle_state_policy.ts`](../src/application/policies/lifecycle_state_policy.ts) · [`src/infrastructure/branch_sync_communication_budget.ts`](../src/infrastructure/branch_sync_communication_budget.ts) · [`src/application/usecases/actions/retire_branch_sync_use_case.ts`](../src/application/usecases/actions/retire_branch_sync_use_case.ts) · [`src/application/ports/issue_completion_ports.ts`](../src/application/ports/issue_completion_ports.ts) · [`src/application/usecases/pull_request_workflow.ts`](../src/application/usecases/pull_request_workflow.ts) · [`src/application/usecases/pull_request_workflow_context.ts`](../src/application/usecases/pull_request_workflow_context.ts) · [`src/infrastructure/composition/pull_request_use_case_composition_root.ts`](../src/infrastructure/composition/pull_request_use_case_composition_root.ts) · [`src/actions/common_action.ts`](../src/actions/common_action.ts) · [`src/actions/github_action.ts`](../src/actions/github_action.ts) · [`scripts/validate-workflow-contract.cjs`](../scripts/validate-workflow-contract.cjs) · [`src/application/ports/reply_request_read_ports.ts`](../src/application/ports/reply_request_read_ports.ts) · [`src/domain/reply_request_digest.ts`](../src/domain/reply_request_digest.ts) · [`src/infrastructure/composition/reply_request_composition_root.ts`](../src/infrastructure/composition/reply_request_composition_root.ts) · [`src/data/repository/reply_request_repository.ts`](../src/data/repository/reply_request_repository.ts) · [`src/application/policies/verification_runtime_policy.ts`](../src/application/policies/verification_runtime_policy.ts) · [`src/infrastructure/verification_runtime_manifest.ts`](../src/infrastructure/verification_runtime_manifest.ts) · [`src/infrastructure/verification_runtime_preflight.ts`](../src/infrastructure/verification_runtime_preflight.ts) · [`src/infrastructure/untrusted_command_environment.ts`](../src/infrastructure/untrusted_command_environment.ts) · [`src/application/ports/git_ports.ts`](../src/application/ports/git_ports.ts) · [`src/application/ports/bugbot_git_ports.ts`](../src/application/ports/bugbot_git_ports.ts) · [`src/application/usecases/steps/commit/bugbot/commit_and_push_preflight.ts`](../src/application/usecases/steps/commit/bugbot/commit_and_push_preflight.ts) · [`src/application/usecases/steps/commit/bugbot/verify_command_runner.ts`](../src/application/usecases/steps/commit/bugbot/verify_command_runner.ts) · [`src/domain/closed_issue_reconciliation.ts`](../src/domain/closed_issue_reconciliation.ts) · [`src/application/errors/closed_issue_reconciliation_error.ts`](../src/application/errors/closed_issue_reconciliation_error.ts) · [`src/application/policies/verification_setup_policy.ts`](../src/application/policies/verification_setup_policy.ts) · [`src/application/ports/verification_setup_ports.ts`](../src/application/ports/verification_setup_ports.ts) · [`src/infrastructure/verification_setup_snapshot.ts`](../src/infrastructure/verification_setup_snapshot.ts) · [`src/infrastructure/verification_setup_result.ts`](../src/infrastructure/verification_setup_result.ts) · [`src/infrastructure/verification_setup_runner.ts`](../src/infrastructure/verification_setup_runner.ts) · [`src/infrastructure/native_verification_protocol.ts`](../src/infrastructure/native_verification_protocol.ts) · [`src/infrastructure/native_verification_candidate.ts`](../src/infrastructure/native_verification_candidate.ts) · [`src/infrastructure/native_verification_staging.ts`](../src/infrastructure/native_verification_staging.ts) · [`src/infrastructure/native_verification_controller.ts`](../src/infrastructure/native_verification_controller.ts) · [`src/infrastructure/native_verification_profiles.ts`](../src/infrastructure/native_verification_profiles.ts) · [`src/infrastructure/native_verification_redaction.ts`](../src/infrastructure/native_verification_redaction.ts) · [`scripts/generate-native-verification-workflows.cjs`](../scripts/generate-native-verification-workflows.cjs) · [`src/infrastructure/verification_executable.ts`](../src/infrastructure/verification_executable.ts) · [`src/infrastructure/native_verification_source_runtime.ts`](../src/infrastructure/native_verification_source_runtime.ts) · [`scripts/prepare-native-source-action.cjs`](../scripts/prepare-native-source-action.cjs) · [`src/domain/copilot_comment_workflow.ts`](../src/domain/copilot_comment_workflow.ts) · [`src/infrastructure/copilot_comment_inputs.json`](../src/infrastructure/copilot_comment_inputs.json) · [`scripts/validate-comment-workflows.cjs`](../scripts/validate-comment-workflows.cjs) · [`src/domain/verification_failure.ts`](../src/domain/verification_failure.ts) · [`src/application/policies/source_verification_dependency_policy.ts`](../src/application/policies/source_verification_dependency_policy.ts) · [`src/infrastructure/source_verification_dependencies.ts`](../src/infrastructure/source_verification_dependencies.ts) · [`src/application/policies/branch_sync_relationship_retirement_policy.ts`](../src/application/policies/branch_sync_relationship_retirement_policy.ts) · [`src/application/usecases/actions/retire_unrelated_branch_sync_cards.ts`](../src/application/usecases/actions/retire_unrelated_branch_sync_cards.ts) · [`src/infrastructure/private_jvm_environment.ts`](../src/infrastructure/private_jvm_environment.ts) · [`src/tooling/__tests__/fixtures/verification_jvm_gradle_probe.cjs`](../src/tooling/__tests__/fixtures/verification_jvm_gradle_probe.cjs) · [`src/tooling/__tests__/fixtures/verification_jvm_gradle_container_fixture.ts`](../src/tooling/__tests__/fixtures/verification_jvm_gradle_container_fixture.ts) · [`src/tooling/__tests__/fixtures/verification_native_fixture.ts`](../src/tooling/__tests__/fixtures/verification_native_fixture.ts) · [`src/infrastructure/verification_python_library_environment.ts`](../src/infrastructure/verification_python_library_environment.ts) · [`src/tooling/__tests__/fixtures/verification_python_container_fixture.ts`](../src/tooling/__tests__/fixtures/verification_python_container_fixture.ts) · [`src/tooling/__tests__/fixtures/verification_python_probe.cjs`](../src/tooling/__tests__/fixtures/verification_python_probe.cjs) · [`src/tooling/__tests__/fixtures/verification_native_setup_action.ts`](../src/tooling/__tests__/fixtures/verification_native_setup_action.ts) · [`src/domain/branch_sync_catalog_diagnostic.ts`](../src/domain/branch_sync_catalog_diagnostic.ts) · [`src/application/policies/application_error_presentation_policy.ts`](../src/application/policies/application_error_presentation_policy.ts) -- Tests: [`src/application/policies/__tests__/branch_sync_notification_policy.test.ts`](../src/application/policies/__tests__/branch_sync_notification_policy.test.ts) · [`src/application/usecases/actions/__tests__/observe_branch_sync_use_case.test.ts`](../src/application/usecases/actions/__tests__/observe_branch_sync_use_case.test.ts) · [`src/application/usecases/branch_sync/__tests__/sync_branch_use_case.test.ts`](../src/application/usecases/branch_sync/__tests__/sync_branch_use_case.test.ts) · [`src/application/usecases/__tests__/push_single_action_contexts.test.ts`](../src/application/usecases/__tests__/push_single_action_contexts.test.ts) · [`src/application/usecases/steps/common/__tests__/transition_notification_workflow.test.ts`](../src/application/usecases/steps/common/__tests__/transition_notification_workflow.test.ts) · [`src/data/repository/branch_sync/__tests__/branch_dependency_policy.test.ts`](../src/data/repository/branch_sync/__tests__/branch_dependency_policy.test.ts) · [`src/data/repository/branch_sync/__tests__/branch_dependency_repository.test.ts`](../src/data/repository/branch_sync/__tests__/branch_dependency_repository.test.ts) · [`src/infrastructure/__tests__/branch_sync_workspace_adapter.test.ts`](../src/infrastructure/__tests__/branch_sync_workspace_adapter.test.ts) · [`src/infrastructure/composition/__tests__/push_single_action_capability_port_binding.test.ts`](../src/infrastructure/composition/__tests__/push_single_action_capability_port_binding.test.ts) · [`src/application/usecases/branch_sync/__tests__/branch_sync_execution_policy.test.ts`](../src/application/usecases/branch_sync/__tests__/branch_sync_execution_policy.test.ts) · [`src/application/errors/__tests__/application_error.test.ts`](../src/application/errors/__tests__/application_error.test.ts) · [`src/application/policies/__tests__/application_error_presentation_policy.test.ts`](../src/application/policies/__tests__/application_error_presentation_policy.test.ts) · [`src/infrastructure/__tests__/git_commit_adapter.test.ts`](../src/infrastructure/__tests__/git_commit_adapter.test.ts) · [`src/infrastructure/__tests__/branch_sync_sdd_catalog.test.ts`](../src/infrastructure/__tests__/branch_sync_sdd_catalog.test.ts) · [`src/infrastructure/__tests__/branch_sync_generated_bundle_validation.test.ts`](../src/infrastructure/__tests__/branch_sync_generated_bundle_validation.test.ts) · [`src/application/policies/__tests__/branch_sync_lifecycle_recovery.test.ts`](../src/application/policies/__tests__/branch_sync_lifecycle_recovery.test.ts) · [`src/infrastructure/__tests__/branch_sync_resolved_workspace.test.ts`](../src/infrastructure/__tests__/branch_sync_resolved_workspace.test.ts) · [`src/infrastructure/__tests__/branch_sync_communication_budget.test.ts`](../src/infrastructure/__tests__/branch_sync_communication_budget.test.ts) · [`src/application/usecases/actions/__tests__/retire_branch_sync_use_case.test.ts`](../src/application/usecases/actions/__tests__/retire_branch_sync_use_case.test.ts) · [`src/application/usecases/__tests__/pull_request_use_case.test.ts`](../src/application/usecases/__tests__/pull_request_use_case.test.ts) · [`src/actions/__tests__/common_action.test.ts`](../src/actions/__tests__/common_action.test.ts) · [`src/actions/__tests__/github_action.test.ts`](../src/actions/__tests__/github_action.test.ts) · [`src/tooling/__tests__/validate_workflow_contract.test.ts`](../src/tooling/__tests__/validate_workflow_contract.test.ts) · [`src/data/repository/__tests__/reply_request_repository.test.ts`](../src/data/repository/__tests__/reply_request_repository.test.ts) · [`src/infrastructure/composition/__tests__/reply_request_composition_root.test.ts`](../src/infrastructure/composition/__tests__/reply_request_composition_root.test.ts) · [`src/application/policies/__tests__/verification_runtime_policy.test.ts`](../src/application/policies/__tests__/verification_runtime_policy.test.ts) · [`src/infrastructure/__tests__/verification_runtime_preflight.test.ts`](../src/infrastructure/__tests__/verification_runtime_preflight.test.ts) · [`src/infrastructure/__tests__/verification_runtime_branch_sync.test.ts`](../src/infrastructure/__tests__/verification_runtime_branch_sync.test.ts) · [`src/infrastructure/__tests__/untrusted_command_environment.test.ts`](../src/infrastructure/__tests__/untrusted_command_environment.test.ts) · [`src/application/usecases/steps/commit/bugbot/__tests__/verify_command_runner.test.ts`](../src/application/usecases/steps/commit/bugbot/__tests__/verify_command_runner.test.ts) · [`src/application/usecases/steps/commit/bugbot/__tests__/commit_verification_workspace.test.ts`](../src/application/usecases/steps/commit/bugbot/__tests__/commit_verification_workspace.test.ts) · [`src/tooling/__tests__/verification_runtime_guidance.test.ts`](../src/tooling/__tests__/verification_runtime_guidance.test.ts) · [`src/application/policies/__tests__/verification_setup_policy.test.ts`](../src/application/policies/__tests__/verification_setup_policy.test.ts) · [`src/infrastructure/__tests__/verification_setup_snapshot.test.ts`](../src/infrastructure/__tests__/verification_setup_snapshot.test.ts) · [`src/infrastructure/__tests__/verification_setup_runner.test.ts`](../src/infrastructure/__tests__/verification_setup_runner.test.ts) · [`src/infrastructure/__tests__/verification_setup_configuration.test.ts`](../src/infrastructure/__tests__/verification_setup_configuration.test.ts) · [`src/infrastructure/__tests__/verification_setup_sync.test.ts`](../src/infrastructure/__tests__/verification_setup_sync.test.ts) · [`src/infrastructure/__tests__/native_verification_runner.test.ts`](../src/infrastructure/__tests__/native_verification_runner.test.ts) · [`src/infrastructure/__tests__/native_verification_protocol.test.ts`](../src/infrastructure/__tests__/native_verification_protocol.test.ts) · [`src/infrastructure/__tests__/native_verification_controller.test.ts`](../src/infrastructure/__tests__/native_verification_controller.test.ts) · [`src/infrastructure/__tests__/native_verification_redaction.test.ts`](../src/infrastructure/__tests__/native_verification_redaction.test.ts) · [`src/infrastructure/__tests__/native_verification_workflow.test.ts`](../src/infrastructure/__tests__/native_verification_workflow.test.ts) · [`src/infrastructure/__tests__/native_verification_profiles.test.ts`](../src/infrastructure/__tests__/native_verification_profiles.test.ts) · [`src/infrastructure/__tests__/verification_executable.test.ts`](../src/infrastructure/__tests__/verification_executable.test.ts) · [`src/infrastructure/__tests__/native_verification_post.test.ts`](../src/infrastructure/__tests__/native_verification_post.test.ts) · [`src/infrastructure/__tests__/native_verification_source_runtime.test.ts`](../src/infrastructure/__tests__/native_verification_source_runtime.test.ts) · [`src/domain/__tests__/copilot_comment_workflow.test.ts`](../src/domain/__tests__/copilot_comment_workflow.test.ts) · [`src/domain/__tests__/verification_failure.test.ts`](../src/domain/__tests__/verification_failure.test.ts) · [`src/infrastructure/__tests__/source_verification_dependencies.test.ts`](../src/infrastructure/__tests__/source_verification_dependencies.test.ts) · [`src/application/usecases/actions/__tests__/retire_unrelated_branch_sync_cards.test.ts`](../src/application/usecases/actions/__tests__/retire_unrelated_branch_sync_cards.test.ts) · [`src/infrastructure/__tests__/private_jvm_environment.test.ts`](../src/infrastructure/__tests__/private_jvm_environment.test.ts) · [`src/infrastructure/__tests__/verification_python_library_environment.test.ts`](../src/infrastructure/__tests__/verification_python_library_environment.test.ts) · [`src/infrastructure/__tests__/git_commit_python_runtime.test.ts`](../src/infrastructure/__tests__/git_commit_python_runtime.test.ts) · [`src/domain/__tests__/branch_sync_catalog_diagnostic.test.ts`](../src/domain/__tests__/branch_sync_catalog_diagnostic.test.ts) · [`src/infrastructure/__tests__/branch_sync_catalog_profiles.test.ts`](../src/infrastructure/__tests__/branch_sync_catalog_profiles.test.ts) · [`src/infrastructure/__tests__/branch_sync_catalog_compatibility_git.test.ts`](../src/infrastructure/__tests__/branch_sync_catalog_compatibility_git.test.ts) · [`src/infrastructure/__tests__/branch_sync_catalog_json_only.test.ts`](../src/infrastructure/__tests__/branch_sync_catalog_json_only.test.ts) +- Core code: [`src/domain/branch_sync_command.ts`](../src/domain/branch_sync_command.ts) · [`src/application/ports/branch_sync_ports.ts`](../src/application/ports/branch_sync_ports.ts) · [`src/application/policies/branch_sync_message_catalog.ts`](../src/application/policies/branch_sync_message_catalog.ts) · [`src/application/policies/branch_sync_notification_policy.ts`](../src/application/policies/branch_sync_notification_policy.ts) · [`src/application/usecases/branch_sync/branch_sync_execution_policy.ts`](../src/application/usecases/branch_sync/branch_sync_execution_policy.ts) · [`src/application/usecases/actions/observe_branch_sync_use_case.ts`](../src/application/usecases/actions/observe_branch_sync_use_case.ts) · [`src/application/usecases/push_single_action_contexts.ts`](../src/application/usecases/push_single_action_contexts.ts) · [`src/application/usecases/steps/common/transition_notification_workflow.ts`](../src/application/usecases/steps/common/transition_notification_workflow.ts) · [`src/data/repository/branch_sync/branch_dependency_policy.ts`](../src/data/repository/branch_sync/branch_dependency_policy.ts) · [`src/data/repository/branch_sync/branch_dependency_repository.ts`](../src/data/repository/branch_sync/branch_dependency_repository.ts) · [`src/infrastructure/composition/main_run_route_composition_root.ts`](../src/infrastructure/composition/main_run_route_composition_root.ts) · [`src/infrastructure/composition/push_single_action_capability_port_binding.ts`](../src/infrastructure/composition/push_single_action_capability_port_binding.ts) · [`src/infrastructure/branch_sync_workspace_adapter.ts`](../src/infrastructure/branch_sync_workspace_adapter.ts) · [`src/prompts/branch_sync_conflicts.ts`](../src/prompts/branch_sync_conflicts.ts) · [`src/data/model/application_error.ts`](../src/data/model/application_error.ts) · [`src/application/policies/application_error_message_catalog.ts`](../src/application/policies/application_error_message_catalog.ts) · [`src/infrastructure/git_commit_adapter.ts`](../src/infrastructure/git_commit_adapter.ts) · [`src/infrastructure/branch_sync_sdd_catalog.ts`](../src/infrastructure/branch_sync_sdd_catalog.ts) · [`src/domain/branch_sync_validation.ts`](../src/domain/branch_sync_validation.ts) · [`src/application/policies/lifecycle_state_policy.ts`](../src/application/policies/lifecycle_state_policy.ts) · [`src/infrastructure/branch_sync_communication_budget.ts`](../src/infrastructure/branch_sync_communication_budget.ts) · [`src/application/usecases/actions/retire_branch_sync_use_case.ts`](../src/application/usecases/actions/retire_branch_sync_use_case.ts) · [`src/application/ports/issue_completion_ports.ts`](../src/application/ports/issue_completion_ports.ts) · [`src/application/usecases/pull_request_workflow.ts`](../src/application/usecases/pull_request_workflow.ts) · [`src/application/usecases/pull_request_workflow_context.ts`](../src/application/usecases/pull_request_workflow_context.ts) · [`src/infrastructure/composition/pull_request_use_case_composition_root.ts`](../src/infrastructure/composition/pull_request_use_case_composition_root.ts) · [`src/actions/common_action.ts`](../src/actions/common_action.ts) · [`src/actions/github_action.ts`](../src/actions/github_action.ts) · [`scripts/validate-workflow-contract.cjs`](../scripts/validate-workflow-contract.cjs) · [`src/application/ports/reply_request_read_ports.ts`](../src/application/ports/reply_request_read_ports.ts) · [`src/domain/reply_request_digest.ts`](../src/domain/reply_request_digest.ts) · [`src/infrastructure/composition/reply_request_composition_root.ts`](../src/infrastructure/composition/reply_request_composition_root.ts) · [`src/data/repository/reply_request_repository.ts`](../src/data/repository/reply_request_repository.ts) · [`src/application/policies/verification_runtime_policy.ts`](../src/application/policies/verification_runtime_policy.ts) · [`src/infrastructure/verification_runtime_manifest.ts`](../src/infrastructure/verification_runtime_manifest.ts) · [`src/infrastructure/verification_runtime_preflight.ts`](../src/infrastructure/verification_runtime_preflight.ts) · [`src/infrastructure/untrusted_command_environment.ts`](../src/infrastructure/untrusted_command_environment.ts) · [`src/application/ports/git_ports.ts`](../src/application/ports/git_ports.ts) · [`src/application/ports/bugbot_git_ports.ts`](../src/application/ports/bugbot_git_ports.ts) · [`src/application/usecases/steps/commit/bugbot/commit_and_push_preflight.ts`](../src/application/usecases/steps/commit/bugbot/commit_and_push_preflight.ts) · [`src/application/usecases/steps/commit/bugbot/verify_command_runner.ts`](../src/application/usecases/steps/commit/bugbot/verify_command_runner.ts) · [`src/domain/closed_issue_reconciliation.ts`](../src/domain/closed_issue_reconciliation.ts) · [`src/application/errors/closed_issue_reconciliation_error.ts`](../src/application/errors/closed_issue_reconciliation_error.ts) · [`src/application/policies/verification_setup_policy.ts`](../src/application/policies/verification_setup_policy.ts) · [`src/application/ports/verification_setup_ports.ts`](../src/application/ports/verification_setup_ports.ts) · [`src/infrastructure/verification_setup_snapshot.ts`](../src/infrastructure/verification_setup_snapshot.ts) · [`src/infrastructure/verification_setup_result.ts`](../src/infrastructure/verification_setup_result.ts) · [`src/infrastructure/verification_setup_runner.ts`](../src/infrastructure/verification_setup_runner.ts) · [`src/infrastructure/native_verification_protocol.ts`](../src/infrastructure/native_verification_protocol.ts) · [`src/infrastructure/native_verification_candidate.ts`](../src/infrastructure/native_verification_candidate.ts) · [`src/infrastructure/native_verification_staging.ts`](../src/infrastructure/native_verification_staging.ts) · [`src/infrastructure/native_verification_controller.ts`](../src/infrastructure/native_verification_controller.ts) · [`src/infrastructure/native_verification_profiles.ts`](../src/infrastructure/native_verification_profiles.ts) · [`src/infrastructure/native_verification_redaction.ts`](../src/infrastructure/native_verification_redaction.ts) · [`scripts/generate-native-verification-workflows.cjs`](../scripts/generate-native-verification-workflows.cjs) · [`src/infrastructure/verification_executable.ts`](../src/infrastructure/verification_executable.ts) · [`src/infrastructure/native_verification_source_runtime.ts`](../src/infrastructure/native_verification_source_runtime.ts) · [`scripts/prepare-native-source-action.cjs`](../scripts/prepare-native-source-action.cjs) · [`src/domain/copilot_comment_workflow.ts`](../src/domain/copilot_comment_workflow.ts) · [`src/infrastructure/copilot_comment_inputs.json`](../src/infrastructure/copilot_comment_inputs.json) · [`scripts/validate-comment-workflows.cjs`](../scripts/validate-comment-workflows.cjs) · [`src/domain/verification_failure.ts`](../src/domain/verification_failure.ts) · [`src/application/policies/source_verification_dependency_policy.ts`](../src/application/policies/source_verification_dependency_policy.ts) · [`src/infrastructure/source_verification_dependencies.ts`](../src/infrastructure/source_verification_dependencies.ts) · [`src/application/policies/branch_sync_relationship_retirement_policy.ts`](../src/application/policies/branch_sync_relationship_retirement_policy.ts) · [`src/application/usecases/actions/retire_unrelated_branch_sync_cards.ts`](../src/application/usecases/actions/retire_unrelated_branch_sync_cards.ts) · [`src/infrastructure/private_jvm_environment.ts`](../src/infrastructure/private_jvm_environment.ts) · [`src/tooling/__tests__/fixtures/verification_jvm_gradle_probe.cjs`](../src/tooling/__tests__/fixtures/verification_jvm_gradle_probe.cjs) · [`src/tooling/__tests__/fixtures/verification_jvm_gradle_container_fixture.ts`](../src/tooling/__tests__/fixtures/verification_jvm_gradle_container_fixture.ts) · [`src/tooling/__tests__/fixtures/verification_native_fixture.ts`](../src/tooling/__tests__/fixtures/verification_native_fixture.ts) · [`src/infrastructure/verification_python_library_environment.ts`](../src/infrastructure/verification_python_library_environment.ts) · [`src/tooling/__tests__/fixtures/verification_python_container_fixture.ts`](../src/tooling/__tests__/fixtures/verification_python_container_fixture.ts) · [`src/tooling/__tests__/fixtures/verification_python_probe.cjs`](../src/tooling/__tests__/fixtures/verification_python_probe.cjs) · [`src/tooling/__tests__/fixtures/verification_native_setup_action.ts`](../src/tooling/__tests__/fixtures/verification_native_setup_action.ts) · [`src/domain/branch_sync_catalog_diagnostic.ts`](../src/domain/branch_sync_catalog_diagnostic.ts) · [`src/application/policies/application_error_presentation_policy.ts`](../src/application/policies/application_error_presentation_policy.ts) · [`src/domain/branch_sync_publication.ts`](../src/domain/branch_sync_publication.ts) · [`src/domain/git_push_failure.ts`](../src/domain/git_push_failure.ts) · [`src/infrastructure/git_push_diagnostic.ts`](../src/infrastructure/git_push_diagnostic.ts) · [`src/application/policies/branch_sync_publication_messages.ts`](../src/application/policies/branch_sync_publication_messages.ts) +- Tests: [`src/application/policies/__tests__/branch_sync_notification_policy.test.ts`](../src/application/policies/__tests__/branch_sync_notification_policy.test.ts) · [`src/application/usecases/actions/__tests__/observe_branch_sync_use_case.test.ts`](../src/application/usecases/actions/__tests__/observe_branch_sync_use_case.test.ts) · [`src/application/usecases/branch_sync/__tests__/sync_branch_use_case.test.ts`](../src/application/usecases/branch_sync/__tests__/sync_branch_use_case.test.ts) · [`src/application/usecases/__tests__/push_single_action_contexts.test.ts`](../src/application/usecases/__tests__/push_single_action_contexts.test.ts) · [`src/application/usecases/steps/common/__tests__/transition_notification_workflow.test.ts`](../src/application/usecases/steps/common/__tests__/transition_notification_workflow.test.ts) · [`src/data/repository/branch_sync/__tests__/branch_dependency_policy.test.ts`](../src/data/repository/branch_sync/__tests__/branch_dependency_policy.test.ts) · [`src/data/repository/branch_sync/__tests__/branch_dependency_repository.test.ts`](../src/data/repository/branch_sync/__tests__/branch_dependency_repository.test.ts) · [`src/infrastructure/__tests__/branch_sync_workspace_adapter.test.ts`](../src/infrastructure/__tests__/branch_sync_workspace_adapter.test.ts) · [`src/infrastructure/composition/__tests__/push_single_action_capability_port_binding.test.ts`](../src/infrastructure/composition/__tests__/push_single_action_capability_port_binding.test.ts) · [`src/application/usecases/branch_sync/__tests__/branch_sync_execution_policy.test.ts`](../src/application/usecases/branch_sync/__tests__/branch_sync_execution_policy.test.ts) · [`src/application/errors/__tests__/application_error.test.ts`](../src/application/errors/__tests__/application_error.test.ts) · [`src/application/policies/__tests__/application_error_presentation_policy.test.ts`](../src/application/policies/__tests__/application_error_presentation_policy.test.ts) · [`src/infrastructure/__tests__/git_commit_adapter.test.ts`](../src/infrastructure/__tests__/git_commit_adapter.test.ts) · [`src/infrastructure/__tests__/branch_sync_sdd_catalog.test.ts`](../src/infrastructure/__tests__/branch_sync_sdd_catalog.test.ts) · [`src/infrastructure/__tests__/branch_sync_generated_bundle_validation.test.ts`](../src/infrastructure/__tests__/branch_sync_generated_bundle_validation.test.ts) · [`src/application/policies/__tests__/branch_sync_lifecycle_recovery.test.ts`](../src/application/policies/__tests__/branch_sync_lifecycle_recovery.test.ts) · [`src/infrastructure/__tests__/branch_sync_resolved_workspace.test.ts`](../src/infrastructure/__tests__/branch_sync_resolved_workspace.test.ts) · [`src/infrastructure/__tests__/branch_sync_communication_budget.test.ts`](../src/infrastructure/__tests__/branch_sync_communication_budget.test.ts) · [`src/application/usecases/actions/__tests__/retire_branch_sync_use_case.test.ts`](../src/application/usecases/actions/__tests__/retire_branch_sync_use_case.test.ts) · [`src/application/usecases/__tests__/pull_request_use_case.test.ts`](../src/application/usecases/__tests__/pull_request_use_case.test.ts) · [`src/actions/__tests__/common_action.test.ts`](../src/actions/__tests__/common_action.test.ts) · [`src/actions/__tests__/github_action.test.ts`](../src/actions/__tests__/github_action.test.ts) · [`src/tooling/__tests__/validate_workflow_contract.test.ts`](../src/tooling/__tests__/validate_workflow_contract.test.ts) · [`src/data/repository/__tests__/reply_request_repository.test.ts`](../src/data/repository/__tests__/reply_request_repository.test.ts) · [`src/infrastructure/composition/__tests__/reply_request_composition_root.test.ts`](../src/infrastructure/composition/__tests__/reply_request_composition_root.test.ts) · [`src/application/policies/__tests__/verification_runtime_policy.test.ts`](../src/application/policies/__tests__/verification_runtime_policy.test.ts) · [`src/infrastructure/__tests__/verification_runtime_preflight.test.ts`](../src/infrastructure/__tests__/verification_runtime_preflight.test.ts) · [`src/infrastructure/__tests__/verification_runtime_branch_sync.test.ts`](../src/infrastructure/__tests__/verification_runtime_branch_sync.test.ts) · [`src/infrastructure/__tests__/untrusted_command_environment.test.ts`](../src/infrastructure/__tests__/untrusted_command_environment.test.ts) · [`src/application/usecases/steps/commit/bugbot/__tests__/verify_command_runner.test.ts`](../src/application/usecases/steps/commit/bugbot/__tests__/verify_command_runner.test.ts) · [`src/application/usecases/steps/commit/bugbot/__tests__/commit_verification_workspace.test.ts`](../src/application/usecases/steps/commit/bugbot/__tests__/commit_verification_workspace.test.ts) · [`src/tooling/__tests__/verification_runtime_guidance.test.ts`](../src/tooling/__tests__/verification_runtime_guidance.test.ts) · [`src/application/policies/__tests__/verification_setup_policy.test.ts`](../src/application/policies/__tests__/verification_setup_policy.test.ts) · [`src/infrastructure/__tests__/verification_setup_snapshot.test.ts`](../src/infrastructure/__tests__/verification_setup_snapshot.test.ts) · [`src/infrastructure/__tests__/verification_setup_runner.test.ts`](../src/infrastructure/__tests__/verification_setup_runner.test.ts) · [`src/infrastructure/__tests__/verification_setup_configuration.test.ts`](../src/infrastructure/__tests__/verification_setup_configuration.test.ts) · [`src/infrastructure/__tests__/verification_setup_sync.test.ts`](../src/infrastructure/__tests__/verification_setup_sync.test.ts) · [`src/infrastructure/__tests__/native_verification_runner.test.ts`](../src/infrastructure/__tests__/native_verification_runner.test.ts) · [`src/infrastructure/__tests__/native_verification_protocol.test.ts`](../src/infrastructure/__tests__/native_verification_protocol.test.ts) · [`src/infrastructure/__tests__/native_verification_controller.test.ts`](../src/infrastructure/__tests__/native_verification_controller.test.ts) · [`src/infrastructure/__tests__/native_verification_redaction.test.ts`](../src/infrastructure/__tests__/native_verification_redaction.test.ts) · [`src/infrastructure/__tests__/native_verification_workflow.test.ts`](../src/infrastructure/__tests__/native_verification_workflow.test.ts) · [`src/infrastructure/__tests__/native_verification_profiles.test.ts`](../src/infrastructure/__tests__/native_verification_profiles.test.ts) · [`src/infrastructure/__tests__/verification_executable.test.ts`](../src/infrastructure/__tests__/verification_executable.test.ts) · [`src/infrastructure/__tests__/native_verification_post.test.ts`](../src/infrastructure/__tests__/native_verification_post.test.ts) · [`src/infrastructure/__tests__/native_verification_source_runtime.test.ts`](../src/infrastructure/__tests__/native_verification_source_runtime.test.ts) · [`src/domain/__tests__/copilot_comment_workflow.test.ts`](../src/domain/__tests__/copilot_comment_workflow.test.ts) · [`src/domain/__tests__/verification_failure.test.ts`](../src/domain/__tests__/verification_failure.test.ts) · [`src/infrastructure/__tests__/source_verification_dependencies.test.ts`](../src/infrastructure/__tests__/source_verification_dependencies.test.ts) · [`src/application/usecases/actions/__tests__/retire_unrelated_branch_sync_cards.test.ts`](../src/application/usecases/actions/__tests__/retire_unrelated_branch_sync_cards.test.ts) · [`src/infrastructure/__tests__/private_jvm_environment.test.ts`](../src/infrastructure/__tests__/private_jvm_environment.test.ts) · [`src/infrastructure/__tests__/verification_python_library_environment.test.ts`](../src/infrastructure/__tests__/verification_python_library_environment.test.ts) · [`src/infrastructure/__tests__/git_commit_python_runtime.test.ts`](../src/infrastructure/__tests__/git_commit_python_runtime.test.ts) · [`src/domain/__tests__/branch_sync_catalog_diagnostic.test.ts`](../src/domain/__tests__/branch_sync_catalog_diagnostic.test.ts) · [`src/infrastructure/__tests__/branch_sync_catalog_profiles.test.ts`](../src/infrastructure/__tests__/branch_sync_catalog_profiles.test.ts) · [`src/infrastructure/__tests__/branch_sync_catalog_compatibility_git.test.ts`](../src/infrastructure/__tests__/branch_sync_catalog_compatibility_git.test.ts) · [`src/infrastructure/__tests__/branch_sync_catalog_json_only.test.ts`](../src/infrastructure/__tests__/branch_sync_catalog_json_only.test.ts) · [`src/domain/__tests__/branch_sync_publication.test.ts`](../src/domain/__tests__/branch_sync_publication.test.ts) · [`src/infrastructure/__tests__/git_push_diagnostic.test.ts`](../src/infrastructure/__tests__/git_push_diagnostic.test.ts) · [`src/application/policies/__tests__/branch_sync_publication_presentation.test.ts`](../src/application/policies/__tests__/branch_sync_publication_presentation.test.ts) · [`src/tooling/__tests__/branch_sync_publication_integration.test.ts`](../src/tooling/__tests__/branch_sync_publication_integration.test.ts) - User documentation: [`docs/issues/branch-synchronization.mdx`](../docs/issues/branch-synchronization.mdx) · [`docs/issues/comment-commands.mdx`](../docs/issues/comment-commands.mdx) · [`docs/issues/notifications-and-auto-close.mdx`](../docs/issues/notifications-and-auto-close.mdx) · [`docs/development/architecture.mdx`](../docs/development/architecture.mdx) · [`docs/security-operations/operations/verification.mdx`](../docs/security-operations/operations/verification.mdx) · [`docs/security-operations/operations/troubleshooting.mdx`](../docs/security-operations/operations/troubleshooting.mdx) · [`docs/agents/repository-collaboration.mdx`](../docs/agents/repository-collaboration.mdx) ### `pull-request-lifecycle` — Pull request lifecycle and enrichment diff --git a/specs/branch-sync-publication-recovery.md b/specs/branch-sync-publication-recovery.md new file mode 100644 index 000000000..9d78fdc6c --- /dev/null +++ b/specs/branch-sync-publication-recovery.md @@ -0,0 +1,363 @@ +# Branch synchronization publication recovery + +- Status: Implemented candidate; remote merge acceptance pending +- Date: 2026-11-03 +- Catalog capability ID: `branch-synchronization` +- Last verified: 2026-11-03, local source/tests/document review; remote gates remain independent +- Owners: Copilot maintainers +- Scope: Workflow-write denial guidance and truthful recovery after committing a + branch synchronization candidate. +- Related issue: [#844](https://github.com/vypdev/copilot/issues/844) +- Review gates: UX, architecture, security, testing, documentation, complete + automated review and 100% modified-line coverage +- Open decisions blocking readiness: none; acceptance execution remains pending. + +## 1. Executive summary + +A completed local merge is no longer an abortable in-progress merge. After a +push failure, Copilot MUST preserve that candidate, observe the requested remote +head through the trusted Git port, and report what it can actually verify. +An explicit workflow-write rejection MUST identify the maintainer's credential +recovery instead of suggesting an unchanged automatic retry. No permission, +branch ownership, verification or publication guard is weakened. + +```text +Authorize -> prepare/verify -> recheck heads -> commit candidate -> normal push + success -> completion + failure -> bounded remote read + -> unpublished / published / unknown +``` + +## 2. Problem, current behavior and evidence + +### 2.1 Problem + +The reported consumer sync commits its merge, receives GitHub's workflow-scope +rejection, then tries `git merge --abort`. Git reports absent MERGE_HEAD. The +reply is generic retryable `workflow.failed`, hiding the required capability +and distinction between an unchanged remote and committed local candidate. + +### 2.2 Observed baseline + +At `c1d593dc`, `BranchSyncWorkspaceAdapter.commitAndPush` clears its in-progress +flag only after push succeeds. `GitCommitAdapter` does not capture push stderr +as a typed safe diagnostic. `SyncBranchUseCase` attempts abort for every thrown +failure and unconditionally says no push completed. A lost acknowledgment can +therefore also produce an unsupported publication claim. + +### 2.3 Evidence and limits + +[#844](https://github.com/vypdev/copilot/issues/844) and both original comments +identify consumer run `41297433053`, job `124821122631`, original child +`695e5c31a466b8a42c8840c46f708141979b35e4`, parent +`a97294385798b31e98c200e7ed93e355ffc7c9a7` and candidate `39f69a7`. +A later independently authorized consumer reconciliation did not fix the SDK. +It grants no exceptional branch-management authority to this implementation. + +The local baseline uses actual SDK source/use case and real temporary Git +repositories. A local server hook reproduces the denial message shape. All six +controls pass: original remote, separate dirty user checkout, committed +candidate, exact candidate parents, no force push and no fixer. Four desired +behaviors fail: specific authorization error, nonretryable guidance, structured +recovery and absence of postcommit abort. This is Git/transport evidence, not +real GitHub credential validation or consumer execution. The versioned +[controlled before/after proof](../quality/branch-sync/evidence/issue-844.json) +records those boundaries and binds the after observation to source hashes; +[real Git regression cases](../src/tooling/__tests__/branch_sync_publication_integration.test.ts) +exercise denial, unrelated rejection, lost acknowledgement and unavailable +remote observation. + +Primary provider contracts: [OAuth scopes](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps) +and [fine-grained permissions](https://docs.github.com/en/rest/authentication/permissions-required-for-fine-grained-personal-access-tokens). +GitHub documents a same-path/content-on-another-branch exception to the classic +workflow scope. Workflow path presence alone is not denial proof. + +### 2.4 Retrospective classification + +Not applicable: the owning implemented SDD remains the baseline; this companion +specifies a correction with independently captured before/after evidence. + +## 3. Actors, surfaces and terminology + +| Actor | Goal | Surface | +|---|---|---| +| Maintainer | Synchronize through the admitted command | Issue/PR reply, run | +| Credential owner | Correct a proven publication capability problem | Existing credential/setup configuration | +| SDK | Preserve candidate and truthful source facts | Semantic result, logs, API | + +**Candidate** is the committed local merge. **Published** means the normal push +succeeded or the requested remote ref independently equals that exact candidate. +**Unchanged** is a successful native remote observation equal to the prepared +original child. **Changed** means another observed head, without claiming its +ancestry. **Unverified** means insufficient identity/read evidence. Runner-local +retention is temporary, not a durable backup or a remote commit link. + +## 4. Requirements, non-goals and invariants + +| Requirement | Contract | +|---|---| +| BSP-R1 | Capture only a bounded trusted-push stderr suffix; classify explicit workflow-write denial without exposing raw text. | +| BSP-R2 | Mark successful commit before later reads/push; never abort or reset that committed candidate during recovery. | +| BSP-R3 | Perform one bounded remote-head observation after failure; missing facts never establish non-publication. | +| BSP-R4 | Recognize a remotely confirmed exact candidate without a second push; bind publication facts to the prepared child. | +| BSP-R5 | Publish nonretryable localized workflow guidance or truthful unknown-publication recovery; preserve authorization and managed-branch ownership. | +| BSP-R6 | Keep docs, catalog, source, bundles and meaningful before/after regression evidence aligned. | + +Non-goals: automatic permission grants/token refresh, new credential inputs, +force push/rebase/reset of user work, remote branch creation/deletion, bypassing +GitHub enforcement, durable candidate artifacts, or consumer-specific recovery. + +## 5. Product journey + +| Stage | Before | Corrected behavior | +|---|---|---| +| Prepare/verify | Existing safety gates | Unchanged | +| Commit | Flag still says merging | Candidate becomes committed immediately | +| Push rejection | Opaque process error | Bounded safe reason and remote observation | +| Recovery | Invalid merge abort | Preserve committed state; no second push | +| Operator | Generic retry | Capability action or explicit publication uncertainty | + +```mermaid +flowchart LR + A[Validated merge] --> B[Commit candidate] + B --> C[Normal push] + C -->|Success| D[Complete] + C -->|Failure| E[Observe exact remote ref once] + E -->|Original head| F[Unpublished candidate retained] + E -->|Exact candidate| D + E -->|Other or unavailable| G[Publication requires inspection] +``` + +Text equivalent: commit ends the abortable phase; a failed push triggers one +read, which either proves original head, confirms the candidate, or preserves +uncertainty. No recovery push, reset or credential expansion occurs. + +## 6. Functional state model + +1. Retain existing command authorization, ref validation, sensitive conflict + limits, credential-free verification and immediate remote-head comparison. +2. Before commit, normal abort/restoration remains applicable under existing + ownership and unchanged-head/merge-parent guards. +3. Once commit returns successfully, clear abortable state before reading its + identity or attempting push. A missing/invalid identity stops publication. +4. Push uses the same existing ref and credential confinement. Its adapter + captures at most 8,192 bytes of stderr suffix; raw error text, stdout and + workflow path presence cannot independently establish workflow capability. +5. On failure, fetch only the existing working ref and read FETCH_HEAD. Sanitize + object IDs to canonical nonzero 40/64-character hex or `unavailable`. +6. Return immutable closed facts: reason, remote state, original/candidate/ + observed IDs and whether push was attempted. Do not store token or stderr. +7. Exact remote candidate confirmation is successful reconciliation, explicitly + worded as verification after a failed response. Original-head binding MUST + match this preparation; inconsistent/replayed facts cannot claim success. +8. All other postcommit outcomes fail without automatic retry. Preserve the + candidate locally and say the runner may discard it later. Never link an + unpublished candidate as if it existed on GitHub. + +| State | Meaning | Next action | +|---|---|---| +| Preparing/verifying | No synchronization commit yet | Existing verification or safe abort | +| Candidate committed | Merge cannot be aborted | Normal push / identity inspection | +| Push accepted | Normal remote publication completed | Existing completion/observer | +| Candidate confirmed remotely | Failed response, exact candidate read | Completion, no duplicate push | +| Workflow denied | Remote reported missing workflow-write capability | Maintainer reviews credential and current ref | +| Original head observed | Candidate not observed on remote | Fix actual cause before fresh managed sync | +| Other/unavailable head | Publication not established | Inspect native ref; no blind retry | + +## 7. Configuration + +No new public setting, permission, state schema or retry switch. Recommended: +keep the managed command and configure an eligible repository publisher through +existing credential/setup ownership. Alternative: existing dry-run or no-agent +modes retain their meanings; neither adds credential authority. + +The 8 KiB diagnostic suffix, closed reason/state enums, canonical identity +validation, one postfailure remote read, no automatic retry/grant/force push, +and preservation of user work are fixed. Classic OAuth/PAT workflow scope and +fine-grained/App workflow-write capability are credential-family-specific; +never infer them solely from a token prefix or changed workflow path. + +## 8. Clean Architecture + +| Layer | Responsibility | Forbidden dependency | +|---|---|---| +| Domain | Closed push/publication facts and validation | GitHub SDK, process, filesystem | +| Infrastructure | Bounded stderr, trusted Git operations, merge phase and remote observation | Presentation text or permission grants | +| Application | Reconcile facts with preparation, choose outcome and semantic recovery | Raw stderr/provider DTOs | +| Presentation | Complete localized impact/action/retained-state text | Mutation or remote-state inference | +| Composition | Existing repository/token binding | New broad credentials or copied orchestration | + +```text +Authorized command -> sync use case -> workspace semantic port + | <- Git workspace adapter + | -> trusted Git port/adapter + -> validated domain facts -> localized presentation +``` + +State is invocation-local. Existing ports remain narrow; typed postcommit +failures extend their behavioral contract. They are not permission to publish. +Architecture/import/port tests enforce boundaries. Real Git fixtures belong in +tooling test support, outside production application/domain paths. + +## 9. UI/UX contract + +Reuse existing correlated replies, summary/check and CLI/API semantic errors. +No extra comment per stderr chunk or retry. SHA values are technical details; +primary text answers status, cause, action and retained state in that order. + +| State | Representative primary content | +|---|---| +| Pending | “Branch synchronization is preparing the merge. No action required.” | +| Verification failed | Existing verified restoration and command-stage guidance | +| Workflow denied | “The remote rejected workflow-file publication. A maintainer must review the publisher credential before rerunning the managed command.” | +| Unpublished | “The remote still points to the original head. The local candidate was retained.” | +| Unknown | “The remote head could not be established; publication remains unknown. Inspect it before retrying.” | +| Confirmed | “The merge candidate was independently confirmed remotely after a failed push response. No second push was attempted.” | + +Both English and Spanish MUST render complete outcomes without untranslated +placeholders. Preserve default/fallback locale and correlation reference. +Use one fixed recovery-guide link inserted after localization; no provider text +can inject links, commands, mentions or capability instructions. Existing +text/headings support mobile, light/dark and screen-reader reading; no new +browser/VoiceOver UI journey is introduced. + +## 10. Failure, recovery and cleanup + +| Failure | Verified effects | Recovery | +|---|---|---| +| Precommit verification/conflict | No synchronization commit/push; restoration as observed | Existing guarded abort | +| Candidate identity read | Commit command completed; no push attempted | Preserve local candidate; inspect identity | +| Explicit workflow denial | Committed candidate plus independently observed remote state | Review eligible credential, then fresh managed sync | +| Other push failure | Publication may have happened | One read; exact candidate completes, other/missing evidence requires inspection | +| Postfailure read fails | No authoritative remote state | Unknown, not “unchanged” | +| Foreign/forged facts | No valid scoped completion | Provider-contract failure; no fabricated publication | + +No reset or abort after commit. No force push or automatic credential change. +Candidate IDs aid diagnosis but are not durable backup promises. A fresh +command starts from current authoritative refs; repeating unchanged missing +capability is not recovery. Product source/runtime/approval/coverage gates are +independent of transport recovery. + +## 11. Security and privacy + +Capture belongs only to trusted push execution. Keep raw stderr/exception text, +URLs, paths, tokens and headers out of recovery variables and logs generated by +this feature. Denial text is a reported cause, not instruction authority. +Existing credentials are confined to trusted fetch/push, never fixer or +verification. No scope probing by publishing workflow files, no other-branch +workaround, no new tools/permissions and no automatic private-source artifact. + +## 12. Observability + +Keep existing correlation/run links. Expose only fixed stage/reason/state, +canonical IDs and push-attempt fact. Reconcile source facts independently of +model wording. Distinguish committed local effect, publication observation and +remote completion. Old raw logs remain original incident evidence, not proof of +current remote state. No progress notifications or comment amplification. + +## 13. Compatibility and rollout + +Normal success, aligned/dry-run, no-agent, verification and catalog paths retain +existing behavior. Add one explicit verified-after-failed-response outcome and +two closed recovery descriptors; no stored operation migration or public input. +Custom workspace implementations must report postcommit outcomes through the +semantic contract; untyped failure cannot claim a known non-publication. +Rollback restores code/presentation together and cannot undo a published merge. + +## 14. Numeric test budget + +Minimum **48 distinct new cases**, each counted once; derive cases from the +following risks rather than adding implementation-mirroring assertions. + +| Area | Minimum | Risks | +|---|---:|---| +| Pure facts/validation | 12 | States, nonzero SHA boundaries, inconsistent/forged data | +| Capture/classification | 8 | Native variants, chunks/tail bound, unrelated errors, secrecy | +| Workspace transitions | 12 | Before/after commit, identity failure, independent observations, no reset/abort/retry | +| Use-case reconciliation | 6 | Original binding, confirmed publication, unknown state, generic compatibility | +| Localized presentation | 6 | EN/ES impact/action/retention, fixed links, no placeholders/secrets | +| Real Git integration | 4 | Server denial, preserved remote/user tree, committed identity, lost response without duplicate publication | +| **Total** | **48** | Existing command, credential, catalog and verification contracts remain mandatory | + +Require 100% changed coverable lines with zero misses/partials in official +Codecov against the correct current base, 100% new pure-policy metrics and +existing repository budgets. Use deterministic clocks/IDs/fakes plus real local +Git; no real credential, permission grant, provider write or consumer execution +in tests. Full Linux/macOS/Windows, complete review and package/docs/spec checks +remain merge prerequisites. No human browser acceptance required for this fix. + +### Local acceptance evidence (2026-11-03) + +The current candidate passes **878 Jest suites / 17,071 tests**, with 35 existing +skips. It adds **105 distinct cases**: 49 facts/validation, 16 capture, +13 workspace, 9 reconciliation, 14 localized presentation and 4 real Git. +The four new pure modules measure 100% lines, statements, functions and branches +(45 lines / 58 branches), enforced by a dedicated exhaustive budget. +Full-suite LCOV measures **114/114 modified coverable lines**, zero misses or +partials against `c1d593dc`. Extension regression tests remain 267/267 with all +coverage metrics at 100%. These are local results, not official Codecov or +remote/native-platform acceptance. The PR must separately verify its current +head and live base before merge; the historical incident is not relabeled as +execution by this runtime. + +## 15. Documentation + +Update branch-synchronization and troubleshooting docs with normal success, +workflow-write denial, temporary candidate retention, other/unavailable heads, +credential-family guidance and managed recovery. Explain that a job permission +setting does not expand a separately configured PAT scope. Preserve observer, +no-agent/dry-run and approval documentation. Link the companion from the owning +SDD/catalog; validate navigation, documentation, schemas and generated catalog. + +## 16. Acceptance + +1. The original-shaped local server denial yields nonretryable authorization + guidance, original remote observation and retained candidate, with no abort. +2. A lost push response followed by the exact remote candidate yields explicit + verified completion with one push only. +3. A different/unavailable remote head yields truthful uncertainty; no retry, + reset, abort, source loss or invented publication occurs. +4. Candidate identity failure never pushes; precommit failure still uses normal + guarded restoration. Separate dirty user work remains untouched. +5. Known workflow-denial variants classify only from bounded push stderr; + unrelated text/paths/exception messages do not demand scope expansion. +6. Forged/inconsistent/scoped-to-another-child facts cannot report success or + disclose untrusted recovery operands. EN/ES views retain all verified facts. + +## 17. Traceability + +| Requirement | Owner | Evidence | Docs | +|---|---|---|---| +| BSP-R1 | Git push diagnostic/adapter | Chunk/classification/security tests | Troubleshooting | +| BSP-R2 | Workspace phase ownership | Real Git and commit-boundary tests | Synchronization | +| BSP-R3 | Remote observation/fact validation | Four-state/failed-read tests | Both pages | +| BSP-R4 | Use-case reconciliation | Exact candidate and original-binding tests | Synchronization | +| BSP-R5 | Semantic recovery/presentation | Descriptor, locale, link and secrecy tests | Troubleshooting | +| BSP-R6 | Catalog/SDDs/bundles | Validators, before/after proof, native gates | This companion | + +## 18. Implementation order + +Freeze meaningful baseline, define closed facts and bounded capture, correct +merge phase, reconcile remote observations, wire semantic/localized recovery, +add real Git/race/security tests, update docs/catalog, build bundles, validate +full coverage/native review, audit exact head/base and merge normally. + +## 19. Definition of Done + +- [ ] BSP-R1–6 and six acceptance scenarios have executable evidence. +- [ ] Minimum test distribution and strict changed-line/pure-policy coverage pass. +- [ ] Architecture, phase ownership, secrecy and no new authority are enforced. +- [ ] All primary states, EN/ES content and temporary retention are accurate. +- [ ] Docs, catalog, bundles, Graphify and required validators pass. +- [ ] Current-head CI/three-platform checks and complete finding-free review pass; + official Codecov verifies every modified line before merge. + +## 20. References and decisions + +- [Owning branch-sync SDD](./branch-synchronization-and-conflict-recovery.md). +- [Verification/setup contract](./repository-verification-setup-hooks.md). +- Decision: act on observed denial; do not infer capability from paths or token + prefixes, automatically grant permission, reset committed work or conflate + missing remote evidence with non-publication. +- Follow-up: durable candidate export or richer provider capability introspection + needs its own reviewed ownership, privacy and permission contract. diff --git a/specs/branch-synchronization-and-conflict-recovery.md b/specs/branch-synchronization-and-conflict-recovery.md index 479363418..f1cad6cd5 100644 --- a/specs/branch-synchronization-and-conflict-recovery.md +++ b/specs/branch-synchronization-and-conflict-recovery.md @@ -173,7 +173,7 @@ changing dependency discovery, merge, authorization, or conflict safety. Replays keep the same card and do not repeat a transition fingerprint. Parent-child chains are reevaluated after bot -pushes. Any failure attempts merge abort and reports whether no push occurred. +pushes. Precommit failures attempt merge abort. After a successful commit, publication failures preserve the committed workspace and report independently observed remote facts; they never attempt merge abort or infer that no push occurred. See [publication recovery](branch-sync-publication-recovery.md) for the phase, capability and uncertain-response contract. ## 7. User-facing configuration diff --git a/specs/catalog.json b/specs/catalog.json index 651511007..bf264bf7c 100644 --- a/specs/catalog.json +++ b/specs/catalog.json @@ -2358,11 +2358,12 @@ "status": "implemented", "scope": "Observe parent drift with one localized status card and transition-only notifications, then safely merge a parent branch into a linked working branch", "owner": "Copilot maintainers", - "lastVerified": "2026-10-30", + "lastVerified": "2026-11-03", "specs": [ "specs/branch-synchronization-and-conflict-recovery.md", "specs/repository-verification-runtime-prerequisites.md", - "specs/repository-verification-setup-hooks.md" + "specs/repository-verification-setup-hooks.md", + "specs/branch-sync-publication-recovery.md" ], "workflows": [ ".github/workflows/copilot_branch_sync.yml", @@ -2455,7 +2456,11 @@ "src/tooling/__tests__/fixtures/verification_python_probe.cjs", "src/tooling/__tests__/fixtures/verification_native_setup_action.ts", "src/domain/branch_sync_catalog_diagnostic.ts", - "src/application/policies/application_error_presentation_policy.ts" + "src/application/policies/application_error_presentation_policy.ts", + "src/domain/branch_sync_publication.ts", + "src/domain/git_push_failure.ts", + "src/infrastructure/git_push_diagnostic.ts", + "src/application/policies/branch_sync_publication_messages.ts" ], "tests": [ "src/application/policies/__tests__/branch_sync_notification_policy.test.ts", @@ -2514,7 +2519,11 @@ "src/domain/__tests__/branch_sync_catalog_diagnostic.test.ts", "src/infrastructure/__tests__/branch_sync_catalog_profiles.test.ts", "src/infrastructure/__tests__/branch_sync_catalog_compatibility_git.test.ts", - "src/infrastructure/__tests__/branch_sync_catalog_json_only.test.ts" + "src/infrastructure/__tests__/branch_sync_catalog_json_only.test.ts", + "src/domain/__tests__/branch_sync_publication.test.ts", + "src/infrastructure/__tests__/git_push_diagnostic.test.ts", + "src/application/policies/__tests__/branch_sync_publication_presentation.test.ts", + "src/tooling/__tests__/branch_sync_publication_integration.test.ts" ], "documentation": [ "docs/issues/branch-synchronization.mdx", diff --git a/src/application/policies/__tests__/application_error_presentation_policy.test.ts b/src/application/policies/__tests__/application_error_presentation_policy.test.ts index 87f8ee504..ff1a254ba 100644 --- a/src/application/policies/__tests__/application_error_presentation_policy.test.ts +++ b/src/application/policies/__tests__/application_error_presentation_policy.test.ts @@ -20,6 +20,12 @@ const RECOVERY_CASES: readonly Readonly<{ english: string; spanish: string; }>[] = [ + { recovery: { id: 'branch-sync-workflow-write-required', variables: { reason: 'workflow-write-required', remoteState: 'unchanged', originalSha: 'a'.repeat(40), candidateSha: 'b'.repeat(40), observedSha: 'a'.repeat(40), pushAttempted: true } }, + english: `The local merge commit step finished; candidate identity: ${'b'.repeat(40)}. A normal push was attempted. The observed remote still points to the original head; the candidate was not observed as published. Original head: ${'a'.repeat(40)}; observed head: ${'a'.repeat(40)}. Recovery did not reset or abort the committed workspace; runner-local storage is temporary.`, + spanish: `Terminó el paso de commit del merge local; identidad del candidato: ${'b'.repeat(40)}. Se intentó un push normal. El remoto observado conserva el head original; no se observó la publicación del candidato. Head original: ${'a'.repeat(40)}; head observado: ${'a'.repeat(40)}. La recuperación no restableció ni abortó el espacio de trabajo confirmado; el almacenamiento del runner es temporal.` }, + { recovery: { id: 'branch-sync-publication-failed', variables: { reason: 'push-failed', remoteState: 'unchanged', originalSha: 'a'.repeat(40), candidateSha: 'b'.repeat(40), observedSha: 'a'.repeat(40), pushAttempted: true } }, + english: `The local merge commit step finished; candidate identity: ${'b'.repeat(40)}. A normal push was attempted. The observed remote still points to the original head; the candidate was not observed as published. Original head: ${'a'.repeat(40)}; observed head: ${'a'.repeat(40)}. Recovery did not reset or abort the committed workspace; runner-local storage is temporary.`, + spanish: `Terminó el paso de commit del merge local; identidad del candidato: ${'b'.repeat(40)}. Se intentó un push normal. El remoto observado conserva el head original; no se observó la publicación del candidato. Head original: ${'a'.repeat(40)}; head observado: ${'a'.repeat(40)}. La recuperación no restableció ni abortó el espacio de trabajo confirmado; el almacenamiento del runner es temporal.` }, { recovery: { id: 'sdd-source-context-unavailable', variables: { sourceCommit: 'a'.repeat(40), phase: 'analysis', reason: 'object-unavailable' } }, english: 'The canonical checkout, existing answers, published SDDs and linked branch were retained; this attempt published no specification or branch.', spanish: 'Se conservaron el checkout canónico, las respuestas, los SDD publicados y la rama enlazada; este intento no publicó ninguna especificación ni rama.' }, diff --git a/src/application/policies/__tests__/branch_sync_publication_presentation.test.ts b/src/application/policies/__tests__/branch_sync_publication_presentation.test.ts new file mode 100644 index 000000000..acd95cefa --- /dev/null +++ b/src/application/policies/__tests__/branch_sync_publication_presentation.test.ts @@ -0,0 +1,41 @@ +import { ApplicationError } from '../../errors/application_error'; +import { buildApplicationErrorPresentation, renderApplicationErrorMarkdown } from '../application_error_presentation_policy'; +import { resolveStaticApplicationErrorCatalog } from '../application_error_message_catalog'; +import type { BranchSyncPublicationDiagnostic } from '../../../domain/branch_sync_publication'; +const originalSha = 'a'.repeat(40), candidateSha = 'b'.repeat(40); +const facts: BranchSyncPublicationDiagnostic = { reason: 'workflow-write-required', remoteState: 'unchanged', originalSha, candidateSha, observedSha: originalSha, pushAttempted: true }; +describe('Localized branch synchronization publication recovery', () => { + it.each(['en-US', 'es-ES'].flatMap(locale => ['unchanged', 'changed', 'unverified'].map(remoteState => ({ locale, remoteState }))))( + 'renders complete safe $locale guidance for $remoteState', ({ locale, remoteState }) => { + const diagnostic = { ...facts, remoteState, observedSha: remoteState === 'unverified' ? 'unavailable' : remoteState === 'changed' ? 'c'.repeat(40) : originalSha } as BranchSyncPublicationDiagnostic; + const error = new ApplicationError('authorization.denied', 'PRIVATE provider output', { retryable: false, + recovery: { id: 'branch-sync-workflow-write-required', variables: diagnostic } }); + const catalog = resolveStaticApplicationErrorCatalog(locale); + const view = buildApplicationErrorPresentation(error, catalog.message), text = renderApplicationErrorMarkdown(error, catalog.message); + expect(view.impact).toContain(locale === 'es-ES' ? 'rechazó' : 'rejected'); + expect(view.action).toContain('/copilot sync-branch'); + expect(view.action).toContain('https://docs.page/vypdev/copilot/security-operations/operations/troubleshooting#branch-sync-workflow-write-recovery'); + expect(view.retainedState).toContain(candidateSha); expect(view.retainedState).toContain(originalSha); expect(view.retainedState).toContain(diagnostic.observedSha); + expect(view.retainedState).toContain(locale === 'es-ES' ? 'temporal' : 'temporary'); + expect(view.retainedState).toContain(locale === 'es-ES' ? 'Se intentó un push normal' : 'A normal push was attempted'); + if (remoteState === 'unverified') expect(view.retainedState).toContain(locale === 'es-ES' ? 'sin verificarse' : 'remains unknown'); + if (remoteState === 'changed') expect(view.retainedState).toContain(locale === 'es-ES' ? 'no determina' : 'does not establish'); + expect(text).not.toMatch(/PRIVATE|\{[a-zA-Z]+\}/u); expect(error.retryable).toBe(false); + }); + it.each(['en-US', 'es-ES'])('does not promise a candidate identity or attempted push in %s', locale => { + const error = new ApplicationError('provider.unavailable', 'PRIVATE', { retryable: false, recovery: { id: 'branch-sync-publication-failed', + variables: { ...facts, reason: 'candidate-unverified', candidateSha: 'unavailable', pushAttempted: false } } }); + const view = buildApplicationErrorPresentation(error, resolveStaticApplicationErrorCatalog(locale).message); + expect(view.retainedState).toContain('unavailable'); expect(view.retainedState).toContain(locale === 'es-ES' ? 'No se intentó ningún push' : 'No push was attempted'); + expect(view.action).toContain(locale === 'es-ES' ? 'no demuestra' : 'not proof'); + }); + it.each([ + { ...facts, remoteState: 'published', observedSha: candidateSha }, + { ...facts, reason: 'push-failed' }, { ...facts, privatePath: 'PRIVATE' }, + ])('rejects inconsistent recovery descriptors before rendering: %p', variables => { + expect(() => new ApplicationError('authorization.denied', 'safe', { recovery: { id: 'branch-sync-workflow-write-required', variables } as never })).toThrow(TypeError); + }); + it('rejects workflow-only facts mislabeled as generic publication failure', () => { + expect(() => new ApplicationError('provider.unavailable', 'safe', { recovery: { id: 'branch-sync-publication-failed', variables: facts } })).toThrow(TypeError); + }); +}); diff --git a/src/application/policies/application_error_message_catalog.ts b/src/application/policies/application_error_message_catalog.ts index 5d3cfabcf..9241532ad 100644 --- a/src/application/policies/application_error_message_catalog.ts +++ b/src/application/policies/application_error_message_catalog.ts @@ -1,3 +1,4 @@ +import { BRANCH_SYNC_PUBLICATION_LABEL_KEYS, ENGLISH_BRANCH_SYNC_PUBLICATION_LABELS, SPANISH_BRANCH_SYNC_PUBLICATION_LABELS, ENGLISH_BRANCH_SYNC_PUBLICATION_CONTENT, SPANISH_BRANCH_SYNC_PUBLICATION_CONTENT } from './branch_sync_publication_messages'; import { BUGBOT_REVIEW_DIAGNOSTIC_LABEL_KEYS, ENGLISH_REVIEW_DIAGNOSTIC_LABELS, SPANISH_REVIEW_DIAGNOSTIC_LABELS } from './bugbot_review_diagnostic_labels'; import { ENGLISH_COMMAND_VALIDATION_CONTENT, SPANISH_COMMAND_VALIDATION_CONTENT } from './comment_command_validation_message_catalog'; import type { @@ -24,6 +25,7 @@ import { const LABEL_KEYS = Object.freeze([ ...BUGBOT_REVIEW_DIAGNOSTIC_LABEL_KEYS, + ...BRANCH_SYNC_PUBLICATION_LABEL_KEYS, 'impact', 'errorCode', 'action', 'retainedState', 'retryable', 'yes', 'no', 'reference', ] as const); const CONTENT_FIELDS = Object.freeze(['impact', 'action', 'retainedState'] as const); @@ -54,6 +56,7 @@ export const APPLICATION_ERROR_MESSAGE_IDS: readonly ApplicationErrorMessageId[] const ENGLISH_LABELS: Readonly> = Object.freeze({ ...ENGLISH_REVIEW_DIAGNOSTIC_LABELS, + ...ENGLISH_BRANCH_SYNC_PUBLICATION_LABELS, impact: 'Impact', errorCode: 'Error code', action: 'Action', @@ -66,6 +69,7 @@ const ENGLISH_LABELS: Readonly> = Object.freeze({ const SPANISH_LABELS: Readonly> = Object.freeze({ ...SPANISH_REVIEW_DIAGNOSTIC_LABELS, + ...SPANISH_BRANCH_SYNC_PUBLICATION_LABELS, impact: 'Impacto', errorCode: 'Código de error', action: 'Acción', @@ -217,6 +221,7 @@ const STORED_CONFIGURATION_RETAINED_ES = 'Esta lectura no modificó la descripci const ENGLISH_RECOVERY_CONTENT: RecoveryContent = Object.freeze({ ...ENGLISH_COMMAND_VALIDATION_CONTENT, + ...ENGLISH_BRANCH_SYNC_PUBLICATION_CONTENT, 'stored-configuration-schema-missing': Object.freeze({ impact: 'Automation stopped for issue/PR #{issueNumber}: the stored configuration has no schema version; version {expectedVersion} is required.', action: STORED_CONFIGURATION_ACTION_EN, @@ -417,6 +422,7 @@ const ENGLISH_RECOVERY_CONTENT: RecoveryContent = Object.freeze({ const SPANISH_RECOVERY_CONTENT: RecoveryContent = Object.freeze({ ...SPANISH_COMMAND_VALIDATION_CONTENT, + ...SPANISH_BRANCH_SYNC_PUBLICATION_CONTENT, 'stored-configuration-schema-missing': Object.freeze({ impact: 'La automatización se detuvo para la issue/PR #{issueNumber}: la configuración almacenada no tiene versión de esquema; se requiere la versión {expectedVersion}.', action: STORED_CONFIGURATION_ACTION_ES, diff --git a/src/application/policies/application_error_presentation_policy.ts b/src/application/policies/application_error_presentation_policy.ts index 533466159..762cafa2d 100644 --- a/src/application/policies/application_error_presentation_policy.ts +++ b/src/application/policies/application_error_presentation_policy.ts @@ -17,6 +17,7 @@ import { // This trusted destination is inserted after localization, never supplied by a body or translation. const STORED_CONFIGURATION_RECOVERY_URL = 'https://docs.page/vypdev/copilot/security-operations/operations/troubleshooting#unsupported-stored-configuration'; const BRANCH_SYNC_CATALOG_RECOVERY_URL = 'https://docs.page/vypdev/copilot/security-operations/operations/troubleshooting#branch-sync-catalog-compatibility'; +const BRANCH_SYNC_PUBLICATION_RECOVERY_URL = 'https://docs.page/vypdev/copilot/security-operations/operations/troubleshooting#branch-sync-workflow-write-recovery'; import { sourceContextDiagnostic } from '../../domain/agent_source_snapshot'; @@ -62,6 +63,15 @@ export function buildApplicationErrorPresentation( ? commandValidationPresentationVariables(error.recovery.id, (error.recovery.variables as Readonly>).command) : error.recovery?.id === 'branch-sync-verification-failed' ? { ...error.recovery.variables, syncCommand: '/copilot sync-branch' } + : error.recovery?.id === 'branch-sync-workflow-write-required' || error.recovery?.id === 'branch-sync-publication-failed' + ? { + reason: error.recovery.variables.reason, originalSha: error.recovery.variables.originalSha, + candidateSha: error.recovery.variables.candidateSha, observedSha: error.recovery.variables.observedSha, + pushStatus: message(error.recovery.variables.pushAttempted ? 'error.label.syncPushAttempted' : 'error.label.syncPushNotAttempted'), + remoteStatus: message(error.recovery.variables.remoteState === 'unchanged' ? 'error.label.syncRemoteUnchanged' + : error.recovery.variables.remoteState === 'changed' ? 'error.label.syncRemoteChanged' : 'error.label.syncRemoteUnverified'), + publicationGuide: BRANCH_SYNC_PUBLICATION_RECOVERY_URL, syncCommand: '/copilot sync-branch', + } : error.recovery?.id === 'branch-sync-catalog-incompatible' ? { ...error.recovery.variables, syncCommand: '/copilot sync-branch', catalogGuide: BRANCH_SYNC_CATALOG_RECOVERY_URL } : error.recovery?.id.startsWith('stored-configuration-') diff --git a/src/application/policies/branch_sync_publication_messages.ts b/src/application/policies/branch_sync_publication_messages.ts new file mode 100644 index 000000000..246bc1d40 --- /dev/null +++ b/src/application/policies/branch_sync_publication_messages.ts @@ -0,0 +1,39 @@ +export const BRANCH_SYNC_PUBLICATION_LABEL_KEYS = ['syncRemoteUnchanged', 'syncRemoteChanged', 'syncRemoteUnverified', 'syncPushAttempted', 'syncPushNotAttempted'] as const; +export const ENGLISH_BRANCH_SYNC_PUBLICATION_LABELS = { + syncRemoteUnchanged: 'The observed remote still points to the original head; the candidate was not observed as published.', + syncRemoteChanged: 'The remote has a different head; this observation does not establish whether it includes the candidate.', + syncRemoteUnverified: 'The remote head could not be established; publication remains unknown.', + syncPushAttempted: 'A normal push was attempted.', syncPushNotAttempted: 'No push was attempted.', +}; +export const SPANISH_BRANCH_SYNC_PUBLICATION_LABELS = { + syncRemoteUnchanged: 'El remoto observado conserva el head original; no se observó la publicación del candidato.', + syncRemoteChanged: 'El remoto tiene otro head; esta observación no determina si incluye el candidato.', + syncRemoteUnverified: 'No se pudo establecer el head remoto; la publicación sigue sin verificarse.', + syncPushAttempted: 'Se intentó un push normal.', syncPushNotAttempted: 'No se intentó ningún push.', +}; +const retained = 'The local merge commit step finished; candidate identity: {candidateSha}. {pushStatus} {remoteStatus} Original head: {originalSha}; observed head: {observedSha}. Recovery did not reset or abort the committed workspace; runner-local storage is temporary.'; +const retainedSpanish = 'Terminó el paso de commit del merge local; identidad del candidato: {candidateSha}. {pushStatus} {remoteStatus} Head original: {originalSha}; head observado: {observedSha}. La recuperación no restableció ni abortó el espacio de trabajo confirmado; el almacenamiento del runner es temporal.'; +export const ENGLISH_BRANCH_SYNC_PUBLICATION_CONTENT = { + 'branch-sync-workflow-write-required': { + impact: 'The remote rejected workflow-file publication by the configured publisher.', + action: 'A maintainer must review the publisher credential and grant the appropriate workflow-write capability through its normal configuration, then rerun {syncCommand} from the current managed PR. Do not retry unchanged credentials or bypass the managed branch. Recovery guide: {publicationGuide}.', + retainedState: retained, + }, + 'branch-sync-publication-failed': { + impact: 'Branch synchronization did not reach a verified remote publication ({reason}).', + action: 'Inspect the current native remote head and the retained candidate before a fresh Action-managed synchronization. An unavailable observation is not proof that nothing was published. Recovery guide: {publicationGuide}.', + retainedState: retained, + }, +}; +export const SPANISH_BRANCH_SYNC_PUBLICATION_CONTENT = { + 'branch-sync-workflow-write-required': { + impact: 'El remoto rechazó la publicación de archivos de workflow mediante la credencial configurada.', + action: 'Un mantenedor debe revisar la credencial y conceder la capacidad de escritura de workflows adecuada mediante su configuración habitual; después, debe ejecutar {syncCommand} desde la PR gestionada actual. No repitas la misma credencial ni eludas la rama gestionada. Guía: {publicationGuide}.', + retainedState: retainedSpanish, + }, + 'branch-sync-publication-failed': { + impact: 'La sincronización no alcanzó una publicación remota verificada ({reason}).', + action: 'Inspecciona el head remoto nativo actual y el candidato conservado antes de una nueva sincronización gestionada por la Action. No disponer de una observación no demuestra que no se publicara nada. Guía: {publicationGuide}.', + retainedState: retainedSpanish, + }, +}; diff --git a/src/application/ports/branch_sync_ports.ts b/src/application/ports/branch_sync_ports.ts index 20690735f..58120b362 100644 --- a/src/application/ports/branch_sync_ports.ts +++ b/src/application/ports/branch_sync_ports.ts @@ -82,6 +82,7 @@ export interface BranchSyncWorkspacePort { childSha: string, token: string, ): Promise; + /** Postcommit failures use validated BranchSyncPublicationFailure facts; never promise non-publication from an untyped error. */ commitAndPush( workingBranch: string, message: string, @@ -109,6 +110,7 @@ export interface BoundBranchSyncWorkspacePort { workingBranch: string, childSha: string, ): Promise; + /** Postcommit failures use validated BranchSyncPublicationFailure facts; never promise non-publication from an untyped error. */ commitAndPush( workingBranch: string, message: string, diff --git a/src/application/usecases/branch_sync/__tests__/sync_branch_use_case.test.ts b/src/application/usecases/branch_sync/__tests__/sync_branch_use_case.test.ts index 4cb5c01ef..f24bbb238 100644 --- a/src/application/usecases/branch_sync/__tests__/sync_branch_use_case.test.ts +++ b/src/application/usecases/branch_sync/__tests__/sync_branch_use_case.test.ts @@ -1,3 +1,4 @@ +import { BranchSyncPublicationFailure, type BranchSyncPublicationDiagnostic } from '../../../../domain/branch_sync_publication'; import type { Execution } from "../../../../data/model/execution"; import type { BranchMergePreparation } from "../../../ports/branch_sync_ports"; import { SyncBranchUseCase } from "../sync_branch_use_case"; @@ -407,3 +408,45 @@ describe("SyncBranchUseCase", () => { expect(JSON.stringify(failedResults)).not.toContain("secret provider detail"); }); }); + + +describe('Scoped publication reconciliation', () => { + const child = 'a'.repeat(40), parent = 'b'.repeat(40), candidate = 'c'.repeat(40); + const facts: BranchSyncPublicationDiagnostic = { reason: 'workflow-write-required', remoteState: 'unchanged', originalSha: child, candidateSha: candidate, observedSha: child, pushAttempted: true }; + it.each([ + facts, + { ...facts, reason: 'push-failed' as const, remoteState: 'unverified' as const, observedSha: 'unavailable' }, + { ...facts, reason: 'push-failed' as const, remoteState: 'changed' as const, observedSha: 'd'.repeat(40) }, + { ...facts, reason: 'candidate-unverified' as const, candidateSha: 'unavailable', pushAttempted: false }, + ])('reports committed failure with closed nonretryable recovery: %p', async diagnostic => { + const context = setup({ kind: 'clean', parentSha: parent, childSha: child }); + context.workspace.commitAndPush.mockRejectedValue(new BranchSyncPublicationFailure(diagnostic)); + const [result] = await context.useCase.invoke({ context: execution(), options }); + expect(result.success).toBe(false); expect(context.workspace.abort).not.toHaveBeenCalled(); + expect(context.workspace.commitAndPush).toHaveBeenCalledTimes(1); + expect(result.errors[0]).toMatchObject({ code: diagnostic.reason === 'workflow-write-required' ? 'authorization.denied' : 'provider.unavailable', retryable: false, + recovery: { id: diagnostic.reason === 'workflow-write-required' ? 'branch-sync-workflow-write-required' : 'branch-sync-publication-failed', variables: diagnostic } }); + }); + it('recognizes exact remote confirmation after a failed response without a second publication', async () => { + const context = setup({ kind: 'clean', parentSha: parent, childSha: child }); + context.workspace.commitAndPush.mockRejectedValue(new BranchSyncPublicationFailure({ ...facts, remoteState: 'published', observedSha: candidate })); + const [result] = await context.useCase.invoke({ context: execution(), options }); + expect(result.success).toBe(true); expect(result.payload).toMatchObject({ outcome: 'merged-verified-remotely', commitSha: candidate }); + expect(result.steps.join(' ')).toContain('No second push'); expect(context.workspace.abort).not.toHaveBeenCalled(); expect(context.workspace.commitAndPush).toHaveBeenCalledTimes(1); + }); + it('rejects publication facts belonging to a different original child', async () => { + const context = setup({ kind: 'clean', parentSha: parent, childSha: child }); + context.workspace.commitAndPush.mockRejectedValue(new BranchSyncPublicationFailure({ ...facts, originalSha: 'd'.repeat(40), observedSha: 'd'.repeat(40) })); + const [result] = await context.useCase.invoke({ context: execution(), options }); + expect(result.errors[0].code).toBe('provider.contract-invalid'); expect(result.errors[0].recovery).toBeUndefined(); + }); + it.each(['prepare', 'malformed', 'generic'] as const)('does not accept unscoped or untyped facts: %s', async scenario => { + const context = setup({ kind: 'clean', parentSha: parent, childSha: child }); + const error = scenario === 'malformed' ? Object.assign(Object.create(BranchSyncPublicationFailure.prototype), { diagnostic: { reason: 'PRIVATE' } }) + : scenario === 'generic' ? Error('PRIVATE token') : new BranchSyncPublicationFailure(facts); + (scenario === 'prepare' ? context.workspace.prepare : context.workspace.commitAndPush).mockRejectedValue(error); + const [result] = await context.useCase.invoke({ context: execution(), options }); + expect(result.success).toBe(false); expect(result.errors[0].recovery).toBeUndefined(); expect(context.workspace.abort).toHaveBeenCalledTimes(1); + expect(result.steps.join(' ')).toContain('could not be verified'); expect(renderApplicationErrorMarkdown(result.errors[0])).not.toContain('PRIVATE'); + }); +}); diff --git a/src/application/usecases/branch_sync/branch_sync_execution_policy.ts b/src/application/usecases/branch_sync/branch_sync_execution_policy.ts index bf4418302..15003ec80 100644 --- a/src/application/usecases/branch_sync/branch_sync_execution_policy.ts +++ b/src/application/usecases/branch_sync/branch_sync_execution_policy.ts @@ -1,3 +1,4 @@ +import type { BranchSyncPublicationDiagnostic } from '../../../domain/branch_sync_publication'; import { isAgentConfigurationReady } from "../../../data/model/agent"; import type { AgentConfiguration } from '../../../domain/agent'; import { Result } from "../../../data/model/result"; @@ -13,7 +14,8 @@ export type BranchSyncOutcome = | "dry-run-clean" | "dry-run-conflicted" | "merged-cleanly" - | "merged-with-agent"; + | "merged-with-agent" + | "merged-verified-remotely"; export interface BranchSyncConflictFailure { readonly reason: string; @@ -59,6 +61,7 @@ export function completedBranchSyncResult(input: { "dry-run-conflicted": `Dry run complete: the merge has ${preparation.kind === "conflicted" ? preparation.conflictPaths.length : 0} conflict(s). Nothing was pushed and no agent was invoked.`, "merged-cleanly": `Merged \`${parentBranch}\` into \`${workingBranch}\` cleanly and pushed the result.`, "merged-with-agent": `Merged \`${parentBranch}\` into \`${workingBranch}\`, used the fixer agent to resolve conflicts, verified the workspace, and pushed the result.`, + "merged-verified-remotely": `The merge candidate \`${input.commitSha}\` was independently confirmed on remote branch \`${workingBranch}\` after a failed push response. No second push was attempted.`, }; return new Result({ id: BRANCH_SYNC_TASK_ID, @@ -98,3 +101,15 @@ export function failedBranchSyncResult(reason: string, cause?: unknown, recovery : toApplicationError(cause, 'workflow.failed', reason, { recovery })], }); } + +/** Publication facts come only from the trusted workspace's bounded remote observation. */ +export function failedBranchSyncPublicationResult(diagnostic: BranchSyncPublicationDiagnostic): Result { + const workflow = diagnostic.reason === 'workflow-write-required'; + const error = new ApplicationError(workflow ? 'authorization.denied' : 'provider.unavailable', + 'Branch synchronization candidate publication requires maintainer review.', { + retryable: false, + recovery: { id: workflow ? 'branch-sync-workflow-write-required' : 'branch-sync-publication-failed', variables: diagnostic }, + }); + return new Result({ id: BRANCH_SYNC_TASK_ID, success: false, executed: true, + steps: ['The local merge commit step finished; inspect the publication facts before retrying.'], errors: [error] }); +} diff --git a/src/application/usecases/branch_sync/sync_branch_use_case.ts b/src/application/usecases/branch_sync/sync_branch_use_case.ts index 30ad388c2..64dafe42a 100644 --- a/src/application/usecases/branch_sync/sync_branch_use_case.ts +++ b/src/application/usecases/branch_sync/sync_branch_use_case.ts @@ -1,3 +1,4 @@ +import { BranchSyncPublicationFailure, isBranchSyncPublicationDiagnostic } from '../../../domain/branch_sync_publication'; import { Result } from "../../../data/model/result"; import { admittedBranchSyncValidationCode } from '../../../domain/branch_sync_validation'; import type { BranchSyncCommandOptions } from "../../../domain/branch_sync_command"; @@ -22,9 +23,10 @@ import { type BranchSyncConflictFailure, completedBranchSyncResult, failedBranchSyncResult, + failedBranchSyncPublicationResult, unavailableBranchSyncResult, } from "./branch_sync_execution_policy"; -import type { ApplicationErrorRecovery } from '../../errors/application_error'; +import { ApplicationError, type ApplicationErrorRecovery } from '../../errors/application_error'; import type { VerificationFailure } from '../../../domain/verification_failure'; import { BranchSyncCatalogFailure, isBranchSyncCatalogDiagnostic } from '../../../domain/branch_sync_catalog_diagnostic'; @@ -66,7 +68,7 @@ export class SyncBranchUseCase implements ParamUseCase; readonly 'branch-sync-verification-failed': BranchSyncVerificationFailure; readonly 'branch-sync-catalog-incompatible': BranchSyncCatalogDiagnostic & Readonly<{ abort: 'completed' | 'unverified' }>; + readonly 'branch-sync-workflow-write-required': BranchSyncPublicationDiagnostic; + readonly 'branch-sync-publication-failed': BranchSyncPublicationDiagnostic; readonly 'sdd-draft-size-exceeded': Readonly>; readonly 'sdd-catalog-unsupported-profile': Readonly>; readonly 'sdd-catalog-format-invalid': Readonly>; @@ -418,6 +423,8 @@ const RECOVERY_VARIABLE_KEYS: Readonly { + it.each([40, 64])('admits a nonzero %i-character Git object identity', length => { + expect(isBranchSyncObjectId('a'.repeat(length))).toBe(true); + expect(admittedBranchSyncObjectId('a'.repeat(length))).toBe('a'.repeat(length)); + }); + it.each([undefined, null, 12, '', '0'.repeat(40), '0'.repeat(64), 'A'.repeat(40), 'a'.repeat(39), 'b'.repeat(41), 'secret/path', 'a'.repeat(40) + '\n'])('rejects invalid identity %p', value => { + expect(isBranchSyncObjectId(value)).toBe(false); expect(admittedBranchSyncObjectId(value)).toBe('unavailable'); + }); + it.each([ + facts, + { ...facts, remoteState: 'published', observedSha: candidateSha }, + { ...facts, remoteState: 'changed', observedSha: 'c'.repeat(40) }, + { ...facts, remoteState: 'unverified', observedSha: 'unavailable' }, + { ...facts, remoteState: 'unverified', originalSha: 'unavailable', observedSha: 'c'.repeat(64) }, + { ...facts, reason: 'workflow-write-required' }, + { ...facts, reason: 'candidate-unverified', candidateSha: 'unavailable', pushAttempted: false }, + { ...facts, reason: 'candidate-unverified', candidateSha: 'unavailable', pushAttempted: false, remoteState: 'unverified', observedSha: 'unavailable' }, + ])('admits only coherent publication facts: %p', diagnostic => { + expect(isBranchSyncPublicationDiagnostic(diagnostic)).toBe(true); + const error = new BranchSyncPublicationFailure(diagnostic as BranchSyncPublicationDiagnostic); + expect(error.diagnostic).toEqual(diagnostic); expect(Object.isFrozen(error)).toBe(true); + expect(Object.isFrozen(error.diagnostic)).toBe(true); expect(error.diagnostic).not.toBe(diagnostic); + }); + it.each([ + null, [], 'PRIVATE', {}, { ...facts, extra: 'PRIVATE' }, { ...facts, reason: 'PRIVATE' }, + { ...facts, remoteState: 'PRIVATE' }, { ...facts, pushAttempted: 'true' }, + { ...facts, originalSha: 'PRIVATE' }, { ...facts, candidateSha: 'PRIVATE' }, { ...facts, observedSha: 'PRIVATE' }, + { ...facts, pushAttempted: false }, { ...facts, candidateSha: originalSha }, + { ...facts, candidateSha: 'unavailable' }, + { ...facts, reason: 'candidate-unverified', pushAttempted: false }, + { ...facts, reason: 'candidate-unverified' }, + { ...facts, reason: 'candidate-unverified', candidateSha: 'unavailable' }, + { ...facts, reason: 'candidate-unverified', pushAttempted: false, candidateSha: 'unavailable', remoteState: 'published', observedSha: 'unavailable' }, + { ...facts, remoteState: 'changed', originalSha: 'unavailable', observedSha: 'c'.repeat(40) }, + { ...facts, remoteState: 'published' }, { ...facts, remoteState: 'unchanged', observedSha: candidateSha }, + { ...facts, remoteState: 'changed' }, { ...facts, remoteState: 'changed', observedSha: candidateSha }, + { ...facts, remoteState: 'changed', observedSha: 'unavailable' }, + { ...facts, remoteState: 'unchanged', originalSha: 'unavailable', observedSha: 'unavailable' }, + { ...facts, remoteState: 'unverified' }, + { ...facts, remoteState: 'unverified', originalSha: 'unavailable', observedSha: candidateSha }, + ])('rejects malformed or contradictory publication facts: %p', diagnostic => { + expect(isBranchSyncPublicationDiagnostic(diagnostic)).toBe(false); + expect(() => new BranchSyncPublicationFailure(diagnostic as BranchSyncPublicationDiagnostic)).toThrow(TypeError); + }); + it('accepts workflow denial only from the closed typed producer', () => { + const error = new GitPushFailure('workflow-write-required'); + expect(Object.isFrozen(error)).toBe(true); expect(admittedGitPushDenial(error)).toBe('workflow-write-required'); + for (const value of [new Error('workflow-write-required'), { reason: 'workflow-write-required' }, new GitPushFailure('unclassified'), null]) { + expect(admittedGitPushDenial(value)).toBe('unclassified'); + } + }); +}); diff --git a/src/domain/branch_sync_publication.ts b/src/domain/branch_sync_publication.ts new file mode 100644 index 000000000..c4b8f3fef --- /dev/null +++ b/src/domain/branch_sync_publication.ts @@ -0,0 +1,43 @@ +export const BRANCH_SYNC_PUBLICATION_REASONS = Object.freeze(['workflow-write-required', 'push-failed', 'candidate-unverified'] as const); +export const BRANCH_SYNC_REMOTE_STATES = Object.freeze(['unchanged', 'published', 'changed', 'unverified'] as const); +export interface BranchSyncPublicationDiagnostic { + readonly reason: typeof BRANCH_SYNC_PUBLICATION_REASONS[number]; + readonly remoteState: typeof BRANCH_SYNC_REMOTE_STATES[number]; + readonly originalSha: string; + readonly candidateSha: string; + readonly observedSha: string; + readonly pushAttempted: boolean; +} +export function isBranchSyncObjectId(value: unknown): value is string { + return typeof value === 'string' && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u.test(value) && !/^0+$/u.test(value); +} +export function admittedBranchSyncObjectId(value: unknown): string { + return isBranchSyncObjectId(value) ? value : 'unavailable'; +} +export function isBranchSyncPublicationDiagnostic(value: unknown): value is BranchSyncPublicationDiagnostic { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const d = value as BranchSyncPublicationDiagnostic; + if (Object.keys(d).sort().join(',') !== 'candidateSha,observedSha,originalSha,pushAttempted,reason,remoteState' + || !BRANCH_SYNC_PUBLICATION_REASONS.includes(d.reason) || !BRANCH_SYNC_REMOTE_STATES.includes(d.remoteState) + || typeof d.pushAttempted !== 'boolean' + || ![d.originalSha, d.candidateSha, d.observedSha].every(x => x === 'unavailable' || isBranchSyncObjectId(x))) return false; + if (d.reason === 'candidate-unverified' && (d.pushAttempted || d.candidateSha !== 'unavailable')) return false; + if (d.reason !== 'candidate-unverified' && (!d.pushAttempted || !isBranchSyncObjectId(d.candidateSha) || d.candidateSha === d.originalSha)) return false; + if (d.remoteState === 'published') return isBranchSyncObjectId(d.candidateSha) && d.observedSha === d.candidateSha; + if (d.remoteState === 'unchanged') return isBranchSyncObjectId(d.originalSha) && d.observedSha === d.originalSha && d.observedSha !== d.candidateSha; + if (d.remoteState === 'changed') return isBranchSyncObjectId(d.originalSha) && isBranchSyncObjectId(d.observedSha) + && d.observedSha !== d.originalSha && d.observedSha !== d.candidateSha; + return (d.observedSha === 'unavailable' || d.originalSha === 'unavailable') + && !(isBranchSyncObjectId(d.candidateSha) && d.observedSha === d.candidateSha); +} +/** A committed candidate is no longer an in-progress Git merge. */ +export class BranchSyncPublicationFailure extends Error { + readonly diagnostic: BranchSyncPublicationDiagnostic; + constructor(diagnostic: BranchSyncPublicationDiagnostic) { + super('Branch synchronization candidate publication requires reconciliation.'); + if (!isBranchSyncPublicationDiagnostic(diagnostic)) throw new TypeError('Invalid branch synchronization publication facts.'); + this.name = 'BranchSyncPublicationFailure'; + this.diagnostic = Object.freeze({ ...diagnostic }); + Object.freeze(this); + } +} diff --git a/src/domain/git_push_failure.ts b/src/domain/git_push_failure.ts new file mode 100644 index 000000000..a113fcda1 --- /dev/null +++ b/src/domain/git_push_failure.ts @@ -0,0 +1,13 @@ +/** Safe facts from the trusted push adapter; never carries raw stderr or credentials. */ +export type GitPushDenial = 'workflow-write-required' | 'unclassified'; +export class GitPushFailure extends Error { + constructor(readonly reason: GitPushDenial) { + super('Git push did not return a successful publication response.'); + this.name = 'GitPushFailure'; + Object.freeze(this); + } +} +export function admittedGitPushDenial(error: unknown): GitPushDenial { + return error instanceof GitPushFailure && error.reason === 'workflow-write-required' + ? 'workflow-write-required' : 'unclassified'; +} diff --git a/src/infrastructure/__tests__/branch_sync_workspace_adapter.test.ts b/src/infrastructure/__tests__/branch_sync_workspace_adapter.test.ts index 199557a63..fc4748492 100644 --- a/src/infrastructure/__tests__/branch_sync_workspace_adapter.test.ts +++ b/src/infrastructure/__tests__/branch_sync_workspace_adapter.test.ts @@ -1,3 +1,4 @@ +import { GitPushFailure } from '../../domain/git_push_failure'; import type { GitCommitPort } from "../../application/ports/git_ports"; import { BranchSyncWorkspaceAdapter } from "../branch_sync_workspace_adapter"; import { execFileSync } from 'node:child_process'; @@ -259,24 +260,25 @@ describe("BranchSyncWorkspaceAdapter", () => { }); it("prepares, validates, commits, and pushes a clean merge", async () => { + const child = 'a'.repeat(40), parent = 'b'.repeat(40), candidate = 'c'.repeat(40); const { git } = createGit({ [STATUS]: ["", "M src/a.ts\n", "M src/a.ts\n", "M src/a.ts\n"], - "rev-parse HEAD": ["child\n", "child\n", "merge\n"], - "rev-parse FETCH_HEAD": ["parent\n", "parent\n", "child\n"], - "merge-base --is-ancestor parent child": [new Error("not ancestor")], + "rev-parse HEAD": [child, child, candidate], + "rev-parse FETCH_HEAD": [parent, parent, child], + ['merge-base --is-ancestor ' + parent + ' ' + child]: [new Error("not ancestor")], [UNRESOLVED]: ["", ""], [INDEX]: ["100644 sha 0\tsrc/a.ts\0", "100644 sha 0\tsrc/a.ts\0"], - "rev-parse MERGE_HEAD": ["parent\n"], + "rev-parse MERGE_HEAD": [parent], }); const adapter = new BranchSyncWorkspaceAdapter(git); await expect(adapter.prepare("develop", "feature/42", "token")).resolves.toMatchObject({ kind: "clean" }); await expect(adapter.validatePreparedMerge([])).resolves.toEqual({ valid: true }); - await expect(adapter.assertRemoteHeadsUnchanged("develop", "parent", "feature/42", "child", "token")) + await expect(adapter.assertRemoteHeadsUnchanged("develop", parent, "feature/42", child, "token")) .resolves.toEqual({ valid: true }); await expect(adapter.commitAndPush( "feature/42", "Merge develop into feature/42", { name: "Bot", email: "bot@example.com" }, "token", - )).resolves.toBe("merge"); + )).resolves.toBe(candidate); expect(git.configureAuthor).toHaveBeenCalledWith("Bot", "bot@example.com"); expect(git.stageAll).toHaveBeenCalled(); expect(git.commit).toHaveBeenCalledWith("Merge develop into feature/42"); @@ -422,3 +424,67 @@ describe("BranchSyncWorkspaceAdapter", () => { expect(context.execute).not.toHaveBeenCalled(); }); }); + + +describe('Committed branch synchronization publication recovery', () => { + const child = 'a'.repeat(40), parent = 'b'.repeat(40), candidate = 'c'.repeat(40); + async function prepared(head: Reply = candidate, observed: Reply = child, original = child) { + return prepareCleanAdapter({ 'rev-parse HEAD': [original, head], 'rev-parse FETCH_HEAD': [parent, observed], + ['merge-base --is-ancestor ' + parent + ' ' + original]: [new Error('not ancestor')] }); + } + it.each([ + ['unchanged', child], ['published', candidate], ['changed', 'd'.repeat(40)], + ['unverified', 'PRIVATE'], ['unverified', new Error('PRIVATE read failure')], + ] as const)('records %s after push denial without aborting or discarding the candidate', async (remoteState, observed) => { + const { adapter, git, execute } = await prepared(candidate, observed); + git.push.mockRejectedValue(new GitPushFailure('workflow-write-required')); + await expect(adapter.commitAndPush('feature/42', 'merge', { name: 'Bot', email: 'bot@example.invalid' }, 'fixture')).rejects.toMatchObject({ + diagnostic: { reason: 'workflow-write-required', remoteState, originalSha: child, candidateSha: candidate, + observedSha: remoteState === 'unverified' ? 'unavailable' : observed, pushAttempted: true }, + }); + await adapter.abort(); + expect(execute.mock.calls.some(([, args]) => args[0] === 'reset' || (args[0] === 'merge' && args[1] === '--abort'))).toBe(false); + expect(git.push).toHaveBeenCalledTimes(1); expect(git.fetch).toHaveBeenCalledTimes(3); + expect(git.fetch).toHaveBeenLastCalledWith('feature/42', 'fixture'); + await expect(adapter.commitAndPush('feature/42', 'again', { name: 'Bot', email: 'b' }, 'fixture')).rejects.toThrow('No prepared'); + }); + it.each(['PRIVATE', child, new Error('PRIVATE candidate read')])('never pushes an unverifiable candidate %p', async head => { + const { adapter, git, execute } = await prepared(head); + await expect(adapter.commitAndPush('feature/42', 'merge', { name: 'Bot', email: 'b' }, 'fixture')).rejects.toMatchObject({ + diagnostic: { reason: 'candidate-unverified', candidateSha: 'unavailable', remoteState: 'unchanged', pushAttempted: false }, + }); + await adapter.abort(); expect(git.push).not.toHaveBeenCalled(); + expect(execute.mock.calls.some(([, args]) => ['reset', 'merge'].includes(args[0]) && args[1] !== '--no-ff')).toBe(false); + }); + it('rejects a nonthrowing failed Git read as unavailable', async () => { + const { adapter, git } = await prepared(candidate, 1); git.push.mockRejectedValue(Error('PRIVATE')); + await expect(adapter.commitAndPush('feature/42', 'merge', { name: 'Bot', email: 'b' }, 'fixture')).rejects.toMatchObject({ + diagnostic: { remoteState: 'unverified', observedSha: 'unavailable' }, + }); + }); + it('does not claim an unchanged remote when authenticated observation fails', async () => { + const { adapter, git } = await prepared(); git.push.mockRejectedValue(Error('PRIVATE')); + git.fetch.mockRejectedValueOnce(Error('PRIVATE fetch')); + await expect(adapter.commitAndPush('feature/42', 'merge', { name: 'Bot', email: 'b' }, 'fixture')).rejects.toMatchObject({ + diagnostic: { reason: 'push-failed', remoteState: 'unverified', observedSha: 'unavailable' }, + }); + await adapter.abort(); expect(git.push).toHaveBeenCalledTimes(1); expect(git.fetch).toHaveBeenCalledTimes(3); + }); + it('does not push if the original identity was invalid', async () => { + const { adapter, git } = await prepared(candidate, parent, 'PRIVATE'); + await expect(adapter.commitAndPush('feature/42', 'merge', { name: 'Bot', email: 'b' }, 'fixture')).rejects.toMatchObject({ + diagnostic: { reason: 'candidate-unverified', remoteState: 'unverified', originalSha: 'unavailable', pushAttempted: false }, + }); + expect(git.push).not.toHaveBeenCalled(); + }); + it('preserves pre-commit abort semantics when the commit itself fails', async () => { + const { adapter, git, execute } = await prepared(child); git.commit.mockRejectedValue(Error('commit rejected')); + await expect(adapter.commitAndPush('feature/42', 'merge', { name: 'Bot', email: 'b' }, 'fixture')).rejects.toThrow('commit rejected'); + await adapter.abort(); expect(execute).toHaveBeenCalledWith('git', ['merge', '--abort']); expect(git.push).not.toHaveBeenCalled(); + }); + it('accepts a successful SHA-256 candidate and makes no redundant remote observation', async () => { + const sha = 'e'.repeat(64); const { adapter, git } = await prepared(sha); + await expect(adapter.commitAndPush('feature/42', 'merge', { name: 'Bot', email: 'b' }, 'fixture')).resolves.toBe(sha); + await adapter.abort(); expect(git.fetch).toHaveBeenCalledTimes(2); expect(git.push).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/infrastructure/__tests__/git_push_diagnostic.test.ts b/src/infrastructure/__tests__/git_push_diagnostic.test.ts new file mode 100644 index 000000000..06f96b56e --- /dev/null +++ b/src/infrastructure/__tests__/git_push_diagnostic.test.ts @@ -0,0 +1,51 @@ +import * as actionsExec from '@actions/exec'; +import { GitCommitAdapter } from '../git_commit_adapter'; +import { captureGitPushDiagnostic, GIT_PUSH_DIAGNOSTIC_BYTES } from '../git_push_diagnostic'; +import { GitPushFailure } from '../../domain/git_push_failure'; +const denial = 'remote: refusing to allow a Personal Access Token to create or update workflow `.github/workflows/ci_check.yml` without `workflow` scope'; +describe('Bounded trusted Git push diagnostic', () => { + it.each([ + denial, + 'remote: refusing to allow an OAuth App to update workflow ci.yml without workflow scope', + 'remote: refusing to allow a GitHub App to create workflow ci.yml without workflows permission', + ])('recognizes only an explicit remote workflow capability denial: %s', text => { + const capture = captureGitPushDiagnostic(); capture.stderr(Buffer.from(text)); expect(capture.classify()).toBe('workflow-write-required'); + }); + it('reassembles a denial across stderr chunks and retains it after a large prefix', () => { + const capture = captureGitPushDiagnostic(); capture.stderr(Buffer.alloc(GIT_PUSH_DIAGNOSTIC_BYTES * 4, 120)); + for (const part of [denial.slice(0, 73), denial.slice(73, 109), denial.slice(109)]) capture.stderr(Buffer.from(part)); + expect(capture.classify()).toBe('workflow-write-required'); + capture.stderr(Buffer.alloc(GIT_PUSH_DIAGNOSTIC_BYTES + 1, 120)); expect(capture.classify()).toBe('unclassified'); + }); + it.each(['', 'Permission denied', '.github/workflows/ci_check.yml', 'workflow scope required', 'refusing to allow a Personal Access Token to update workflow x\nwithout workflow scope', 'refusing to allow a user to update workflow x without workflow scope'])('does not invent capability facts from %p', text => { + const capture = captureGitPushDiagnostic(); capture.stderr(Buffer.from(text)); expect(capture.classify()).toBe('unclassified'); + }); + it.each(['nonzero', 'exception', 'generic', 'success'] as const)('maps push outcome %s without leaking raw output', async outcome => { + const execute = jest.fn(async (_program: string, _args: string[], options?: { stderr?: (data: Buffer) => void; env?: Record }) => { + options?.stderr?.(Buffer.from(outcome === 'generic' ? 'PRIVATE credential or path' : denial)); + if (outcome === 'exception' || outcome === 'generic') throw new Error('PRIVATE exception or token'); + return outcome === 'nonzero' ? 1 : 0; + }); + const push = new GitCommitAdapter(execute).push('feature/42', 'fixture-not-real'); + if (outcome === 'success') await expect(push).resolves.toBeUndefined(); + else { + const error = await push.catch(e => e); + expect(error).toBeInstanceOf(GitPushFailure); + expect(error.reason).toBe(outcome === 'generic' ? 'unclassified' : 'workflow-write-required'); + expect(error.message).not.toMatch(/PRIVATE|ci_check|fixture-not-real/); + } + expect(execute).toHaveBeenCalledTimes(1); + expect(execute.mock.calls[0][1]).toEqual(['push', 'origin', 'feature/42']); + expect(execute.mock.calls[0][2]?.env?.GIT_CONFIG_KEY_0).toBe('http.https://github.com/.extraheader'); + }); + it('rejects a nonthrowing failed fetch instead of treating a stale FETCH_HEAD as an observation', async () => { + await expect(new GitCommitAdapter(async () => 1).fetch('feature/42')).rejects.toThrow('Git fetch did not complete'); + }); + it('captures default-executor stderr silently, without publishing it in Actions logs', async () => { + const native = jest.spyOn(actionsExec, 'exec').mockImplementation(async (_program, _args, options) => { + expect(options?.silent).toBe(true); options?.listeners?.stderr?.(Buffer.from(denial)); throw Error('PRIVATE'); + }); + try { await expect(new GitCommitAdapter().push('feature/42')).rejects.toMatchObject({ reason: 'workflow-write-required' }); } + finally { native.mockRestore(); } + }); +}); diff --git a/src/infrastructure/branch_sync_workspace_adapter.ts b/src/infrastructure/branch_sync_workspace_adapter.ts index ba22d36ac..bbb9b9616 100644 --- a/src/infrastructure/branch_sync_workspace_adapter.ts +++ b/src/infrastructure/branch_sync_workspace_adapter.ts @@ -1,3 +1,5 @@ +import { admittedGitPushDenial } from '../domain/git_push_failure'; +import { admittedBranchSyncObjectId, isBranchSyncObjectId, BranchSyncPublicationFailure, type BranchSyncPublicationDiagnostic } from '../domain/branch_sync_publication'; import { COMMUNICATION_BUDGET_PATH, refreshBranchSyncCommunicationBudget } from './branch_sync_communication_budget'; import type { BranchMergePreparation, @@ -199,11 +201,35 @@ export class BranchSyncWorkspaceAdapter implements BranchSyncWorkspacePort { await this.git.configureAuthor(author.name, author.email); await this.git.stageAll(); await this.git.commit(message); - const sha = await this.read("git", ["rev-parse", "HEAD"]); - await this.git.push(workingBranch, token); + const originalSha = admittedBranchSyncObjectId(this.snapshot.childSha); + // Commit succeeded: never abort/reset this candidate, even if identity/read/push now fails. this.snapshot = undefined; this.mergeInProgress = false; - return sha; + this.resolutionValidated = false; + let candidateSha = 'unavailable'; + let pushAttempted = false; + try { + candidateSha = admittedBranchSyncObjectId(await this.read('git', ['rev-parse', 'HEAD'])); + if (!isBranchSyncObjectId(candidateSha) || !isBranchSyncObjectId(originalSha) || candidateSha === originalSha) { + candidateSha = 'unavailable'; + throw new Error('Candidate identity is unavailable.'); + } + pushAttempted = true; + await this.git.push(workingBranch, token); + return candidateSha; + } catch (error) { + let observedSha = 'unavailable'; + try { + await this.git.fetch(workingBranch, token); + observedSha = admittedBranchSyncObjectId(await this.read('git', ['rev-parse', 'FETCH_HEAD'])); + } catch { /* Missing observation never proves non-publication. */ } + const remoteState = isBranchSyncObjectId(candidateSha) && observedSha === candidateSha ? 'published' + : isBranchSyncObjectId(originalSha) && observedSha === originalSha ? 'unchanged' + : isBranchSyncObjectId(originalSha) && isBranchSyncObjectId(observedSha) ? 'changed' : 'unverified'; + const reason: BranchSyncPublicationDiagnostic['reason'] = !pushAttempted ? 'candidate-unverified' + : admittedGitPushDenial(error) === 'workflow-write-required' ? 'workflow-write-required' : 'push-failed'; + throw new BranchSyncPublicationFailure({ reason, remoteState, originalSha, candidateSha, observedSha, pushAttempted }); + } } async abort(): Promise { @@ -290,7 +316,9 @@ export class BranchSyncWorkspaceAdapter implements BranchSyncWorkspacePort { private async readRaw(program: string, args: string[]): Promise { const chunks: Buffer[] = []; - await this.git.execute(program, args, { stdout: (data) => chunks.push(data) }); + if (await this.git.execute(program, args, { stdout: (data) => chunks.push(data) }) !== 0) { + throw new Error('Git workspace observation did not complete successfully.'); + } return Buffer.concat(chunks).toString("utf8"); } } diff --git a/src/infrastructure/git_commit_adapter.ts b/src/infrastructure/git_commit_adapter.ts index d2d880cc6..4b9ca751a 100644 --- a/src/infrastructure/git_commit_adapter.ts +++ b/src/infrastructure/git_commit_adapter.ts @@ -1,3 +1,5 @@ +import { GitPushFailure } from '../domain/git_push_failure'; +import { captureGitPushDiagnostic } from './git_push_diagnostic'; import { verificationExecutable } from './verification_executable'; import { nativeVerificationRetainsProfile } from './native_verification_profiles'; import * as exec from '@actions/exec'; @@ -11,7 +13,7 @@ import { prepareRepositoryVerification } from './verification_setup_runner'; import { captureSourceDependencyPreparation, type SourceDependencyPreparation } from './source_verification_dependencies'; import { captureVerificationPythonLibraries } from './verification_python_library_environment'; -type GitCommandOptions = { stdout?: (data: Buffer) => void; env?: Record; untrusted?: boolean; verifyRuntimePrerequisites?: boolean; resolveFromEnvironment?: boolean }; +type GitCommandOptions = { stdout?: (data: Buffer) => void; stderr?: (data: Buffer) => void; env?: Record; untrusted?: boolean; verifyRuntimePrerequisites?: boolean; resolveFromEnvironment?: boolean }; type GitCommandExecutor = (program: string, args: string[], options?: GitCommandOptions) => Promise; export class GitCommitAdapter implements GitCommitPort { @@ -21,8 +23,9 @@ export class GitCommitAdapter implements GitCommitPort { ? exec.exec(options.resolveFromEnvironment && options.env ? verificationExecutable(program, options.env) : program, args, { // Captured Git reads can contain NUL-delimited records that // hide subsequent lines in GitHub's Actions log viewer. - ...(options.stdout ? { silent: true } : {}), - ...(options.stdout ? { listeners: { stdout: options.stdout } } : {}), + ...(options.stdout || options.stderr ? { silent: true } : {}), + ...(options.stdout || options.stderr ? { listeners: { ...(options.stdout ? { stdout: options.stdout } : {}), + ...(options.stderr ? { stderr: options.stderr } : {}) } } : {}), ...(options.env ? { env: options.env } : {}), }) : exec.exec(program, args), @@ -63,7 +66,9 @@ export class GitCommitAdapter implements GitCommitPort { } async fetch(branch: string, token?: string): Promise { - await this.executeAuthenticated(['fetch', 'origin', branch], token); + if (await this.executeAuthenticated(['fetch', 'origin', branch], token) !== 0) { + throw new Error('Git fetch did not complete successfully.'); + } } async stageAll(): Promise { @@ -79,16 +84,22 @@ export class GitCommitAdapter implements GitCommitPort { } async push(branch: string, token?: string): Promise { - await this.executeAuthenticated(['push', 'origin', branch], token); + const diagnostic = captureGitPushDiagnostic(); + try { + const code = await this.executeAuthenticated(['push', 'origin', branch], token, diagnostic.stderr); + if (code !== 0) throw new GitPushFailure(diagnostic.classify()); + } catch { + throw new GitPushFailure(diagnostic.classify()); + } } - private async executeAuthenticated(args: string[], token?: string): Promise { + private async executeAuthenticated(args: string[], token?: string, stderr?: (data: Buffer) => void): Promise { if (!token?.trim()) { - await this.execute('git', args); - return; + return stderr ? this.execute('git', args, { stderr }) : this.execute('git', args); } const environment = buildGitAuthenticationEnvironment(token); - await this.execute('git', args, { + return this.execute('git', args, { + ...(stderr ? { stderr } : {}), // Supply authentication only to this trusted git subprocess. The // agent process never receives this value and nothing is persisted // in the repository's git configuration or remote URL. diff --git a/src/infrastructure/git_push_diagnostic.ts b/src/infrastructure/git_push_diagnostic.ts new file mode 100644 index 000000000..b48602df6 --- /dev/null +++ b/src/infrastructure/git_push_diagnostic.ts @@ -0,0 +1,18 @@ +import type { GitPushDenial } from '../domain/git_push_failure'; +export const GIT_PUSH_DIAGNOSTIC_BYTES = 8_192; +/** Bounded suffix keeps the terminal remote rejection after progress output. */ +export function captureGitPushDiagnostic(): { readonly stderr: (data: Buffer) => void; readonly classify: () => GitPushDenial } { + let tail = Buffer.alloc(0); + return { + stderr: data => { + const part = data.subarray(Math.max(0, data.length - GIT_PUSH_DIAGNOSTIC_BYTES)); + tail = Buffer.concat([tail.subarray(Math.max(0, tail.length - (GIT_PUSH_DIAGNOSTIC_BYTES - part.length))), part]); + }, + classify: () => { + const text = tail.toString('utf8').replace(/[`'"]/gu, ''); + // Path presence, arbitrary exception text and generic authorization errors are not scope proof. + return /refusing to allow (?:a Personal Access Token|an OAuth App|a GitHub App) to (?:create or update|create|update) workflow\b[^\r\n]*\bwithout (?:workflow scope|workflows permission)\b/iu.test(text) + ? 'workflow-write-required' : 'unclassified'; + }, + }; +} diff --git a/src/tooling/__tests__/branch_sync_publication_integration.test.ts b/src/tooling/__tests__/branch_sync_publication_integration.test.ts new file mode 100644 index 000000000..2b9c84c26 --- /dev/null +++ b/src/tooling/__tests__/branch_sync_publication_integration.test.ts @@ -0,0 +1,23 @@ +import { runPublicationFixture, type PublicationFixtureScenario } from './fixtures/branch_sync_publication_fixture'; +import { renderApplicationErrorMarkdown } from '../../application/policies/application_error_presentation_policy'; +describe('Real local Git publication recovery', () => { + it.each(['workflow-denial', 'generic-denial', 'lost-acknowledgement', 'observation-unavailable'])( + 'reconciles %s without duplicate publication, candidate loss or changes to a dirty user checkout', async scenario => { + const proof = await runPublicationFixture(scenario); + expect(proof.candidate).not.toBe(proof.child); expect(proof.parents).toEqual([proof.child, proof.parent]); + expect(proof.userUnchanged).toBe(true); expect(proof.fixerCalls).toBe(0); + expect(proof.trace.filter(args => args[0] === 'push')).toHaveLength(1); + expect(proof.trace.some(args => args.includes('--force') || args.includes('--force-with-lease') || args[0] === 'reset' || args.includes('--abort'))).toBe(false); + if (scenario === 'lost-acknowledgement') { + expect(proof.result.success).toBe(true); expect(proof.remote).toBe(proof.candidate); + expect(proof.result.payload).toMatchObject({ outcome: 'merged-verified-remotely', commitSha: proof.candidate }); + } else { + expect(proof.result.success).toBe(false); expect(proof.remote).toBe(proof.child); + const error = proof.result.errors[0]; expect(error.retryable).toBe(false); + expect(error.code).toBe(scenario === 'generic-denial' ? 'provider.unavailable' : 'authorization.denied'); + expect(error.recovery?.variables).toMatchObject({ candidateSha: proof.candidate, originalSha: proof.child, pushAttempted: true, + remoteState: scenario === 'observation-unavailable' ? 'unverified' : 'unchanged', observedSha: scenario === 'observation-unavailable' ? 'unavailable' : proof.child }); + expect(renderApplicationErrorMarkdown(error)).not.toMatch(/PRIVATE|fixture-not-a-real-credential|\.github\/workflows\/fixture/); + } + }, 45_000); +}); diff --git a/src/tooling/__tests__/fixtures/branch_sync_publication_fixture.ts b/src/tooling/__tests__/fixtures/branch_sync_publication_fixture.ts new file mode 100644 index 000000000..4d0a5e633 --- /dev/null +++ b/src/tooling/__tests__/fixtures/branch_sync_publication_fixture.ts @@ -0,0 +1,67 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { GitCommitAdapter } from '../../../infrastructure/git_commit_adapter'; +import { BranchSyncWorkspaceAdapter } from '../../../infrastructure/branch_sync_workspace_adapter'; +import { SyncBranchUseCase } from '../../../application/usecases/branch_sync/sync_branch_use_case'; +export type PublicationFixtureScenario = 'workflow-denial' | 'generic-denial' | 'lost-acknowledgement' | 'observation-unavailable'; +/** Local bare Git transport only; the hook models a provider message, not GitHub credential capability. */ +export async function runPublicationFixture(scenario: PublicationFixtureScenario) { + const dir = mkdtempSync(join(tmpdir(), 'copilot-publication-')); + const remote = join(dir, 'remote.git'), seed = join(dir, 'seed'), managed = join(dir, 'managed'), user = join(dir, 'user'); + const home = join(dir, 'home'); mkdirSync(home); const config = join(home, 'gitconfig'); writeFileSync(config, ''); + const env = { PATH: process.env.PATH, HOME: home, GIT_CONFIG_GLOBAL: config, GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0', + GIT_AUTHOR_DATE: '2026-11-03T00:00:00Z', GIT_COMMITTER_DATE: '2026-11-03T00:00:00Z', + ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}), ...(process.env.TEMP ? { TEMP: process.env.TEMP } : {}) }; + const run = (cwd: string, args: string[]) => execFileSync('git', args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim(); + const commit = (message: string) => run(seed, ['-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', '-c', 'commit.gpgsign=false', 'commit', '-m', message]); + const trace: string[][] = []; + try { + run(dir, ['init', '--bare', remote]); run(dir, ['init', '--initial-branch=develop', seed]); + writeFileSync(join(seed, 'base.txt'), 'base\n'); run(seed, ['add', '.']); commit('base'); run(seed, ['branch', 'feature/8']); + mkdirSync(join(seed, '.github/workflows'), { recursive: true }); + writeFileSync(join(seed, '.github/workflows/fixture.yml'), 'name: Fixture\non: push\n'); run(seed, ['add', '.']); commit('parent workflow'); + const parent = run(seed, ['rev-parse', 'HEAD']); run(seed, ['checkout', 'feature/8']); + writeFileSync(join(seed, 'child.txt'), 'child\n'); run(seed, ['add', '.']); commit('child'); const child = run(seed, ['rev-parse', 'HEAD']); + run(seed, ['remote', 'add', 'origin', remote]); run(seed, ['push', 'origin', 'develop', 'feature/8']); + run(dir, ['clone', '--branch', 'feature/8', remote, managed]); run(dir, ['clone', '--branch', 'feature/8', remote, user]); + writeFileSync(join(user, 'authored-note.txt'), 'authored original\n'); + const userState = () => ({ head: run(user, ['rev-parse', 'HEAD']), status: run(user, ['status', '--porcelain']), note: readFileSync(join(user, 'authored-note.txt'), 'utf8') }); + const before = userState(); + if (scenario !== 'lost-acknowledgement') { + const denial = scenario === 'generic-denial' ? 'PRIVATE unrelated server rejection' + : 'refusing to allow a Personal Access Token to create or update workflow .github/workflows/fixture.yml without workflow scope'; + writeFileSync(join(remote, 'hooks/pre-receive'), '#!/bin/sh\nprintf \'%s\\n\' \'' + denial + '\' >&2\nexit 1\n', { mode: 0o755 }); + } + let attempted = false, fixerCalls = 0; + const git = new GitCommitAdapter(async (program, args, options) => { + trace.push(args); + if (scenario === 'observation-unavailable' && attempted && args[0] === 'fetch') throw Error('PRIVATE missing observation'); + const commandEnv = { ...env } as Record; + for (const [key, value] of Object.entries(options?.env ?? {})) if (/^GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+)$/u.test(key)) commandEnv[key] = value; + try { + if (args[0] === 'push') attempted = true; + const bytes = execFileSync(program, args, { cwd: managed, env: commandEnv, stdio: ['ignore', 'pipe', 'pipe'] }); options?.stdout?.(bytes); + if (scenario === 'lost-acknowledgement' && args[0] === 'push') throw Error('PRIVATE lost transport acknowledgement'); + return 0; + } catch (cause) { + const stderr = (cause as { stderr?: Buffer }).stderr; + if (stderr) options?.stderr?.(stderr); + throw cause; + } + }); + const workspace = new BranchSyncWorkspaceAdapter(git, managed), token = 'fixture-not-a-real-credential'; + const bound = { prepare: (a: string, b: string) => workspace.prepare(a, b, token), validatePreparedMerge: (paths: readonly string[]) => workspace.validatePreparedMerge(paths), + assertRemoteHeadsUnchanged: (a: string, b: string, c: string, d: string) => workspace.assertRemoteHeadsUnchanged(a, b, c, d, token), + commitAndPush: (a: string, b: string, c: { name: string; email: string }) => workspace.commitAndPush(a, b, c, token), abort: () => workspace.abort() }; + const useCase = new SyncBranchUseCase({ resolveTarget: async () => ({ issueNumber: 8, conversationNumber: 15, parentBranch: 'develop', workingBranch: 'feature/8' }), listOpenDependencies: async () => [] }, bound, + { fix: async () => { fixerCalls++; throw Error('No fixer belongs to the clean fixture.'); } }, + { getUserDetails: async () => ({ name: 'Fixture', email: 'fixture@example.invalid' }), getUser: async () => 'fixture' }, git); + const [result] = await useCase.invoke({ context: { conversationNumber: 15, repository: { owner: 'fixture', name: 'repo' }, agentConfiguration: { provider: 'codex', model: 'unused' }, verifyCommands: [] }, options: { dryRun: false, useAgent: false } }); + const candidate = run(managed, ['rev-parse', 'HEAD']); + return { result, child, parent, candidate, remote: run(seed, ['ls-remote', 'origin', 'refs/heads/feature/8']).split(/\s/u)[0], + parents: run(managed, ['show', '-s', '--format=%P', 'HEAD']).split(' '), + userUnchanged: JSON.stringify(before) === JSON.stringify(userState()), trace, fixerCalls }; + } finally { rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } +}