diff --git a/.github/ci.json b/.github/ci.json new file mode 100644 index 0000000..f9b63db --- /dev/null +++ b/.github/ci.json @@ -0,0 +1,10 @@ +{ + "runtimeRoot": "package", + "testMode": "pester", + "testEntry": null, + "tests": [ + "tests/ExchangeLogReport.Tests.ps1" + ], + "packageBuilder": "tools/New-ExchangeLogReportPackage.ps1", + "packageEntry": "Invoke-ExchangeLogReport.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..c3ed7ce --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,76 @@ +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":"PowerShell 7","shell":"pwsh","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 (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 (PowerShell 7) + if: matrix.shell == 'pwsh' + shell: pwsh + run: .\.github\scripts\Invoke-CI.ps1 -Stage Test -OutputDirectory "$env:RUNNER_TEMP\ci-results" + + - 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 + 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..063cffe --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,89 @@ +{ + "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/ExchangeLogReport\",\"Nico77600/MailboxMessageReport\",\"Nico77600/MeetingCleanup\",\"Nico77600/MeetingCleanupOnPrem\",\"Nico77600/MessageTraceReport\",\"Nico77600/PurviewDlpReport\",\"Nico77600/PurviewDlpReport-Fabric\",\"Nico77600/RecipientLimitReport\"]'), github.repository) && (github.event_name == 'pull_request' || github.ref == 'refs/heads/main') }}", + "runs-on": "${{ matrix.runner }}", + "timeout-minutes": 25, + "permissions": { + "contents": "read", + "security-events": "write" + }, + "strategy": { + "fail-fast": false, + "max-parallel": 2, + "matrix": { + "include": [ + { + "language": "actions", + "runner": "ubuntu-latest", + "config": { + "name": "GitHub Actions workflows", + "paths": [".github/workflows"] + } + }, + { + "language": "csharp", + "runner": "windows-2022", + "config": { + "name": "Production C# loose-source pilot", + "paths": ["package/src"], + "paths-ignore": ["tests", "package/lib", "tools"] + } + } + ] + } + }, + "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": "${{ toJson(matrix.config) }}" + } + }, + { + "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 d96b8a7..b54b011 100644 --- a/README.md +++ b/README.md @@ -238,4 +238,7 @@ The tool was also validated on a lab of four Exchange Server SE servers (two sit ## Disclaimer -Personal project, provided as is. It is not an official Microsoft product and is not supported by Microsoft. Test it in your environment before production use. +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.