From b7a7c30d1bbdb0167ae3e3c15a56ad00cb9274b3 Mon Sep 17 00:00:00 2001 From: Nicolas Fabert <195452170+Nico77600@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:19:27 +0100 Subject: [PATCH 1/2] ci: harden provenance and add validation Pin trusted actions, add project CI and advisory CodeQL, and update the personal-project disclaimer. --- .github/ci.json | 11 ++ .github/scripts/Initialize-CIModules.ps1 | 28 ++++ .github/scripts/Invoke-CI.ps1 | 150 ++++++++++++++++++ .github/workflows/attest-release.yml | 187 ++++++++++++++++++++--- .github/workflows/ci.yml | 59 +++++++ .github/workflows/codeql.yml | 71 +++++++++ AGENTS.md | 12 ++ README.md | 7 +- 8 files changed, 501 insertions(+), 24 deletions(-) create mode 100644 .github/ci.json create mode 100644 .github/scripts/Initialize-CIModules.ps1 create mode 100644 .github/scripts/Invoke-CI.ps1 create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/codeql.yml diff --git a/.github/ci.json b/.github/ci.json new file mode 100644 index 0000000..b9f16fe --- /dev/null +++ b/.github/ci.json @@ -0,0 +1,11 @@ +{ + "runtimeRoot": ".", + "testMode": "gate", + "testEntry": "tests/Invoke-TestGate.ps1", + "tests": [ + "tests/PraRemoteMailbox.Tests.ps1", + "tests/PraRemoteMailbox.Gui.Tests.ps1" + ], + "packageBuilder": "tools/New-PraPackage.ps1", + "packageEntry": "Invoke-PraRemoteMailbox.ps1" +} diff --git a/.github/scripts/Initialize-CIModules.ps1 b/.github/scripts/Initialize-CIModules.ps1 new file mode 100644 index 0000000..8c72c5a --- /dev/null +++ b/.github/scripts/Initialize-CIModules.ps1 @@ -0,0 +1,28 @@ +#Requires -Version 5.1 +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$Destination +) + +$ErrorActionPreference = 'Stop' +[void][IO.Directory]::CreateDirectory($Destination) +Save-Module -Name Pester -RequiredVersion 6.1.0 -Repository PSGallery -Path $Destination -Force -ErrorAction Stop +Save-Module -Name PSScriptAnalyzer -RequiredVersion 1.25.0 -Repository PSGallery -Path $Destination -Force -ErrorAction Stop + +foreach ($module in @( + @{ Name = 'Pester'; Version = '6.1.0' }, + @{ Name = 'PSScriptAnalyzer'; Version = '1.25.0' } +)) { + $manifest = Join-Path $Destination ('{0}\{1}\{0}.psd1' -f $module.Name, $module.Version) + if (-not (Test-Path -LiteralPath $manifest -PathType Leaf)) { + throw "The pinned module was not saved: $manifest" + } + $data = Import-PowerShellDataFile -LiteralPath $manifest + if ([string]$data.ModuleVersion -cne $module.Version) { + throw "The saved module version does not match: $manifest" + } +} + +if (-not $env:GITHUB_ENV) { throw 'The GitHub Actions environment file is missing.' } +'CI_MODULE_PATH={0}' -f [IO.Path]::GetFullPath($Destination) | + Out-File -LiteralPath $env:GITHUB_ENV -Encoding utf8 -Append diff --git a/.github/scripts/Invoke-CI.ps1 b/.github/scripts/Invoke-CI.ps1 new file mode 100644 index 0000000..3c0bb71 --- /dev/null +++ b/.github/scripts/Invoke-CI.ps1 @@ -0,0 +1,150 @@ +#Requires -Version 5.1 +[CmdletBinding()] +param( + [Parameter(Mandatory)][ValidateSet('Analyze', 'Test', 'Package')][string]$Stage, + [Parameter(Mandatory)][string]$OutputDirectory, + [string]$ModuleDirectory = $env:CI_MODULE_PATH +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +if ($PSVersionTable.PSEdition -eq 'Desktop') { + foreach ($name in 'Microsoft.PowerShell.Utility', 'Microsoft.PowerShell.Management', 'Microsoft.PowerShell.Security') { + Import-Module (Join-Path $PSHOME ('Modules\{0}\{0}.psd1' -f $name)) -ErrorAction Stop + } +} +$root = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..')) +$configuration = Get-Content -LiteralPath (Join-Path $root '.github\ci.json') -Raw | ConvertFrom-Json +$OutputDirectory = [IO.Path]::GetFullPath($OutputDirectory) +[void][IO.Directory]::CreateDirectory($OutputDirectory) + +if ($ModuleDirectory) { + $env:PSModulePath = $ModuleDirectory + [IO.Path]::PathSeparator + $env:PSModulePath +} + +function Resolve-RepositoryFile { + param([Parameter(Mandatory)][string]$RelativePath) + if ([IO.Path]::IsPathRooted($RelativePath)) { throw 'A repository-relative path is required.' } + $path = [IO.Path]::GetFullPath((Join-Path $root $RelativePath)) + if (-not $path.StartsWith($root.TrimEnd('\') + '\', [StringComparison]::OrdinalIgnoreCase) -or + -not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "Repository file missing or outside the repository: $RelativePath" + } + $path +} + +switch ($Stage) { + 'Analyze' { + Import-Module PSScriptAnalyzer -RequiredVersion 1.25.0 -Force -ErrorAction Stop + $tracked = @(git -C $root ls-files -- '*.ps1' '*.psm1' '*.psd1') + if ($LASTEXITCODE -ne 0) { throw 'Cannot enumerate the tracked PowerShell sources.' } + $runtime = [string]$configuration.runtimeRoot + $files = @($tracked | Where-Object { + ($_ -notmatch '^(tests|tools|lib|\.github)/') -and + ($runtime -eq '.' -or $_.StartsWith($runtime.TrimEnd('/') + '/', [StringComparison]::Ordinal)) + }) + if ($files.Count -eq 0) { throw 'No tracked runtime PowerShell sources were found.' } + $parseFailures = @() + $findings = @(foreach ($relative in $files) { + $path = Resolve-RepositoryFile $relative + $tokens = $null + $parseErrors = $null + $null = [Management.Automation.Language.Parser]::ParseFile($path, [ref]$tokens, [ref]$parseErrors) + foreach ($errorItem in $parseErrors) { + $parseFailures += [pscustomobject]@{ + File = $relative; Line = $errorItem.Extent.StartLineNumber; Message = $errorItem.Message + } + } + foreach ($finding in @(Invoke-ScriptAnalyzer -Path $path -ErrorAction Stop)) { + [pscustomobject]@{ + File = $relative; Line = $finding.Line; Column = $finding.Column + Severity = [string]$finding.Severity; Rule = $finding.RuleName; Message = $finding.Message + } + } + }) + ConvertTo-Json -InputObject $findings -Depth 5 | + Out-File -LiteralPath (Join-Path $OutputDirectory 'script-analyzer.json') -Encoding utf8 + ConvertTo-Json -InputObject $parseFailures -Depth 5 | + Out-File -LiteralPath (Join-Path $OutputDirectory 'syntax-errors.json') -Encoding utf8 + $summary = [ordered]@{ + Files = $files.Count; Analyzer = '1.25.0'; SyntaxErrors = $parseFailures.Count + Errors = @($findings | Where-Object Severity -eq 'Error').Count + Warnings = @($findings | Where-Object Severity -eq 'Warning').Count + Informative = @($findings | Where-Object Severity -eq 'Information').Count + DiagnosticsPolicy = 'Advisory; syntax or analyzer execution failures still fail the job.' + } + $summary | ConvertTo-Json | + Out-File -LiteralPath (Join-Path $OutputDirectory 'analysis-summary.json') -Encoding utf8 + Write-Output ($summary | ConvertTo-Json -Compress) + if ($parseFailures.Count) { throw 'Runtime PowerShell sources contain syntax errors.' } + } + 'Test' { + Import-Module Pester -RequiredVersion 6.1.0 -Force -ErrorAction Stop + if ($configuration.testMode -eq 'gate') { + $gate = Resolve-RepositoryFile $configuration.testEntry + $executable = if ($PSVersionTable.PSEdition -eq 'Desktop') { + Join-Path $PSHOME 'powershell.exe' + } else { Join-Path $PSHOME 'pwsh.exe' } + & $executable -NoProfile -NonInteractive -File $gate + if ($LASTEXITCODE -ne 0) { throw "The existing project test gate failed with exit code $LASTEXITCODE." } + } elseif ($configuration.testMode -eq 'runner') { + $runner = Resolve-RepositoryFile $configuration.testEntry + & { + # Preserve the existing runner's normal caller semantics. + Set-StrictMode -Off + & $runner + } + } elseif ($configuration.testMode -eq 'pester') { + $testFiles = @($configuration.tests | ForEach-Object { Resolve-RepositoryFile $_ }) + if ($testFiles.Count -eq 0) { throw 'No test files were configured.' } + $pester = New-PesterConfiguration + $pester.Run.Path = $testFiles + $pester.Run.PassThru = $true + $pester.Run.Exit = $false + $pester.Output.Verbosity = 'Normal' + $pester.TestResult.Enabled = $true + $pester.TestResult.OutputFormat = 'NUnitXml' + $pester.TestResult.OutputPath = Join-Path $OutputDirectory 'pester-results.xml' + $result = & { + # Do not impose this wrapper's StrictMode on existing tests. + Set-StrictMode -Off + Invoke-Pester -Configuration $pester + } + if ($null -eq $result -or $result.TotalCount -le 0 -or $result.PassedCount -le 0 -or + $result.FailedCount -gt 0 -or $result.FailedBlocksCount -gt 0 -or + $result.FailedContainersCount -gt 0 -or [string]$result.Result -ne 'Passed') { + throw 'The project tests did not complete successfully.' + } + [ordered]@{ + Total = $result.TotalCount; Passed = $result.PassedCount + Failed = $result.FailedCount; Skipped = $result.SkippedCount + } | ConvertTo-Json | Out-File -LiteralPath (Join-Path $OutputDirectory 'test-summary.json') -Encoding utf8 + } else { throw "Unsupported test mode: $($configuration.testMode)" } + } + 'Package' { + if (-not $configuration.packageBuilder) { throw 'This project has no package builder configured.' } + $builder = Resolve-RepositoryFile $configuration.packageBuilder + $destination = Join-Path $OutputDirectory 'package' + if (Test-Path -LiteralPath $destination) { throw 'The package output already exists; it will not be overwritten.' } + & { + Set-StrictMode -Off + & $builder -Destination $destination + } + $entry = Join-Path $destination $configuration.packageEntry + if (-not (Test-Path -LiteralPath $entry -PathType Leaf)) { + throw "The existing package builder did not produce the expected entry point: $entry" + } + $files = @(Get-ChildItem -LiteralPath $destination -Recurse -File) + if ($files.Count -eq 0) { throw 'The built package is empty.' } + $inventory = @(foreach ($file in $files) { + [pscustomobject]@{ + File = $file.FullName.Substring($destination.Length + 1) + Bytes = $file.Length + SHA256 = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + } + }) + ConvertTo-Json -InputObject $inventory -Depth 5 | + Out-File -LiteralPath (Join-Path $OutputDirectory 'package-inventory.json') -Encoding utf8 + Write-Output ('Existing package builder completed: {0} files.' -f $files.Count) + } +} diff --git a/.github/workflows/attest-release.yml b/.github/workflows/attest-release.yml index 3b7b42f..b70a28b 100644 --- a/.github/workflows/attest-release.yml +++ b/.github/workflows/attest-release.yml @@ -6,42 +6,183 @@ on: workflow_dispatch: inputs: tag: - description: Release tag to attest; leave empty to use the current ref - required: false + description: Existing published release tag to attest (default branch only) + required: true type: string permissions: contents: read - id-token: write - attestations: write + +concurrency: + group: attest-release-${{ github.repository }}-${{ github.event.release.tag_name || inputs.tag }} + cancel-in-progress: false jobs: attest: + if: >- + github.repository_owner == 'Nico77600' && + ( + (github.event_name == 'release' && github.event.action == 'published' && github.event.release.draft == false) || + (github.event_name == 'workflow_dispatch' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch)) + ) runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + id-token: write + attestations: write steps: - - name: Check out source - uses: actions/checkout@v4 - with: - ref: ${{ github.event.release.tag_name || inputs.tag || github.ref }} - - - name: Collect release assets + - name: Download and verify distributed ZIP assets + shell: bash env: GH_TOKEN: ${{ github.token }} - RELEASE_REF: ${{ github.event.release.tag_name || inputs.tag || github.ref_name }} + GH_HOST: github.com + RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }} + EXPECTED_RELEASE_ID: ${{ github.event.release.id }} + EVENT_ACTION: ${{ github.event.action }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: | set -euo pipefail - rm -rf dist - mkdir dist - release_tag="${RELEASE_REF##*/}" - if gh release view "$release_tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - gh release download "$release_tag" --repo "$GITHUB_REPOSITORY" --dir dist - fi - if ! find dist -type f -print -quit | grep -q .; then - archive="dist/${GITHUB_REPOSITORY##*/}-${release_tag}.tar.gz" - tar --exclude=.git --exclude=./dist -czf "$archive" . - fi + python3 - <<'PY' + import hashlib + import json + import os + from pathlib import Path + import re + import subprocess + from urllib.parse import quote + import zipfile + + def fail(message): + raise SystemExit(message) + + def positive_integer(value, label): + if type(value) is not int or value <= 0: + fail(f"Invalid {label}") + return value + + repository = os.environ["GITHUB_REPOSITORY"] + if not re.fullmatch(r"Nico77600/[A-Za-z0-9_.-]+", repository): + fail("Unexpected repository owner or name") + event = os.environ["GITHUB_EVENT_NAME"] + if event == "workflow_dispatch": + branch = os.environ["DEFAULT_BRANCH"] + if not branch or os.environ["GITHUB_REF"] != f"refs/heads/{branch}": + fail("Manual attestation must run from the default branch") + elif event == "release": + if os.environ["EVENT_ACTION"] != "published": + fail("Only published release events are trusted") + else: + fail("Unsupported event") + tag = os.environ.get("RELEASE_TAG", "") + if not tag: + fail("An explicit release tag is required") + subprocess.run(["git", "check-ref-format", f"refs/tags/{tag}"], check=True) + encoded_tag = quote(tag, safe="") + base = f"repos/{repository}" + + def api(endpoint, *, paginate=False, output=None): + command = [ + "gh", "api", "--hostname", "github.com", "--method", "GET", + "-H", "X-GitHub-Api-Version: 2022-11-28", + "-H", "Accept: application/octet-stream" if output is not None + else "Accept: application/vnd.github+json", + ] + if paginate: + command.extend(["--paginate", "--slurp"]) + command.append(endpoint) + # gh/Go strip Authorization on cross-host redirects; do not add a token header. + result = subprocess.run( + command, check=True, stdout=output if output is not None else subprocess.PIPE + ) + if output is None: + return json.loads(result.stdout) + + reference = api(f"{base}/git/ref/tags/{encoded_tag}") + if not isinstance(reference, dict) or reference.get("ref") != f"refs/tags/{tag}": + fail("The exact release tag does not exist") + + def release(): + metadata = api(f"{base}/releases/tags/{encoded_tag}") + if not isinstance(metadata, dict) or metadata.get("tag_name") != tag: + fail("Release tag does not match the requested tag") + if metadata.get("draft") is not False or not metadata.get("published_at"): + fail("The release must be published and not a draft") + release_id = positive_integer(metadata.get("id"), "release ID") + if event == "release" and str(release_id) != os.environ["EXPECTED_RELEASE_ID"]: + fail("Release ID does not match the published event") + return release_id + + def zip_assets(release_id): + pages = api(f"{base}/releases/{release_id}/assets?per_page=100", paginate=True) + if not isinstance(pages, list) or any(not isinstance(page, list) for page in pages): + fail("Invalid paginated release asset response") + assets = [asset for page in pages for asset in page] + if not assets: + fail("The release has no distributed assets; no source archive fallback") + selected = {} + seen = set() + for asset in assets: + if not isinstance(asset, dict) or not isinstance(asset.get("name"), str): + fail("Invalid release asset metadata") + asset_id = positive_integer(asset.get("id"), "asset ID") + if asset_id in seen: + fail("Duplicate release asset ID") + seen.add(asset_id) + if not asset["name"].lower().endswith(".zip"): + continue + if asset.get("state") != "uploaded": + fail("A ZIP asset is not fully uploaded") + size = positive_integer(asset.get("size"), "ZIP asset size") + digest = asset.get("digest") + if digest is not None and ( + not isinstance(digest, str) or not re.fullmatch(r"sha256:[0-9a-fA-F]{64}", digest) + ): + fail("Invalid or unsupported ZIP asset digest") + selected[asset_id] = (asset["name"], size, digest.lower() if digest else None) + if not selected: + fail("The release has no distributed ZIP assets") + if len(selected) > 1024: + fail("The release exceeds the attestation action's 1024-subject limit") + return selected + + release_id = release() + assets = zip_assets(release_id) + # A fresh directory and numeric filenames exclude stale files and name/path injection. + Path("dist").mkdir() + for asset_id, (name, expected_size, expected_digest) in sorted(assets.items()): + target = Path("dist") / f"{asset_id}.zip" + try: + with target.open("xb") as output: + api(f"{base}/releases/assets/{asset_id}", output=output) + if target.stat().st_size != expected_size: + fail(f"Size mismatch for ZIP asset {asset_id}") + hasher = hashlib.sha256() + with target.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + hasher.update(chunk) + stream.seek(0) + if not zipfile.is_zipfile(stream): + fail(f"Asset {asset_id} is not a ZIP file") + digest = f"sha256:{hasher.hexdigest()}" + if expected_digest is not None and digest != expected_digest: + fail(f"SHA-256 mismatch for ZIP asset {asset_id}") + print(json.dumps({ + "asset_id": asset_id, "release_name": name, "subject": str(target), + "size": expected_size, "digest": digest, + "github_digest_verified": expected_digest is not None, + }, ensure_ascii=True)) + except BaseException: + target.unlink(missing_ok=True) + raise + if release() != release_id or zip_assets(release_id) != assets: + fail("Release ZIP assets changed during download; retry with a stable release") + PY + # Binds distributed ZIP bytes to this repository/workflow; does not prove their original build. - name: Generate artifact attestation - uses: actions/attest@v4 + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4 with: - subject-path: 'dist/*' + subject-path: 'dist/*.zip' + push-to-registry: false + create-storage-record: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5f432bd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,59 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.repository }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + name: Validate (${{ matrix.engine }}) + runs-on: windows-2022 + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + include: [{"engine":"Windows PowerShell 5.1","shell":"powershell","package":true}] + steps: + - name: Check out the tested revision + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Load pinned analysis and test modules + shell: pwsh + run: .\.github\scripts\Initialize-CIModules.ps1 -Destination "$env:RUNNER_TEMP\ci-modules" + + - name: Analyze PowerShell sources + shell: ${{ matrix.shell }} + run: .\.github\scripts\Invoke-CI.ps1 -Stage Analyze -OutputDirectory "$env:RUNNER_TEMP\ci-results" + + - name: Run the existing project tests + shell: ${{ matrix.shell }} + run: .\.github\scripts\Invoke-CI.ps1 -Stage Test -OutputDirectory "$env:RUNNER_TEMP\ci-results" + + - name: Build with the existing package tool + if: matrix.package + shell: ${{ matrix.shell }} + run: .\.github\scripts\Invoke-CI.ps1 -Stage Package -OutputDirectory "$env:RUNNER_TEMP\ci-results" + + - name: Retain public CI evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: ci-${{ matrix.engine }} + path: | + ${{ runner.temp }}/ci-results + artifacts/pester-results.xml + tests/evidence/gate + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..7e249ba --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,71 @@ +{ + "name": "CodeQL (advisory)", + "on": { + "pull_request": { + "branches": ["main"] + }, + "push": { + "branches": ["main"] + }, + "schedule": [ + { + "cron": "41 5 * * 3" + } + ], + "workflow_dispatch": {} + }, + "permissions": {}, + "concurrency": { + "group": "codeql-${{ github.workflow }}-${{ github.ref }}", + "cancel-in-progress": true + }, + "jobs": { + "analyze": { + "name": "CodeQL ${{ matrix.language }} (advisory)", + "if": "${{ contains(fromJSON('[\"Nico77600/EasOAuthMailbox\",\"Nico77600/Exchange2013-2016-to-2019-Migration\",\"Nico77600/ExchangeLogReport\",\"Nico77600/ExchangeMailboxMigration\",\"Nico77600/MailboxMessageReport\",\"Nico77600/MeetingCleanup\",\"Nico77600/MeetingCleanupOnPrem\",\"Nico77600/MessageTraceReport\",\"Nico77600/PraCloudMailbox\",\"Nico77600/PraRemoteMailbox\",\"Nico77600/PurviewDlpReport\",\"Nico77600/PurviewDlpReport-Fabric\",\"Nico77600/RecipientLimitReport\",\"Nico77600/WebServicesClient\",\"Nico77600/XTapSharingMigration\"]'), github.repository) && (github.event_name == 'pull_request' || github.ref == 'refs/heads/main') }}", + "runs-on": "ubuntu-latest", + "timeout-minutes": 25, + "permissions": { + "contents": "read", + "security-events": "write" + }, + "strategy": { + "fail-fast": false, + "max-parallel": 2, + "matrix": { + "language": ["actions"] + } + }, + "steps": [ + { + "name": "Check out event revision", + "uses": "actions/checkout@11d5960a326750d5838078e36cf38b85af677262", + "with": { + "persist-credentials": false + } + }, + { + "name": "Initialize source-only analysis", + "uses": "github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d", + "with": { + "languages": "${{ matrix.language }}", + "build-mode": "none", + "threads": 2, + "dependency-caching": "false", + "config": "{\"name\":\"GitHub Actions workflows\",\"paths\":[\".github/workflows\"]}" + } + }, + { + "name": "Analyze and upload advisory results", + "uses": "github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d", + "with": { + "category": "/language:${{ matrix.language }}", + "upload": "always", + "upload-database": "false", + "wait-for-processing": "true" + } + } + ] + } + } +} diff --git a/AGENTS.md b/AGENTS.md index afa287e..8d8b21b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,3 +32,15 @@ Direct pushes, force pushes, and deletion of public main are prohibited. Do not Keep secret scanning and push protection enabled. Do not routinely bypass alerts. Dependency scanning is not a replacement for the project's tests and analysis. Follow SECURITY.md for sensitive reports. Maintain versioned backups of public Git history, release assets, and settings outside the repository; keep an additional copy off the development machine. For a new public repository, configure these branch/tag protections and future-release immutability before the first release. Enable dependency graph, Dependabot alerts, secret scanning, push protection, and private vulnerability reporting. Personal-account repositories do not automatically inherit these settings. Automatic merges and automated dependency-update pull requests are not part of this baseline. + +## CI and provenance + +GitHub Actions runs the existing tests and package builder on a disposable Windows runner using the project's configured PowerShell edition. It does not connect to the development lab or use publication credentials. CI helpers live under .github/scripts and are not part of the runtime package. + +PowerShell syntax and analyzer execution must succeed. PSScriptAnalyzer diagnostics and CodeQL findings are initially advisory, not automatically fixed. CodeQL analyzes workflows and, where configured, C# sources; it does not analyze PowerShell. An unsuccessful scan is not evidence of security or complete coverage. + +Required validation checks are enabled only after successful GitHub runs. Do not bypass them or weaken existing tests to publish. If a check fails, inspect its evidence and address the cause. + +The release attestation workflow does not build or execute repository code. It attests the actual published ZIP bytes; it does not prove their original build. Manual attestation must run from the default branch with an explicit existing release tag. Missing releases or ZIPs fail explicitly; no source archive is substituted. + +Actions are pinned to full commit IDs. Updates require review of the action source and an explicit pin change. Repository policies may reject unpinned or unapproved actions; never relax them to merge a contribution. diff --git a/README.md b/README.md index 950cdd6..56c4e7b 100644 --- a/README.md +++ b/README.md @@ -183,4 +183,9 @@ needs PowerShell 7.4+. ## Disclaimer -Personal project, provided as is. It is not an official Microsoft product and is not supported by Microsoft. The tool changes production objects in Active Directory and creates mailboxes in Exchange Online: run the preview, review the plan, keep the backups and test in a lab before using it in a real disaster. \ No newline at end of file +This Script is a Personal project. +It's provided "AS-IS". It's not an official Microsoft product so no support can be expected from Microsoft. + +As any scripts you must read carefully the documentation and test it first in a Test environment before any run in Production. + +The tool changes production objects in Active Directory and creates mailboxes in Exchange Online: run the preview, review the plan, keep the backups and test in a lab before using it in a real disaster. \ No newline at end of file From e4b991d60f37796d5ffdb6cc31e9137b7dde5fe8 Mon Sep 17 00:00:00 2001 From: Nicolas Fabert <195452170+Nico77600@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:41:08 +0100 Subject: [PATCH 2/2] fix: use supported CI shell contexts Select static PowerShell shells per matrix engine so GitHub can execute the existing project checks. --- .github/workflows/ci.yml | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f432bd..83e288f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,17 +33,34 @@ jobs: shell: pwsh run: .\.github\scripts\Initialize-CIModules.ps1 -Destination "$env:RUNNER_TEMP\ci-modules" - - name: Analyze PowerShell sources - shell: ${{ matrix.shell }} + - name: Analyze PowerShell sources (PowerShell 7) + if: matrix.shell == 'pwsh' + shell: pwsh + run: .\.github\scripts\Invoke-CI.ps1 -Stage Analyze -OutputDirectory "$env:RUNNER_TEMP\ci-results" + + - name: Analyze PowerShell sources (Windows PowerShell 5.1) + if: matrix.shell == 'powershell' + shell: powershell run: .\.github\scripts\Invoke-CI.ps1 -Stage Analyze -OutputDirectory "$env:RUNNER_TEMP\ci-results" - - name: Run the existing project tests - shell: ${{ matrix.shell }} + - name: Run the existing project tests (PowerShell 7) + if: matrix.shell == 'pwsh' + shell: pwsh run: .\.github\scripts\Invoke-CI.ps1 -Stage Test -OutputDirectory "$env:RUNNER_TEMP\ci-results" - - name: Build with the existing package tool - if: matrix.package - shell: ${{ matrix.shell }} + - name: Run the existing project tests (Windows PowerShell 5.1) + if: matrix.shell == 'powershell' + shell: powershell + run: .\.github\scripts\Invoke-CI.ps1 -Stage Test -OutputDirectory "$env:RUNNER_TEMP\ci-results" + + - name: Build with the existing package tool (PowerShell 7) + if: matrix.package && matrix.shell == 'pwsh' + shell: pwsh + run: .\.github\scripts\Invoke-CI.ps1 -Stage Package -OutputDirectory "$env:RUNNER_TEMP\ci-results" + + - name: Build with the existing package tool (Windows PowerShell 5.1) + if: matrix.package && matrix.shell == 'powershell' + shell: powershell run: .\.github\scripts\Invoke-CI.ps1 -Stage Package -OutputDirectory "$env:RUNNER_TEMP\ci-results" - name: Retain public CI evidence