diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index faa45cb0..27195be1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -119,23 +119,22 @@ jobs: .\tools\test-release-compliance-negative.ps1 -PublishDirectory artifacts/win-x64 + - name: Verify final package hashes and provenance failures + shell: pwsh + run: >- + .\tools\test-final-package-gate-negative.ps1 + -PublishDirectory artifacts/win-x64 + - name: Create and verify Windows alpha archive shell: pwsh run: | $archivePath = "artifacts/${{ steps.release.outputs.artifact_name }}.zip" $reportPath = "artifacts/${{ steps.release.outputs.artifact_name }}-compliance.json" - Remove-Item $archivePath -Force -ErrorAction SilentlyContinue - Remove-Item $reportPath -Force -ErrorAction SilentlyContinue - Compress-Archive ` - -Path "artifacts/win-x64/*" ` - -DestinationPath $archivePath ` - -CompressionLevel Optimal - .\tools\verify-release-compliance.ps1 ` + .\tools\new-verified-release-package.ps1 ` -DirectoryPath artifacts/win-x64 ` -ArchivePath $archivePath ` - -ReportPath $reportPath - .\tools\verify-dotnet-component-coverage.ps1 ` - -ArchivePath $archivePath + -ReportPath $reportPath ` + -DistributionChannel github-actions-artifact - name: Upload verified Windows alpha and compliance report uses: actions/upload-artifact@v4 diff --git a/docs/BUILD.md b/docs/BUILD.md index 7b9b8cd4..d64edfc6 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -1,6 +1,6 @@ -# Build and package SightAdapt as a standalone EXE +# Build and package SightAdapt -These steps create a self-contained Windows x64 executable and verified ZIP. +These steps create the maintained self-contained Windows x64 portable ZIP and its schema-3 compliance report. ## Prerequisites @@ -18,22 +18,26 @@ Windows Desktop Runtime 8.0.29 .\tools\verify-release-metadata.ps1 ``` -`Directory.Build.props` is the canonical source for product, SDK, runtime, RID, publish mode and artifact metadata. A mismatched reviewed configuration or blocked maintainer decision stops the build. +`Directory.Build.props` is the canonical source for product, SDK, runtime, RID, publish mode and artifact metadata. -## 2. Restore and test +## 2. Restore, build and test ```powershell dotnet restore .\src\SightAdapt\SightAdapt.csproj dotnet restore .\tests\SightAdapt.Tests\SightAdapt.Tests.csproj +dotnet build .\src\SightAdapt\SightAdapt.csproj ` + --configuration Release ` + --no-restore + dotnet test .\tests\SightAdapt.Tests\SightAdapt.Tests.csproj ` --configuration Release ` --no-restore ``` -Both `project.assets.json` files are required later. The SBOM generator reads all package entries and dependency edges from the application and test restore graphs, not only direct `PackageReference` elements. +Both `project.assets.json` files are consumed by the complete SBOM/license inventory. -## 3. Publish and capture bundle inputs +## 3. Publish ```powershell dotnet publish .\src\SightAdapt\SightAdapt.csproj ` @@ -42,51 +46,27 @@ dotnet publish .\src\SightAdapt\SightAdapt.csproj ` --output .\artifacts\win-x64 ``` -The project captures the SDK-provided `FilesToBundle` list in `artifacts\dotnet-files-to-bundle.tsv`. This temporary file identifies exact single-file inputs; absolute paths are not copied into the release ZIP. +The publish target captures SDK `FilesToBundle` inputs in `artifacts\dotnet-files-to-bundle.tsv`. The temporary file is not included in the ZIP. -## 4. Import exact official .NET notices +## 4. Generate legal and supply-chain evidence ```powershell .\tools\generate-dotnet-notices.ps1 ` -PublishDirectory .\artifacts\win-x64 -``` - -This verifies the runtime release train and official SDK ZIP SHA-512, then writes the exact Microsoft license, third-party notices and base metadata. - -## 5. Generate component-level notice coverage -```powershell .\tools\generate-dotnet-component-coverage.ps1 ` -PublishDirectory .\artifacts\win-x64 -``` - -This maps every embedded package asset and every loose runtime binary to: - -- exact package and version; -- package SHA-512; -- package-relative asset path; -- component SHA-256; -- reviewed notice mapping. - -The step also adds exact package notice sections for components not covered by the official .NET release bundle. Unreviewed, non-shipped or unmapped package components fail the build. - -## 6. Generate redistribution summary -```powershell .\tools\generate-dotnet-redistribution-notice.ps1 ` -PublishDirectory .\artifacts\win-x64 -``` - -## 7. Generate SBOM and license report -```powershell .\tools\generate-sbom.ps1 ` -PublishDirectory .\artifacts\win-x64 ``` -The generator writes `DEPENDENCIES.md`, schema-2 `LICENSE-REPORT.json` and SPDX 2.3 `SBOM.spdx.json` from the same inventory. For every NuGet package it retains exact package SHA-512 and `.nuspec` SHA-256 plus declared license metadata. Direct and transitive application/test packages are discovered automatically; the dependency policy supplies decisions and custom overrides rather than the inventory itself. +These steps generate exact Microsoft notices, component mappings, `DEPENDENCIES.md`, schema-2 `LICENSE-REPORT.json` and SPDX 2.3 `SBOM.spdx.json`. -## 8. Run negative checks +## 5. Run negative checks ```powershell .\tools\test-sbom-license-negative.ps1 ` @@ -94,31 +74,29 @@ The generator writes `DEPENDENCIES.md`, schema-2 `LICENSE-REPORT.json` and SPDX .\tools\test-release-compliance-negative.ps1 ` -PublishDirectory .\artifacts\win-x64 + +.\tools\test-final-package-gate-negative.ps1 ` + -PublishDirectory .\artifacts\win-x64 ``` -The validators must reject a transitive package with no resolved license, an incomplete package, stale redistribution metadata and a package with a deliberately removed component mapping. +The tests reject unknown licenses, incomplete/stale/unmapped packages, changed archive bytes, incorrect commit provenance, inconsistent tags and inactive channels. -## 9. Create and verify the final ZIP +## 6. Create the maintained portable ZIP ```powershell $archive = '.\artifacts\SightAdapt-0.5.0.50-alpha-win-x64.zip' $report = '.\artifacts\SightAdapt-0.5.0.50-alpha-win-x64-compliance.json' -Compress-Archive ` - -Path '.\artifacts\win-x64\*' ` - -DestinationPath $archive ` - -CompressionLevel Optimal - -.\tools\verify-release-compliance.ps1 ` - -DirectoryPath .\artifacts\win-x64 ` +.\tools\new-verified-release-package.ps1 ` + -DirectoryPath '.\artifacts\win-x64' ` -ArchivePath $archive ` - -ReportPath $report - -.\tools\verify-dotnet-component-coverage.ps1 ` - -ArchivePath $archive + -ReportPath $report ` + -DistributionChannel 'local-portable-zip' ``` -The compliance gate checks license evidence, package counts, graph edges and scope-aware SPDX relationships in addition to the existing package invariants. Retain the verified archive and compliance report together. +Do not create an official package with a separate `Compress-Archive` command. The reusable entry point creates the archive, runs all final validators and records per-file SHA-256 equality and Git provenance. + +GitHub Actions uses the same command with channel `github-actions-artifact`; commit/ref and workflow/run values are read from the GitHub environment. ## Expected package root @@ -136,7 +114,11 @@ LICENSE-REPORT.json PRIVACY.md ``` -Additional loose native runtime DLLs are allowed only when schema-2 metadata maps each file by exact output path and SHA-256. +The compliance report is kept next to the ZIP because it contains the final archive SHA-256. + +## Maintained scope + +`release/distribution-channels.json` currently allows only the GitHub Actions portable ZIP and the local maintainer portable ZIP. GitHub Releases, installers, store packages and mirrors remain inactive until separately implemented with the same final gate. ## Clean rebuild @@ -147,4 +129,4 @@ Remove-Item .\artifacts\SightAdapt-*-win-x64.zip -Force -ErrorAction SilentlyCon Remove-Item .\artifacts\SightAdapt-*-win-x64-compliance.json -Force -ErrorAction SilentlyContinue ``` -Do not publish when metadata review, notice import, component coverage, complete dependency/license review, negative checks or final-package validation fails. +Do not publish when any generator, negative test or final-package validation fails. diff --git a/docs/PACKAGING.md b/docs/PACKAGING.md index 81425f56..0f6433f0 100644 --- a/docs/PACKAGING.md +++ b/docs/PACKAGING.md @@ -1,126 +1,91 @@ # SightAdapt binary packaging standard -This document defines the minimum contents and verification rules for every binary distribution of SightAdapt. +## Maintained format + +SightAdapt currently maintains one binary distribution format: a self-contained Windows x64 portable ZIP. + +The allowed producer contexts are registered in `release/distribution-channels.json`. Both the GitHub Actions artifact and a maintainer-created local ZIP must use `tools/new-verified-release-package.ps1`. + +Installers, store packages, GitHub Releases and mirrors are not maintained channels yet. Their future implementation must activate a registry entry and use the reusable final-package gate. ## Canonical required files -The machine-readable source of truth is `release/required-files.txt`. +The machine-readable package manifest is `release/required-files.txt`. + +Every portable ZIP must contain at its root: | File | Purpose | |---|---| | `SightAdapt.exe` | Application executable and single-file container | | `LICENSE.txt` | MIT License for SightAdapt project code | -| `THIRD-PARTY-NOTICES.txt` | Exact .NET notices and package-specific notice sections | -| `DOTNET-LICENSE-NOTICE.txt` | Exact Microsoft .NET license text and source metadata | -| `DOTNET-NOTICE-METADATA.json` | Exact package, component, notice and checksum evidence | -| `MICROSOFT-DOTNET-REDISTRIBUTION.txt` | Generated maintainer-reviewed redistribution notice | -| `THIRD-PARTY-NAMES-AND-DRM-NOTICE.txt` | Mark ownership, no-endorsement and DRM/access-control boundaries | -| `DEPENDENCIES.md` | Human-readable dependency inventory generated from complete restore graphs | -| `SBOM.spdx.json` | SPDX 2.3 package graph and shipped-file inventory | -| `LICENSE-REPORT.json` | Schema-2 dependency-license evidence and policy result | -| `PRIVACY.md` | Application privacy and local-data notice | - -A package is incomplete if a required file is missing, unreadable, stale, inconsistent with pinned inputs, contains a binary without exact component notice evidence or has an unresolved dependency/license result. +| `THIRD-PARTY-NOTICES.txt` | Exact .NET and package-specific notices | +| `DOTNET-LICENSE-NOTICE.txt` | Exact Microsoft .NET license text and source evidence | +| `DOTNET-NOTICE-METADATA.json` | Exact package/component/notice evidence | +| `MICROSOFT-DOTNET-REDISTRIBUTION.txt` | Maintainer-reviewed redistribution summary | +| `THIRD-PARTY-NAMES-AND-DRM-NOTICE.txt` | Affiliation, trademark and DRM boundaries | +| `DEPENDENCIES.md` | Human-readable dependency inventory | +| `SBOM.spdx.json` | SPDX 2.3 package and file inventory | +| `LICENSE-REPORT.json` | Complete license-evidence and policy result | +| `PRIVACY.md` | Local data and privacy notice | + +The files must be readable without running SightAdapt. ## Publish sequence -1. `SightAdapt.csproj` copies the static legal baseline and captures SDK `FilesToBundle` inputs. -2. `generate-dotnet-notices.ps1` imports exact official .NET legal text. -3. `generate-dotnet-component-coverage.ps1` maps all embedded and loose package components and adds package-specific notices. -4. `generate-dotnet-redistribution-notice.ps1` renders the reviewed redistribution summary. -5. `generate-sbom.ps1` traverses complete application/test restore graphs, collects package license evidence and generates `DEPENDENCIES.md`, `LICENSE-REPORT.json` and `SBOM.spdx.json`. -6. Negative tests prove rejection of incomplete, stale, unmapped and unknown-license inputs. - -## Exact component coverage - -Schema-2 `DOTNET-NOTICE-METADATA.json` records: - -- SHA-256 of the temporary `FilesToBundle` manifest; -- exact package identities and NuGet SHA-512 values; -- repository and license metadata; -- notice mappings for exact .NET release text and package-specific terms; -- one sanitized record per embedded or loose component; -- package-relative asset path and component SHA-256; -- zero unmapped external components. - -The reviewed alpha contains 452 mapped package components across: - -- `Microsoft.NETCore.App.Runtime.win-x64/8.0.29`; -- `Microsoft.WindowsDesktop.App.Runtime.win-x64/8.0.29`; -- `Microsoft.Windows.SDK.NET.Ref/10.0.19041.56`. - -`Microsoft.Windows.SDK.NET.Ref` is classified as `shipped-embedded`, because `Microsoft.Windows.SDK.NET.dll` and `WinRT.Runtime.dll` are present in `FilesToBundle`. - -## Complete dependency and license inventory +1. restore, build and test; +2. publish into a clean staging directory; +3. import exact Microsoft notices; +4. generate component-level notice coverage; +5. generate the redistribution summary; +6. generate `DEPENDENCIES.md`, `LICENSE-REPORT.json` and `SBOM.spdx.json`; +7. run negative compliance tests; +8. call the reusable package entry point; +9. retain the ZIP and schema-3 compliance report together. -The SBOM generator uses both application and test `project.assets.json` files. Every NuGet package in `libraries`, every package dependency edge in `targets`, every framework download dependency, each package contributing published components, the pinned SDK and each maintained GitHub Action is inventoried. - -For NuGet packages the generated evidence retains exact package SHA-512, `.nuspec` SHA-256, declared license metadata, packaged-license SHA-256 where applicable, repository information and direct/transitive scope. `release/dependency-policy.json` supplies allow/deny/review decisions and selected custom-license overrides; it does not define which packages exist. - -SPDX root relationships distinguish scopes: - -- shipped packages: `SightAdapt DEPENDS_ON package`; -- test-only packages: `package TEST_DEPENDENCY_OF SightAdapt`; -- SDK, Actions, restore-only and application-build packages: `package BUILD_DEPENDENCY_OF SightAdapt`. - -Package-to-package restore edges remain `DEPENDS_ON` and identify the originating graph. - -## Final package validation +## Reusable package command ```powershell $archive = '.\artifacts\SightAdapt-0.5.0.50-alpha-win-x64.zip' $report = '.\artifacts\SightAdapt-0.5.0.50-alpha-win-x64-compliance.json' -Compress-Archive ` - -Path '.\artifacts\win-x64\*' ` - -DestinationPath $archive ` - -CompressionLevel Optimal - -.\tools\verify-release-compliance.ps1 ` +.\tools\new-verified-release-package.ps1 ` -DirectoryPath '.\artifacts\win-x64' ` -ArchivePath $archive ` - -ReportPath $report - -.\tools\verify-dotnet-component-coverage.ps1 ` - -ArchivePath $archive + -ReportPath $report ` + -DistributionChannel 'local-portable-zip' ``` -The general compliance gate validates package, metadata, license-report and SBOM invariants. The focused component validator independently verifies package evidence, notice mappings, exact loose-binary hashes and absence of unmapped binaries. +In GitHub Actions the channel is `github-actions-artifact`; GitHub environment variables supply commit/ref and workflow/run provenance. -## Negative checks +## Final-package invariants -```powershell -.\tools\test-sbom-license-negative.ps1 ` - -PublishDirectory '.\artifacts\win-x64' +The reusable gate verifies: -.\tools\test-release-compliance-negative.ps1 ` - -PublishDirectory '.\artifacts\win-x64' -``` +- the channel is registered as maintained; +- the final package contains the canonical legal/compliance bundle; +- exact .NET notices, component coverage, SBOM and license report match the build; +- every staged path exists in the ZIP and no unexpected path was added; +- every staged file SHA-256 equals the corresponding ZIP entry SHA-256; +- source commit SHA matches repository HEAD; +- tag and source ref are consistent; +- the archive name, size and SHA-256 are recorded; +- the final report is schema 3 with `result: pass`. -The workflow proves rejection of: +The report is distributed alongside the ZIP, not inside it, because it records the final ZIP checksum. -- a transitive package with valid package metadata but no declared/resolved license; -- an incomplete package; -- stale redistribution metadata; -- a package containing a real runtime DLL after its component mapping is deliberately removed. +## Negative checks -## Distribution formats +The workflow proves rejection of incomplete, stale, unmapped, unknown-license, byte-modified and provenance-inconsistent packages. It also proves that a planned channel cannot publish before activation. -The same legal/compliance bundle is required for Actions artifacts, manual ZIPs, installers, store packages, portable builds and mirrors. Each maintained format must validate its final installed or unpacked file set. Mirrors publish the same verified bytes and report. +## Adding a new format or channel -## Release checklist +A new GitHub Release, installer, store package or mirror must be implemented in its own tracked work. The implementation must: -1. verify canonical release metadata and maintainer decision; -2. restore application and test projects; -3. build, test, publish and capture `FilesToBundle`; -4. import exact official .NET notices; -5. generate exact package/component coverage; -6. generate the redistribution summary; -7. generate complete SBOM, license report and dependency inventory; -8. resolve every unknown, denied, review-required or evidence failure; -9. prove unknown-transitive, incomplete, stale and unmapped inputs are rejected; -10. create and run both validators against the final package; -11. retain the package and compliance report; -12. publish identical verified bytes to official mirrors. +- define its final installed/unpacked staging tree; +- preserve the canonical legal bundle; +- activate its registry entry; +- call the reusable final-package gate; +- retain the verified container and report; +- add container-specific tests. -Do not publish when any notice, package hash, component mapping, restore graph, SBOM, license, metadata or maintainer-decision check fails. +Do not describe a planned channel as maintained before those controls exist. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index c816556e..04483e8e 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -1,20 +1,26 @@ -# SightAdapt release naming +# SightAdapt release rules -This document defines public naming, attribution, intended-purpose and package-compliance rules for SightAdapt releases. +This document defines public naming, claims and package-compliance rules. -## Public release identity +## Public identity GitHub Release title: `SightAdapt™ ` -Technical identifiers remain plain, for example: +Technical identifiers remain plain: - tag: `v0.5.0.50-alpha`; - executable: `SightAdapt.exe`; - archive: `SightAdapt-0.5.0.50-alpha-win-x64.zip`; - package identifier: `KeyffMS.SightAdapt`. +## Maintained distribution scope + +The only maintained binary format is the Windows x64 portable ZIP. Its maintained producer contexts are listed in `release/distribution-channels.json`. + +GitHub Releases are not yet an active publication channel; implementation is tracked in #98. Installers, store packages and official mirrors are also inactive until separately implemented with the reusable final-package gate. + ## Release-note opening Begin with: @@ -23,51 +29,33 @@ Begin with: Then identify version/status, supported Windows/architecture, website, repository, publisher and MIT License. -## Intended-purpose and store claims - -All release, store, directory, screenshot, metadata and support materials must follow [the intended-purpose policy](legal/INTENDED-PURPOSE-AND-MDR.md). +## Intended-purpose claims -The maintained scope is general-purpose accessibility/display personalization, not a medical purpose. Do not: - -- select a medical-device category; -- target a named disease, disorder, impairment or patient group; -- claim diagnosis, treatment, therapy, prevention, monitoring or clinical alleviation; -- describe display/color correction as clinical vision correction; -- claim clinical effectiveness or professional endorsement. +SightAdapt is maintained as general-purpose accessibility and display-personalization software, not a medical product. Do not claim diagnosis, treatment, therapy, prevention, monitoring, clinical correction or clinical effectiveness. Use where appropriate: > SightAdapt is general-purpose accessibility and display-personalization software. It is not intended to diagnose, treat, prevent, monitor or clinically alleviate any disease, injury or disability, and it is not a substitute for professional medical or eye-care advice. -Features or marketing that create a medical purpose are outside the maintained SightAdapt scope and must not be released under the current policy. - -## Patent and commercialization statements +## Patent and legal statements -Before material paid distribution, enterprise commitments or large-scale commercial deployment, complete the internal [patent-risk review](legal/PATENT-FTO-GATE.md) and the maintainer release-risk decision. +Before material paid distribution, enterprise commitments or large-scale commercial deployment, complete the internal patent-risk and maintainer release-risk decisions. -Do not state that SightAdapt is patent-free, patent-cleared or non-infringing. Do not provide patent warranties or indemnities. The project does not plan an external patent audit. +Do not state that SightAdapt is legally cleared, fully compliant, patent-free, patent-cleared, non-infringing, professionally audited or MDR approved. ## Third-party names and protected content -Use neutral factual compatibility wording such as `works with` or `tested with `. Do not imply official integration, partnership, certification, support or endorsement without a written relationship. +Use neutral compatibility wording such as `works with` or `tested with `. Do not imply partnership, certification, support or endorsement without a written relationship. When third-party compatibility is discussed, include: > Third-party product names and trademarks are the property of their respective owners and are used only to identify applications selected or configured by the user. SightAdapt is not affiliated with, sponsored by or endorsed by Microsoft or those owners unless an explicit written relationship is identified. SightAdapt does not circumvent DRM or other access controls, and protected content may remain unavailable or unfilterable. -Do not use third-party logos, branded icons, trade dress or promotional assets without a documented basis. - -## Maintainer release-risk gate +Do not use third-party logos, branded icons or trade dress without a documented basis. -Repository notices, SBOMs, policies and CI checks are internal project controls, not a professional audit or complete legal clearance. +## Mark notice -Before a materially different production, paid, store or enterprise release, follow [the maintainer release-risk review](legal/LEGAL-RELEASE-GATE.md). Record the decision using [the maintainer template](legal/MAINTAINER-RELEASE-REVIEW-TEMPLATE.md), tied to the exact artifact checksum and defined scope. - -Do not use `legally cleared`, `fully compliant`, `non-infringing`, `professionally audited`, `MDR approved` or equivalent wording. - -## Required mark notice - -Include once in the release description or linked legal material: +Include once in release or linked legal material: > SightAdapt™ is an unregistered product mark used by KeyffMS / aiteracja.pl. @@ -80,22 +68,24 @@ Do not use `®` unless an actual registration exists. - Use plain `SightAdapt` in technical filenames and identifiers. - Use `https://aiteracja.pl/sightadapt/` as the canonical product URL. - Use `Publisher` for `KeyffMS / aiteracja.pl`. -- Follow [the product identity standard](BRAND.md). -## Binary release gate +## Binary package gate -Every binary distribution must comply with [the binary packaging standard](PACKAGING.md) and contain every file in `release/required-files.txt`. - -Validate the staged directory and final package: +Every maintained binary package must contain every file in `release/required-files.txt` and be created through: ```powershell -.\tools\verify-release-compliance.ps1 ` +.\tools\new-verified-release-package.ps1 ` -DirectoryPath ` -ArchivePath ` - -ReportPath + -ReportPath ` + -DistributionChannel ``` -Publish the package and compliance report together. Do not publish when the report result is not `pass`. +The command creates the ZIP, runs the base compliance and component validators, compares every staged/archive file by SHA-256 and records commit/ref/workflow provenance. + +Publish the ZIP and matching schema-3 report together. Do not publish when the report result is not `pass`. + +A future GitHub Release workflow must activate `github-release` in `release/distribution-channels.json`, require an immutable tag and publish the exact verified ZIP and report. Until then the final gate rejects that channel. ## Minimal release header diff --git a/docs/legal/RELEASE-COMPLIANCE-GATE.md b/docs/legal/RELEASE-COMPLIANCE-GATE.md index 4bfc98c4..6d5cca5e 100644 --- a/docs/legal/RELEASE-COMPLIANCE-GATE.md +++ b/docs/legal/RELEASE-COMPLIANCE-GATE.md @@ -1,67 +1,91 @@ -# Release compliance gate +# Final package compliance gate -## Purpose +## Maintained distribution scope -The release compliance gate treats the license, privacy, dependency, notice and SBOM bundle as a release invariant. It runs after publication and before artifact upload. It does not change application behavior. +SightAdapt currently maintains one binary format: a self-contained Windows x64 portable ZIP. + +The format has two maintained producer contexts recorded in `release/distribution-channels.json`: + +- `github-actions-artifact` — the ZIP uploaded by `.github/workflows/build.yml`; +- `local-portable-zip` — a maintainer-created ZIP produced through the same entry point. + +GitHub Releases, installers, store packages and official mirrors are planned or inactive channels. They are not treated as maintained distributions until their implementation changes the registry and invokes the same reusable gate. GitHub Release publication remains tracked by #98. + +## Reusable entry point + +All maintained packages are created with: + +```powershell +.\tools\new-verified-release-package.ps1 ` + -DirectoryPath ` + -ArchivePath ` + -ReportPath ` + -DistributionChannel +``` + +The entry point creates the ZIP and invokes `tools/verify-final-package.ps1`. Packaging code must not replace this sequence with an unverified `Compress-Archive` call. ## Authoritative inputs -- `release/required-files.txt` — package manifest; -- `Directory.Build.props` — product, artifact, SDK, runtime and RID metadata; -- `SightAdapt.csproj` — target framework and publish settings; -- `release/dotnet-redistribution-review.json` — reviewed configuration, template checksum and maintainer decision; -- `DOTNET-NOTICE-METADATA.json` — exact Microsoft source/checksum/runtime evidence; -- `MICROSOFT-DOTNET-REDISTRIBUTION.txt` — generated maintainer-reviewed package notice; -- `LICENSE-REPORT.json` — dependency-license policy result; -- `SBOM.spdx.json` — component and shipped-file inventory; -- final staged directory and ZIP. +- `release/distribution-channels.json` — maintained and planned channels; +- `release/required-files.txt` — canonical package manifest; +- `Directory.Build.props` and `SightAdapt.csproj` — product and publish identity; +- `release/dotnet-redistribution-review.json` — maintainer redistribution decision; +- `DOTNET-NOTICE-METADATA.json` — exact component and notice evidence; +- `LICENSE-REPORT.json` and `SBOM.spdx.json` — complete dependency and file inventory; +- the final staged directory and ZIP; +- Git commit/ref, optional release tag and workflow/run identity. -## Checks +## Validation layers -`verify-release-compliance.ps1` verifies: +The final gate consumes the existing focused validators rather than duplicating their domain logic: -- all manifest files exist, are non-empty and readable; -- required text has no unresolved placeholders; -- archive naming and build metadata are consistent; -- every staged file is in the ZIP and no unexpected file was added; -- exact-version .NET metadata matches product/SDK/runtime/RID/publish inputs; -- redistribution configuration and template SHA-256 remain reviewed; -- the generated redistribution notice identifies the exact artifact and maintainer decision; -- a `blocked` maintainer decision stops packaging; -- the license report is `pass` and matches the build; -- the SPDX SBOM describes the release and every shipped file; -- SightAdapt is identified as the single-file container for embedded runtime components. +1. `verify-release-compliance.ps1` validates the legal bundle, exact metadata, SBOM, licenses and staged/archive path sets; +2. `verify-dotnet-component-coverage.ps1` validates every embedded and loose package component; +3. `verify-final-package.ps1` validates distribution-channel state, provenance and SHA-256 equality for every staged/archive file. -The report contains the build identity, archive SHA-256, redistribution review date, maintainer decision/owner/issue, notice SHA-256, manifest, file lists and failures. +The final report uses schema 3 and records: -No field represents an external audit or legal clearance. +- distribution channel and format; +- source commit SHA and repository HEAD SHA; +- source ref and release tag, when applicable; +- workflow name, run ID and run attempt; +- archive name, size and SHA-256; +- one staged/archive SHA-256 comparison record per file; +- base compliance, component coverage and hash-comparison results; +- package/SBOM counts and failures. -## Negative package tests +A report is publishable only when `result` is `pass`. -`test-release-compliance-negative.ps1` proves that: +## Provenance rules -1. an incomplete package is rejected; -2. a complete package with a deliberately changed redistribution runtime header is rejected as stale. +- The source commit must be a full 40-character Git SHA and match the checked-out repository HEAD. +- A tag value is valid only with the matching `refs/tags/` source ref. +- Channels marked `requiresTag` cannot publish an untagged package. +- Channels marked `requiresWorkflowRun` require GitHub workflow/run provenance. +- A channel listed only under `plannedChannels` is rejected by the gate. -## Workflow order +## Negative validation -1. verify canonical metadata and maintainer review record; -2. restore, build and test; -3. publish the self-contained directory; -4. generate exact-version .NET notices; -5. generate the maintainer-reviewed redistribution notice; -6. generate SBOM, dependency summary and license report; -7. reject incomplete/stale packages; -8. create the final ZIP; -9. run the compliance gate; -10. upload the verified ZIP and report together. +CI proves rejection of: -No upload runs after a failed gate. +- an incomplete package; +- stale redistribution metadata; +- an unmapped runtime binary; +- an unknown-license transitive dependency; +- a ZIP whose file bytes differ from the staged directory; +- a source commit that does not match repository HEAD; +- inconsistent tag/ref provenance; +- the planned but inactive `github-release` channel. -## Other distribution workflows +## Future channels -GitHub Releases, installers, store packages, portable packages and mirrors must use the same staged directory and manifest, validate final contents, retain a checksum/report and stop before publication on failure. +A new installer, store package, GitHub Release or mirror implementation must, in the same pull request: -## Maintenance +1. define the final installed or unpacked staging tree; +2. activate its channel in `release/distribution-channels.json`; +3. invoke `new-verified-release-package.ps1` or an equivalent adapter that ends in `verify-final-package.ps1`; +4. retain the verified package and matching schema-3 report; +5. add channel-specific negative tests where the container differs from portable ZIP. -Update the canonical manifest first when package requirements change. A change to the reviewed .NET configuration, maintainer decision or notice wording must update `release/dotnet-redistribution-review.json`. Update generators, documentation and packaging workflows in the same pull request. +Official mirrors must publish byte-identical verified artifacts and the matching report. diff --git a/release/distribution-channels.json b/release/distribution-channels.json new file mode 100644 index 00000000..953c1985 --- /dev/null +++ b/release/distribution-channels.json @@ -0,0 +1,48 @@ +{ + "schemaVersion": 1, + "maintainedChannels": [ + { + "id": "github-actions-artifact", + "format": "portable-zip", + "producer": ".github/workflows/build.yml", + "entrypoint": "tools/new-verified-release-package.ps1", + "manifest": "release/required-files.txt", + "requiresWorkflowRun": true, + "requiresTag": false + }, + { + "id": "local-portable-zip", + "format": "portable-zip", + "producer": "maintainer-local-build", + "entrypoint": "tools/new-verified-release-package.ps1", + "manifest": "release/required-files.txt", + "requiresWorkflowRun": false, + "requiresTag": false + } + ], + "plannedChannels": [ + { + "id": "github-release", + "format": "portable-zip", + "trackingIssue": 98, + "requiresTag": true + }, + { + "id": "installer", + "format": "installer", + "requiresTag": true + }, + { + "id": "store-package", + "format": "store-package", + "trackingIssue": 107, + "requiresTag": true + }, + { + "id": "official-mirror", + "format": "mirror", + "trackingIssue": 109, + "requiresTag": true + } + ] +} diff --git a/tools/new-verified-release-package.ps1 b/tools/new-verified-release-package.ps1 new file mode 100644 index 00000000..02386dbc --- /dev/null +++ b/tools/new-verified-release-package.ps1 @@ -0,0 +1,86 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$DirectoryPath, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$ArchivePath, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$ReportPath, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$DistributionChannel, + + [string]$SourceCommitSha, + [string]$SourceRef, + [string]$ReleaseTag, + [string]$WorkflowName, + [string]$WorkflowRunId, + [string]$WorkflowRunAttempt +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$directory = (Resolve-Path -LiteralPath $DirectoryPath).Path +$archiveFullPath = [System.IO.Path]::GetFullPath($ArchivePath) +$reportFullPath = [System.IO.Path]::GetFullPath($ReportPath) +$archiveDirectory = [System.IO.Path]::GetDirectoryName($archiveFullPath) +$reportDirectory = [System.IO.Path]::GetDirectoryName($reportFullPath) +[System.IO.Directory]::CreateDirectory($archiveDirectory) | Out-Null +[System.IO.Directory]::CreateDirectory($reportDirectory) | Out-Null + +$files = @(Get-ChildItem -LiteralPath $directory -File -Recurse) +if ($files.Count -eq 0) { + throw "Publish directory contains no files: $directory" +} + +Remove-Item -LiteralPath $archiveFullPath -Force -ErrorAction SilentlyContinue +Remove-Item -LiteralPath $reportFullPath -Force -ErrorAction SilentlyContinue + +Compress-Archive ` + -Path (Join-Path $directory '*') ` + -DestinationPath $archiveFullPath ` + -CompressionLevel Optimal + +$arguments = @{ + DirectoryPath = $directory + ArchivePath = $archiveFullPath + ReportPath = $reportFullPath + DistributionChannel = $DistributionChannel +} +if (-not [string]::IsNullOrWhiteSpace($SourceCommitSha)) { + $arguments.SourceCommitSha = $SourceCommitSha +} +if (-not [string]::IsNullOrWhiteSpace($SourceRef)) { + $arguments.SourceRef = $SourceRef +} +if (-not [string]::IsNullOrWhiteSpace($ReleaseTag)) { + $arguments.ReleaseTag = $ReleaseTag +} +if (-not [string]::IsNullOrWhiteSpace($WorkflowName)) { + $arguments.WorkflowName = $WorkflowName +} +if (-not [string]::IsNullOrWhiteSpace($WorkflowRunId)) { + $arguments.WorkflowRunId = $WorkflowRunId +} +if (-not [string]::IsNullOrWhiteSpace($WorkflowRunAttempt)) { + $arguments.WorkflowRunAttempt = $WorkflowRunAttempt +} + +& (Join-Path $PSScriptRoot 'verify-final-package.ps1') @arguments + +$report = Get-Content -LiteralPath $reportFullPath -Raw | ConvertFrom-Json +if ([int]$report.schemaVersion -ne 3 -or + [string]$report.result -ne 'pass') { + throw "Verified package report is not a passing schema-3 report: $reportFullPath" +} + +Write-Host "Created verified package: $archiveFullPath" +Write-Host "Archive SHA-256: $($report.archive.sha256)" +Write-Host "Distribution channel: $($report.distribution.channel)" diff --git a/tools/test-final-package-gate-negative.ps1 b/tools/test-final-package-gate-negative.ps1 new file mode 100644 index 00000000..911470d6 --- /dev/null +++ b/tools/test-final-package-gate-negative.ps1 @@ -0,0 +1,180 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$PublishDirectory +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if ($null -eq ('System.IO.Compression.ZipFile' -as [type])) { + Add-Type -AssemblyName System.IO.Compression.FileSystem +} + +$root = Split-Path -Parent $PSScriptRoot +$publish = (Resolve-Path -LiteralPath $PublishDirectory).Path +[xml]$props = Get-Content -LiteralPath (Join-Path $root 'Directory.Build.props') +$group = $props.Project.PropertyGroup +$productVersion = [string]$group.SightAdaptProductVersion +$artifactName = ([string]$group.SightAdaptArtifactName).Replace( + '$(SightAdaptProductVersion)', + $productVersion) +$sourceCommit = (& git -C $root rev-parse HEAD).Trim() +if ($LASTEXITCODE -ne 0 -or + $sourceCommit -notmatch '^[0-9A-Fa-f]{40}$') { + throw 'The negative final-package test cannot resolve repository HEAD.' +} + +$tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ( + 'sightadapt-final-gate-negative-' + [Guid]::NewGuid().ToString('N')) +[System.IO.Directory]::CreateDirectory($tempRoot) | Out-Null + +function Assert-FinalGateRejected( + [scriptblock]$Action, + [string]$Scenario, + [string]$ExpectedMessage) { + $rejected = $false + try { + & $Action + } + catch { + $rejected = $true + $message = [string]$_.Exception.Message + if (-not $message.Contains($ExpectedMessage, [StringComparison]::OrdinalIgnoreCase)) { + throw "$Scenario was rejected for an unexpected reason: $message" + } + Write-Host "$Scenario was rejected as expected: $message" + } + if (-not $rejected) { + throw "$Scenario unexpectedly passed validation." + } +} + +try { + $baselineDirectory = Join-Path $tempRoot 'baseline' + [System.IO.Directory]::CreateDirectory($baselineDirectory) | Out-Null + $baselineArchive = Join-Path $baselineDirectory "$artifactName.zip" + $baselineReport = Join-Path $baselineDirectory "$artifactName-compliance.json" + + & (Join-Path $PSScriptRoot 'new-verified-release-package.ps1') ` + -DirectoryPath $publish ` + -ArchivePath $baselineArchive ` + -ReportPath $baselineReport ` + -DistributionChannel 'local-portable-zip' ` + -SourceCommitSha $sourceCommit ` + -SourceRef 'refs/heads/final-gate-negative' ` + -WorkflowName 'negative-test' ` + -WorkflowRunId 'local' ` + -WorkflowRunAttempt '1' + + $tamperedDirectory = Join-Path $tempRoot 'tampered' + [System.IO.Directory]::CreateDirectory($tamperedDirectory) | Out-Null + $tamperedArchive = Join-Path $tamperedDirectory "$artifactName.zip" + Copy-Item -LiteralPath $baselineArchive -Destination $tamperedArchive + + $zip = [System.IO.Compression.ZipFile]::Open( + $tamperedArchive, + [System.IO.Compression.ZipArchiveMode]::Update) + try { + $entry = $zip.GetEntry('PRIVACY.md') + if ($null -eq $entry) { + throw 'The hash-mismatch test requires PRIVACY.md in the archive.' + } + $stream = $entry.Open() + $reader = [System.IO.StreamReader]::new($stream) + try { + $text = $reader.ReadToEnd() + } + finally { + $reader.Dispose() + $stream.Dispose() + } + $entry.Delete() + $replacement = $zip.CreateEntry('PRIVACY.md') + $replacementStream = $replacement.Open() + $writer = [System.IO.StreamWriter]::new( + $replacementStream, + [System.Text.UTF8Encoding]::new($false)) + try { + $writer.Write($text + [Environment]::NewLine + 'tampered archive copy') + } + finally { + $writer.Dispose() + $replacementStream.Dispose() + } + } + finally { + $zip.Dispose() + } + + Assert-FinalGateRejected ` + -Scenario 'The archive with modified file bytes' ` + -ExpectedMessage 'SHA-256 mismatch' ` + -Action { + & (Join-Path $PSScriptRoot 'verify-final-package.ps1') ` + -DirectoryPath $publish ` + -ArchivePath $tamperedArchive ` + -ReportPath (Join-Path $tamperedDirectory 'tampered-report.json') ` + -DistributionChannel 'local-portable-zip' ` + -SourceCommitSha $sourceCommit ` + -SourceRef 'refs/heads/final-gate-negative' ` + -WorkflowName 'negative-test' ` + -WorkflowRunId 'local' ` + -WorkflowRunAttempt '1' + } + + Assert-FinalGateRejected ` + -Scenario 'The package with incorrect source commit provenance' ` + -ExpectedMessage 'does not match repository HEAD' ` + -Action { + & (Join-Path $PSScriptRoot 'verify-final-package.ps1') ` + -DirectoryPath $publish ` + -ArchivePath $baselineArchive ` + -ReportPath (Join-Path $baselineDirectory 'wrong-commit-report.json') ` + -DistributionChannel 'local-portable-zip' ` + -SourceCommitSha '0000000000000000000000000000000000000000' ` + -SourceRef 'refs/heads/final-gate-negative' ` + -WorkflowName 'negative-test' ` + -WorkflowRunId 'local' ` + -WorkflowRunAttempt '1' + } + + Assert-FinalGateRejected ` + -Scenario 'The package with inconsistent tag provenance' ` + -ExpectedMessage 'does not match source ref' ` + -Action { + & (Join-Path $PSScriptRoot 'verify-final-package.ps1') ` + -DirectoryPath $publish ` + -ArchivePath $baselineArchive ` + -ReportPath (Join-Path $baselineDirectory 'wrong-tag-report.json') ` + -DistributionChannel 'local-portable-zip' ` + -SourceCommitSha $sourceCommit ` + -SourceRef 'refs/tags/v0.0.0-test' ` + -ReleaseTag 'v0.0.0-other' ` + -WorkflowName 'negative-test' ` + -WorkflowRunId 'local' ` + -WorkflowRunAttempt '1' + } + + Assert-FinalGateRejected ` + -Scenario 'The unimplemented GitHub Release channel' ` + -ExpectedMessage 'planned but not maintained' ` + -Action { + & (Join-Path $PSScriptRoot 'verify-final-package.ps1') ` + -DirectoryPath $publish ` + -ArchivePath $baselineArchive ` + -ReportPath (Join-Path $baselineDirectory 'planned-channel-report.json') ` + -DistributionChannel 'github-release' ` + -SourceCommitSha $sourceCommit ` + -SourceRef 'refs/heads/final-gate-negative' ` + -WorkflowName 'negative-test' ` + -WorkflowRunId 'local' ` + -WorkflowRunAttempt '1' + } +} +finally { + Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue +} + +Write-Host 'Negative final-package hash and provenance validation passed.' diff --git a/tools/verify-final-package.ps1 b/tools/verify-final-package.ps1 new file mode 100644 index 00000000..57f1dbd6 --- /dev/null +++ b/tools/verify-final-package.ps1 @@ -0,0 +1,377 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$DirectoryPath, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$ArchivePath, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$ReportPath, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$DistributionChannel, + + [string]$SourceCommitSha, + [string]$SourceRef, + [string]$ReleaseTag, + [string]$WorkflowName, + [string]$WorkflowRunId, + [string]$WorkflowRunAttempt, + + [string]$ManifestPath = + (Join-Path $PSScriptRoot '..\release\required-files.txt'), + + [string]$ChannelsPath = + (Join-Path $PSScriptRoot '..\release\distribution-channels.json') +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if ($null -eq ('System.IO.Compression.ZipFile' -as [type])) { + Add-Type -AssemblyName System.IO.Compression.FileSystem +} + +$root = Split-Path -Parent $PSScriptRoot +$directory = (Resolve-Path -LiteralPath $DirectoryPath).Path +$archivePathResolved = (Resolve-Path -LiteralPath $ArchivePath).Path +$manifest = (Resolve-Path -LiteralPath $ManifestPath).Path +$channelsFile = (Resolve-Path -LiteralPath $ChannelsPath).Path +$reportFullPath = [System.IO.Path]::GetFullPath($ReportPath) +[System.IO.Directory]::CreateDirectory( + [System.IO.Path]::GetDirectoryName($reportFullPath)) | Out-Null + +$failures = [System.Collections.Generic.List[string]]::new() +$fileEvidence = [System.Collections.Generic.List[object]]::new() +$baseReport = $null +$componentCoverageResult = 'not-run' +$distributionFormat = $null +$channelRecord = $null + +function Invoke-GitValue([string[]]$Arguments) { + try { + $output = & git -C $root @Arguments 2>$null + if ($LASTEXITCODE -eq 0) { + return (($output | Out-String).Trim()) + } + } + catch { + return $null + } + return $null +} + +function Get-ArchiveEntrySha256( + [System.IO.Compression.ZipArchiveEntry]$Entry) { + $stream = $Entry.Open() + $sha256 = [System.Security.Cryptography.SHA256]::Create() + try { + return [System.BitConverter]::ToString( + $sha256.ComputeHash($stream)).Replace('-', '').ToLowerInvariant() + } + finally { + $sha256.Dispose() + $stream.Dispose() + } +} + +if ([string]::IsNullOrWhiteSpace($SourceCommitSha)) { + $SourceCommitSha = if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_SHA)) { + $env:GITHUB_SHA + } + else { + Invoke-GitValue @('rev-parse', 'HEAD') + } +} +if ([string]::IsNullOrWhiteSpace($SourceRef)) { + if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_REF)) { + $SourceRef = $env:GITHUB_REF + } + else { + $branch = Invoke-GitValue @('rev-parse', '--abbrev-ref', 'HEAD') + $SourceRef = if (-not [string]::IsNullOrWhiteSpace($branch) -and + $branch -ne 'HEAD') { + "refs/heads/$branch" + } + else { + 'local-detached' + } + } +} +if ([string]::IsNullOrWhiteSpace($ReleaseTag) -and + [string]$env:GITHUB_REF_TYPE -eq 'tag') { + $ReleaseTag = [string]$env:GITHUB_REF_NAME +} +if ([string]::IsNullOrWhiteSpace($WorkflowName)) { + $WorkflowName = if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_WORKFLOW)) { + $env:GITHUB_WORKFLOW + } + else { + 'local' + } +} +if ([string]::IsNullOrWhiteSpace($WorkflowRunId)) { + $WorkflowRunId = if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ID)) { + $env:GITHUB_RUN_ID + } + else { + 'local' + } +} +if ([string]::IsNullOrWhiteSpace($WorkflowRunAttempt)) { + $WorkflowRunAttempt = if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_RUN_ATTEMPT)) { + $env:GITHUB_RUN_ATTEMPT + } + else { + '1' + } +} + +try { + $channels = Get-Content -LiteralPath $channelsFile -Raw | ConvertFrom-Json + if ([int]$channels.schemaVersion -ne 1) { + $failures.Add("Unsupported distribution-channel schema '$($channels.schemaVersion)'.") + } + $channelRecord = @($channels.maintainedChannels | Where-Object { + [string]$_.id -eq $DistributionChannel + }) | Select-Object -First 1 + if ($null -eq $channelRecord) { + $planned = @($channels.plannedChannels | Where-Object { + [string]$_.id -eq $DistributionChannel + }) | Select-Object -First 1 + if ($null -ne $planned) { + $failures.Add("Distribution channel '$DistributionChannel' is planned but not maintained.") + $distributionFormat = [string]$planned.format + } + else { + $failures.Add("Distribution channel '$DistributionChannel' is not registered.") + } + } + else { + $distributionFormat = [string]$channelRecord.format + if ([string]$channelRecord.entrypoint -ne + 'tools/new-verified-release-package.ps1') { + $failures.Add("Maintained channel '$DistributionChannel' does not use the reusable package entrypoint.") + } + if ([string]$channelRecord.manifest -ne + 'release/required-files.txt') { + $failures.Add("Maintained channel '$DistributionChannel' does not use the canonical manifest.") + } + } +} +catch { + $failures.Add("Distribution-channel registry cannot be validated: $($_.Exception.Message)") +} + +if ([string]$SourceCommitSha -notmatch '^[0-9A-Fa-f]{40}$') { + $failures.Add("Source commit '$SourceCommitSha' is not a full 40-character Git SHA.") +} +$repositoryHead = Invoke-GitValue @('rev-parse', 'HEAD') +if (-not [string]::IsNullOrWhiteSpace($repositoryHead) -and + [string]$SourceCommitSha -match '^[0-9A-Fa-f]{40}$' -and + -not $repositoryHead.Equals($SourceCommitSha, [StringComparison]::OrdinalIgnoreCase)) { + $failures.Add("Source commit '$SourceCommitSha' does not match repository HEAD '$repositoryHead'.") +} +if ([string]::IsNullOrWhiteSpace($SourceRef)) { + $failures.Add('Source ref is required.') +} +$tagFromRef = $null +if ([string]$SourceRef -match '^refs/tags/(?.+)$') { + $tagFromRef = $Matches['tag'] + if ([string]::IsNullOrWhiteSpace($ReleaseTag)) { + $failures.Add("Source ref '$SourceRef' requires a release tag value.") + } + elseif ($ReleaseTag -ne $tagFromRef) { + $failures.Add("Release tag '$ReleaseTag' does not match source ref '$SourceRef'.") + } +} +elseif (-not [string]::IsNullOrWhiteSpace($ReleaseTag)) { + $failures.Add("Release tag '$ReleaseTag' is present but source ref '$SourceRef' is not a tag ref.") +} +if ($null -ne $channelRecord -and + [bool]$channelRecord.requiresTag -and + [string]::IsNullOrWhiteSpace($ReleaseTag)) { + $failures.Add("Distribution channel '$DistributionChannel' requires a release tag.") +} +if ($null -ne $channelRecord -and + [bool]$channelRecord.requiresWorkflowRun -and + ([string]::IsNullOrWhiteSpace($WorkflowName) -or + [string]::IsNullOrWhiteSpace($WorkflowRunId) -or + $WorkflowRunId -eq 'local')) { + $failures.Add("Distribution channel '$DistributionChannel' requires GitHub workflow/run provenance.") +} + +$baseReportPath = Join-Path ([System.IO.Path]::GetTempPath()) ( + 'sightadapt-base-compliance-' + [Guid]::NewGuid().ToString('N') + '.json') +try { + try { + & (Join-Path $PSScriptRoot 'verify-release-compliance.ps1') ` + -DirectoryPath $directory ` + -ArchivePath $archivePathResolved ` + -ReportPath $baseReportPath ` + -ManifestPath $manifest + $baseReport = Get-Content -LiteralPath $baseReportPath -Raw | ConvertFrom-Json + } + catch { + $failures.Add("Base release compliance failed: $($_.Exception.Message)") + if ([System.IO.File]::Exists($baseReportPath)) { + try { + $baseReport = Get-Content -LiteralPath $baseReportPath -Raw | ConvertFrom-Json + } + catch { + $baseReport = $null + } + } + } + + try { + & (Join-Path $PSScriptRoot 'verify-dotnet-component-coverage.ps1') ` + -ArchivePath $archivePathResolved + $componentCoverageResult = 'pass' + } + catch { + $componentCoverageResult = 'fail' + $failures.Add("Component-coverage validation failed: $($_.Exception.Message)") + } + + try { + $stagedFiles = @( + Get-ChildItem -LiteralPath $directory -File -Recurse | + Sort-Object FullName + ) + $archiveEntries = @{} + $archive = [System.IO.Compression.ZipFile]::OpenRead($archivePathResolved) + try { + foreach ($entry in $archive.Entries) { + if ([string]::IsNullOrWhiteSpace($entry.Name)) { + continue + } + $path = $entry.FullName.Replace('\', '/').TrimStart('/') + $key = $path.ToLowerInvariant() + if ($archiveEntries.ContainsKey($key)) { + $failures.Add("Archive contains duplicate path '$path'.") + continue + } + $archiveEntries[$key] = [pscustomobject]@{ + path = $path + size = [long]$entry.Length + sha256 = Get-ArchiveEntrySha256 $entry + } + } + } + finally { + $archive.Dispose() + } + + $stagedKeys = [System.Collections.Generic.HashSet[string]]::new( + [StringComparer]::OrdinalIgnoreCase) + foreach ($file in $stagedFiles) { + $relative = [System.IO.Path]::GetRelativePath( + $directory, + $file.FullName).Replace('\', '/') + $key = $relative.ToLowerInvariant() + [void]$stagedKeys.Add($key) + $stagedSha256 = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + $archiveRecord = if ($archiveEntries.ContainsKey($key)) { + $archiveEntries[$key] + } + else { + $null + } + $archiveSha256 = if ($null -ne $archiveRecord) { + [string]$archiveRecord.sha256 + } + else { + $null + } + $matches = $null -ne $archiveRecord -and + $stagedSha256 -eq $archiveSha256 + if ($null -eq $archiveRecord) { + $failures.Add("Staged file '$relative' is missing from the final archive.") + } + elseif (-not $matches) { + $failures.Add("Staged/archive SHA-256 mismatch for '$relative'.") + } + $fileEvidence.Add([ordered]@{ + path = $relative + size = [long]$file.Length + stagedSha256 = $stagedSha256 + archiveSha256 = $archiveSha256 + match = $matches + }) + } + foreach ($archiveRecord in $archiveEntries.Values) { + if (-not $stagedKeys.Contains([string]$archiveRecord.path)) { + $failures.Add("Archive file '$($archiveRecord.path)' is not present in the staged directory.") + } + } + } + catch { + $failures.Add("Staged/archive hash comparison failed: $($_.Exception.Message)") + } + + $archiveSha256 = (Get-FileHash -LiteralPath $archivePathResolved -Algorithm SHA256).Hash + $report = [ordered]@{ + schemaVersion = 3 + generatedAtUtc = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ') + result = if ($failures.Count -eq 0) { 'pass' } else { 'fail' } + productVersion = if ($null -ne $baseReport) { [string]$baseReport.productVersion } else { $null } + sdkVersion = if ($null -ne $baseReport) { [string]$baseReport.sdkVersion } else { $null } + runtimeVersion = if ($null -ne $baseReport) { [string]$baseReport.runtimeVersion } else { $null } + targetFramework = if ($null -ne $baseReport) { [string]$baseReport.targetFramework } else { $null } + runtimeIdentifier = if ($null -ne $baseReport) { [string]$baseReport.runtimeIdentifier } else { $null } + publishMode = if ($null -ne $baseReport) { [string]$baseReport.publishMode } else { $null } + distribution = [ordered]@{ + channel = $DistributionChannel + format = $distributionFormat + registry = 'release/distribution-channels.json' + entrypoint = 'tools/new-verified-release-package.ps1' + manifest = 'release/required-files.txt' + } + provenance = [ordered]@{ + sourceCommitSha = $SourceCommitSha + repositoryHeadSha = $repositoryHead + sourceRef = $SourceRef + releaseTag = if ([string]::IsNullOrWhiteSpace($ReleaseTag)) { $null } else { $ReleaseTag } + workflowName = $WorkflowName + workflowRunId = $WorkflowRunId + workflowRunAttempt = $WorkflowRunAttempt + } + archive = [ordered]@{ + file = [System.IO.Path]::GetFileName($archivePathResolved) + size = (Get-Item -LiteralPath $archivePathResolved).Length + sha256 = $archiveSha256 + } + validation = [ordered]@{ + baseComplianceSchemaVersion = if ($null -ne $baseReport) { [int]$baseReport.schemaVersion } else { $null } + baseComplianceResult = if ($null -ne $baseReport) { [string]$baseReport.result } else { 'unavailable' } + componentCoverageResult = $componentCoverageResult + stagedArchiveHashComparison = if (@($fileEvidence | Where-Object { -not $_.match }).Count -eq 0) { 'pass' } else { 'fail' } + } + licenseReportPackageCount = if ($null -ne $baseReport) { $baseReport.licenseReportPackageCount } else { $null } + sbomPackageCount = if ($null -ne $baseReport) { $baseReport.sbomPackageCount } else { $null } + sbomRelationshipCount = if ($null -ne $baseReport) { $baseReport.sbomRelationshipCount } else { $null } + fileEvidence = @($fileEvidence) + failures = @($failures) + } + [System.IO.File]::WriteAllText( + $reportFullPath, + ($report | ConvertTo-Json -Depth 12) + [Environment]::NewLine, + [System.Text.UTF8Encoding]::new($false)) +} +finally { + Remove-Item -LiteralPath $baseReportPath -Force -ErrorAction SilentlyContinue +} + +if ($failures.Count -gt 0) { + $details = $failures | ForEach-Object { " - $_" } + throw "Final package gate failed:`n$($details -join "`n")" +} + +Write-Host "Final package gate passed: $archivePathResolved" +Write-Host "Compliance report: $reportFullPath"