From 0c811291747544c5c243fe28356714ec496bebfa Mon Sep 17 00:00:00 2001 From: Prangshuman Das Date: Tue, 6 Oct 2026 18:10:01 +0200 Subject: [PATCH 1/3] Add isolated paired SQL database-name reset diagnostic Related to AB#646383; diagnostic only, does not resolve the work item. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3952f078-a881-4da8-ad96-13b727e48a91 --- .github/AL-Go-Settings.json | 2 +- .github/workflows/CICD.yaml | 469 ++---------------- .github/workflows/PullRequestHandler.yaml | 6 +- build/scripts/NewBcContainer.ps1 | 21 + build/scripts/ParallelTestExecution.psm1 | 49 +- .../SqlResetPilot/ContainerPreflight.ps1 | 34 ++ .../scripts/SqlResetPilot/Finalize.Tests.ps1 | 53 ++ build/scripts/SqlResetPilot/Finalize.ps1 | 28 ++ .../scripts/SqlResetPilot/Lifecycle.Tests.ps1 | 77 +++ build/scripts/SqlResetPilot/Lifecycle.psm1 | 131 +++++ build/scripts/SqlResetPilot/Prepare.ps1 | 53 ++ build/scripts/SqlResetPilot/README.md | 52 ++ 12 files changed, 547 insertions(+), 428 deletions(-) create mode 100644 build/scripts/SqlResetPilot/ContainerPreflight.ps1 create mode 100644 build/scripts/SqlResetPilot/Finalize.Tests.ps1 create mode 100644 build/scripts/SqlResetPilot/Finalize.ps1 create mode 100644 build/scripts/SqlResetPilot/Lifecycle.Tests.ps1 create mode 100644 build/scripts/SqlResetPilot/Lifecycle.psm1 create mode 100644 build/scripts/SqlResetPilot/Prepare.ps1 create mode 100644 build/scripts/SqlResetPilot/README.md diff --git a/.github/AL-Go-Settings.json b/.github/AL-Go-Settings.json index 9e192b255ea..60bb867acb2 100644 --- a/.github/AL-Go-Settings.json +++ b/.github/AL-Go-Settings.json @@ -2,7 +2,7 @@ "$schema": "https://raw.githubusercontent.com/microsoft/AL-Go/91b96c2b294be6f823277dafe6f03350abfb9d23/Actions/.Modules/settings.schema.json", "type": "PTE", "templateUrl": "https://github.com/microsoft/AL-Go-PTE@preview", - "bcContainerHelperVersion": "preview", + "bcContainerHelperVersion": "6.1.19-preview2811389", "runs-on": "windows-latest", "cacheImageName": "", "UsePsSession": true, diff --git a/.github/workflows/CICD.yaml b/.github/workflows/CICD.yaml index 4c650a3d64b..76134472083 100644 --- a/.github/workflows/CICD.yaml +++ b/.github/workflows/CICD.yaml @@ -1,453 +1,86 @@ -name: ' CI/CD' +name: 'SQL reset paired pilot - DO NOT MERGE' on: workflow_dispatch: - push: - paths-ignore: - - '**.md' - - '.github/workflows/*.yaml' - - '!.github/workflows/CICD.yaml' - branches: [ 'main', 'releases/*' ] - -defaults: - run: - shell: powershell permissions: actions: read contents: read id-token: write - pages: read - security-events: write packages: read -env: - workflowDepth: 2 - ALGoOrgSettings: ${{ vars.ALGoOrgSettings }} - ALGoRepoSettings: ${{ vars.ALGoRepoSettings }} - jobs: - Initialization: - needs: [ ] - runs-on: [ windows-latest ] - outputs: - telemetryScopeJson: ${{ steps.init.outputs.telemetryScopeJson }} - environmentsMatrixJson: ${{ steps.DetermineDeploymentEnvironments.outputs.EnvironmentsMatrixJson }} - environmentCount: ${{ steps.DetermineDeploymentEnvironments.outputs.EnvironmentCount }} - deploymentEnvironmentsJson: ${{ steps.DetermineDeploymentEnvironments.outputs.DeploymentEnvironmentsJson }} - generateALDocArtifact: ${{ steps.DetermineDeploymentEnvironments.outputs.GenerateALDocArtifact }} - deployALDocArtifact: ${{ steps.DetermineDeploymentEnvironments.outputs.DeployALDocArtifact }} - deliveryTargetsJson: ${{ steps.DetermineDeliveryTargets.outputs.DeliveryTargetsJson }} - githubRunner: ${{ steps.ReadSettings.outputs.GitHubRunnerJson }} - githubRunnerShell: ${{ steps.ReadSettings.outputs.GitHubRunnerShell }} - projects: ${{ steps.determineProjectsToBuild.outputs.ProjectsJson }} - skippedProjects: ${{ steps.determineProjectsToBuild.outputs.SkippedProjectsJson }} - projectDependenciesJson: ${{ steps.determineProjectsToBuild.outputs.ProjectDependenciesJson }} - buildOrderJson: ${{ steps.determineProjectsToBuild.outputs.BuildOrderJson }} - baselineWorkflowRunId: ${{ steps.determineProjectsToBuild.outputs.BaselineWorkflowRunId }} - baselineWorkflowSHA: ${{ steps.determineProjectsToBuild.outputs.BaselineWorkflowSHA }} - workflowDepth: ${{ steps.DetermineWorkflowDepth.outputs.WorkflowDepth }} - powerPlatformSolutionFolder: ${{ steps.DeterminePowerPlatformSolutionFolder.outputs.powerPlatformSolutionFolder }} - trackALAlertsInGitHub: ${{ steps.SetALCodeAnalysisVar.outputs.trackALAlertsInGitHub }} - steps: - - name: Dump Workflow Information - uses: microsoft/AL-Go/Actions/DumpWorkflowInfo@91b96c2b294be6f823277dafe6f03350abfb9d23 - with: - shell: powershell - - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - lfs: true - - - name: Initialize the workflow - id: init - uses: microsoft/AL-Go/Actions/WorkflowInitialize@91b96c2b294be6f823277dafe6f03350abfb9d23 - with: - shell: powershell - - - name: Read settings - id: ReadSettings - uses: microsoft/AL-Go/Actions/ReadSettings@91b96c2b294be6f823277dafe6f03350abfb9d23 - with: - shell: powershell - get: type,powerPlatformSolutionFolder,useGitSubmodules,trackALAlertsInGitHub - - - name: Set AL Code Analysis Var output - id: SetALCodeAnalysisVar - run: | - Write-Host "trackALAlertsInGitHub environment variable: '$($env:trackALAlertsInGitHub)'" - Add-Content -Encoding UTF8 -Path $env:GITHUB_OUTPUT -Value "trackALAlertsInGitHub=$($env:trackALAlertsInGitHub)" - - - name: Read submodules token - id: ReadSubmodulesToken - if: env.useGitSubmodules != 'false' && env.useGitSubmodules != '' - uses: microsoft/AL-Go/Actions/ReadSecrets@91b96c2b294be6f823277dafe6f03350abfb9d23 - with: - shell: powershell - gitHubSecrets: ${{ toJson(secrets) }} - getSecrets: '-gitSubmodulesToken' - - - name: Checkout Submodules - if: env.useGitSubmodules != 'false' && env.useGitSubmodules != '' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - lfs: true - submodules: ${{ env.useGitSubmodules }} - token: '${{ fromJson(steps.ReadSubmodulesToken.outputs.Secrets).gitSubmodulesToken }}' - - - name: Determine Workflow Depth - id: DetermineWorkflowDepth - run: | - Add-Content -Encoding UTF8 -Path $env:GITHUB_OUTPUT -Value "WorkflowDepth=$($env:workflowDepth)" - - - name: Determine Projects To Build - id: determineProjectsToBuild - uses: microsoft/AL-Go/Actions/DetermineProjectsToBuild@91b96c2b294be6f823277dafe6f03350abfb9d23 - with: - shell: powershell - maxBuildDepth: ${{ env.workflowDepth }} - - - name: Determine PowerPlatform Solution Folder - id: DeterminePowerPlatformSolutionFolder - if: env.type == 'PTE' - run: | - Add-Content -Encoding UTF8 -Path $env:GITHUB_OUTPUT -Value "powerPlatformSolutionFolder=$($env:powerPlatformSolutionFolder)" - - - name: Determine Delivery Target Secrets - id: DetermineDeliveryTargetSecrets - uses: microsoft/AL-Go/Actions/DetermineDeliveryTargets@91b96c2b294be6f823277dafe6f03350abfb9d23 - with: - shell: powershell - projectsJson: '${{ steps.determineProjectsToBuild.outputs.ProjectsJson }}' - checkContextSecrets: 'false' - - - name: Read secrets - id: ReadSecrets - uses: microsoft/AL-Go/Actions/ReadSecrets@91b96c2b294be6f823277dafe6f03350abfb9d23 - with: - shell: powershell - gitHubSecrets: ${{ toJson(secrets) }} - getSecrets: ${{ steps.DetermineDeliveryTargetSecrets.outputs.ContextSecrets }} - - - name: Determine Delivery Targets - id: DetermineDeliveryTargets - uses: microsoft/AL-Go/Actions/DetermineDeliveryTargets@91b96c2b294be6f823277dafe6f03350abfb9d23 - env: - Secrets: '${{ steps.ReadSecrets.outputs.Secrets }}' - with: - shell: powershell - projectsJson: '${{ steps.determineProjectsToBuild.outputs.ProjectsJson }}' - checkContextSecrets: 'true' - - - name: Determine Deployment Environments - id: DetermineDeploymentEnvironments - uses: microsoft/AL-Go/Actions/DetermineDeploymentEnvironments@91b96c2b294be6f823277dafe6f03350abfb9d23 - env: - GITHUB_TOKEN: ${{ github.token }} - with: - shell: powershell - getEnvironments: '*' - type: 'CD' - - CheckForUpdates: - needs: [ Initialization ] - runs-on: [ windows-latest ] - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Read settings - uses: microsoft/AL-Go/Actions/ReadSettings@91b96c2b294be6f823277dafe6f03350abfb9d23 - with: - shell: powershell - get: templateUrl - - - name: Read secrets - id: ReadSecrets - uses: microsoft/AL-Go/Actions/ReadSecrets@91b96c2b294be6f823277dafe6f03350abfb9d23 - with: - shell: powershell - gitHubSecrets: ${{ toJson(secrets) }} - getSecrets: 'ghTokenWorkflow' - - - name: Check for updates to AL-Go system files - uses: microsoft/AL-Go/Actions/CheckForUpdates@91b96c2b294be6f823277dafe6f03350abfb9d23 - env: - GITHUB_TOKEN: ${{ github.token }} - with: - shell: powershell - templateUrl: ${{ env.templateUrl }} - token: ${{ fromJson(steps.ReadSecrets.outputs.Secrets).ghTokenWorkflow }} - downloadLatest: true - - Build1: - needs: [ Initialization ] - if: (!failure()) && (!cancelled()) && fromJson(needs.Initialization.outputs.buildOrderJson)[0].projectsCount > 0 + Pilot: + if: github.ref == 'refs/heads/features/646383-sql-reset-pilot' && github.run_attempt == 1 strategy: - matrix: - include: ${{ fromJson(needs.Initialization.outputs.buildOrderJson)[0].buildDimensions }} fail-fast: false - name: Build ${{ matrix.projectName }} (${{ matrix.buildMode }}) - uses: ./.github/workflows/_BuildALGoProject.yaml - secrets: inherit - with: - shell: ${{ matrix.githubRunnerShell }} - runsOn: ${{ matrix.githubRunner }} - project: ${{ matrix.project }} - projectName: ${{ matrix.projectName }} - buildMode: ${{ matrix.buildMode }} - skippedProjectsJson: ${{ needs.Initialization.outputs.skippedProjects }} - projectDependenciesJson: ${{ needs.Initialization.outputs.projectDependenciesJson }} - baselineWorkflowRunId: ${{ needs.Initialization.outputs.baselineWorkflowRunId }} - baselineWorkflowSHA: ${{ needs.Initialization.outputs.baselineWorkflowSHA }} - secrets: 'licenseFileUrl,codeSignCertificateUrl,*codeSignCertificatePassword,keyVaultCertificateUrl,*keyVaultCertificatePassword,keyVaultClientId,gitHubPackagesContext,applicationInsightsConnectionString' - signArtifacts: true - useArtifactCache: true - needsContext: ${{ toJson(needs) }} - - Build: - needs: [ Initialization, Build1 ] - if: (!failure()) && (!cancelled()) && (needs.Build1.result == 'success' || needs.Build1.result == 'skipped') && fromJson(needs.Initialization.outputs.buildOrderJson)[1].projectsCount > 0 - strategy: matrix: - include: ${{ fromJson(needs.Initialization.outputs.buildOrderJson)[1].buildDimensions }} - fail-fast: false - name: Build ${{ matrix.projectName }} (${{ matrix.buildMode }}) - uses: ./.github/workflows/_BuildALGoProject.yaml - secrets: inherit - with: - shell: ${{ matrix.githubRunnerShell }} - runsOn: ${{ matrix.githubRunner }} - project: ${{ matrix.project }} - projectName: ${{ matrix.projectName }} - buildMode: ${{ matrix.buildMode }} - skippedProjectsJson: ${{ needs.Initialization.outputs.skippedProjects }} - projectDependenciesJson: ${{ needs.Initialization.outputs.projectDependenciesJson }} - baselineWorkflowRunId: ${{ needs.Initialization.outputs.baselineWorkflowRunId }} - baselineWorkflowSHA: ${{ needs.Initialization.outputs.baselineWorkflowSHA }} - secrets: 'licenseFileUrl,codeSignCertificateUrl,*codeSignCertificatePassword,keyVaultCertificateUrl,*keyVaultCertificatePassword,keyVaultClientId,gitHubPackagesContext,applicationInsightsConnectionString' - signArtifacts: true - useArtifactCache: true - needsContext: ${{ toJson(needs) }} - - CodeAnalysisUpload: - needs: [ Initialization, Build ] - if: (!cancelled()) && (needs.Initialization.outputs.trackALAlertsInGitHub == 'True') - runs-on: [ windows-latest ] - name: Code Analysis Processing - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Download artifacts - ErrorLogs - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - env: - ACTIONS_RUNNER_DISABLE_NODE_MAGLEV: 1 - if: (success() || failure()) - with: - pattern: '*-*ErrorLogs-*' - path: '${{ github.workspace }}/ErrorLogs/' - merge-multiple: true - - - name: Process AL Code Analysis Logs - id: ProcessALCodeAnalysisLogs - if: (success() || failure()) - uses: microsoft/AL-Go/Actions/ProcessALCodeAnalysisLogs@91b96c2b294be6f823277dafe6f03350abfb9d23 - with: - shell: powershell - - - name: Upload SARIF file to GitHub - uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 - if: (success() || failure()) && (hashFiles(format('{0}/ErrorLogs/output.sarif.json',github.workspace)) != '') - with: - sarif_file: '${{ github.workspace }}/ErrorLogs/output.sarif.json' - category: "ALCodeAnalysis" - - DeployALDoc: - needs: [ Initialization, Build ] - if: (!cancelled()) && (needs.Build.result == 'success' || needs.Build.result == 'skipped') && needs.Initialization.outputs.generateALDocArtifact == 1 && github.ref_name == 'main' - runs-on: [ windows-latest ] - name: Deploy Reference Documentation - permissions: - contents: read - actions: read - pages: write - id-token: write - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Download artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - env: - ACTIONS_RUNNER_DISABLE_NODE_MAGLEV: 1 - with: - path: '.artifacts' - - - name: Read settings - uses: microsoft/AL-Go/Actions/ReadSettings@91b96c2b294be6f823277dafe6f03350abfb9d23 - with: - shell: powershell - - - name: Determine ArtifactUrl - id: determineArtifactUrl - uses: microsoft/AL-Go/Actions/DetermineArtifactUrl@91b96c2b294be6f823277dafe6f03350abfb9d23 - with: - shell: powershell - - - name: Setup Pages - if: needs.Initialization.outputs.deployALDocArtifact == 1 - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - - - name: Build Reference Documentation - uses: microsoft/AL-Go/Actions/BuildReferenceDocumentation@91b96c2b294be6f823277dafe6f03350abfb9d23 - with: - shell: powershell - artifacts: '.artifacts' - artifactUrl: ${{ env.artifact }} - - - name: Upload pages artifact - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 - with: - path: ".aldoc/_site/" - - - name: Deploy to GitHub Pages - if: needs.Initialization.outputs.deployALDocArtifact == 1 - id: deployment - uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 - - Deploy: - needs: [ Initialization, Build1, Build ] - if: (!cancelled()) && (needs.Build1.result == 'success' || needs.Build1.result == 'skipped') && (needs.Build.result == 'success' || needs.Build.result == 'skipped') && needs.Initialization.outputs.environmentCount > 0 - strategy: ${{ fromJson(needs.Initialization.outputs.environmentsMatrixJson) }} - runs-on: ${{ fromJson(matrix.os) }} - name: Deploy to ${{ matrix.environment }} + arm: [control, fresh] + runs-on: GitHub-BCAppsPL + timeout-minutes: 120 defaults: run: - shell: ${{ matrix.shell }} - environment: - name: ${{ matrix.environment }} - url: ${{ steps.Deploy.outputs.environmentUrl }} + shell: powershell env: - ALGoEnvSettings: ${{ vars.ALGoEnvironmentSettings }} - ALGoEnvName: ${{ matrix.environment }} + ALGoOrgSettings: ${{ vars.ALGoOrgSettings }} + ALGoRepoSettings: ${{ vars.ALGoRepoSettings }} + BC_SQL_PILOT_ARM: ${{ matrix.arm }} + BuildMode: IntegrationTests steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Download artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - env: - ACTIONS_RUNNER_DISABLE_NODE_MAGLEV: 1 - with: - path: '.artifacts' - - - name: Read settings - uses: microsoft/AL-Go/Actions/ReadSettings@91b96c2b294be6f823277dafe6f03350abfb9d23 - with: - shell: ${{ matrix.shell }} - get: type,powerPlatformSolutionFolder - - - name: EnvName - id: envName - run: | - $errorActionPreference = "Stop"; $ProgressPreference = "SilentlyContinue"; Set-StrictMode -Version 2.0 - $envName = '${{ matrix.environment }}'.split(' ')[0] - Add-Content -Encoding UTF8 -Path $env:GITHUB_OUTPUT -Value "envName=$envName" - - - name: Read secrets - id: ReadSecrets - uses: microsoft/AL-Go/Actions/ReadSecrets@91b96c2b294be6f823277dafe6f03350abfb9d23 - with: - shell: ${{ matrix.shell }} - gitHubSecrets: ${{ toJson(secrets) }} - getSecrets: '${{ steps.envName.outputs.envName }}-AuthContext,${{ steps.envName.outputs.envName }}_AuthContext,AuthContext' - - - name: Deploy to Business Central - id: Deploy - uses: microsoft/AL-Go/Actions/Deploy@91b96c2b294be6f823277dafe6f03350abfb9d23 - env: - Secrets: '${{ steps.ReadSecrets.outputs.Secrets }}' - with: - shell: ${{ matrix.shell }} - environmentName: ${{ matrix.environment }} - artifactsFolder: '.artifacts' - type: 'CD' - deploymentEnvironmentsJson: ${{ needs.Initialization.outputs.deploymentEnvironmentsJson }} - - - name: Deploy to Power Platform - if: env.type == 'PTE' && env.powerPlatformSolutionFolder != '' - uses: microsoft/AL-Go/Actions/DeployPowerPlatform@91b96c2b294be6f823277dafe6f03350abfb9d23 - env: - Secrets: '${{ steps.ReadSecrets.outputs.Secrets }}' + - name: Checkout experiment + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: - shell: powershell - environmentName: ${{ matrix.environment }} - artifactsFolder: '.artifacts' - deploymentEnvironmentsJson: ${{ needs.Initialization.outputs.deploymentEnvironmentsJson }} - - Deliver: - needs: [ Initialization, Build1, Build ] - if: (!cancelled()) && (needs.Build1.result == 'success' || needs.Build1.result == 'skipped') && (needs.Build.result == 'success' || needs.Build.result == 'skipped') && needs.Initialization.outputs.deliveryTargetsJson != '[]' - strategy: - matrix: - deliveryTarget: ${{ fromJson(needs.Initialization.outputs.deliveryTargetsJson) }} - fail-fast: false - runs-on: [ windows-latest ] - name: Deliver to ${{ matrix.deliveryTarget }} - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + lfs: true + fetch-depth: 2 - - name: Download artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - name: Validate provenance and download exact compiled PR2 packages + id: packages env: - ACTIONS_RUNNER_DISABLE_NODE_MAGLEV: 1 - with: - path: '.artifacts' + GH_TOKEN: ${{ github.token }} + run: | + & .\build\scripts\SqlResetPilot\Prepare.ps1 - - name: Read settings + - name: Read pinned W1 Integration settings uses: microsoft/AL-Go/Actions/ReadSettings@91b96c2b294be6f823277dafe6f03350abfb9d23 with: shell: powershell + project: build/projects/Test Apps W1 + buildMode: IntegrationTests - - name: Read secrets - id: ReadSecrets + - name: Read normal disposable CI license and package secrets + id: secrets uses: microsoft/AL-Go/Actions/ReadSecrets@91b96c2b294be6f823277dafe6f03350abfb9d23 with: shell: powershell gitHubSecrets: ${{ toJson(secrets) }} - getSecrets: '${{ matrix.deliveryTarget }}Context' + getSecrets: 'licenseFileUrl,gitHubPackagesContext,applicationInsightsConnectionString,appDependencySecrets' - - name: Deliver - uses: microsoft/AL-Go/Actions/Deliver@91b96c2b294be6f823277dafe6f03350abfb9d23 + - name: Execute original pipeline with bounded clean-test prefix + uses: microsoft/AL-Go/Actions/RunPipeline@91b96c2b294be6f823277dafe6f03350abfb9d23 env: - Secrets: '${{ steps.ReadSecrets.outputs.Secrets }}' + Secrets: '${{ steps.secrets.outputs.Secrets }}' with: shell: powershell - type: 'CD' - projects: ${{ needs.Initialization.outputs.projects }} - deliveryTarget: ${{ matrix.deliveryTarget }} - artifacts: '.artifacts' - - PostProcess: - needs: [ Initialization, Build1, Build, Deploy, Deliver, DeployALDoc, CodeAnalysisUpload ] - if: (!cancelled()) - runs-on: [ windows-latest ] - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + project: build/projects/Test Apps W1 + buildMode: IntegrationTests + artifact: https://bcinsider-fvh2ekdjecfjd6gk.b02.azurefd.net/sandbox/30.0.55683.0/w1 + installAppsJson: ${{ steps.packages.outputs.apps }} + installTestAppsJson: ${{ steps.packages.outputs.tests }} - - name: Finalize the workflow - id: PostProcess - uses: microsoft/AL-Go/Actions/WorkflowPostProcess@91b96c2b294be6f823277dafe6f03350abfb9d23 - env: - GITHUB_TOKEN: ${{ github.token }} - with: - shell: powershell - telemetryScopeJson: ${{ needs.Initialization.outputs.telemetryScopeJson }} - currentJobContext: ${{ toJson(job) }} + - name: Export diagnostics and tear down only owned disposable container + if: always() + run: | + & .\build\scripts\SqlResetPilot\Finalize.ps1 + + - name: Preserve raw outcomes (including failures) + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: sql-reset-pilot-${{ matrix.arm }}-${{ github.run_id }} + retention-days: 7 + if-no-files-found: error + path: | + sql-reset-pilot-output/** + build/projects/Test Apps W1/TestResults*.xml + build/projects/Test Apps W1/ContainerEventLog.evtx + build/projects/Test Apps W1/BuildOutput.txt diff --git a/.github/workflows/PullRequestHandler.yaml b/.github/workflows/PullRequestHandler.yaml index 5bf5c05d665..bfafea2b708 100644 --- a/.github/workflows/PullRequestHandler.yaml +++ b/.github/workflows/PullRequestHandler.yaml @@ -35,7 +35,7 @@ jobs: Initialization: needs: [ PregateCheck ] - if: (!failure() && !cancelled()) + if: (!failure() && !cancelled()) && github.head_ref != 'features/646383-sql-reset-pilot' runs-on: [ windows-latest ] outputs: projects: ${{ steps.determineProjectsToBuild.outputs.ProjectsJson }} @@ -93,7 +93,7 @@ jobs: Build1: needs: [ Initialization, CustomJob-VerifyAppChanges ] - if: (!failure()) && (!cancelled()) && fromJson(needs.Initialization.outputs.buildOrderJson)[0].projectsCount > 0 + if: (!failure()) && (!cancelled()) && needs.Initialization.result == 'success' && fromJson(needs.Initialization.outputs.buildOrderJson)[0].projectsCount > 0 strategy: matrix: include: ${{ fromJson(needs.Initialization.outputs.buildOrderJson)[0].buildDimensions }} @@ -119,7 +119,7 @@ jobs: Build: needs: [ Initialization, Build1, CustomJob-VerifyAppChanges ] - if: (!failure()) && (!cancelled()) && (needs.Build1.result == 'success' || needs.Build1.result == 'skipped') && fromJson(needs.Initialization.outputs.buildOrderJson)[1].projectsCount > 0 + if: (!failure()) && (!cancelled()) && needs.Initialization.result == 'success' && (needs.Build1.result == 'success' || needs.Build1.result == 'skipped') && fromJson(needs.Initialization.outputs.buildOrderJson)[1].projectsCount > 0 strategy: matrix: include: ${{ fromJson(needs.Initialization.outputs.buildOrderJson)[1].buildDimensions }} diff --git a/build/scripts/NewBcContainer.ps1 b/build/scripts/NewBcContainer.ps1 index 78871ab947b..974459b5f5d 100644 --- a/build/scripts/NewBcContainer.ps1 +++ b/build/scripts/NewBcContainer.ps1 @@ -22,7 +22,28 @@ if ($platformVersion) { $parameters.platformArtifactUrl = "$platformUrl/platform" } +if ($env:BC_SQL_PILOT_ARM) { + & (Join-Path $PSScriptRoot 'SqlResetPilot\ContainerPreflight.ps1') -Parameters $parameters +} New-BcContainer @parameters +if ($env:BC_SQL_PILOT_ARM) { + $runtime = Invoke-ScriptInBcContainer -containerName $parameters.ContainerName -scriptblock { + $commands = @('Get-NAVTenant', 'Dismount-NAVTenant', 'Mount-NAVTenant', 'Test-NAVDatabase', 'Copy-NAVDatabase', 'Remove-NAVDatabase') + @{ + commands = @($commands | ForEach-Object { + $command = Get-Command $_ -ErrorAction Stop + @{ name = $command.Name; version = [string]$command.Version; parameters = @($command.Parameters.Keys) } + }) + nst = @(Get-Process 'Microsoft.Dynamics.Nav.Server' | ForEach-Object { + @{ pid = $_.Id; version = $_.FileVersion } + }) + } + } + $runtime | ConvertTo-Json -Depth 6 | Set-Content (Join-Path $env:BC_SQL_PILOT_OUTPUT 'runtime-preflight.json') + if (@($runtime.nst).Count -ne 1 -or $runtime.nst[0].version -notmatch '^30\.0\.55665\.0(?:\s|$)') { + throw 'Installed NST is not the predeclared 30.0.55665.0 runtime.' + } +} if ($parameters.auth -in @('UserPassword', 'NavUserPassword')) { if (-not $parameters.credential) { diff --git a/build/scripts/ParallelTestExecution.psm1 b/build/scripts/ParallelTestExecution.psm1 index f8ed562ccee..4adfc6a0b21 100644 --- a/build/scripts/ParallelTestExecution.psm1 +++ b/build/scripts/ParallelTestExecution.psm1 @@ -13,6 +13,9 @@ if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) { } } Import-Module (Join-Path $PSScriptRoot "ALAppBuild.psm1" -Resolve) +if ($env:BC_SQL_PILOT_ARM) { + Import-Module (Join-Path $PSScriptRoot 'SqlResetPilot\Lifecycle.psm1') +} <# .SYNOPSIS @@ -376,9 +379,12 @@ function New-BcTestTenantTemplate { [string]$SourceDatabaseName ) - $result = @(Invoke-ScriptInBcContainer -containerName $ContainerName -useSession $false -scriptblock { Param($sourceDatabaseName) + $result = @(Invoke-ScriptInBcContainer -containerName $ContainerName -useSession $false -scriptblock { Param($sourceDatabaseName, $sqlPilot) $templateDatabaseName = "$sourceDatabaseName-test-template" - $maxAttempts = 3 + if ($sqlPilot -and (Test-NAVDatabase -DatabaseName $templateDatabaseName)) { + throw 'Pilot template already exists; it must never be overwritten.' + } + $maxAttempts = if ($sqlPilot) { 1 } else { 3 } $retryDelaySeconds = 5 for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { try { @@ -398,7 +404,7 @@ function New-BcTestTenantTemplate { } } $templateDatabaseName - } -argumentList $SourceDatabaseName) + } -argumentList $SourceDatabaseName, ([bool]$env:BC_SQL_PILOT_ARM)) if ($result.Count -eq 0) { throw "Creating the clean test tenant template returned no database name." @@ -430,6 +436,12 @@ function Reset-BcTestTenant { [string]$TemplateDatabaseName ) + if ($env:BC_SQL_PILOT_ARM) { + Invoke-SqlPilotReset -ContainerName $ContainerName -Tenant $Tenant -DatabaseName $TenantDatabaseName ` + -TemplateDatabaseName $TemplateDatabaseName + return + } + Invoke-ScriptInBcContainer -containerName $ContainerName -useSession $false -scriptblock { Param($tenant, $tenantDatabaseName, $templateDatabaseName) @@ -635,13 +647,16 @@ function Receive-TestJobResult { $outcome = 'Passed' if ($Job.State -eq 'Failed' -or $Job.State -eq 'Stopped') { - if ((Test-TransientTestFailure $output) -and -not $Retried.ContainsKey($Entry.appName)) { + if (-not $env:BC_SQL_PILOT_ARM -and (Test-TransientTestFailure $output) -and -not $Retried.ContainsKey($Entry.appName)) { $outcome = 'Transient' } else { $outcome = 'Failed' } } + if ($env:BC_SQL_PILOT_ARM) { + $output | Set-Content (Join-Path $env:BC_SQL_PILOT_OUTPUT "worker-$($Job.Id).log") -Encoding UTF8 + } Remove-Job -Job $Job -Force return [PSCustomObject]@{ @@ -836,6 +851,9 @@ function Invoke-RequiredDisabledTestExecution { -ScriptPath $ScriptPath -TestType $TestType -State $state -Verb $dispatch.Verb } $null = Wait-ForAllTestJobs -state $state + if ($env:BC_SQL_PILOT_ARM -and $state.hasFailures) { + break + } } return (-not $state.hasFailures) @@ -1327,6 +1345,10 @@ function Invoke-ParallelTestExecution { } $tenantInfo = @(Get-AvailableBcTenantInfo -containerName $parameters.containerName) + if ($env:BC_SQL_PILOT_ARM) { + Initialize-SqlResetPilot -ContainerName $parameters.containerName -TenantInfo $tenantInfo ` + -Arm $env:BC_SQL_PILOT_ARM -RunId $env:GITHUB_RUN_ID -OutputDirectory $env:BC_SQL_PILOT_OUTPUT + } $tenants = @($tenantInfo | ForEach-Object { $_.Id }) Write-Host "Available tenants: $($tenants -join ', ')" @@ -1384,6 +1406,11 @@ function Invoke-ParallelTestExecution { -AppNamesToTest $appNamesToTest -AppIdByName $appIdByName ) } + if ($env:BC_SQL_PILOT_ARM) { + $requiredDisabledWorkItems = @(Select-SqlPilotPrefix -WorkItems $requiredDisabledWorkItems) + $requiredDisabledWorkItems | ConvertTo-Json -Depth 5 | + Set-Content (Join-Path $env:BC_SQL_PILOT_OUTPUT 'ordered-prefix.json') -Encoding UTF8 + } $templateDatabaseName = "" try { if ($requiredDisabledWorkItems.Count -gt 0) { @@ -1417,13 +1444,23 @@ function Invoke-ParallelTestExecution { } } finally { - foreach ($cleanTenant in $cleanTenantInfo) { + foreach ($cleanTenant in @($cleanTenantInfo | Where-Object { -not $env:BC_SQL_PILOT_ARM })) { Reset-BcTestTenant -ContainerName $parameters.containerName -Tenant $cleanTenant.Id ` -TenantDatabaseName $cleanTenant.DatabaseName -TemplateDatabaseName $templateDatabaseName } } } + if ($env:BC_SQL_PILOT_ARM) { + Merge-TenantTestResults -parameters $parameters -tenants $tenants -workItems $requiredDisabledWorkItems -rerunSuffixes @() + $state.finalResult = -not $state.hasFailures + $state.completed = $true + $state | ConvertTo-Json -Depth 5 | Set-Content $stateFile -Force + @{ completedUtc = [DateTime]::UtcNow.ToString('o'); passed = $state.finalResult; selectedCodeunits = $requiredDisabledWorkItems.Count } | + ConvertTo-Json | Set-Content (Join-Path $env:BC_SQL_PILOT_OUTPUT 'outcome.json') -Encoding UTF8 + return $state.finalResult + } + # Single dispatch loop, FIFO. TestConfiguration.json lists the smallest app first (a cheap # serial warmup) and the rest longest-first (LPT, keeps the tail short). The retry cap lives in # Receive-TestJobResult: an app already in $state.retried is classified as Failed on a re-fail. @@ -1521,7 +1558,7 @@ function Invoke-ParallelTestExecution { return $allPassed } finally { - if ($templateDatabaseName) { + if ($templateDatabaseName -and -not $env:BC_SQL_PILOT_ARM) { Remove-BcTestTenantTemplate -ContainerName $parameters.containerName ` -TemplateDatabaseName $templateDatabaseName } diff --git a/build/scripts/SqlResetPilot/ContainerPreflight.ps1 b/build/scripts/SqlResetPilot/ContainerPreflight.ps1 new file mode 100644 index 00000000000..d15e6d33df4 --- /dev/null +++ b/build/scripts/SqlResetPilot/ContainerPreflight.ps1 @@ -0,0 +1,34 @@ +param([hashtable]$Parameters) +$ErrorActionPreference = 'Stop' +if ($env:GITHUB_REF -ne 'refs/heads/features/646383-sql-reset-pilot' -or + $env:GITHUB_EVENT_NAME -ne 'workflow_dispatch' -or $env:GITHUB_RUN_ATTEMPT -ne '1' -or + $env:BC_SQL_PILOT_ARM -notin @('control', 'fresh') -or -not $env:BC_SQL_PILOT_OUTPUT) { + throw 'Refusing SQL pilot outside its disposable manually dispatched CI job.' +} +$expectedName = "bcbuildprojectsTestAppsW1$($env:GITHUB_RUN_ID)" +if ($Parameters.ContainerName -ne $expectedName -or (Test-BcContainer -containerName $expectedName)) { + throw 'Pilot container name is unexpected or already exists. No replacement is authorized.' +} +$module = Get-Module BcContainerHelper -ErrorAction Stop +$helperVersion = & $module { $bcContainerHelperVersion } +if ($helperVersion -ne '6.1.19-preview2811389') { throw "Wrong BCH version: $helperVersion" } +$settings = $env:Settings | ConvertFrom-Json +if ($settings.country -ne 'w1' -or $settings.testType -ne 'IntegrationTest' -or + $settings.numberOfTenantsForTesting -ne 4 -or $settings.companyName -ne 'My Company' -or + -not $settings.enableCleanTestCodeunitExecution -or $settings.enableTaskScheduler) { + throw 'Effective W1 Integration settings differ from the predeclared experiment.' +} +if (-not (Get-Command New-BcContainer).Parameters.ContainsKey('useGenericImage')) { + throw 'Exact BCH does not expose the supported generic-image parameter.' +} +$Parameters.useGenericImage = 'mcr.microsoft.com/businesscentral@sha256:c899d12093ad7bbdbfd08ccc0e6294e0f98c682c7e35db4ecfbca345fb068492' +if ($Parameters.platformArtifactUrl -ne 'https://bcinsider-fvh2ekdjecfjd6gk.b02.azurefd.net/platform/30.0.55665.0/platform') { + throw 'Platform artifact is not the pinned source platform.' +} +@{ + container = $expectedName; runId = $env:GITHUB_RUN_ID; arm = $env:BC_SQL_PILOT_ARM + helperVersion = $helperVersion; image = $Parameters.useGenericImage + imageGenericTag = '1.0.2.128'; platform = '30.0.55665.0' + hostOS = [Environment]::OSVersion.Version.ToString() + registeredUtc = [DateTime]::UtcNow.ToString('o') +} | ConvertTo-Json | Set-Content (Join-Path $env:BC_SQL_PILOT_OUTPUT 'container-ownership.json') -Encoding UTF8 diff --git a/build/scripts/SqlResetPilot/Finalize.Tests.ps1 b/build/scripts/SqlResetPilot/Finalize.Tests.ps1 new file mode 100644 index 00000000000..deca7127d20 --- /dev/null +++ b/build/scripts/SqlResetPilot/Finalize.Tests.ps1 @@ -0,0 +1,53 @@ +function global:Test-BcContainer { param($containerName) throw 'Live container call forbidden.' } +function global:Get-BcContainerEventLog { param($containerName, [switch]$doNotOpen) throw 'Live container call forbidden.' } +function global:Remove-BcContainer { param($containerName) throw 'Live container call forbidden.' } + +Describe 'SQL pilot final cleanup ownership' { + BeforeEach { + $script:oldWorkspace = $env:GITHUB_WORKSPACE + $script:oldRun = $env:GITHUB_RUN_ID + $script:oldArm = $env:BC_SQL_PILOT_ARM + $script:oldHelper = $env:BcContainerHelperPath + $env:GITHUB_WORKSPACE = $PSScriptRoot + $env:GITHUB_RUN_ID = '123' + $env:BC_SQL_PILOT_ARM = 'fresh' + $env:BcContainerHelperPath = Join-Path $PSScriptRoot 'BcContainerHelper.ps1' + Mock Import-Module {} + Mock Test-Path { $true } + Mock Get-Content { '{"runId":"123","arm":"fresh","container":"bcbuildprojectsTestAppsW1123"}' } + Mock Get-ChildItem {} + Mock Copy-Item {} + Mock Set-Content {} + $global:sqlPilotTestContainerExists = $true + Mock Test-BcContainer { $global:sqlPilotTestContainerExists } + Mock Get-BcContainerEventLog { 'events.evtx' } + Mock Remove-BcContainer { $global:sqlPilotTestContainerExists = $false } + } + AfterEach { + $env:GITHUB_WORKSPACE = $script:oldWorkspace + $env:GITHUB_RUN_ID = $script:oldRun + $env:BC_SQL_PILOT_ARM = $script:oldArm + $env:BcContainerHelperPath = $script:oldHelper + Remove-Variable sqlPilotTestContainerExists -Scope Global + } + It 'exports before disposing only the registered container' { + & (Join-Path $PSScriptRoot 'Finalize.ps1') + Assert-MockCalled Get-BcContainerEventLog -Times 1 -Exactly -Scope It + Assert-MockCalled Remove-BcContainer -Times 1 -Exactly -Scope It -ParameterFilter { $containerName -eq 'bcbuildprojectsTestAppsW1123' } + } + It 'still tears down if exporting fails and keeps that failure visible' { + Mock Get-BcContainerEventLog { throw 'event export failed' } + { & (Join-Path $PSScriptRoot 'Finalize.ps1') } | Should -Throw + Assert-MockCalled Remove-BcContainer -Times 1 -Exactly -Scope It + } + It 'refuses deletion if ownership was not recorded' { + Mock Test-Path { $false } + & (Join-Path $PSScriptRoot 'Finalize.ps1') + Assert-MockCalled Remove-BcContainer -Times 0 -Exactly -Scope It + } + It 'refuses deletion when run, arm or exact name differs' { + Mock Get-Content { '{"runId":"999","arm":"fresh","container":"shared"}' } + { & (Join-Path $PSScriptRoot 'Finalize.ps1') } | Should -Throw + Assert-MockCalled Remove-BcContainer -Times 0 -Exactly -Scope It + } +} diff --git a/build/scripts/SqlResetPilot/Finalize.ps1 b/build/scripts/SqlResetPilot/Finalize.ps1 new file mode 100644 index 00000000000..41a667bd01d --- /dev/null +++ b/build/scripts/SqlResetPilot/Finalize.ps1 @@ -0,0 +1,28 @@ +$ErrorActionPreference = 'Stop' +$output = Join-Path $env:GITHUB_WORKSPACE 'sql-reset-pilot-output' +$ownershipPath = Join-Path $output 'container-ownership.json' +if (-not (Test-Path $ownershipPath)) { + Write-Host 'No owned container was registered; no container deletion is authorized.' + return +} +$ownership = Get-Content $ownershipPath -Raw | ConvertFrom-Json +if ($ownership.runId -ne $env:GITHUB_RUN_ID -or $ownership.arm -ne $env:BC_SQL_PILOT_ARM -or + $ownership.container -ne "bcbuildprojectsTestAppsW1$($env:GITHUB_RUN_ID)") { + throw 'Cleanup ownership mismatch.' +} +Import-Module (Join-Path (Split-Path $env:BcContainerHelperPath -Parent) 'BcContainerHelper.psm1') -ErrorAction Stop +try { + if (Test-BcContainer -containerName $ownership.container) { + $events = Get-BcContainerEventLog -containerName $ownership.container -doNotOpen + Copy-Item $events (Join-Path $output 'final-container.evtx') + } + $project = Join-Path $env:GITHUB_WORKSPACE 'build\projects\Test Apps W1' + Get-ChildItem $project -Filter 'TestResults*.xml' -File | Copy-Item -Destination $output +} finally { + if (Test-BcContainer -containerName $ownership.container) { + Remove-BcContainer -containerName $ownership.container + } + @{ completedUtc = [DateTime]::UtcNow.ToString('o'); containerRemaining = (Test-BcContainer -containerName $ownership.container) } | + ConvertTo-Json | Set-Content (Join-Path $output 'cleanup.json') -Encoding UTF8 +} +if (Test-BcContainer -containerName $ownership.container) { throw 'Owned container teardown did not complete.' } diff --git a/build/scripts/SqlResetPilot/Lifecycle.Tests.ps1 b/build/scripts/SqlResetPilot/Lifecycle.Tests.ps1 new file mode 100644 index 00000000000..da023382fb4 --- /dev/null +++ b/build/scripts/SqlResetPilot/Lifecycle.Tests.ps1 @@ -0,0 +1,77 @@ +Import-Module (Join-Path $PSScriptRoot 'Lifecycle.psm1') -Force +function global:Invoke-ScriptInBcContainer { + param($containerName, $useSession, $scriptblock, $argumentList) + throw 'No live container calls are permitted in unit tests.' +} + +Describe 'SQL reset pilot ownership and mapping' { + BeforeEach { + $script:tenants = @('default','tenant2','tenant3','tenant4') | ForEach-Object { + [PSCustomObject]@{ Id = $_; DatabaseName = $_ } + } + Initialize-SqlResetPilot 'owned' $script:tenants 'fresh' '123456789' $PSScriptRoot + } + It 'uses a fresh database target and updates the original caller mapping for the next cycle' { + $first = Get-SqlResetPlan 'owned' 'tenant2' 'tenant2' 'default' + $first.Destination | Should -Be 'tenant2_r123456789_g1' + Complete-SqlResetPlan $first + $script:tenants[1].DatabaseName | Should -Be $first.Destination + $second = Get-SqlResetPlan 'owned' 'tenant2' $script:tenants[1].DatabaseName 'default-test-template' + $second.Previous | Should -Be $first.Destination + $second.Destination | Should -Be 'tenant2_r123456789_g2' + } + It 'retains the original SQL target in control while increasing its observation label' { + Initialize-SqlResetPilot 'owned' $script:tenants 'control' '123456789' $PSScriptRoot + $first = Get-SqlResetPlan 'owned' 'tenant3' 'tenant3' 'default-test-template' + Complete-SqlResetPlan $first + $second = Get-SqlResetPlan 'owned' 'tenant3' 'tenant3' 'default-test-template' + $second.Destination | Should -Be 'tenant3' + $second.Generation | Should -Be 2 + } + It 'rejects stale mappings after a successful rename' { + Complete-SqlResetPlan (Get-SqlResetPlan 'owned' 'tenant2' 'tenant2' 'default') + { Get-SqlResetPlan 'owned' 'tenant2' 'tenant2' 'default' } | Should -Throw + } + It 'never accepts the primary or template as a deletion target' { + { Get-SqlResetPlan 'owned' 'default' 'default' 'default-test-template' } | Should -Throw + { Get-SqlResetPlan 'owned' 'tenant2' 'default-test-template' 'default' } | Should -Throw + { Get-SqlResetPlan 'owned' 'tenant2' 'app' 'default' } | Should -Throw + } + It 'rejects unowned containers, workers and source databases' { + { Get-SqlResetPlan 'shared' 'tenant2' 'tenant2' 'default' } | Should -Throw + { Get-SqlResetPlan 'owned' 'tenant5' 'tenant5' 'default' } | Should -Throw + { Get-SqlResetPlan 'owned' 'tenant2' 'tenant2' 'shared' } | Should -Throw + } + It 'rejects unsafe run identities and unexpected original mappings' { + { Initialize-SqlResetPilot 'owned' $script:tenants 'fresh' '1;drop' $PSScriptRoot } | Should -Throw + $script:tenants[1].DatabaseName = 'default' + { Initialize-SqlResetPilot 'owned' $script:tenants 'fresh' '123' $PSScriptRoot } | Should -Throw + } + It 'preserves the seven original three-worker batches and stops after the target batch' { + $ids = @(139700,139702,139703,139706,139725,139726,139732,139739,139742,139745,139802,139803,139806,139826,139832,139854,139972,139780,148315,148318,148343,148344) + $items = @($ids | ForEach-Object { [PSCustomObject]@{ CodeunitId = $_ } }) + $prefix = @(Select-SqlPilotPrefix $items) + $prefix.Count | Should -Be 21 + $prefix[18].CodeunitId | Should -Be 148315 + $prefix[20].CodeunitId | Should -Be 148343 + $items[0].CodeunitId = 1 + { Select-SqlPilotPrefix $items } | Should -Throw + } + It 'advances mapping only after the container operation succeeds' { + Mock Invoke-ScriptInBcContainer -ModuleName Lifecycle { @{ state = 'Operational' } } + Mock Add-Content -ModuleName Lifecycle {} + Invoke-SqlPilotReset 'owned' 'tenant2' 'tenant2' 'default' + $script:tenants[1].DatabaseName | Should -Be 'tenant2_r123456789_g1' + Assert-MockCalled Invoke-ScriptInBcContainer -ModuleName Lifecycle -Times 1 -Exactly -Scope It -ParameterFilter { + $containerName -eq 'owned' -and $argumentList.Previous -eq 'tenant2' -and + $argumentList.Destination -eq 'tenant2_r123456789_g1' + } + } + It 'leaves mapping unchanged and propagates a failed restore without retrying' { + Mock Invoke-ScriptInBcContainer -ModuleName Lifecycle { throw 'copy failed' } + Mock Add-Content -ModuleName Lifecycle {} + { Invoke-SqlPilotReset 'owned' 'tenant2' 'tenant2' 'default' } | Should -Throw + $script:tenants[1].DatabaseName | Should -Be 'tenant2' + Assert-MockCalled Invoke-ScriptInBcContainer -ModuleName Lifecycle -Times 1 -Exactly -Scope It + } +} diff --git a/build/scripts/SqlResetPilot/Lifecycle.psm1 b/build/scripts/SqlResetPilot/Lifecycle.psm1 new file mode 100644 index 00000000000..180d63232b6 --- /dev/null +++ b/build/scripts/SqlResetPilot/Lifecycle.psm1 @@ -0,0 +1,131 @@ +Set-StrictMode -Version 2 +$script:pilot = $null + +function Initialize-SqlResetPilot { + param([string]$ContainerName, [array]$TenantInfo, [string]$Arm, [string]$RunId, [string]$OutputDirectory) + if ($Arm -notin @('control', 'fresh') -or $RunId -notmatch '^\d{1,20}$') { throw 'Invalid pilot identity.' } + if ($TenantInfo.Count -ne 4 -or @($TenantInfo.Id | Sort-Object -Unique).Count -ne 4) { + throw 'Pilot requires exactly default and three distinct workers.' + } + $workers = @{} + foreach ($id in @('tenant2', 'tenant3', 'tenant4')) { + $info = @($TenantInfo | Where-Object Id -eq $id) + if ($info.Count -ne 1 -or $info[0].DatabaseName -ne $id) { throw 'Unexpected initial owned worker mapping.' } + $workers[$id] = @{ Info = $info[0]; Generation = 0; Owned = @($id); Used = @($id) } + } + $source = @($TenantInfo | Where-Object Id -eq 'default') + if ($source.Count -ne 1 -or $source[0].DatabaseName -ne 'default') { throw 'Unexpected protected source mapping.' } + $script:pilot = @{ + Container = $ContainerName; Arm = $Arm; RunId = $RunId; Workers = $workers + OutputDirectory = $OutputDirectory + } +} + +function Get-SqlResetPlan { + param([string]$ContainerName, [string]$Tenant, [string]$DatabaseName, [string]$TemplateDatabaseName) + if (-not $script:pilot -or $ContainerName -ne $script:pilot.Container -or + -not $script:pilot.Workers.ContainsKey($Tenant)) { throw 'Unowned container or tenant.' } + $worker = $script:pilot.Workers[$Tenant] + if ($DatabaseName -ne $worker.Info.DatabaseName -or $DatabaseName -notin $worker.Owned -or + $TemplateDatabaseName -notin @('default', 'default-test-template') -or + $DatabaseName -in @('default', 'default-test-template', 'app') -or + $DatabaseName -eq $TemplateDatabaseName) { throw 'Unsafe or stale database mapping.' } + $generation = $worker.Generation + 1 + $destination = if ($script:pilot.Arm -eq 'fresh') { "${Tenant}_r$($script:pilot.RunId)_g$generation" } else { $DatabaseName } + if ($destination -notmatch '^tenant[234](?:_r\d{1,20}_g\d+)?$' -or $destination.Length -gt 64 -or + ($script:pilot.Arm -eq 'fresh' -and $destination -in $worker.Used)) { throw 'Unsafe or reused destination.' } + [PSCustomObject]@{ + Tenant = $Tenant; Previous = $DatabaseName; Destination = $destination + Template = $TemplateDatabaseName; Generation = $generation + } +} + +function Complete-SqlResetPlan { + param($Plan) + $worker = $script:pilot.Workers[$Plan.Tenant] + $worker.Info.DatabaseName = $Plan.Destination + $worker.Generation = $Plan.Generation + $worker.Owned = @($worker.Owned | Where-Object { $_ -ne $Plan.Previous }) + @($Plan.Destination) + $worker.Used += $Plan.Destination +} + +function Invoke-SqlPilotReset { + param([string]$ContainerName, [string]$Tenant, [string]$DatabaseName, [string]$TemplateDatabaseName) + $plan = Get-SqlResetPlan $ContainerName $Tenant $DatabaseName $TemplateDatabaseName + $trace = Join-Path $script:pilot.OutputDirectory 'reset-timeline.jsonl' + @{ phase = 'reset-start'; utc = [DateTime]::UtcNow.ToString('o'); arm = $script:pilot.Arm; plan = $plan } | + ConvertTo-Json -Depth 5 -Compress | Add-Content $trace + try { + $result = Invoke-ScriptInBcContainer -containerName $ContainerName -useSession $false -scriptblock { + param($plan) + $ErrorActionPreference = 'Stop' + $stopwatch = [Diagnostics.Stopwatch]::StartNew() + $mounted = Get-NAVTenant -ServerInstance $ServerInstance -Tenant $plan.Tenant + $localServers = @('.', 'localhost', $env:COMPUTERNAME, '.\SQLEXPRESS', 'localhost\SQLEXPRESS', "$env:COMPUTERNAME\SQLEXPRESS") + if ($mounted.DatabaseName -ne $plan.Previous -or $mounted.DatabaseServer -notin $localServers) { + throw 'Actual mounted mapping differs from the owned local SQL mapping.' + } + if ($plan.Destination -ne $plan.Previous -and (Test-NAVDatabase -DatabaseName $plan.Destination)) { + throw 'Fresh database destination already exists; refusing to overwrite it.' + } + function Read-PilotIdentity($name) { + $connection = New-Object System.Data.SqlClient.SqlConnection 'Server=.\SQLEXPRESS;Database=master;Integrated Security=True;TrustServerCertificate=True' + try { + $connection.Open() + $command = $connection.CreateCommand() + $command.CommandText = 'SELECT name, database_id, create_date, service_broker_guid FROM sys.databases WHERE name = @name' + $null = $command.Parameters.AddWithValue('@name', $name) + $reader = $command.ExecuteReader() + if (-not $reader.Read()) { throw 'Owned SQL database identity is missing.' } + @{ name = [string]$reader[0]; databaseId = [int]$reader[1]; created = [string]$reader[2]; serviceBrokerGuid = [string]$reader[3] } + } finally { $connection.Dispose() } + } + $before = Read-PilotIdentity $plan.Previous + $started = [DateTime]::UtcNow.ToString('o') + Dismount-NAVTenant -ServerInstance $ServerInstance -Tenant $plan.Tenant -Force | Out-Null + if (Test-NAVDatabase -DatabaseName $plan.Previous) { + Remove-NAVDatabase -DatabaseName $plan.Previous | Out-Null + } + Copy-NAVDatabase -SourceDatabaseName $plan.Template -DestinationDatabaseName $plan.Destination -DatabaseServer '.' | Out-Null + Mount-NAVTenant -ServerInstance $ServerInstance -Id $plan.Tenant -DatabaseServer '.' ` + -DatabaseName $plan.Destination -OverwriteTenantIdInDatabase -Force | Out-Null + while ((Get-NAVTenant -ServerInstance $ServerInstance -Tenant $plan.Tenant).State -eq 'Mounting') { + if ($stopwatch.Elapsed.TotalSeconds -ge 300) { throw 'Tenant mount exceeded baseline 300-second bound.' } + Start-Sleep -Milliseconds 250 + } + $mounted = Get-NAVTenant -ServerInstance $ServerInstance -Tenant $plan.Tenant + if ($mounted.State -notin @('Operational', 'OperationalWithWarnings') -or $mounted.DatabaseName -ne $plan.Destination) { + throw 'Mounted state or mapping differs from the expected new generation.' + } + @{ + startedUtc = $started; completedUtc = [DateTime]::UtcNow.ToString('o') + before = $before; after = (Read-PilotIdentity $plan.Destination) + databaseServer = [string]$mounted.DatabaseServer; state = [string]$mounted.State + detailedState = [string]$mounted.DetailedState; forceRefresh = $false + nst = @(Get-Process 'Microsoft.Dynamics.Nav.Server' | ForEach-Object { + @{ pid = $_.Id; version = $_.FileVersion } + }) + } + } -argumentList $plan + Complete-SqlResetPlan $plan + @{ phase = 'reset-complete'; utc = [DateTime]::UtcNow.ToString('o'); plan = $plan; observation = $result } | + ConvertTo-Json -Depth 8 -Compress | Add-Content $trace + } catch { + # Do not log exception text: SQL/provider diagnostics can contain connection information. + @{ phase = 'reset-failed'; utc = [DateTime]::UtcNow.ToString('o'); plan = $plan; errorType = $_.Exception.GetType().FullName } | + ConvertTo-Json -Depth 5 -Compress | Add-Content $trace + throw + } +} + +function Select-SqlPilotPrefix { + param([array]$WorkItems) + $ids = @(139700,139702,139703,139706,139725,139726,139732,139739,139742,139745,139802,139803,139806,139826,139832,139854,139972,139780,148315,148318,148343) + if ($WorkItems.Count -lt $ids.Count) { throw 'Discovery returned an incomplete pilot prefix.' } + for ($i = 0; $i -lt $ids.Count; $i++) { + if ([int]$WorkItems[$i].CodeunitId -ne $ids[$i]) { throw "Original W1 discovery order differs at position $i." } + } + @($WorkItems | Select-Object -First $ids.Count) +} + +Export-ModuleMember -Function Initialize-SqlResetPilot, Get-SqlResetPlan, Complete-SqlResetPlan, Invoke-SqlPilotReset, Select-SqlPilotPrefix diff --git a/build/scripts/SqlResetPilot/Prepare.ps1 b/build/scripts/SqlResetPilot/Prepare.ps1 new file mode 100644 index 00000000000..50556207828 --- /dev/null +++ b/build/scripts/SqlResetPilot/Prepare.ps1 @@ -0,0 +1,53 @@ +$ErrorActionPreference = 'Stop' +$base = 'c4953dceffe02a017adad34973e1955017bf5d20' +if ($env:GITHUB_REPOSITORY -ne 'microsoft/BCApps' -or + $env:GITHUB_REF -ne 'refs/heads/features/646383-sql-reset-pilot' -or + $env:GITHUB_EVENT_NAME -ne 'workflow_dispatch' -or $env:GITHUB_RUN_ATTEMPT -ne '1' -or + $env:BC_SQL_PILOT_ARM -notin @('control', 'fresh')) { + throw 'This diagnostic is restricted to its explicitly dispatched disposable CI branch, attempt one.' +} +git merge-base --is-ancestor $base HEAD +if ($LASTEXITCODE -ne 0) { throw 'PR2 base is not an ancestor.' } +git diff --exit-code $base HEAD -- src +if ($LASTEXITCODE -ne 0) { throw 'AL/package source differs from the pinned compiled PR2 source.' } + +$output = Join-Path $env:GITHUB_WORKSPACE 'sql-reset-pilot-output' +New-Item -ItemType Directory $output -Force | Out-Null +"BC_SQL_PILOT_OUTPUT=$output" | Add-Content $env:GITHUB_ENV +@{ phase = 'package-preflight'; arm = $env:BC_SQL_PILOT_ARM; run = $env:GITHUB_RUN_ID; utc = [DateTime]::UtcNow.ToString('o') } | + ConvertTo-Json | Set-Content (Join-Path $output 'start.json') -Encoding UTF8 +$headers = @{ Authorization = "Bearer $env:GH_TOKEN"; Accept = 'application/vnd.github+json' } +$api = 'https://api.github.com/repos/microsoft/BCApps' +$artifacts = @( + @{ Kind = 'apps'; Id = 11374170356; Digest = '683343673e6efeb7699734b250d2ee5004d2d83fe08e57f324c4c645c09612db' }, + @{ Kind = 'tests'; Id = 11374145359; Digest = 'f75ce06920437328e5d79a5512eddcc2425624bb696c339c108478c83554171d' } +) +$manifest = @() +foreach ($artifact in $artifacts) { + $metadata = Invoke-RestMethod "$api/actions/artifacts/$($artifact.Id)" -Headers $headers + if ($metadata.expired -or $metadata.workflow_run.id -ne 37372848860 -or + $metadata.workflow_run.head_sha -ne $base -or $metadata.digest -ne "sha256:$($artifact.Digest)") { + throw 'Exact artifact provenance/digest check failed; no moving-build fallback is permitted.' + } + $destination = Join-Path $env:GITHUB_WORKSPACE "sql-reset-pilot-packages\$($artifact.Kind)" + New-Item -ItemType Directory $destination -Force | Out-Null + $zip = "$destination.zip" + Invoke-WebRequest "$api/actions/artifacts/$($artifact.Id)/zip" -Headers $headers -OutFile $zip -UseBasicParsing + if ((Get-FileHash $zip -Algorithm SHA256).Hash -ne $artifact.Digest) { throw 'Artifact archive hash mismatch.' } + Expand-Archive $zip $destination + $files = @(Get-ChildItem $destination -Filter '*.app' -Recurse -File | Sort-Object FullName) + if ($files.Count -eq 0) { throw 'Pinned artifact contains no app packages.' } + $list = Join-Path $destination 'packages.json' + ConvertTo-Json -InputObject @($files.FullName) | Set-Content $list -Encoding UTF8 + "$($artifact.Kind)=$list" | Add-Content $env:GITHUB_OUTPUT + $manifest += @{ + artifactId = $artifact.Id; sourceRun = 37372848860; sourceHead = $base + kind = $artifact.Kind; archiveSha256 = $artifact.Digest + files = @($files | ForEach-Object { @{ name = $_.Name; sha256 = (Get-FileHash $_.FullName).Hash } }) + } +} +@{ + experimentHead = $env:GITHUB_SHA; run = $env:GITHUB_RUN_ID; arm = $env:BC_SQL_PILOT_ARM + budgetMinutes = 120; startedUtc = [DateTime]::UtcNow.ToString('o') + artifacts = $manifest +} | ConvertTo-Json -Depth 8 | Set-Content (Join-Path $output 'provenance.json') -Encoding UTF8 diff --git a/build/scripts/SqlResetPilot/README.md b/build/scripts/SqlResetPilot/README.md new file mode 100644 index 00000000000..f43cd35b66c --- /dev/null +++ b/build/scripts/SqlResetPilot/README.md @@ -0,0 +1,52 @@ +# Diagnostic only — do not merge + +One manually dispatched paired W1 Integration pilot on top of PR #11860, +`c4953dceffe02a017adad34973e1955017bf5d20`. Related context: AB#646383; +this experiment does **not** resolve that work item and is outside the native +merge group. No auto-merge, queue entry, automatic rerun or main-branch change. + +The branch-only `CICD.yaml` replaces the normal CI matrix with two fresh +`GitHub-BCAppsPL` containers, capped at **120 minutes per arm** (240 runner-minutes). +PR project discovery is disabled for this experiment branch to avoid a full-country +build. Dispatch the existing registered `CICD.yaml` with this branch as its ref. +Do not dispatch a second pair without new authorization. + +Both arms use the exact same PR2 app/test archive IDs and SHA256 digests, +BCH `6.1.19-preview2811389`, NST `30.0.55665.0`, W1 application artifact +`30.0.55683.0`, image digest, original license provisioning, auth files, +test assertions and three-worker scheduling. No AL source is changed or compiled +against a moving head. The image digest resolves the original generic +`1.0.2.128` LTSC2025-dev image; the historic log did not record its digest. +The pilot records the runner OS; compare both manifests before interpretation. + +The original discovery runs unchanged. Its ordered clean-test prefix must match +the 21 codeunits captured in W1 job 112207627851, run 37372848860. Seven batches +end with Expense Users, Capabilities and Activity Log. No ordinary-test phase, +HTTP warmup, auth mutation, extra sleep, force-refresh or pool clearing is added. +The existing mount-state polling remains. Copy failures and test failures are +terminal; baseline copy/transient retries are deliberately disabled in both arms. +A failed batch drains its three workers and prevents later batches. + +Control restores worker databases under `tenant2/3/4`. Treatment preserves those +logical IDs/URLs, but uses `tenantN_rRUN_gGENERATION` database names. Both arms +read the same SQL identity fields before and after reset. This administrative +probe has a possible timing effect and does not reveal the NST internal pool +generation. A fresh SQL name does not guarantee a fresh database ID or GUID. + +Deletion is allowed only for a tracked worker's previously mounted local database, +after dismount. A stale/foreign mapping or pre-existing fresh target fails closed. +The caller's tenant objects are updated after each successful mount, including +the discovery restore. Default/application/template databases are not deleted by +the pilot. The immutable template is retained until whole-container teardown. +The ordinary disposable pipeline teardown plus the final ownership-checked +fallback clean up the complete container, including partial failed copies. +Runner loss/hard cancellation may prevent exports/finally; that is inconclusive. + +Artifacts contain package hashes, effective identities, ordered worklist, +reset UTC/SQL identity timeline, worker output, JUnit and raw EVTX. +No credentials, access keys or authorization headers are added to diagnostics. +Missing artifacts, setup drift, timeout or no reproduction mean **inconclusive**. +Control-only reproduction is a name-reuse signal, not proof of a production fix. + +Local validation (Pester 5, no NST required): +`Invoke-Pester .\build\scripts\SqlResetPilot` From ae6513a3a3b8902513e69af929331c17272a221d Mon Sep 17 00:00:00 2001 From: Prangshuman Das Date: Tue, 6 Oct 2026 18:33:11 +0200 Subject: [PATCH 2/3] Match SQL pilot action shells to baseline PowerShell 7 Preserve failed preparation as inconclusive; one replacement pair authorized. Related to AB#646383, diagnostic only. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3952f078-a881-4da8-ad96-13b727e48a91 --- .github/workflows/CICD.yaml | 24 +++++++++++++++++++----- build/scripts/SqlResetPilot/README.md | 22 ++++++++++++++++++++++ 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/.github/workflows/CICD.yaml b/.github/workflows/CICD.yaml index 76134472083..a6a72e3733d 100644 --- a/.github/workflows/CICD.yaml +++ b/.github/workflows/CICD.yaml @@ -20,7 +20,7 @@ jobs: timeout-minutes: 120 defaults: run: - shell: powershell + shell: pwsh env: ALGoOrgSettings: ${{ vars.ALGoOrgSettings }} ALGoRepoSettings: ${{ vars.ALGoRepoSettings }} @@ -31,7 +31,21 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: lfs: true - fetch-depth: 2 + fetch-depth: 3 + + - name: Verify baseline PowerShell 7 host + run: | + if ($PSVersionTable.PSEdition -ne 'Core' -or $PSVersionTable.PSVersion.Major -ne 7) { + throw 'The pinned baseline executes on PowerShell 7; this pilot must use the same shell.' + } + $output = Join-Path $env:GITHUB_WORKSPACE 'sql-reset-pilot-output' + New-Item -ItemType Directory $output -Force | Out-Null + @{ + edition = $PSVersionTable.PSEdition + version = $PSVersionTable.PSVersion.ToString() + executable = (Get-Process -Id $PID).Path + utc = [DateTime]::UtcNow.ToString('o') + } | ConvertTo-Json | Tee-Object -FilePath (Join-Path $output 'shell-preflight.json') - name: Validate provenance and download exact compiled PR2 packages id: packages @@ -43,7 +57,7 @@ jobs: - name: Read pinned W1 Integration settings uses: microsoft/AL-Go/Actions/ReadSettings@91b96c2b294be6f823277dafe6f03350abfb9d23 with: - shell: powershell + shell: pwsh project: build/projects/Test Apps W1 buildMode: IntegrationTests @@ -51,7 +65,7 @@ jobs: id: secrets uses: microsoft/AL-Go/Actions/ReadSecrets@91b96c2b294be6f823277dafe6f03350abfb9d23 with: - shell: powershell + shell: pwsh gitHubSecrets: ${{ toJson(secrets) }} getSecrets: 'licenseFileUrl,gitHubPackagesContext,applicationInsightsConnectionString,appDependencySecrets' @@ -60,7 +74,7 @@ jobs: env: Secrets: '${{ steps.secrets.outputs.Secrets }}' with: - shell: powershell + shell: pwsh project: build/projects/Test Apps W1 buildMode: IntegrationTests artifact: https://bcinsider-fvh2ekdjecfjd6gk.b02.azurefd.net/sandbox/30.0.55683.0/w1 diff --git a/build/scripts/SqlResetPilot/README.md b/build/scripts/SqlResetPilot/README.md index f43cd35b66c..56ad191a661 100644 --- a/build/scripts/SqlResetPilot/README.md +++ b/build/scripts/SqlResetPilot/README.md @@ -11,6 +11,28 @@ PR project discovery is disabled for this experiment branch to avoid a full-coun build. Dispatch the existing registered `CICD.yaml` with this branch as its ref. Do not dispatch a second pair without new authorization. +## Authorized preparation correction + +The initial pair, run `37493608127`, never created a container or ran AL tests. +It incorrectly selected Windows PowerShell 5.1 while original W1 job +`112207627851` used `pwsh` 7. `Get-PlatformVersions` therefore failed parsing an +HTTP response because the Internet Explorer engine was unavailable. This is an +experiment preparation/configuration bug, **not a network failure or SQL result**. +The failed pair remains inconclusive and its logs/artifacts are retained. + +One replacement pair is explicitly authorized after correcting the job shell +**and all AL-Go composite action shell inputs** to `pwsh`. `Invoke-AlGoAction` +invokes its supplied scriptblock in that shell; it does not select the shell. +A preflight records and requires PowerShell 7 before package preparation. +Checkout includes the unchanged PR2 ancestor through the two diagnostic commits. +No PlatformHelper, vendor action, parsing workaround or shell policy is changed. + +The failed preparation consumed 13m16s (control) plus 14m23s (fresh), totaling +27m39s of runner time. The replacement remains capped at 120 minutes per arm +(240 additional runner-minutes; 267m39s cumulative worst-case including the failed +preparation). Record actual old/new preparation time separately. No further +dispatch or failed-job rerun is authorized after the replacement. + Both arms use the exact same PR2 app/test archive IDs and SHA256 digests, BCH `6.1.19-preview2811389`, NST `30.0.55665.0`, W1 application artifact `30.0.55683.0`, image digest, original license provisioning, auth files, From d7dd4dddcd49da397ee42de1677d1b7d44622149 Mon Sep 17 00:00:00 2001 From: Prangshuman Das Date: Wed, 7 Oct 2026 11:26:30 +0200 Subject: [PATCH 3/3] Allow bounded control-only SQL reset diagnostic repetitions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3952f078-a881-4da8-ad96-13b727e48a91 --- .github/workflows/CICD.yaml | 15 ++++++++-- build/scripts/SqlResetPilot/README.md | 40 +++++++++++++++++++++++---- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/.github/workflows/CICD.yaml b/.github/workflows/CICD.yaml index a6a72e3733d..1dd4b8052ee 100644 --- a/.github/workflows/CICD.yaml +++ b/.github/workflows/CICD.yaml @@ -1,7 +1,16 @@ -name: 'SQL reset paired pilot - DO NOT MERGE' +name: 'SQL reset pilot - DO NOT MERGE' on: workflow_dispatch: + inputs: + arm: + description: 'Control only by default; paired requires separate authorization' + required: true + type: choice + default: control + options: + - control + - paired permissions: actions: read @@ -15,7 +24,7 @@ jobs: strategy: fail-fast: false matrix: - arm: [control, fresh] + arm: ${{ fromJSON(inputs.arm == 'paired' && '["control","fresh"]' || '["control"]') }} runs-on: GitHub-BCAppsPL timeout-minutes: 120 defaults: @@ -31,7 +40,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: lfs: true - fetch-depth: 3 + fetch-depth: 0 - name: Verify baseline PowerShell 7 host run: | diff --git a/build/scripts/SqlResetPilot/README.md b/build/scripts/SqlResetPilot/README.md index 56ad191a661..b127afef724 100644 --- a/build/scripts/SqlResetPilot/README.md +++ b/build/scripts/SqlResetPilot/README.md @@ -1,12 +1,14 @@ # Diagnostic only — do not merge -One manually dispatched paired W1 Integration pilot on top of PR #11860, +A manually dispatched W1 Integration diagnostic on top of PR #11860, `c4953dceffe02a017adad34973e1955017bf5d20`. Related context: AB#646383; this experiment does **not** resolve that work item and is outside the native merge group. No auto-merge, queue entry, automatic rerun or main-branch change. -The branch-only `CICD.yaml` replaces the normal CI matrix with two fresh -`GitHub-BCAppsPL` containers, capped at **120 minutes per arm** (240 runner-minutes). +The branch-only `CICD.yaml` replaces the normal CI matrix with disposable +`GitHub-BCAppsPL` containers, capped at **120 minutes per arm**. +Its `arm` choice defaults to `control` (one job); `paired` explicitly selects +control and fresh (two jobs, 240 runner-minutes) and requires separate authorization. PR project discovery is disabled for this experiment branch to avoid a full-country build. Dispatch the existing registered `CICD.yaml` with this branch as its ref. Do not dispatch a second pair without new authorization. @@ -24,14 +26,40 @@ One replacement pair is explicitly authorized after correcting the job shell **and all AL-Go composite action shell inputs** to `pwsh`. `Invoke-AlGoAction` invokes its supplied scriptblock in that shell; it does not select the shell. A preflight records and requires PowerShell 7 before package preparation. -Checkout includes the unchanged PR2 ancestor through the two diagnostic commits. +Checkout now fetches full history so further diagnostic commits cannot hide the +unchanged PR2 ancestor from the provenance guard. No PlatformHelper, vendor action, parsing workaround or shell policy is changed. The failed preparation consumed 13m16s (control) plus 14m23s (fresh), totaling 27m39s of runner time. The replacement remains capped at 120 minutes per arm (240 additional runner-minutes; 267m39s cumulative worst-case including the failed -preparation). Record actual old/new preparation time separately. No further -dispatch or failed-job rerun is authorized after the replacement. +preparation). Record actual old/new preparation time separately. That authorization +allowed no further dispatch or failed-job rerun after the replacement. + +## October 7 control-only repetitions + +Replacement paired run `37496613531` completed successfully for both arms. +Its control baseline recorded 234 passed cases and 19 skipped cases across +20 codeunits with executed cases in the selected 21-codeunit, seven-batch prefix. +This did not reproduce the error and does not establish that name reuse is safe. + +New authorization permits **exactly three additional independent control-only +dispatches**, each with `arm=control`, attempt one, capped at 120 minutes: +**360 additional runner-minutes maximum**. No treatment, automatic retry, +replacement dispatch, or failed-job rerun is authorized. Each run owns a distinct +container named `bcbuildprojectsTestAppsW1`; no workflow concurrency group +cancels or serializes these independent trials. + +These controls use the same original PR2 compiled app/test artifacts from run +`37372848860`, head `c4953dceffe02a017adad34973e1955017bf5d20`, not rebuilt tests. +They repeat only the existing first seven clean batches (21 selected codeunits), +not the ordinary lane or the full 192-codeunit cohort. Compare against 234 passes +and 19 skips if a trial reaches completion; failures can stop the prefix early. +Password-file authentication, reused `tenant2/3/4` database names, three-worker +scheduling, runtime/package pins, reset logic and diagnostic reads are unchanged. +Check artifact availability before dispatch; expired artifacts have no fallback. +Launch verification is not a test result: inspect final logs, JUnit, provenance +and cleanup artifacts separately before drawing conclusions. Both arms use the exact same PR2 app/test archive IDs and SHA256 digests, BCH `6.1.19-preview2811389`, NST `30.0.55665.0`, W1 application artifact