diff --git a/.github/workflows/hotfix_workflow.yml b/.github/workflows/hotfix_workflow.yml index 5ba20c1f7..a0dc50735 100644 --- a/.github/workflows/hotfix_workflow.yml +++ b/.github/workflows/hotfix_workflow.yml @@ -365,6 +365,7 @@ jobs: uses: ./ with: single-action: published_deployment_action + merge-queue-check-attestations: ${{ vars.MERGE_QUEUE_CHECK_ATTESTATIONS || '[]' }} single-action-issue: ${{ inputs.issue }} issue-workflow-profile: ${{ vars.COPILOT_ISSUE_WORKFLOW_PROFILE || '' }} single-action-operation-id: ${{ inputs.operation-id }} diff --git a/.github/workflows/release_workflow.yml b/.github/workflows/release_workflow.yml index 54805a236..688b8f8d5 100644 --- a/.github/workflows/release_workflow.yml +++ b/.github/workflows/release_workflow.yml @@ -365,6 +365,7 @@ jobs: uses: ./ with: single-action: published_deployment_action + merge-queue-check-attestations: ${{ vars.MERGE_QUEUE_CHECK_ATTESTATIONS || '[]' }} single-action-issue: ${{ inputs.issue }} issue-workflow-profile: ${{ vars.COPILOT_ISSUE_WORKFLOW_PROFILE || '' }} single-action-operation-id: ${{ inputs.operation-id }} diff --git a/docs/issues/deployment-orchestration.mdx b/docs/issues/deployment-orchestration.mdx index 2b3b1a0f1..085ea171d 100644 --- a/docs/issues/deployment-orchestration.mdx +++ b/docs/issues/deployment-orchestration.mdx @@ -329,8 +329,10 @@ branch roles and orchestration choices are snapshotted; changing those Variables does not rewrite an in-flight deployment. Merge-queue attestations are the exception: they are live repository policy and are reread with effective target rules at every readiness decision. Both publishing workflows and the managed-PR -continuation workflow pass `MERGE_QUEUE_CHECK_ATTESTATIONS`, so a multi-target -operation does not fall back to stale or empty trust policy between events. +continuation workflow pass `MERGE_QUEUE_CHECK_ATTESTATIONS` (with `[]` only as +the absent-variable fallback). Normal publication callbacks and reconciliation-only +resume both receive this live value in consumer and source workflows, so a +multi-target operation does not fall back to stale or empty trust policy between events. A recommended non-interactive fragment is: diff --git a/scripts/validate-workflow-contract.cjs b/scripts/validate-workflow-contract.cjs index 51eb5fb60..64a69df77 100644 --- a/scripts/validate-workflow-contract.cjs +++ b/scripts/validate-workflow-contract.cjs @@ -1145,6 +1145,10 @@ function assertPhaseConditions(relativeFile, jobs, finalJob) { if (publishedActions.length !== 2) { throw new Error(`${relativeFile} must project publication through published_deployment_action.`); } + if ([...preparationActions, ...publishedActions].some(step => + step.with?.['merge-queue-check-attestations'] !== "${{ vars.MERGE_QUEUE_CHECK_ATTESTATIONS || '[]' }}")) { + throw new Error(`${relativeFile} must pass live merge-queue attestations on every preparation and reconciliation entrypoint.`); + } const trustedContinuations = Object.values(jobs).flatMap(job => job.steps ?? []) .filter(step => isCopilotAction(step) && [ 'create_tag', diff --git a/setup/workflows/hotfix_workflow.yml b/setup/workflows/hotfix_workflow.yml index bcd718bcf..10efa7cd1 100644 --- a/setup/workflows/hotfix_workflow.yml +++ b/setup/workflows/hotfix_workflow.yml @@ -179,6 +179,7 @@ jobs: uses: vypdev/copilot@v3 with: single-action: published_deployment_action + merge-queue-check-attestations: ${{ vars.MERGE_QUEUE_CHECK_ATTESTATIONS || '[]' }} single-action-issue: ${{ inputs.issue }} issue-workflow-profile: ${{ vars.COPILOT_ISSUE_WORKFLOW_PROFILE || '' }} single-action-operation-id: ${{ inputs.operation-id }} diff --git a/setup/workflows/release_workflow.yml b/setup/workflows/release_workflow.yml index 63b340a85..b5300c717 100644 --- a/setup/workflows/release_workflow.yml +++ b/setup/workflows/release_workflow.yml @@ -179,6 +179,7 @@ jobs: uses: vypdev/copilot@v3 with: single-action: published_deployment_action + merge-queue-check-attestations: ${{ vars.MERGE_QUEUE_CHECK_ATTESTATIONS || '[]' }} single-action-issue: ${{ inputs.issue }} issue-workflow-profile: ${{ vars.COPILOT_ISSUE_WORKFLOW_PROFILE || '' }} single-action-operation-id: ${{ inputs.operation-id }} diff --git a/specs/merge-queue-readiness.md b/specs/merge-queue-readiness.md index aa52a8ca8..cad8b464e 100644 --- a/specs/merge-queue-readiness.md +++ b/specs/merge-queue-readiness.md @@ -1075,3 +1075,26 @@ feature must prevent and violates the event-driven no-polling architecture. for merge-queue targets. - Automatic edits to rulesets or vendor CI. - A live destructive merge-queue test in a user repository. + + +### Publication callback policy parity (2026-10-21, #643) + +Preparation, normal publication, reconciliation-only resume and managed-PR +continuation MUST all pass the live `MERGE_QUEUE_CHECK_ATTESTATIONS` Repository +Variable with an absent-value `[]` fallback. Consumer templates and source +release/hotfix workflows share this contract. A normal callback starts +reconciliation just as resume does; neither may substitute an empty literal or +a snapshotted preparation value. Target selection, producer identity, failed +policy handling and retained publication state remain unchanged. No new input, +permission, UI state, automatic retry or deployment is introduced. + +Flow: prepared promotion -> confirmed publication -> live target policy -> +reconciliation, or the existing blocked recovery state. Existing status cards +and recovery guidance remain authoritative; install updated generated workflows +to adopt the correction. The added numeric test budget is **24 distinct cases**: +source/consumer × release/hotfix × preparation/publication/resume × missing/stale +input. Existing continuation tests cover its fourth entrypoint. Structural +workflow validation rejects either omission or a literal value; all canonical +workflows must pass. Documentation: deployment orchestration's live-policy mapping. +Acceptance: each entrypoint supplies the expression on the actual Action step; +no publication/release is launched to test wiring. diff --git a/src/tooling/__tests__/validate_workflow_contract.test.ts b/src/tooling/__tests__/validate_workflow_contract.test.ts index 2da3fc728..db7489c7a 100644 --- a/src/tooling/__tests__/validate_workflow_contract.test.ts +++ b/src/tooling/__tests__/validate_workflow_contract.test.ts @@ -645,6 +645,21 @@ describe('workflow contract validator', () => { } }); + it.each(mutationDirectories.flatMap(directory => mutationWorkflowNames.flatMap(fileName => + ['prepare', 'publish', 'resume'].flatMap(phase => ['missing', 'stale'].map(value => [directory, fileName, phase, value])))))( + 'rejects %s/%s %s with %s merge-queue attestations', (directory, fileName, phase, value) => { + expectMutationRejected(directory, fileName, workflow => { + const action = phase === 'prepare' ? 'prepare_deployment_action' : 'published_deployment_action'; + const job = phase === 'resume' ? workflow.jobs['resume-reconciliation'] + : Object.entries(workflow.jobs).find(([id, job]) => id !== 'resume-reconciliation' + && (job.steps ?? []).some((step: any) => step.with?.['single-action'] === action))![1]; + const step = job.steps.find((step: any) => step.with?.['single-action'] === action); + if (value === 'missing') delete step.with['merge-queue-check-attestations']; + else step.with['merge-queue-check-attestations'] = '[]'; + }, 'live merge-queue attestations'); + }, + ); + it.each(mutationWorkflowNames)('enforces the active compiled-files budget for %s', (fileName) => { expectMutationRejected('.github/workflows', fileName, (workflow) => { workflow.jobs['prepare-compiled-files']['timeout-minutes'] = PREPARE_COMPILED_TIMEOUT_MINUTES - 1;